Compare commits

...

20 Commits

Author SHA1 Message Date
Waleed
f0d1950477 v0.6.21: concurrency FF, blog theme 2026-04-02 13:08:59 -07:00
Waleed
727bb1cadb fix(bullmq): restore CONCURRENCY_CONTROL_ENABLED flag guard (#3903) 2026-04-02 13:07:50 -07:00
Waleed
e2e29cefd7 fix(blog): use landing theme variables in MDX components (#3900) 2026-04-02 12:34:38 -07:00
Waleed
0fdd8ffb55 v0.6.20: oauth default credential name, models pages, new models, rippling and rootly integrations 2026-04-02 11:44:24 -07:00
Waleed
45f053a383 feat(rootly): add Rootly incident management integration with 14 tools (#3899)
* feat(rootly): add Rootly incident management integration with 14 tools

* fix(rootly): address PR review feedback - PATCH method, totalCount, environmentIds

- Changed update_incident HTTP method from PUT to PATCH per Rootly API spec
- Fixed totalCount in all 9 list tools to use data.meta?.total_count from API response
- Added missing updateEnvironmentIds subBlock and params mapping for update_incident

* fix(rootly): add id to PATCH body and unchanged option to update status dropdown

- Include incident id in JSON:API PATCH body per spec requirement
- Add 'Unchanged' empty option to updateStatus dropdown to avoid accidental overwrites

* icon update

* improvement(rootly): complete block-tool alignment and fix validation gaps

- Add missing get_incident output fields (private, shortUrl, closedAt)
- Add missing block subBlocks: createPrivate, alertStatus, alertExternalId, listAlertsServices
- Add pageNumber subBlocks for all 9 list operations
- Add teams/environments filter subBlocks for list_incidents and list_alerts
- Add environmentIds subBlock for create_alert
- Add empty default options to all optional dropdowns (createStatus, createKind, listIncidentsSort, eventVisibility)
- Wire all new subBlocks in tools.config.params and inputs
- Regenerate docs

* fix(rootly): align tools with OpenAPI spec

- list_incident_types: use filter[name] instead of unsupported filter[search]
- list_severities: add missing search param (filter[search])
- create_incident: title is optional per API (auto-generated if null)
- update_incident: add kind, private, labels, incidentTypeIds,
  functionalityIds, cancellationMessage params
- create/update/list incidents: add scheduled, in_progress, completed
  status values
- create_alert: fix status description (only open/triggered on create)
- add_incident_event: add updatedAt to response
- block: add matching subBlocks and params for all new tool fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rootly): final validation fixes from OpenAPI spec audit

- update_incident: change PATCH to PUT per OpenAPI spec
- index.ts: add types re-export
- types.ts: fix id fields to string | null (matches ?? null runtime)
- block: add value initializers to 4 dropdowns missing them
- registry: fix alphabetical order (incident_types before incidents)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* reorg

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 11:40:40 -07:00
Waleed
225d5d551a improvement(models): update default to claude-sonnet-4-6 and reorganize OpenAI models (#3898)
* improvement(models): update default to claude-sonnet-4-6 and reorganize OpenAI models

* fix(tests): update stale claude-sonnet-4-5 references to claude-sonnet-4-6

* fix(combobox): rename misleading claudeSonnet45 variable to defaultModelOption
2026-04-02 10:51:27 -07:00
Waleed
a78f3f9c2e fix(credential): fix service_account migration to avoid unsafe enum usage in same transaction (#3897) 2026-04-02 10:16:08 -07:00
Waleed
080a0a6123 feat(rippling): expand Rippling integration from to 86 tools, landing updates (#3886)
* feat(rippling): expand Rippling integration from 16 to 86 tools

* fix(rippling): add required constraints on name and data subBlocks for create operations

* fix(rippling): add subblock ID migrations for removed legacy fields

* fix(docs): add MANUAL-CONTENT markers to tailscale docs and regenerate

* fix(rippling): add missing response fields to tool transforms

Add fields found missing by validation agents:
- list_companies: physical_address
- list/get_supergroups: sub_group_type, read_only, parent, mutually_exclusive_key, cumulatively_exhaustive_default, include_terminated
- list/get/create/update_custom_object: native_category_id, managed_package_install_id, owner_id
- list/get/create/update_custom_app: icon, pages
- list/get/create/update_custom_object_field: managed_package_install_id

* fix(rippling): add missing block outputs and required data conditions

- Add 17 missing collection output keys (titles, workLocations, supergroups, etc.)
- Add delete/bulk/report output keys (deleted, results, report_id, etc.)
- Mark data subBlock required for create_business_partner, create_custom_app,
  and create_custom_object_field (all have required params via data JSON spread)
- Add optional: true to get_current_user work_email and company_id outputs

* fix(rippling): add missing supergroup fields and fix validation issues

- Add 5 missing supergroup fields (allow_non_employees, can_override_role_states, priority, is_invisible, ignore_prov_group_matching) to types, list, and get tools
- Fix ok fallback from true to false in supergroup inclusion/exclusion member update tools
- Fix truthy check to null check for description param in create_custom_object_field

* fix(rippling): add missing custom page fields and structured custom setting responses

- Add 5 missing CustomPage fields (components, actions, canvas_actions, variables, media) to types and all page tools
- Replace opaque data blob with structured field mapping in create/update custom setting transforms
- Fix secret_value type cast consistency in list_custom_settings

* fix(rippling): add missing response fields, fix truthy checks, and improve UX

- Add 9 missing Worker fields (location, gender, date_of_birth, race, ethnicity, citizenship, termination_details, custom_fields, country_fields)
- Add 5 missing User fields (name, emails, phone_numbers, addresses, photos)
- Add worker expandable field to GroupMember types and all 3 member list tools
- Add 5 optional params to trigger_report_run (includeObjectIds, includeTotalRows, formatDateFields, formatCurrencyFields, outputType)
- Fix truthy checks to null checks in create_department, create/update_work_location
- Fix customObjectId subBlock label to say "API Name" instead of "ID"

* update docs

* fix(rippling): fix truthy checks, add missing fields, and regenerate docs

- Replace all `if (params.x)` with `if (params.x != null)` across 30+ tool files to prevent empty string/false/zero suppression
- Add expandable `parent` and `department_hierarchy` fields to department tools
- Add expandable `parent` field to team tools
- Add `company` expandable field to get_current_user
- Add `addressType` param to create/update work location tools
- Fix `secret_value` output type from 'json' to 'string' in list_custom_settings
- Regenerate docs for all 86 tools from current definitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): add all remaining spec fields and regenerate docs

- Add 6 advanced params to create_custom_object_field: required, rqlDefinition,
  formulaAttrMetas, section, derivedFieldFormula, derivedAggregatedField
- Add 6 advanced params to update_custom_object_field: required, rqlDefinition,
  formulaAttrMetas, section, derivedFieldFormula, nameFieldDetails
- Add 4 record output fields to all custom object record tools: created_by,
  last_modified_by, owner_role, system_updated_at
- Add cursor param to get_current_user
- Add __meta response field to get_report_run
- Regenerate docs for all 86 tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): align all tools with OpenAPI spec

- Add __meta to 14 GET-by-ID tools (MetaResponse pattern)
- Fix supergroup tools: add filter to list_supergroups, remove invalid
  cursor from 4 list endpoints, revert update members to PATCH with
  Operations body
- Fix query_custom_object_records: use query/limit/cursor body params,
  return cursor instead of nextLink
- Fix bulk_create: use rows_to_write per spec
- Fix create/update record body wrappers with externalId support
- Update types.ts param interfaces and block config mappings
- Add limit param mapping with Number() conversion in block config
- Regenerate docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): address PR review comments — add dedicated subBlocks, fix data duplication, expand externalId condition

- Add dedicated apiName, businessPartnerGroupId, workerId, dataType subBlocks so required params are no longer hidden behind opaque data JSON
- Narrow `data: item` in custom object record tools to only include dynamic fields, avoiding duplication of enumerated fields
- Expand externalId subBlock condition to include create/update custom object record operations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): remove data JSON required for ops with dedicated subBlocks

create_business_partner, create_custom_app, and create_custom_object_field
now have dedicated subBlocks for their required params, so the data JSON
field is supplementary (not required) for those operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): use rest-destructuring for all custom object record data output

The spec uses additionalProperties for custom fields at the top level,
not a nested `data` sub-object. Use the same rest-destructuring pattern
across all 6 custom object record tools so `data` only contains dynamic
fields, not duplicates of enumerated standard fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): make update_custom_object_record data param optional in type

Matches the tool's `required: false` — users may update only external_id
without changing data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): add dedicated streetAddress subBlock for create_work_location

streetAddress is required by the tool but had no dedicated subBlock —
users had to include it in the data JSON. Now has its own required
subBlock matching the pattern used by all other required params.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): add allOrNothing subBlock for bulk operations

The bulk create/update/delete tools accept an optional allOrNothing
boolean param, but it had no subBlock and no way to be passed through
the block UI. Added as an advanced-mode dropdown with boolean coercion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): derive spreadOps from DATA_OPS to prevent divergence

Replace the hardcoded spreadOps array with a derivation from the
file-level DATA_OPS constant minus non-spread operations. This ensures
new create/update operations added to DATA_OPS automatically get
spread behavior without needing a second manual update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* updated

* fix(rippling): replace generic JSON outputs with specific fields per API spec

- Extract file_url, expires_at, output_type from report run result blob
- Rename bulk create/update outputs to createdRecords/updatedRecords
- Fix list_custom_settings output key mismatch (settings → customSettings)
- Make data optional for update_custom_object_record in block
- Update block outputs to match new tool output fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix landing

* restore FF

* fix(rippling): add wandConfig, clean titles, and migrate legacy operation values

- Remove "(JSON)" suffix from all subBlock titles
- Add wandConfig with AI prompts for filter, expand, orderBy, query, data, records, and dataType fields
- Add OPERATION_VALUE_MIGRATIONS to migrate old operation values (list_employees → list_workers, etc.) preventing runtime errors on saved workflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(rippling): fix grammar typos and revert unnecessary migration

- Fix "a object" → "an object" in update/delete object category descriptions
- Revert OPERATION_VALUE_MIGRATIONS (unnecessary for low-usage integration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(landing): add interactive workspace preview tabs

Adds Tables, Files, Knowledge Base, Logs, and Scheduled Tasks preview
components to the landing hero, with sidebar nav items that switch to each view.

* test updates

* refactor(landing): clean up code quality issues in preview components

- Replace widthMultiplier with explicit width on PreviewColumn
- Replace key={i} with key={Icon.name} in connectorIcons
- Scope --c-active CSS variable to sidebar container, eliminating hardcoded #363636 duplication
- Replace '-  -  -' fallback with em dash
- Type onSelectNav as (id: SidebarView) removing the unsafe cast

* fix(landing): use stable index key in connectorIcons to avoid minification breakage

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 01:30:43 -07:00
Theodore Li
fc6fe193fa fix(credential) fix credential migration (#3896)
* fix(credential) fix credential migration

* Fix lint

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 04:29:01 -04:00
Theodore Li
bbc704fe05 feat(credentials) Add google service account support (#3828)
* feat(auth): allow google service account

* Add gmail support for google services

* Refresh creds on typing in impersonated email

* Switch to adding subblock impersonateUserEmail conditionally

* Directly pass subblock for impersonateUserEmail

* Fix lint

* Update documentation for google service accounts

* Fix lint

* Address comments

* Remove hardcoded scopes, remove orphaned migration script

* Simplify subblocks for google service account

* Fix lint

* Fix build error

* Fix documentation scopes listed for google service accounts

* Fix issue with credential selector, remove bigquery and ad support

* create credentialCondition

* Shift conditional render out of subblock

* Simplify sublock values

* Fix security message

* Handle tool service accounts

* Address bugbot

* Fix lint

* Fix manual credential input not showing impersonate

* Fix tests

* Allow watching param id and subblock ids

* Fix bad test

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 03:08:13 -04:00
Theodore Li
c016537564 fix(blog): Fix blog not loading (#3895)
* Fix blog not loading

* Use emcn icon

---------

Co-authored-by: Theodore Li <theo@sim.ai>
2026-04-02 00:53:31 -04:00
Waleed
4c94f3cf78 improvement(providers): audit and update all provider model definitions (#3893)
* improvement(providers): audit and update all provider model definitions

* fix(providers): add maxOutputTokens to azure/o3 and azure/o4-mini

* fix(providers): move maxOutputTokens inside capabilities for azure models
2026-04-01 19:32:16 -07:00
Vikhyath Mondreti
27a11a269d improvement(workflow): seed start block on server side (#3890)
* improvement(workflow): seed start block on server side

* add creating state machine for optimistic switch

* fix worksapce switch

* address comments

* address error handling at correct level
2026-04-01 19:04:34 -07:00
Waleed
2c174ca4f6 feat(landing): added models pages (#3888)
* feat(landing): added models pages

* fix(models): address PR review feedback

Correct model structured-data price bounds, remove dead code in the models catalog helpers, and harden OG font loading with graceful fallbacks.

Made-with: Cursor

* relative imports, build fix

* lint

* fix(models): remove dead og-utils exports, fix formatTokenCount null guard
2026-04-01 18:23:35 -07:00
Waleed
ac831b85b2 chore(bun): update bunfig.toml (#3889)
* chore(bun): update bunfig.toml

* outdated bun lock

* chore(deps): downgrade @aws-sdk/client-secrets-manager to 3.940.0
2026-04-01 17:21:00 -07:00
Waleed
8527ae5d3b feat(providers): server-side credential hiding for Azure and Bedrock (#3884)
* fix: allow Bedrock provider to use AWS SDK default credential chain

Remove hard requirement for explicit AWS credentials in Bedrock provider.
When access key and secret key are not provided, the AWS SDK automatically
falls back to its default credential chain (env vars, instance profile,
ECS task role, EKS IRSA, SSO).

Closes #3694

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: add partial credential guard for Bedrock provider

Reject configurations where only one of bedrockAccessKeyId or
bedrockSecretKey is provided, preventing silent fallback to the
default credential chain with a potentially different identity.

Add tests covering all credential configuration scenarios.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: clean up bedrock test lint and dead code

Remove unused config parameter and dead _lastConfig assignment
from mock factory. Break long mockReturnValue chain to satisfy
biome line-length rule.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: address greptile review feedback on PR #3708

Use BedrockRuntimeClientConfig from SDK instead of inline type.
Add default return value for prepareToolsWithUsageControl mock.

Signed-off-by: majiayu000 <1835304752@qq.com>

* feat(providers): server-side credential hiding for Azure and Bedrock

* fix(providers): revert Bedrock credential fields to required with original placeholders

* fix(blocks): add hideWhenEnvSet to getProviderCredentialSubBlocks for Azure and Bedrock

* fix(agent): use getProviderCredentialSubBlocks() instead of duplicating credential subblocks

* fix(blocks): consolidate Vertex credential into shared factory with basic/advanced mode

* fix(types): resolve pre-existing TypeScript errors across auth, secrets, and copilot

* lint

* improvement(blocks): make Vertex AI project ID a password field

* fix(blocks): preserve vertexCredential subblock ID for backwards compatibility

* fix(blocks): follow canonicalParamId pattern correctly for vertex credential subblocks

* fix(blocks): keep vertexCredential subblock ID stable to preserve saved workflow state

* fix(blocks): add canonicalParamId to vertexCredential basic subblock to complete the swap pair

* fix types

* more types

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-04-01 16:27:54 -07:00
Waleed
076c835ba2 improvement(credentials): consolidate OAuth modals and auto-fill credential name (#3887)
* improvement(credentials): consolidate OAuth modals and auto-fill credential name

* fix(credentials): context-aware subtitle for KB vs workflow
2026-04-01 15:48:49 -07:00
Vikhyath Mondreti
df6ceb61a4 fix(envvar): remove dead env var 2026-04-01 14:01:13 -07:00
Vikhyath Mondreti
2ede12aa0e fix(cost): worker crash incremenental case (#3885) 2026-04-01 11:42:19 -07:00
Waleed
42fb434354 fix(encryption): specify authTagLength on all AES-GCM cipher/decipher calls (#3883)
* fix: specify authTagLength in AES-GCM decipheriv calls

Fixes missing authTagLength parameter in createDecipheriv calls using
AES-256-GCM mode. Without explicit tag length specification, the
application may be tricked into accepting shorter authentication tags,
potentially allowing ciphertext spoofing.

CWE-310: Cryptographic Issues (gcm-no-tag-length)

* fix: specify authTagLength on createCipheriv calls for AES-GCM consistency

Complements #3881 by adding explicit authTagLength: 16 to the encrypt
side as well, ensuring both cipher and decipher specify the tag length.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: clean up crypto modules

- Fix error: any → error: unknown with proper type guard in encryption.ts
- Eliminate duplicate iv.toString('hex') calls in both encrypt functions
- Remove redundant string split in decryptApiKey (was splitting twice)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* new turborepo version

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: NLmejiro <kuroda.k1021@gmail.com>
2026-04-01 10:46:58 -07:00
304 changed files with 37148 additions and 4226 deletions

View File

@@ -192,7 +192,7 @@ In the block config (`blocks/blocks/{service}.ts`), add `hideWhenHosted: true` t
},
```
The visibility is controlled by `isSubBlockHiddenByHostedKey()` in `lib/workflows/subblocks/visibility.ts`, which checks the `isHosted` feature flag.
The visibility is controlled by `isSubBlockHidden()` in `lib/workflows/subblocks/visibility.ts`, which checks both the `isHosted` feature flag (`hideWhenHosted`) and optional env var conditions (`hideWhenEnvSet`).
### Excluding Specific Operations from Hosted Key Support

File diff suppressed because one or more lines are too long

View File

@@ -139,6 +139,7 @@ import {
ResendIcon,
RevenueCatIcon,
RipplingIcon,
RootlyIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -320,6 +321,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
rootly: RootlyIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,

View File

@@ -131,7 +131,7 @@ Erkennt personenbezogene Daten mithilfe von Microsoft Presidio. Unterstützt üb
**Anwendungsfälle:**
- Blockieren von Inhalten mit sensiblen persönlichen Informationen
- Maskieren von personenbezogenen Daten vor der Protokollierung oder Speicherung
- Einhaltung der DSGVO, HIPAA und anderer Datenschutzbestimmungen
- Einhaltung der DSGVO und anderer Datenschutzbestimmungen
- Bereinigung von Benutzereingaben vor der Verarbeitung
## Konfiguration

View File

@@ -132,7 +132,7 @@ Detects personally identifiable information using Microsoft Presidio. Supports o
**Use Cases:**
- Block content containing sensitive personal information
- Mask PII before logging or storing data
- Compliance with GDPR, HIPAA, and other privacy regulations
- Compliance with GDPR and other privacy regulations
- Sanitize user inputs before processing
## Configuration

View File

@@ -0,0 +1,206 @@
---
title: Google Service Accounts
description: Set up Google service accounts with domain-wide delegation for Gmail, Sheets, Drive, Calendar, and other Google services
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { Image } from '@/components/ui/image'
import { FAQ } from '@/components/ui/faq'
Google service accounts with domain-wide delegation let your workflows access Google APIs on behalf of users in your Google Workspace domain — without requiring each user to complete an OAuth consent flow. This is ideal for automated workflows that need to send emails, read spreadsheets, or manage files across your organization.
For example, you could build a workflow that iterates through a list of employees, impersonates each one to read their Google Docs, and uploads the contents to a shared knowledge base — all without requiring any of those users to sign in.
## Prerequisites
Before adding a service account to Sim, you need to configure it in the Google Cloud Console and Google Workspace Admin Console.
### 1. Create a Service Account in Google Cloud
<Steps>
<Step>
Go to the [Google Cloud Console](https://console.cloud.google.com/) and select your project (or create one)
</Step>
<Step>
Navigate to **IAM & Admin** → **Service Accounts**
</Step>
<Step>
Click **Create Service Account**, give it a name and description, then click **Create and Continue**
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-create-service-account.png"
alt="Google Cloud Console — Create service account form"
width={700}
height={500}
className="my-4"
/>
</div>
</Step>
<Step>
Skip the optional role and user access steps and click **Done**
</Step>
<Step>
Click on the newly created service account, go to the **Keys** tab, and click **Add Key** → **Create new key**
</Step>
<Step>
Select **JSON** as the key type and click **Create**. A JSON key file will download — keep this safe
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-create-private-key.png"
alt="Google Cloud Console — Create private key dialog with JSON selected"
width={700}
height={400}
className="my-4"
/>
</div>
</Step>
</Steps>
<Callout type="warn">
The JSON key file contains your service account's private key. Treat it like a password — do not commit it to source control or share it publicly.
</Callout>
### 2. Enable the Required APIs
In the Google Cloud Console, go to **APIs & Services** → **Library** and enable the APIs for the services your workflows will use. See the [scopes reference](#scopes-reference) below for the full list of APIs by service.
### 3. Set Up Domain-Wide Delegation
<Steps>
<Step>
In the Google Cloud Console, go to **IAM & Admin** → **Service Accounts**, click on your service account, and copy the **Client ID** (the numeric ID, not the email)
</Step>
<Step>
Open the [Google Workspace Admin Console](https://admin.google.com/) and navigate to **Security** → **Access and data control** → **API controls**
</Step>
<Step>
Click **Manage Domain Wide Delegation**, then click **Add new**
</Step>
<Step>
Paste the **Client ID** from your service account, then add the OAuth scopes for the services your workflows need. Copy the full scope URLs from the [scopes reference](#scopes-reference) below — only authorize scopes for services you plan to use.
<div className="flex justify-center">
<Image
src="/static/credentials/gcp-add-client-id.png"
alt="Google Workspace Admin Console — Add a new client ID with OAuth scopes"
width={350}
height={300}
className="my-4"
/>
</div>
</Step>
<Step>
Click **Authorize**
</Step>
</Steps>
<Callout type="info">
Domain-wide delegation must be configured by a Google Workspace admin. If you are not an admin, send the Client ID and required scopes to your admin.
</Callout>
### Scopes Reference
The table below lists every Google service that supports service account authentication in Sim, the API to enable in Google Cloud Console, and the delegation scopes to authorize. Copy the scope string for each service you need and paste it into the Google Workspace Admin Console.
<table>
<thead>
<tr>
<th className="whitespace-nowrap">Service</th>
<th className="whitespace-nowrap">API to Enable</th>
<th>Delegation Scopes</th>
</tr>
</thead>
<tbody>
<tr><td>Gmail</td><td>Gmail API</td><td><code>{'https://www.googleapis.com/auth/gmail.send'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.modify'}</code><br/><code>{'https://www.googleapis.com/auth/gmail.labels'}</code></td></tr>
<tr><td>Google Sheets</td><td>Google Sheets API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Drive</td><td>Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Docs</td><td>Google Docs API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Slides</td><td>Google Slides API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/drive.file'}</code></td></tr>
<tr><td>Google Forms</td><td>Google Forms API, Google Drive API</td><td><code>{'https://www.googleapis.com/auth/drive'}</code><br/><code>{'https://www.googleapis.com/auth/forms.body'}</code><br/><code>{'https://www.googleapis.com/auth/forms.responses.readonly'}</code></td></tr>
<tr><td>Google Calendar</td><td>Google Calendar API</td><td><code>{'https://www.googleapis.com/auth/calendar'}</code></td></tr>
<tr><td>Google Contacts</td><td>People API</td><td><code>{'https://www.googleapis.com/auth/contacts'}</code></td></tr>
<tr><td>BigQuery</td><td>BigQuery API</td><td><code>{'https://www.googleapis.com/auth/bigquery'}</code></td></tr>
<tr><td>Google Tasks</td><td>Tasks API</td><td><code>{'https://www.googleapis.com/auth/tasks'}</code></td></tr>
<tr><td>Google Vault</td><td>Vault API, Cloud Storage API</td><td><code>{'https://www.googleapis.com/auth/ediscovery'}</code><br/><code>{'https://www.googleapis.com/auth/devstorage.read_only'}</code></td></tr>
<tr><td>Google Groups</td><td>Admin SDK API</td><td><code>{'https://www.googleapis.com/auth/admin.directory.group'}</code><br/><code>{'https://www.googleapis.com/auth/admin.directory.group.member'}</code></td></tr>
<tr><td>Google Meet</td><td>Google Meet API</td><td><code>{'https://www.googleapis.com/auth/meetings.space.created'}</code><br/><code>{'https://www.googleapis.com/auth/meetings.space.readonly'}</code></td></tr>
</tbody>
</table>
<Callout type="info">
You only need to enable APIs and authorize scopes for the services you plan to use. When authorizing multiple services, combine their scope strings with commas into a single entry in the Admin Console.
</Callout>
## Adding the Service Account to Sim
Once Google Cloud and Workspace are configured, add the service account as a credential in Sim.
<Steps>
<Step>
Open your workspace **Settings** and go to the **Integrations** tab
</Step>
<Step>
Search for "Google Service Account" and click **Connect**
<div className="flex justify-center">
<Image
src="/static/credentials/integrations-service-account.png"
alt="Integrations page showing Google Service Account"
width={800}
height={150}
className="my-4"
/>
</div>
</Step>
<Step>
Paste the full contents of your JSON key file into the text area
<div className="flex justify-center">
<Image
src="/static/credentials/add-service-account.png"
alt="Add Google Service Account dialog"
width={350}
height={420}
className="my-6"
/>
</div>
</Step>
<Step>
Give the credential a display name (the service account email is used by default)
</Step>
<Step>
Click **Save**
</Step>
</Steps>
The JSON key file is validated for the required fields (`type`, `client_email`, `private_key`, `project_id`) and encrypted before being stored.
## Using Delegated Access in Workflows
When you use a Google block (Gmail, Sheets, Drive, etc.) in a workflow and select a service account credential, an **Impersonate User Email** field appears below the credential selector.
Enter the email address of the Google Workspace user you want the service account to act as. For example, if you enter `alice@yourcompany.com`, the workflow will send emails from Alice's account, read her spreadsheets, or access her calendar — depending on the scopes you authorized.
<div className="flex justify-center">
<Image
src="/static/credentials/workflow-impersonated-account.png"
alt="Gmail block in a workflow showing the Impersonated Account field with a service account credential"
width={800}
height={350}
className="my-4"
/>
</div>
<Callout type="warn">
The impersonated email must belong to a user in the Google Workspace domain where you configured domain-wide delegation. Impersonating external email addresses will fail.
</Callout>
<FAQ items={[
{ question: "Can I use a service account without domain-wide delegation?", answer: "Yes, but it will only be able to access resources owned by the service account itself (e.g., spreadsheets shared directly with the service account email). Without delegation, you cannot impersonate users or access their personal data like Gmail." },
{ question: "What happens if the impersonation email field is left blank?", answer: "The service account will authenticate as itself. This works for accessing shared resources (like a Google Sheet shared with the service account email) but will fail for user-specific APIs like Gmail." },
{ question: "Can I use the same service account for multiple Google services?", answer: "Yes. A single service account can be used across Gmail, Sheets, Drive, Calendar, and other Google services — as long as the required API is enabled in Google Cloud and the corresponding scopes are authorized in the Workspace admin console." },
{ question: "How do I rotate the service account key?", answer: "Create a new JSON key in the Google Cloud Console under your service account's Keys tab, then update the credential in Sim with the new key. Delete the old key from Google Cloud once the new one is working." },
{ question: "Does the impersonated user need a Google Workspace license?", answer: "Yes. Domain-wide delegation only works with users who have a Google Workspace account in the domain. Consumer Gmail accounts (e.g., @gmail.com) cannot be impersonated." },
]} />

View File

@@ -0,0 +1,5 @@
{
"title": "Credentials",
"pages": ["index", "google-service-account"],
"defaultOpen": false
}

View File

@@ -134,6 +134,7 @@
"resend",
"revenuecat",
"rippling",
"rootly",
"s3",
"salesforce",
"search",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,510 @@
---
title: Rootly
description: Manage incidents, alerts, and on-call with Rootly
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="rootly"
color="#6C72C8"
/>
{/* MANUAL-CONTENT-START:intro */}
[Rootly](https://rootly.com/) is an incident management platform that helps teams respond to, mitigate, and learn from incidents — all without leaving Slack or your existing tools. Rootly automates on-call alerting, incident workflows, status page updates, and retrospectives so engineering teams can resolve issues faster and reduce toil.
**Why Rootly?**
- **End-to-End Incident Management:** Create, track, update, and resolve incidents with full lifecycle support — from initial triage through retrospective.
- **On-Call Alerting:** Create and manage alerts with deduplication, routing, and escalation to ensure the right people are notified immediately.
- **Timeline Events:** Add structured timeline events to incidents for clear, auditable incident narratives.
- **Service Catalog:** Maintain a catalog of services and map them to incidents for precise impact tracking.
- **Severity & Prioritization:** Use configurable severity levels to prioritize incidents and drive appropriate response urgency.
- **Retrospectives:** Access post-incident retrospectives to identify root causes, capture learnings, and drive reliability improvements.
**Using Rootly in Sim**
Sim's Rootly integration connects your agentic workflows directly to your Rootly account using an API key. With operations spanning incidents, alerts, services, severities, teams, environments, functionalities, incident types, and retrospectives, you can build powerful incident management automations without writing backend code.
**Key benefits of using Rootly in Sim:**
- **Automated incident creation:** Trigger incident creation from monitoring alerts, customer reports, or anomaly detection workflows with full metadata including severity, services, and teams.
- **Incident lifecycle automation:** Automatically update incident status, add timeline events, and attach mitigation or resolution messages as your response progresses.
- **Alert management:** Create and list alerts with deduplication support to integrate Rootly into your existing monitoring and notification pipelines.
- **Organizational awareness:** Query services, severities, teams, environments, functionalities, and incident types to build context-aware incident workflows.
- **Retrospective insights:** List and filter retrospectives to feed post-incident learnings into continuous improvement workflows.
Whether you're automating incident response, building on-call alerting pipelines, or driving post-incident learning, Rootly in Sim gives you direct, secure access to the Rootly API — no middleware required. Simply configure your API key, select the operation you need, and let Sim handle the rest.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Rootly incident management into workflows. Create and manage incidents, alerts, services, severities, and retrospectives.
## Tools
### `rootly_create_incident`
Create a new incident in Rootly with optional severity, services, and teams.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `title` | string | No | The title of the incident \(auto-generated if not provided\) |
| `summary` | string | No | A summary of the incident |
| `severityId` | string | No | Severity ID to attach to the incident |
| `status` | string | No | Incident status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
| `kind` | string | No | Incident kind \(normal, normal_sub, test, test_sub, example, example_sub, backfilled, scheduled, scheduled_sub\) |
| `serviceIds` | string | No | Comma-separated service IDs to attach |
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
| `incidentTypeIds` | string | No | Comma-separated incident type IDs to attach |
| `functionalityIds` | string | No | Comma-separated functionality IDs to attach |
| `labels` | string | No | Labels as JSON object, e.g. \{"platform":"osx","version":"1.29"\} |
| `private` | boolean | No | Create as a private incident \(cannot be undone\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incident` | object | The created incident |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
### `rootly_get_incident`
Retrieve a single incident by ID from Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incident` | object | The incident details |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
### `rootly_update_incident`
Update an existing incident in Rootly (status, severity, summary, etc.).
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to update |
| `title` | string | No | Updated incident title |
| `summary` | string | No | Updated incident summary |
| `severityId` | string | No | Updated severity ID |
| `status` | string | No | Updated status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
| `kind` | string | No | Incident kind \(normal, normal_sub, test, test_sub, example, example_sub, backfilled, scheduled, scheduled_sub\) |
| `private` | boolean | No | Set incident as private \(cannot be undone\) |
| `serviceIds` | string | No | Comma-separated service IDs |
| `environmentIds` | string | No | Comma-separated environment IDs |
| `groupIds` | string | No | Comma-separated team/group IDs |
| `incidentTypeIds` | string | No | Comma-separated incident type IDs to attach |
| `functionalityIds` | string | No | Comma-separated functionality IDs to attach |
| `labels` | string | No | Labels as JSON object, e.g. \{"platform":"osx","version":"1.29"\} |
| `mitigationMessage` | string | No | How was the incident mitigated? |
| `resolutionMessage` | string | No | How was the incident resolved? |
| `cancellationMessage` | string | No | Why was the incident cancelled? |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incident` | object | The updated incident |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
### `rootly_list_incidents`
List incidents from Rootly with optional filtering by status, severity, and more.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `status` | string | No | Filter by status \(in_triage, started, detected, acknowledged, mitigated, resolved, closed, cancelled, scheduled, in_progress, completed\) |
| `severity` | string | No | Filter by severity slug |
| `search` | string | No | Search term to filter incidents |
| `services` | string | No | Filter by service slugs \(comma-separated\) |
| `teams` | string | No | Filter by team slugs \(comma-separated\) |
| `environments` | string | No | Filter by environment slugs \(comma-separated\) |
| `sort` | string | No | Sort order \(e.g., -created_at, created_at, -started_at\) |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incidents` | array | List of incidents |
| ↳ `id` | string | Unique incident ID |
| ↳ `sequentialId` | number | Sequential incident number |
| ↳ `title` | string | Incident title |
| ↳ `slug` | string | Incident slug |
| ↳ `kind` | string | Incident kind |
| ↳ `summary` | string | Incident summary |
| ↳ `status` | string | Incident status |
| ↳ `private` | boolean | Whether the incident is private |
| ↳ `url` | string | URL to the incident |
| ↳ `shortUrl` | string | Short URL to the incident |
| ↳ `severityName` | string | Severity name |
| ↳ `severityId` | string | Severity ID |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| ↳ `startedAt` | string | Start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `closedAt` | string | Closed date |
| `totalCount` | number | Total number of incidents returned |
### `rootly_create_alert`
Create a new alert in Rootly for on-call notification and routing.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `summary` | string | Yes | The summary of the alert |
| `description` | string | No | A detailed description of the alert |
| `source` | string | Yes | The source of the alert \(e.g., api, manual, datadog, pagerduty\) |
| `status` | string | No | Alert status on creation \(open, triggered\) |
| `serviceIds` | string | No | Comma-separated service IDs to attach |
| `groupIds` | string | No | Comma-separated team/group IDs to attach |
| `environmentIds` | string | No | Comma-separated environment IDs to attach |
| `externalId` | string | No | External ID for the alert |
| `externalUrl` | string | No | External URL for the alert |
| `deduplicationKey` | string | No | Alerts sharing the same deduplication key are treated as a single alert |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alert` | object | The created alert |
| ↳ `id` | string | Unique alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
### `rootly_list_alerts`
List alerts from Rootly with optional filtering by status, source, and services.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `status` | string | No | Filter by status \(open, triggered, acknowledged, resolved\) |
| `source` | string | No | Filter by source \(e.g., api, datadog, pagerduty\) |
| `services` | string | No | Filter by service slugs \(comma-separated\) |
| `environments` | string | No | Filter by environment slugs \(comma-separated\) |
| `groups` | string | No | Filter by team/group slugs \(comma-separated\) |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `alerts` | array | List of alerts |
| ↳ `id` | string | Unique alert ID |
| ↳ `summary` | string | Alert summary |
| ↳ `description` | string | Alert description |
| ↳ `source` | string | Alert source |
| ↳ `status` | string | Alert status |
| ↳ `externalId` | string | External ID |
| ↳ `externalUrl` | string | External URL |
| ↳ `deduplicationKey` | string | Deduplication key |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of alerts returned |
### `rootly_add_incident_event`
Add a timeline event to an existing incident in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `incidentId` | string | Yes | The ID of the incident to add the event to |
| `event` | string | Yes | The summary/description of the event |
| `visibility` | string | No | Event visibility \(internal or external\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventId` | string | The ID of the created event |
| `event` | string | The event summary |
| `visibility` | string | Event visibility \(internal or external\) |
| `occurredAt` | string | When the event occurred |
| `createdAt` | string | Creation date |
| `updatedAt` | string | Last update date |
### `rootly_list_services`
List services from Rootly with optional search filtering.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter services |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `services` | array | List of services |
| ↳ `id` | string | Unique service ID |
| ↳ `name` | string | Service name |
| ↳ `slug` | string | Service slug |
| ↳ `description` | string | Service description |
| ↳ `color` | string | Service color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of services returned |
### `rootly_list_severities`
List severity levels configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter severities |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `severities` | array | List of severity levels |
| ↳ `id` | string | Unique severity ID |
| ↳ `name` | string | Severity name |
| ↳ `slug` | string | Severity slug |
| ↳ `description` | string | Severity description |
| ↳ `severity` | string | Severity level \(critical, high, medium, low\) |
| ↳ `color` | string | Severity color |
| ↳ `position` | number | Display position |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of severities returned |
### `rootly_list_teams`
List teams (groups) configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter teams |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `teams` | array | List of teams |
| ↳ `id` | string | Unique team ID |
| ↳ `name` | string | Team name |
| ↳ `slug` | string | Team slug |
| ↳ `description` | string | Team description |
| ↳ `color` | string | Team color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of teams returned |
### `rootly_list_environments`
List environments configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter environments |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `environments` | array | List of environments |
| ↳ `id` | string | Unique environment ID |
| ↳ `name` | string | Environment name |
| ↳ `slug` | string | Environment slug |
| ↳ `description` | string | Environment description |
| ↳ `color` | string | Environment color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of environments returned |
### `rootly_list_incident_types`
List incident types configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Filter incident types by name |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `incidentTypes` | array | List of incident types |
| ↳ `id` | string | Unique incident type ID |
| ↳ `name` | string | Incident type name |
| ↳ `slug` | string | Incident type slug |
| ↳ `description` | string | Incident type description |
| ↳ `color` | string | Incident type color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of incident types returned |
### `rootly_list_functionalities`
List functionalities configured in Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `search` | string | No | Search term to filter functionalities |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `functionalities` | array | List of functionalities |
| ↳ `id` | string | Unique functionality ID |
| ↳ `name` | string | Functionality name |
| ↳ `slug` | string | Functionality slug |
| ↳ `description` | string | Functionality description |
| ↳ `color` | string | Functionality color |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of functionalities returned |
### `rootly_list_retrospectives`
List incident retrospectives (post-mortems) from Rootly.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Rootly API key |
| `status` | string | No | Filter by status \(draft, published\) |
| `search` | string | No | Search term to filter retrospectives |
| `pageSize` | number | No | Number of items per page \(default: 20\) |
| `pageNumber` | number | No | Page number for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `retrospectives` | array | List of retrospectives |
| ↳ `id` | string | Unique retrospective ID |
| ↳ `title` | string | Retrospective title |
| ↳ `status` | string | Status \(draft or published\) |
| ↳ `url` | string | URL to the retrospective |
| ↳ `startedAt` | string | Incident start date |
| ↳ `mitigatedAt` | string | Mitigation date |
| ↳ `resolvedAt` | string | Resolution date |
| ↳ `createdAt` | string | Creation date |
| ↳ `updatedAt` | string | Last update date |
| `totalCount` | number | Total number of retrospectives returned |

View File

@@ -10,6 +10,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#2E2D2D"
/>
{/* MANUAL-CONTENT-START:intro */}
## Overview
[Tailscale](https://tailscale.com) is a zero-config mesh VPN built on WireGuard that makes it easy to connect devices, services, and users across any network. The Tailscale block lets you automate network management tasks like device provisioning, access control, route management, and DNS configuration directly from your Sim workflows.
@@ -39,6 +40,14 @@ Every operation requires a **tailnet** parameter. This is typically your organiz
- **Key lifecycle**: Create, list, inspect, and revoke auth keys
- **User auditing**: List all users in the tailnet and their roles
- **Policy review**: Retrieve the current ACL policy for inspection or backup
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Interact with the Tailscale API to manage devices, DNS, ACLs, auth keys, users, and routes across your tailnet.
## Tools
@@ -100,8 +109,6 @@ Get details of a specific device by ID
| `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections |
| `lastSeen` | string | Last seen timestamp |
| `created` | string | Creation timestamp |
| `enabledRoutes` | array | Approved subnet routes |
| `advertisedRoutes` | array | Requested subnet routes |
| `isExternal` | boolean | Whether the device is external |
| `updateAvailable` | boolean | Whether an update is available |
| `machineKey` | string | Machine key |
@@ -263,6 +270,7 @@ Set the DNS nameservers for the tailnet
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dns` | array | Updated list of DNS nameserver addresses |
| `magicDNS` | boolean | Whether MagicDNS is enabled |
### `tailscale_get_dns_preferences`
@@ -375,7 +383,7 @@ Create a new auth key for the tailnet to pre-authorize devices
| `reusable` | boolean | No | Whether the key can be used more than once |
| `ephemeral` | boolean | No | Whether devices authenticated with this key are ephemeral |
| `preauthorized` | boolean | No | Whether devices are pre-authorized \(skip manual approval\) |
| `tags` | string | Yes | Comma-separated list of tags for devices using this key \(e.g., "tag:server,tag:prod"\) |
| `tags` | string | No | Comma-separated list of tags for devices using this key \(e.g., "tag:server,tag:prod"\) |
| `description` | string | No | Description for the auth key |
| `expirySeconds` | number | No | Key expiry time in seconds \(default: 90 days\) |

View File

@@ -131,7 +131,7 @@ Detecta información de identificación personal utilizando Microsoft Presidio.
**Casos de uso:**
- Bloquear contenido que contiene información personal sensible
- Enmascarar PII antes de registrar o almacenar datos
- Cumplimiento de GDPR, HIPAA y otras regulaciones de privacidad
- Cumplimiento de GDPR y otras regulaciones de privacidad
- Sanear entradas de usuario antes del procesamiento
## Configuración

View File

@@ -131,7 +131,7 @@ Détecte les informations personnelles identifiables à l'aide de Microsoft Pres
**Cas d'utilisation :**
- Bloquer le contenu contenant des informations personnelles sensibles
- Masquer les PII avant de journaliser ou stocker des données
- Conformité avec le RGPD, HIPAA et autres réglementations sur la confidentialité
- Conformité avec le RGPD et autres réglementations sur la confidentialité
- Assainir les entrées utilisateur avant traitement
## Configuration

View File

@@ -131,7 +131,7 @@ Microsoft Presidioを使用して個人を特定できる情報を検出しま
**ユースケース:**
- 機密性の高い個人情報を含むコンテンツをブロック
- データのログ記録や保存前にPIIをマスク
- GDPR、HIPAA、その他のプライバシー規制への準拠
- GDPR、その他のプライバシー規制への準拠
- 処理前のユーザー入力のサニタイズ
## 設定

View File

@@ -131,7 +131,7 @@ Guardrails 模块通过针对多种验证类型检查内容,验证并保护您
**使用场景:**
- 阻止包含敏感个人信息的内容
- 在记录或存储数据之前屏蔽 PII
- 符合 GDPR、HIPAA 和其他隐私法规
- 符合 GDPR 和其他隐私法规
- 在处理之前清理用户输入
## 配置

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -29,6 +29,14 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
# AZURE_OPENAI_API_VERSION= # Azure OpenAI API version
# AZURE_ANTHROPIC_ENDPOINT= # Azure Anthropic endpoint (AI Foundry)
# AZURE_ANTHROPIC_API_KEY= # Azure Anthropic API key
# AZURE_ANTHROPIC_API_VERSION= # Azure Anthropic API version (e.g., 2023-06-01)
# NEXT_PUBLIC_AZURE_CONFIGURED=true # Set when Azure credentials are pre-configured above. Hides endpoint/key/version fields in Agent block UI.
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.

View File

@@ -4,11 +4,11 @@
* SEO:
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
* - `<h2 id="enterprise-heading">` for the section title.
* - Compliance certs (SOC 2, HIPAA) as visible `<strong>` text.
* - Compliance cert (SOC 2) as visible `<strong>` text.
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
*
* GEO:
* - Entity-rich: "Sim is SOC 2 and HIPAA compliant" — not "We are compliant."
* - Entity-rich: "Sim is SOC 2 compliant" — not "We are compliant."
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
* as an atomic answer block for "What enterprise features does Sim offer?".
*/
@@ -66,7 +66,7 @@ const FEATURE_TAGS = [
function TrustStrip() {
return (
<div className='mx-6 mt-4 grid grid-cols-1 overflow-hidden rounded-lg border border-[var(--landing-bg-elevated)] sm:grid-cols-3 md:mx-8'>
{/* SOC 2 + HIPAA combined */}
{/* SOC 2 */}
<Link
href='https://app.vanta.com/sim.ai/trust/v35ia0jil4l7dteqjgaktn'
target='_blank'
@@ -83,10 +83,10 @@ function TrustStrip() {
/>
<div className='flex flex-col gap-[3px]'>
<strong className='font-[430] font-season text-small text-white leading-none'>
SOC 2 & HIPAA
SOC 2
</strong>
<span className='font-[430] font-season text-[color-mix(in_srgb,var(--landing-text-subtle)_55%,transparent)] text-xs leading-none tracking-[0.02em] transition-colors group-hover:text-[color-mix(in_srgb,var(--landing-text-subtle)_75%,transparent)]'>
Type II · PHI protected
Type II
</span>
</div>
</Link>

View File

@@ -26,6 +26,7 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
// { label: 'Templates', href: '/templates' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Models', href: '/models' },
// { label: 'Academy', href: '/academy' },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },

View File

@@ -42,7 +42,7 @@ export default function Hero() {
1,000+ integrations and LLMs including OpenAI, Claude, Gemini, Mistral, and xAI to
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
HIPAA compliant.
SOC2 compliant.
</p>
<div

View File

@@ -0,0 +1,157 @@
import { File } from '@/components/emcn/icons'
import { DocxIcon, PdfIcon } from '@/components/icons/document-icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import {
LandingPreviewResource,
ownerCell,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
/** Generic audio/zip icon using basic SVG since no dedicated component exists */
function AudioIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M9 18V5l12-2v13' />
<circle cx='6' cy='18' r='3' />
<circle cx='18' cy='16' r='3' />
</svg>
)
}
function JsonlIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
function ZipIcon({ className }: { className?: string }) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
className={className}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M10 6h1' />
<path d='M10 10h1' />
<path d='M10 14h1' />
<path d='M9 18h2v2h-2z' />
</svg>
)
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'size', header: 'Size' },
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'Q1 Performance Report.pdf' },
size: { label: '2.4 MB' },
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
created: { label: '3 hours ago' },
owner: ownerCell('T', 'Theo L.'),
},
},
{
id: '2',
cells: {
name: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'product-screenshots.zip' },
size: { label: '18.7 MB' },
type: { icon: <ZipIcon className='h-[14px] w-[14px]' />, label: 'ZIP' },
created: { label: '1 day ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'training-dataset.jsonl' },
size: { label: '892 KB' },
type: { icon: <JsonlIcon className='h-[14px] w-[14px]' />, label: 'JSONL' },
created: { label: '3 days ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '4',
cells: {
name: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'brand-guidelines.pdf' },
size: { label: '5.1 MB' },
type: { icon: <PdfIcon className='h-[14px] w-[14px]' />, label: 'PDF' },
created: { label: '1 week ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '5',
cells: {
name: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'customer-interviews.mp3' },
size: { label: '45.2 MB' },
type: { icon: <AudioIcon className='h-[14px] w-[14px]' />, label: 'Audio' },
created: { label: 'March 20th, 2026' },
owner: ownerCell('V', 'Vik M.'),
},
},
{
id: '6',
cells: {
name: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'onboarding-playbook.docx' },
size: { label: '1.1 MB' },
type: { icon: <DocxIcon className='h-[14px] w-[14px]' />, label: 'DOCX' },
created: { label: 'March 14th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
/**
* Static landing preview of the Files workspace page.
*/
export function LandingPreviewFiles() {
return (
<LandingPreviewResource
icon={File}
title='Files'
createLabel='Upload file'
searchPlaceholder='Search files...'
columns={COLUMNS}
rows={ROWS}
/>
)
}

View File

@@ -0,0 +1,105 @@
import { Database } from '@/components/emcn/icons'
import {
AirtableIcon,
AsanaIcon,
ConfluenceIcon,
GoogleDocsIcon,
GoogleDriveIcon,
JiraIcon,
SalesforceIcon,
SlackIcon,
ZendeskIcon,
} from '@/components/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const DB_ICON = <Database className='h-[14px] w-[14px]' />
function connectorIcons(icons: React.ComponentType<{ className?: string }>[]) {
return {
content: (
<div className='flex items-center gap-1'>
{icons.map((Icon, i) => (
<Icon key={i} className='h-3.5 w-3.5 flex-shrink-0' />
))}
</div>
),
}
}
const COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'connectors', header: 'Connectors' },
{ id: 'created', header: 'Created' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: DB_ICON, label: 'Product Documentation' },
documents: { label: '847' },
tokens: { label: '1,284,392' },
connectors: connectorIcons([AsanaIcon, GoogleDocsIcon]),
created: { label: '2 days ago' },
},
},
{
id: '2',
cells: {
name: { icon: DB_ICON, label: 'Customer Support KB' },
documents: { label: '234' },
tokens: { label: '892,104' },
connectors: connectorIcons([ZendeskIcon, SlackIcon]),
created: { label: '1 week ago' },
},
},
{
id: '3',
cells: {
name: { icon: DB_ICON, label: 'Engineering Wiki' },
documents: { label: '1,203' },
tokens: { label: '2,847,293' },
connectors: connectorIcons([ConfluenceIcon, JiraIcon]),
created: { label: 'March 12th, 2026' },
},
},
{
id: '4',
cells: {
name: { icon: DB_ICON, label: 'Marketing Assets' },
documents: { label: '189' },
tokens: { label: '634,821' },
connectors: connectorIcons([GoogleDriveIcon, AirtableIcon]),
created: { label: 'March 5th, 2026' },
},
},
{
id: '5',
cells: {
name: { icon: DB_ICON, label: 'Sales Playbook' },
documents: { label: '92' },
tokens: { label: '418,570' },
connectors: connectorIcons([SalesforceIcon]),
created: { label: 'February 28th, 2026' },
},
},
]
export function LandingPreviewKnowledge() {
return (
<LandingPreviewResource
icon={Database}
title='Knowledge Base'
createLabel='New base'
searchPlaceholder='Search knowledge bases...'
columns={COLUMNS}
rows={ROWS}
/>
)
}

View File

@@ -0,0 +1,321 @@
'use client'
import { useMemo, useState } from 'react'
import { Download } from 'lucide-react'
import { ArrowUpDown, Badge, Library, ListFilter, Search } from '@/components/emcn'
import type { BadgeProps } from '@/components/emcn/components/badge/badge'
import { cn } from '@/lib/core/utils/cn'
interface LogRow {
id: string
workflowName: string
workflowColor: string
date: string
status: 'completed' | 'error' | 'running'
cost: string
trigger: 'webhook' | 'api' | 'schedule' | 'manual' | 'mcp' | 'chat'
triggerLabel: string
duration: string
}
type BadgeVariant = BadgeProps['variant']
const STATUS_VARIANT: Record<LogRow['status'], BadgeVariant> = {
completed: 'gray',
error: 'red',
running: 'amber',
}
const STATUS_LABELS: Record<LogRow['status'], string> = {
completed: 'Completed',
error: 'Error',
running: 'Running',
}
const TRIGGER_VARIANT: Record<LogRow['trigger'], BadgeVariant> = {
webhook: 'orange',
api: 'blue',
schedule: 'green',
manual: 'gray-secondary',
mcp: 'cyan',
chat: 'purple',
}
const MOCK_LOGS: LogRow[] = [
{
id: '1',
workflowName: 'Customer Onboarding',
workflowColor: '#4f8ef7',
date: 'Apr 1 10:42 AM',
status: 'running',
cost: '-',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '-',
},
{
id: '2',
workflowName: 'Lead Enrichment',
workflowColor: '#33C482',
date: 'Apr 1 09:15 AM',
status: 'error',
cost: '318 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '2.7s',
},
{
id: '3',
workflowName: 'Email Campaign',
workflowColor: '#a855f7',
date: 'Apr 1 08:30 AM',
status: 'completed',
cost: '89 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '0.8s',
},
{
id: '4',
workflowName: 'Data Pipeline',
workflowColor: '#f97316',
date: 'Mar 31 10:14 PM',
status: 'completed',
cost: '241 credits',
trigger: 'webhook',
triggerLabel: 'Webhook',
duration: '4.1s',
},
{
id: '5',
workflowName: 'Invoice Processing',
workflowColor: '#ec4899',
date: 'Mar 31 08:45 PM',
status: 'completed',
cost: '112 credits',
trigger: 'manual',
triggerLabel: 'Manual',
duration: '0.9s',
},
{
id: '6',
workflowName: 'Support Triage',
workflowColor: '#0ea5e9',
date: 'Mar 31 07:22 PM',
status: 'completed',
cost: '197 credits',
trigger: 'api',
triggerLabel: 'API',
duration: '1.6s',
},
{
id: '7',
workflowName: 'Content Moderator',
workflowColor: '#f59e0b',
date: 'Mar 31 06:11 PM',
status: 'error',
cost: '284 credits',
trigger: 'schedule',
triggerLabel: 'Schedule',
duration: '3.2s',
},
]
type SortKey = 'workflowName' | 'date' | 'status' | 'cost' | 'trigger' | 'duration'
const COL_HEADERS: { key: SortKey; label: string }[] = [
{ key: 'workflowName', label: 'Workflow' },
{ key: 'date', label: 'Date' },
{ key: 'status', label: 'Status' },
{ key: 'cost', label: 'Cost' },
{ key: 'trigger', label: 'Trigger' },
{ key: 'duration', label: 'Duration' },
]
export function LandingPreviewLogs() {
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [activeTab, setActiveTab] = useState<'logs' | 'dashboard'>('logs')
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? MOCK_LOGS.filter(
(log) =>
log.workflowName.toLowerCase().includes(q) ||
log.triggerLabel.toLowerCase().includes(q) ||
STATUS_LABELS[log.status].toLowerCase().includes(q)
)
: MOCK_LOGS
if (!sortKey) return filtered
return [...filtered].sort((a, b) => {
const av = sortKey === 'cost' ? a.cost.replace(/\D/g, '') : a[sortKey]
const bv = sortKey === 'cost' ? b.cost.replace(/\D/g, '') : b[sortKey]
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [search, sortKey, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<Library className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<h1 className='font-medium text-[var(--text-body)] text-sm'>Logs</h1>
</div>
<div className='flex items-center gap-1'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Download className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Export
</div>
<button
type='button'
onClick={() => setActiveTab('logs')}
className='rounded-md px-2 py-1 text-caption transition-colors'
style={{
backgroundColor: activeTab === 'logs' ? 'var(--surface-active)' : 'transparent',
color: activeTab === 'logs' ? 'var(--text-body)' : 'var(--text-secondary)',
}}
>
Logs
</button>
<button
type='button'
onClick={() => setActiveTab('dashboard')}
className='rounded-md px-2 py-1 text-caption transition-colors'
style={{
backgroundColor:
activeTab === 'dashboard' ? 'var(--surface-active)' : 'transparent',
color: activeTab === 'dashboard' ? 'var(--text-body)' : 'var(--text-secondary)',
}}
>
Dashboard
</button>
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder='Search logs...'
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSort(sortKey ?? 'workflowName')}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table — uses <table> for pixel-perfect column alignment with headers */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '18%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '18%' }} />
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{COL_HEADERS.map(({ key, label }) => (
<th
key={key}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSort(key)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortKey === key ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'
)}
>
{label}
{sortKey === key && <ArrowUpDown className='h-[10px] w-[10px] opacity-60' />}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((log) => (
<tr
key={log.id}
className='h-[44px] cursor-default transition-colors hover-hover:bg-[var(--surface-3)]'
>
<td className='px-6 align-middle'>
<div className='flex items-center gap-2'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px] border-[1.5px]'
style={{
backgroundColor: log.workflowColor,
borderColor: `${log.workflowColor}60`,
backgroundClip: 'padding-box',
}}
/>
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-caption'>
{log.workflowName}
</span>
</div>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.date}
</td>
<td className='px-6 align-middle'>
<Badge variant={STATUS_VARIANT[log.status]} size='sm' dot>
{STATUS_LABELS[log.status]}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.cost}
</td>
<td className='px-6 align-middle'>
<Badge variant={TRIGGER_VARIANT[log.trigger]} size='sm'>
{log.triggerLabel}
</Badge>
</td>
<td className='px-6 align-middle text-[var(--text-secondary)] text-caption'>
{log.duration}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
'use client'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { ArrowUpDown, ListFilter, Plus, Search } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
export interface PreviewColumn {
id: string
header: string
width?: number
}
export interface PreviewCell {
icon?: ReactNode
label?: string
content?: ReactNode
}
export interface PreviewRow {
id: string
cells: Record<string, PreviewCell>
}
interface LandingPreviewResourceProps {
icon: React.ComponentType<{ className?: string }>
title: string
createLabel: string
searchPlaceholder: string
columns: PreviewColumn[]
rows: PreviewRow[]
onRowClick?: (id: string) => void
}
export function ownerCell(initial: string, name: string): PreviewCell {
return {
icon: (
<span className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{initial}
</span>
),
label: name,
}
}
export function LandingPreviewResource({
icon: Icon,
title,
createLabel,
searchPlaceholder,
columns,
rows,
onRowClick,
}: LandingPreviewResourceProps) {
const [search, setSearch] = useState('')
const [sortColId, setSortColId] = useState<string | null>(null)
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
function handleSortClick(colId: string) {
if (sortColId === colId) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortColId(colId)
setSortDir('asc')
}
}
const sorted = useMemo(() => {
const q = search.toLowerCase()
const filtered = q
? rows.filter((row) =>
Object.values(row.cells).some((cell) => cell.label?.toLowerCase().includes(q))
)
: rows
if (!sortColId) return filtered
return [...filtered].sort((a, b) => {
const av = a.cells[sortColId]?.label ?? ''
const bv = b.cells[sortColId]?.label ?? ''
const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' })
return sortDir === 'asc' ? cmp : -cmp
})
}, [rows, search, sortColId, sortDir])
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Header */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<h1 className='font-medium text-[var(--text-body)] text-sm'>{title}</h1>
</div>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<Plus className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
{createLabel}
</div>
</div>
</div>
{/* Options bar */}
<div className='border-[var(--border)] border-b px-6 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex flex-1 items-center gap-2.5'>
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-icon)]' />
<input
type='text'
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
className='flex-1 bg-transparent text-[var(--text-body)] text-caption outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
<div className='flex items-center gap-1.5'>
<div className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption'>
<ListFilter className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Filter
</div>
<button
type='button'
onClick={() => handleSortClick(sortColId ?? columns[0]?.id)}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
<ArrowUpDown className='mr-1.5 h-[14px] w-[14px] text-[var(--text-icon)]' />
Sort
</button>
</div>
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-hidden'>
<table className='w-full table-fixed text-sm'>
<colgroup>
{columns.map((col, i) => (
<col
key={col.id}
style={i === 0 ? { minWidth: col.width ?? 200 } : { width: col.width ?? 160 }}
/>
))}
</colgroup>
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-6 py-1.5 text-left align-middle font-normal text-caption'
>
<button
type='button'
onClick={() => handleSortClick(col.id)}
className={cn(
'flex items-center gap-1 transition-colors hover-hover:text-[var(--text-secondary)]',
sortColId === col.id
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-muted)]'
)}
>
{col.header}
{sortColId === col.id && (
<ArrowUpDown className='h-[10px] w-[10px] opacity-60' />
)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr
key={row.id}
onClick={() => onRowClick?.(row.id)}
className={cn(
'transition-colors hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer'
)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-6 py-2.5 align-middle'>
{cell?.content ? (
cell.content
) : (
<span
className={cn(
'flex min-w-0 items-center gap-3 font-medium text-sm',
colIdx === 0
? 'text-[var(--text-body)]'
: 'text-[var(--text-secondary)]'
)}
>
{cell?.icon && (
<span className='flex-shrink-0 text-[var(--text-icon)]'>
{cell.icon}
</span>
)}
<span className='truncate'>{cell?.label ?? '—'}</span>
</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { Calendar } from '@/components/emcn/icons'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import { LandingPreviewResource } from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CAL_ICON = <Calendar className='h-[14px] w-[14px]' />
const COLUMNS: PreviewColumn[] = [
{ id: 'task', header: 'Task' },
{ id: 'schedule', header: 'Schedule', width: 240 },
{ id: 'nextRun', header: 'Next Run' },
{ id: 'lastRun', header: 'Last Run' },
]
const ROWS: PreviewRow[] = [
{
id: '1',
cells: {
task: { icon: CAL_ICON, label: 'Sync CRM contacts' },
schedule: { label: 'Recurring, every day at 9:00 AM' },
nextRun: { label: 'Tomorrow' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '2',
cells: {
task: { icon: CAL_ICON, label: 'Generate weekly report' },
schedule: { label: 'Recurring, every Monday at 8:00 AM' },
nextRun: { label: 'In 5 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '3',
cells: {
task: { icon: CAL_ICON, label: 'Clean up stale files' },
schedule: { label: 'Recurring, every Sunday at midnight' },
nextRun: { label: 'In 2 days' },
lastRun: { label: '6 days ago' },
},
},
{
id: '4',
cells: {
task: { icon: CAL_ICON, label: 'Send performance digest' },
schedule: { label: 'Recurring, every Friday at 5:00 PM' },
nextRun: { label: 'In 3 days' },
lastRun: { label: '3 days ago' },
},
},
{
id: '5',
cells: {
task: { icon: CAL_ICON, label: 'Backup production data' },
schedule: { label: 'Recurring, every 4 hours' },
nextRun: { label: 'In 2 hours' },
lastRun: { label: '2 hours ago' },
},
},
{
id: '6',
cells: {
task: { icon: CAL_ICON, label: 'Scrape competitor pricing' },
schedule: { label: 'Recurring, every Tuesday at 6:00 AM' },
nextRun: { label: 'In 6 days' },
lastRun: { label: '1 week ago' },
},
},
]
/**
* Static landing preview of the Scheduled Tasks workspace page.
*/
export function LandingPreviewScheduledTasks() {
return (
<LandingPreviewResource
icon={Calendar}
title='Scheduled Tasks'
createLabel='New scheduled task'
searchPlaceholder='Search scheduled tasks...'
columns={COLUMNS}
rows={ROWS}
/>
)
}

View File

@@ -10,14 +10,25 @@ import {
Settings,
Table,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/workflow-data'
export type SidebarView =
| 'home'
| 'workflow'
| 'tables'
| 'files'
| 'knowledge'
| 'logs'
| 'scheduled-tasks'
interface LandingPreviewSidebarProps {
workflows: PreviewWorkflow[]
activeWorkflowId: string
activeView: 'home' | 'workflow'
activeView: SidebarView
onSelectWorkflow: (id: string) => void
onSelectHome: () => void
onSelectNav: (id: SidebarView) => void
}
/**
@@ -39,7 +50,7 @@ const C = {
const WORKSPACE_NAV = [
{ id: 'tables', label: 'Tables', icon: Table },
{ id: 'files', label: 'Files', icon: File },
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
{ id: 'knowledge', label: 'Knowledge Base', icon: Database },
{ id: 'scheduled-tasks', label: 'Scheduled Tasks', icon: Calendar },
{ id: 'logs', label: 'Logs', icon: Library },
] as const
@@ -49,20 +60,42 @@ const FOOTER_NAV = [
{ id: 'settings', label: 'Settings', icon: Settings },
] as const
function StaticNavItem({
function NavItem({
icon: Icon,
label,
isActive,
onClick,
}: {
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>
label: string
isActive?: boolean
onClick?: () => void
}) {
if (!onClick) {
return (
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
{label}
</span>
</div>
)
}
return (
<div className='pointer-events-none mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2'>
<button
type='button'
onClick={onClick}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isActive && 'bg-[var(--c-active)]'
)}
>
<Icon className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
{label}
</span>
</div>
</button>
)
}
@@ -77,13 +110,16 @@ export function LandingPreviewSidebar({
activeView,
onSelectWorkflow,
onSelectHome,
onSelectNav,
}: LandingPreviewSidebarProps) {
const isHomeActive = activeView === 'home'
return (
<div
className='flex h-full w-[248px] flex-shrink-0 flex-col pt-3'
style={{ backgroundColor: C.SURFACE_1 }}
style={
{ backgroundColor: C.SURFACE_1, '--c-active': C.SURFACE_ACTIVE } as React.CSSProperties
}
>
{/* Workspace Header */}
<div className='flex-shrink-0 px-2.5'>
@@ -116,21 +152,17 @@ export function LandingPreviewSidebar({
<button
type='button'
onClick={onSelectHome}
className='mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors'
style={{ backgroundColor: isHomeActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isHomeActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
className={cn(
'mx-0.5 flex h-[28px] items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[var(--c-active)]',
isHomeActive && 'bg-[var(--c-active)]'
)}
>
<Home className='h-[14px] w-[14px] flex-shrink-0' style={{ color: C.TEXT_ICON }} />
<span className='truncate text-[13px]' style={{ color: C.TEXT_BODY, fontWeight: 450 }}>
Home
</span>
</button>
<StaticNavItem icon={Search} label='Search' />
<NavItem icon={Search} label='Search' />
</div>
{/* Workspace */}
@@ -142,7 +174,13 @@ export function LandingPreviewSidebar({
</div>
<div className='flex flex-col gap-0.5 px-2'>
{WORKSPACE_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
<NavItem
key={item.id}
icon={item.icon}
label={item.label}
isActive={activeView === item.id}
onClick={() => onSelectNav(item.id)}
/>
))}
</div>
</div>
@@ -164,14 +202,10 @@ export function LandingPreviewSidebar({
key={workflow.id}
type='button'
onClick={() => onSelectWorkflow(workflow.id)}
className='group mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors'
style={{ backgroundColor: isActive ? C.SURFACE_ACTIVE : 'transparent' }}
onMouseEnter={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = C.SURFACE_ACTIVE
}}
onMouseLeave={(e) => {
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
className={cn(
'mx-0.5 flex h-[28px] w-full items-center gap-2 rounded-[8px] px-2 transition-colors hover-hover:bg-[#363636]',
isActive && 'bg-[#363636]'
)}
>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px] border-[2.5px]'
@@ -197,7 +231,7 @@ export function LandingPreviewSidebar({
{/* Footer */}
<div className='flex flex-shrink-0 flex-col gap-0.5 px-2 pt-[9px] pb-2'>
{FOOTER_NAV.map((item) => (
<StaticNavItem key={item.id} icon={item.icon} label={item.label} />
<NavItem key={item.id} icon={item.icon} label={item.label} />
))}
</div>
</div>

View File

@@ -0,0 +1,552 @@
'use client'
import { useState } from 'react'
import { Checkbox } from '@/components/emcn'
import {
ChevronDown,
Columns3,
Rows3,
Table,
TypeBoolean,
TypeNumber,
TypeText,
} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type {
PreviewColumn,
PreviewRow,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
import {
LandingPreviewResource,
ownerCell,
} from '@/app/(home)/components/landing-preview/components/landing-preview-resource/landing-preview-resource'
const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
const CELL_CHECKBOX =
'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'
const CELL_HEADER_CHECKBOX =
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
const CELL_CONTENT =
'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small'
const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]'
const LIST_COLUMNS: PreviewColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
]
const TABLE_METAS: Record<string, string> = {
'1': 'Customer Leads',
'2': 'Product Catalog',
'3': 'Campaign Analytics',
'4': 'User Profiles',
'5': 'Invoice Records',
}
const TABLE_ICON = <Table className='h-[14px] w-[14px]' />
const COLUMNS_ICON = <Columns3 className='h-[14px] w-[14px]' />
const ROWS_ICON = <Rows3 className='h-[14px] w-[14px]' />
const LIST_ROWS: PreviewRow[] = [
{
id: '1',
cells: {
name: { icon: TABLE_ICON, label: 'Customer Leads' },
columns: { icon: COLUMNS_ICON, label: '8' },
rows: { icon: ROWS_ICON, label: '2,847' },
created: { label: '2 days ago' },
owner: ownerCell('S', 'Sarah K.'),
},
},
{
id: '2',
cells: {
name: { icon: TABLE_ICON, label: 'Product Catalog' },
columns: { icon: COLUMNS_ICON, label: '12' },
rows: { icon: ROWS_ICON, label: '1,203' },
created: { label: '5 days ago' },
owner: ownerCell('A', 'Alex M.'),
},
},
{
id: '3',
cells: {
name: { icon: TABLE_ICON, label: 'Campaign Analytics' },
columns: { icon: COLUMNS_ICON, label: '6' },
rows: { icon: ROWS_ICON, label: '534' },
created: { label: '1 week ago' },
owner: ownerCell('W', 'Emaan K.'),
},
},
{
id: '4',
cells: {
name: { icon: TABLE_ICON, label: 'User Profiles' },
columns: { icon: COLUMNS_ICON, label: '15' },
rows: { icon: ROWS_ICON, label: '18,492' },
created: { label: '2 weeks ago' },
owner: ownerCell('J', 'Jordan P.'),
},
},
{
id: '5',
cells: {
name: { icon: TABLE_ICON, label: 'Invoice Records' },
columns: { icon: COLUMNS_ICON, label: '9' },
rows: { icon: ROWS_ICON, label: '742' },
created: { label: 'March 15th, 2026' },
owner: ownerCell('S', 'Sarah K.'),
},
},
]
interface SpreadsheetColumn {
id: string
label: string
type: 'text' | 'number' | 'boolean'
width: number
}
interface SpreadsheetRow {
id: string
cells: Record<string, string>
}
const COLUMN_TYPE_ICONS = {
text: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
} as const
const SPREADSHEET_DATA: Record<string, { columns: SpreadsheetColumn[]; rows: SpreadsheetRow[] }> = {
'1': {
columns: [
{ id: 'name', label: 'Name', type: 'text', width: 160 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'company', label: 'Company', type: 'text', width: 160 },
{ id: 'score', label: 'Score', type: 'number', width: 100 },
{ id: 'qualified', label: 'Qualified', type: 'boolean', width: 120 },
],
rows: [
{
id: '1',
cells: {
name: 'Alice Johnson',
email: 'alice@acme.com',
company: 'Acme Corp',
score: '87',
qualified: 'true',
},
},
{
id: '2',
cells: {
name: 'Bob Williams',
email: 'bob@techco.io',
company: 'TechCo',
score: '62',
qualified: 'false',
},
},
{
id: '3',
cells: {
name: 'Carol Davis',
email: 'carol@startup.co',
company: 'StartupCo',
score: '94',
qualified: 'true',
},
},
{
id: '4',
cells: {
name: 'Dan Miller',
email: 'dan@bigcorp.com',
company: 'BigCorp',
score: '71',
qualified: 'true',
},
},
{
id: '5',
cells: {
name: 'Eva Chen',
email: 'eva@design.io',
company: 'Design IO',
score: '45',
qualified: 'false',
},
},
{
id: '6',
cells: {
name: 'Frank Lee',
email: 'frank@ventures.co',
company: 'Ventures',
score: '88',
qualified: 'true',
},
},
],
},
'2': {
columns: [
{ id: 'sku', label: 'SKU', type: 'text', width: 120 },
{ id: 'name', label: 'Product Name', type: 'text', width: 200 },
{ id: 'price', label: 'Price', type: 'number', width: 100 },
{ id: 'stock', label: 'In Stock', type: 'number', width: 120 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
sku: 'PRD-001',
name: 'Wireless Headphones',
price: '79.99',
stock: '234',
active: 'true',
},
},
{
id: '2',
cells: { sku: 'PRD-002', name: 'USB-C Hub', price: '49.99', stock: '89', active: 'true' },
},
{
id: '3',
cells: {
sku: 'PRD-003',
name: 'Laptop Stand',
price: '39.99',
stock: '0',
active: 'false',
},
},
{
id: '4',
cells: {
sku: 'PRD-004',
name: 'Mechanical Keyboard',
price: '129.99',
stock: '52',
active: 'true',
},
},
{
id: '5',
cells: { sku: 'PRD-005', name: 'Webcam HD', price: '89.99', stock: '17', active: 'true' },
},
{
id: '6',
cells: {
sku: 'PRD-006',
name: 'Mouse Pad XL',
price: '24.99',
stock: '0',
active: 'false',
},
},
],
},
'3': {
columns: [
{ id: 'campaign', label: 'Campaign', type: 'text', width: 180 },
{ id: 'clicks', label: 'Clicks', type: 'number', width: 100 },
{ id: 'conversions', label: 'Conversions', type: 'number', width: 140 },
{ id: 'spend', label: 'Spend ($)', type: 'number', width: 130 },
{ id: 'active', label: 'Active', type: 'boolean', width: 90 },
],
rows: [
{
id: '1',
cells: {
campaign: 'Spring Sale 2026',
clicks: '12,847',
conversions: '384',
spend: '2,400',
active: 'true',
},
},
{
id: '2',
cells: {
campaign: 'Email Reactivation',
clicks: '3,201',
conversions: '97',
spend: '450',
active: 'false',
},
},
{
id: '3',
cells: {
campaign: 'Referral Program',
clicks: '8,923',
conversions: '210',
spend: '1,100',
active: 'true',
},
},
{
id: '4',
cells: {
campaign: 'Product Launch',
clicks: '24,503',
conversions: '891',
spend: '5,800',
active: 'true',
},
},
{
id: '5',
cells: {
campaign: 'Retargeting Q1',
clicks: '6,712',
conversions: '143',
spend: '980',
active: 'false',
},
},
],
},
'4': {
columns: [
{ id: 'username', label: 'Username', type: 'text', width: 140 },
{ id: 'email', label: 'Email', type: 'text', width: 200 },
{ id: 'plan', label: 'Plan', type: 'text', width: 120 },
{ id: 'seats', label: 'Seats', type: 'number', width: 100 },
{ id: 'active', label: 'Active', type: 'boolean', width: 100 },
],
rows: [
{
id: '1',
cells: {
username: 'alice_j',
email: 'alice@acme.com',
plan: 'Pro',
seats: '5',
active: 'true',
},
},
{
id: '2',
cells: {
username: 'bobw',
email: 'bob@techco.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '3',
cells: {
username: 'carol_d',
email: 'carol@startup.co',
plan: 'Enterprise',
seats: '25',
active: 'true',
},
},
{
id: '4',
cells: {
username: 'dan.m',
email: 'dan@bigcorp.com',
plan: 'Pro',
seats: '10',
active: 'false',
},
},
{
id: '5',
cells: {
username: 'eva_chen',
email: 'eva@design.io',
plan: 'Starter',
seats: '1',
active: 'true',
},
},
{
id: '6',
cells: {
username: 'frank_lee',
email: 'frank@ventures.co',
plan: 'Enterprise',
seats: '50',
active: 'true',
},
},
],
},
'5': {
columns: [
{ id: 'invoice', label: 'Invoice #', type: 'text', width: 140 },
{ id: 'client', label: 'Client', type: 'text', width: 160 },
{ id: 'amount', label: 'Amount ($)', type: 'number', width: 130 },
{ id: 'paid', label: 'Paid', type: 'boolean', width: 80 },
],
rows: [
{
id: '1',
cells: { invoice: 'INV-2026-001', client: 'Acme Corp', amount: '4,800.00', paid: 'true' },
},
{
id: '2',
cells: { invoice: 'INV-2026-002', client: 'TechCo', amount: '1,200.00', paid: 'true' },
},
{
id: '3',
cells: { invoice: 'INV-2026-003', client: 'StartupCo', amount: '750.00', paid: 'false' },
},
{
id: '4',
cells: { invoice: 'INV-2026-004', client: 'BigCorp', amount: '12,500.00', paid: 'true' },
},
{
id: '5',
cells: { invoice: 'INV-2026-005', client: 'Design IO', amount: '3,300.00', paid: 'false' },
},
],
},
}
interface SpreadsheetViewProps {
tableId: string
tableName: string
onBack: () => void
}
function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) {
const data = SPREADSHEET_DATA[tableId] ?? SPREADSHEET_DATA['1']
const [selectedCell, setSelectedCell] = useState<{ row: string; col: string } | null>(null)
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
{/* Breadcrumb header — matches real ResourceHeader breadcrumb layout */}
<div className='border-[var(--border)] border-b px-4 py-[8.5px]'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={onBack}
className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-secondary)] text-sm transition-colors hover-hover:text-[var(--text-body)]'
>
<Table className='mr-3 h-[14px] w-[14px] text-[var(--text-icon)]' />
Tables
</button>
<span className='select-none text-[var(--text-icon)] text-sm'>/</span>
<span className='inline-flex items-center px-2 py-1 font-medium text-[var(--text-body)] text-sm'>
{tableName}
<ChevronDown className='ml-2 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</span>
</div>
</div>
{/* Spreadsheet — matches exact real table editor structure */}
<div className='min-h-0 flex-1 overflow-auto overscroll-none'>
<table className='table-fixed border-separate border-spacing-0 text-small'>
<colgroup>
<col style={{ width: 40 }} />
{data.columns.map((col) => (
<col key={col.id} style={{ width: col.width }} />
))}
</colgroup>
<thead className='sticky top-0 z-10'>
<tr>
<th className={CELL_HEADER_CHECKBOX} />
{data.columns.map((col) => {
const Icon = COLUMN_TYPE_ICONS[col.type] ?? TypeText
return (
<th key={col.id} className={CELL_HEADER}>
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{col.label}
</span>
<ChevronDown className='ml-auto h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{data.rows.map((row, rowIdx) => (
<tr key={row.id}>
<td className={cn(CELL_CHECKBOX, 'text-center')}>
<span className='text-[var(--text-tertiary)] text-xs tabular-nums'>
{rowIdx + 1}
</span>
</td>
{data.columns.map((col) => {
const isSelected = selectedCell?.row === row.id && selectedCell?.col === col.id
const cellValue = row.cells[col.id] ?? ''
return (
<td
key={col.id}
onClick={() => setSelectedCell({ row: row.id, col: col.id })}
className={cn(
CELL,
'relative cursor-default text-[var(--text-body)]',
isSelected && 'bg-[rgba(37,99,235,0.06)]'
)}
>
{isSelected && <div className={SELECTION_OVERLAY} />}
<div className={CELL_CONTENT}>
{col.type === 'boolean' ? (
<div className='flex min-h-[20px] items-center justify-center'>
<Checkbox
size='sm'
checked={cellValue === 'true'}
className='pointer-events-none'
/>
</div>
) : (
cellValue
)}
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export function LandingPreviewTables() {
const [openTableId, setOpenTableId] = useState<string | null>(null)
if (openTableId !== null) {
return (
<SpreadsheetView
tableId={openTableId}
tableName={TABLE_METAS[openTableId] ?? 'Table'}
onBack={() => setOpenTableId(null)}
/>
)
}
return (
<LandingPreviewResource
icon={Table}
title='Tables'
createLabel='New table'
searchPlaceholder='Search tables...'
columns={LIST_COLUMNS}
rows={LIST_ROWS}
onRowClick={(id) => setOpenTableId(id)}
/>
)
}

View File

@@ -228,13 +228,13 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
{tools && tools.length > 0 && (
<div className='flex items-center gap-2'>
<span className='flex-shrink-0 font-normal text-[#b3b3b3] text-[14px]'>Tools</span>
<div className='flex flex-1 flex-wrap items-center justify-end gap-2'>
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
{tools.map((tool) => {
const ToolIcon = BLOCK_ICONS[tool.type]
return (
<div
key={tool.type}
className='flex items-center gap-2 rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-2 py-1'
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
>
<div
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'

View File

@@ -127,6 +127,60 @@ const SELF_HEALING_CRM_WORKFLOW: PreviewWorkflow = {
edges: [{ id: 'e-3', source: 'schedule-1', target: 'mothership-1' }],
}
/**
* Customer Support Agent workflow — Gmail Trigger -> Agent (KB + Notion tools) -> Slack
*/
const CUSTOMER_SUPPORT_WORKFLOW: PreviewWorkflow = {
id: 'wf-customer-support',
name: 'Customer Support Agent',
color: '#0EA5E9',
blocks: [
{
id: 'gmail-1',
name: 'Gmail',
type: 'gmail',
bgColor: '#E0E0E0',
rows: [
{ title: 'Event', value: 'New Email' },
{ title: 'Label', value: 'Support' },
],
position: { x: 80, y: 140 },
hideTargetHandle: true,
},
{
id: 'agent-3',
name: 'Support Agent',
type: 'agent',
bgColor: '#701ffc',
rows: [
{ title: 'Model', value: 'gpt-5.4' },
{ title: 'System Prompt', value: 'Resolve customer issues...' },
],
tools: [
{ name: 'Knowledge', type: 'knowledge_base', bgColor: '#10B981' },
{ name: 'Notion', type: 'notion', bgColor: '#181C1E' },
],
position: { x: 420, y: 40 },
},
{
id: 'slack-3',
name: 'Slack',
type: 'slack',
bgColor: '#611f69',
rows: [
{ title: 'Channel', value: '#support' },
{ title: 'Operation', value: 'Send Message' },
],
position: { x: 420, y: 260 },
hideSourceHandle: true,
},
],
edges: [
{ id: 'e-cs-1', source: 'gmail-1', target: 'agent-3' },
{ id: 'e-cs-2', source: 'gmail-1', target: 'slack-3' },
],
}
/**
* Empty "New Agent" workflow — a single note prompting the user to start building
*/
@@ -153,6 +207,7 @@ const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
SELF_HEALING_CRM_WORKFLOW,
IT_SERVICE_WORKFLOW,
CUSTOMER_SUPPORT_WORKFLOW,
NEW_AGENT_WORKFLOW,
]

View File

@@ -2,9 +2,15 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { motion, type Variants } from 'framer-motion'
import { LandingPreviewFiles } from '@/app/(home)/components/landing-preview/components/landing-preview-files/landing-preview-files'
import { LandingPreviewHome } from '@/app/(home)/components/landing-preview/components/landing-preview-home/landing-preview-home'
import { LandingPreviewKnowledge } from '@/app/(home)/components/landing-preview/components/landing-preview-knowledge/landing-preview-knowledge'
import { LandingPreviewLogs } from '@/app/(home)/components/landing-preview/components/landing-preview-logs/landing-preview-logs'
import { LandingPreviewPanel } from '@/app/(home)/components/landing-preview/components/landing-preview-panel/landing-preview-panel'
import { LandingPreviewScheduledTasks } from '@/app/(home)/components/landing-preview/components/landing-preview-scheduled-tasks/landing-preview-scheduled-tasks'
import type { SidebarView } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewSidebar } from '@/app/(home)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar'
import { LandingPreviewTables } from '@/app/(home)/components/landing-preview/components/landing-preview-tables/landing-preview-tables'
import { LandingPreviewWorkflow } from '@/app/(home)/components/landing-preview/components/landing-preview-workflow/landing-preview-workflow'
import {
EASE_OUT,
@@ -46,18 +52,16 @@ const panelVariants: Variants = {
* Interactive workspace preview for the hero section.
*
* Renders a lightweight replica of the Sim workspace with:
* - A sidebar with two selectable workflows
* - A sidebar with selectable workflows and workspace nav items
* - A ReactFlow canvas showing the active workflow's blocks and edges
* - Static previews of Tables, Files, Knowledge Base, Logs, and Scheduled Tasks
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
*
* Everything except the workflow items and the copilot input is non-interactive.
* On mount the sidebar slides from left and the panel from right. The canvas
* background stays fully opaque; individual block nodes animate in with a
* staggered fade. Edges draw left-to-right. Animations only fire on initial
* load — workflow switches render instantly.
* Only workflow items, the home button, workspace nav items, and the copilot input
* are interactive. Animations only fire on initial load.
*/
export function LandingPreview() {
const [activeView, setActiveView] = useState<'home' | 'workflow'>('workflow')
const [activeView, setActiveView] = useState<SidebarView>('workflow')
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
const isInitialMount = useRef(true)
@@ -74,11 +78,34 @@ export function LandingPreview() {
setActiveView('home')
}, [])
const handleSelectNav = useCallback((id: SidebarView) => {
setActiveView(id)
}, [])
const activeWorkflow =
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
const isWorkflowView = activeView === 'workflow'
function renderContent() {
switch (activeView) {
case 'workflow':
return <LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
case 'home':
return <LandingPreviewHome />
case 'tables':
return <LandingPreviewTables />
case 'files':
return <LandingPreviewFiles />
case 'knowledge':
return <LandingPreviewKnowledge />
case 'logs':
return <LandingPreviewLogs />
case 'scheduled-tasks':
return <LandingPreviewScheduledTasks />
}
}
return (
<motion.div
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[var(--landing-bg-surface)] antialiased'
@@ -93,6 +120,7 @@ export function LandingPreview() {
activeView={activeView}
onSelectWorkflow={handleSelectWorkflow}
onSelectHome={handleSelectHome}
onSelectNav={handleSelectNav}
/>
</motion.div>
<div className='flex min-w-0 flex-1 flex-col py-2 pr-2 pl-2 lg:pl-0'>
@@ -104,11 +132,7 @@ export function LandingPreview() {
: 'relative flex min-w-0 flex-1 flex-col overflow-hidden'
}
>
{isWorkflowView ? (
<LandingPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
) : (
<LandingPreviewHome />
)}
{renderContent()}
</div>
<motion.div
className={isWorkflowView ? 'hidden lg:flex' : 'hidden'}

View File

@@ -80,7 +80,7 @@ const PRICING_TIERS: PricingTier[] = [
'Custom execution limits',
'Custom concurrency limits',
'Unlimited log retention',
'SSO & SCIM · SOC2 & HIPAA',
'SSO & SCIM · SOC2',
'Self hosting · Dedicated support',
],
cta: { label: 'Book a demo', action: 'demo-request' },

View File

@@ -93,7 +93,7 @@ export default function StructuredData() {
url: 'https://sim.ai',
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 compliant.',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Web',
browserRequirements: 'Requires a modern browser with JavaScript enabled',
@@ -179,7 +179,7 @@ export default function StructuredData() {
name: 'What is Sim?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 compliant.',
},
},
{
@@ -211,7 +211,7 @@ export default function StructuredData() {
name: 'What enterprise features does Sim offer?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
text: 'Sim offers SOC2 compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
},
},
],

View File

@@ -8,6 +8,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Copy } from '@/components/emcn/icons'
import { LinkedInIcon, xIcon as XIcon } from '@/components/icons'
interface ShareButtonProps {
url: string
@@ -50,10 +52,17 @@ export function ShareButton({ url, title }: ShareButtonProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onSelect={handleCopyLink}>
<Copy className='h-4 w-4' />
{copied ? 'Copied!' : 'Copy link'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareTwitter}>Share on X</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareLinkedIn}>Share on LinkedIn</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareTwitter}>
<XIcon className='h-4 w-4' />
Share on X
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleShareLinkedIn}>
<LinkedInIcon className='h-4 w-4' />
Share on LinkedIn
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,63 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
export interface LandingFAQItem {
question: string
answer: string
}
interface LandingFAQProps {
faqs: LandingFAQItem[]
}
export function LandingFAQ({ faqs }: LandingFAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<div className='divide-y divide-[var(--landing-border)]'>
{faqs.map(({ question, answer }, index) => {
const isOpen = openIndex === index
return (
<div key={question}>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
className='flex w-full items-start justify-between gap-4 py-5 text-left'
aria-expanded={isOpen}
>
<span
className={cn(
'font-[500] text-[15px] leading-snug transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
)}
>
{question}
</span>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
{isOpen && (
<div className='pb-5'>
<p className='text-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
{answer}
</p>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,8 +1,4 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import type { FAQItem } from '@/app/(landing)/integrations/data/types'
interface IntegrationFAQProps {
@@ -10,49 +6,5 @@ interface IntegrationFAQProps {
}
export function IntegrationFAQ({ faqs }: IntegrationFAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return (
<div className='divide-y divide-[var(--landing-border)]'>
{faqs.map(({ question, answer }, index) => {
const isOpen = openIndex === index
return (
<div key={question}>
<button
type='button'
onClick={() => setOpenIndex(isOpen ? null : index)}
className='flex w-full items-start justify-between gap-4 py-5 text-left'
aria-expanded={isOpen}
>
<span
className={cn(
'font-[500] text-[15px] leading-snug transition-colors',
isOpen
? 'text-[var(--landing-text)]'
: 'text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
)}
>
{question}
</span>
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-[#555] transition-transform duration-200',
isOpen ? 'rotate-180' : 'rotate-0'
)}
aria-hidden='true'
/>
</button>
{isOpen && (
<div className='pb-5'>
<p className='text-[14px] text-[var(--landing-text-muted)] leading-[1.75]'>
{answer}
</p>
</div>
)}
</div>
)
})}
</div>
)
return <LandingFAQ faqs={faqs} />
}

View File

@@ -139,6 +139,7 @@ import {
ResendIcon,
RevenueCatIcon,
RipplingIcon,
RootlyIcon,
S3Icon,
SalesforceIcon,
SearchIcon,
@@ -320,6 +321,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
resend: ResendIcon,
revenuecat: RevenueCatIcon,
rippling: RipplingIcon,
rootly: RootlyIcon,
s3: S3Icon,
salesforce: SalesforceIcon,
search: SearchIcon,

View File

@@ -9293,90 +9293,358 @@
"type": "rippling",
"slug": "rippling",
"name": "Rippling",
"description": "Manage employees, leave, departments, and company data in Rippling",
"longDescription": "Integrate Rippling into your workflow. Manage employees, departments, teams, leave requests, work locations, groups, candidates, and company information.",
"description": "Manage workers, departments, custom objects, and company data in Rippling",
"longDescription": "Integrate Rippling Platform into your workflow. Manage workers, users, departments, teams, titles, work locations, business partners, supergroups, custom objects, custom apps, custom pages, custom settings, object categories, reports, and draft hires.",
"bgColor": "#FFCC1C",
"iconName": "RipplingIcon",
"docsUrl": "https://docs.sim.ai/tools/rippling",
"operations": [
{
"name": "List Employees",
"description": "List all employees in Rippling with optional pagination"
"name": "List Workers",
"description": "List all workers with optional filtering and pagination"
},
{
"name": "Get Employee",
"description": "Get details for a specific employee by ID"
"name": "Get Worker",
"description": "Get a specific worker by ID"
},
{
"name": "List Employees (Including Terminated)",
"description": "List all employees in Rippling including terminated employees with optional pagination"
"name": "List Users",
"description": "List all users with optional pagination"
},
{
"name": "List Departments",
"description": "List all departments in the Rippling organization"
"name": "Get User",
"description": "Get a specific user by ID"
},
{
"name": "List Teams",
"description": "List all teams in Rippling"
},
{
"name": "List Levels",
"description": "List all position levels in Rippling"
},
{
"name": "List Work Locations",
"description": "List all work locations in Rippling"
},
{
"name": "Get Company",
"description": "Get details for the current company in Rippling"
},
{
"name": "Get Company Activity",
"description": "Get activity events for the current company in Rippling"
},
{
"name": "List Custom Fields",
"description": "List all custom fields defined in Rippling"
"name": "List Companies",
"description": "List all companies"
},
{
"name": "Get Current User",
"description": "Get the current authenticated user details"
"description": "Get SSO information for the current user"
},
{
"name": "List Leave Requests",
"description": "List leave requests in Rippling with optional filtering by date range and status"
"name": "List Entitlements",
"description": "List all entitlements"
},
{
"name": "Approve/Decline Leave Request",
"description": "Approve or decline a leave request in Rippling"
"name": "List Departments",
"description": "List all departments"
},
{
"name": "List Leave Balances",
"description": "List leave balances for all employees in Rippling"
"name": "Get Department",
"description": "Get a specific department by ID"
},
{
"name": "Get Leave Balance",
"description": "Get leave balance for a specific employee by role ID"
"name": "Create Department",
"description": "Create a new department"
},
{
"name": "List Leave Types",
"description": "List company leave types configured in Rippling"
"name": "Update Department",
"description": "Update an existing department"
},
{
"name": "Create Group",
"description": "Create a new group in Rippling"
"name": "List Teams",
"description": "List all teams"
},
{
"name": "Update Group",
"description": "Update an existing group in Rippling"
"name": "Get Team",
"description": "Get a specific team by ID"
},
{
"name": "Push Candidate",
"description": "Push a candidate to onboarding in Rippling"
"name": "List Employment Types",
"description": "List all employment types"
},
{
"name": "Get Employment Type",
"description": "Get a specific employment type by ID"
},
{
"name": "List Titles",
"description": "List all titles"
},
{
"name": "Get Title",
"description": "Get a specific title by ID"
},
{
"name": "Create Title",
"description": "Create a new title"
},
{
"name": "Update Title",
"description": "Update an existing title"
},
{
"name": "Delete Title",
"description": "Delete a title"
},
{
"name": "List Custom Fields",
"description": "List all custom fields"
},
{
"name": "List Job Functions",
"description": "List all job functions"
},
{
"name": "Get Job Function",
"description": "Get a specific job function by ID"
},
{
"name": "List Work Locations",
"description": "List all work locations"
},
{
"name": "Get Work Location",
"description": "Get a specific work location by ID"
},
{
"name": "Create Work Location",
"description": "Create a new work location"
},
{
"name": "Update Work Location",
"description": "Update a work location"
},
{
"name": "Delete Work Location",
"description": "Delete a work location"
},
{
"name": "List Business Partners",
"description": "List all business partners"
},
{
"name": "Get Business Partner",
"description": "Get a specific business partner by ID"
},
{
"name": "Create Business Partner",
"description": "Create a new business partner"
},
{
"name": "Delete Business Partner",
"description": "Delete a business partner"
},
{
"name": "List Business Partner Groups",
"description": "List all business partner groups"
},
{
"name": "Get Business Partner Group",
"description": "Get a specific business partner group by ID"
},
{
"name": "Create Business Partner Group",
"description": "Create a new business partner group"
},
{
"name": "Delete Business Partner Group",
"description": "Delete a business partner group"
},
{
"name": "List Supergroups",
"description": "List all supergroups"
},
{
"name": "Get Supergroup",
"description": "Get a specific supergroup by ID"
},
{
"name": "List Supergroup Members",
"description": "List members of a supergroup"
},
{
"name": "List Supergroup Inclusion Members",
"description": "List inclusion members of a supergroup"
},
{
"name": "List Supergroup Exclusion Members",
"description": "List exclusion members of a supergroup"
},
{
"name": "Update Supergroup Inclusion Members",
"description": "Update inclusion members of a supergroup"
},
{
"name": "Update Supergroup Exclusion Members",
"description": "Update exclusion members of a supergroup"
},
{
"name": "List Custom Objects",
"description": "List all custom objects"
},
{
"name": "Get Custom Object",
"description": "Get a custom object by API name"
},
{
"name": "Create Custom Object",
"description": "Create a new custom object"
},
{
"name": "Update Custom Object",
"description": "Update a custom object"
},
{
"name": "Delete Custom Object",
"description": "Delete a custom object"
},
{
"name": "List Custom Object Fields",
"description": "List all fields for a custom object"
},
{
"name": "Get Custom Object Field",
"description": "Get a specific field of a custom object"
},
{
"name": "Create Custom Object Field",
"description": "Create a field on a custom object"
},
{
"name": "Update Custom Object Field",
"description": "Update a field on a custom object"
},
{
"name": "Delete Custom Object Field",
"description": "Delete a field from a custom object"
},
{
"name": "List Custom Object Records",
"description": "List all records for a custom object"
},
{
"name": "Get Custom Object Record",
"description": "Get a specific custom object record"
},
{
"name": "Get Custom Object Record by External ID",
"description": "Get a custom object record by external ID"
},
{
"name": "Query Custom Object Records",
"description": "Query custom object records with filters"
},
{
"name": "Create Custom Object Record",
"description": "Create a custom object record"
},
{
"name": "Update Custom Object Record",
"description": "Update a custom object record"
},
{
"name": "Delete Custom Object Record",
"description": "Delete a custom object record"
},
{
"name": "Bulk Create Custom Object Records",
"description": "Bulk create custom object records"
},
{
"name": "Bulk Update Custom Object Records",
"description": "Bulk update custom object records"
},
{
"name": "Bulk Delete Custom Object Records",
"description": "Bulk delete custom object records"
},
{
"name": "List Custom Apps",
"description": "List all custom apps"
},
{
"name": "Get Custom App",
"description": "Get a specific custom app"
},
{
"name": "Create Custom App",
"description": "Create a new custom app"
},
{
"name": "Update Custom App",
"description": "Update a custom app"
},
{
"name": "Delete Custom App",
"description": "Delete a custom app"
},
{
"name": "List Custom Pages",
"description": "List all custom pages"
},
{
"name": "Get Custom Page",
"description": "Get a specific custom page"
},
{
"name": "Create Custom Page",
"description": "Create a new custom page"
},
{
"name": "Update Custom Page",
"description": "Update a custom page"
},
{
"name": "Delete Custom Page",
"description": "Delete a custom page"
},
{
"name": "List Custom Settings",
"description": "List all custom settings"
},
{
"name": "Get Custom Setting",
"description": "Get a specific custom setting"
},
{
"name": "Create Custom Setting",
"description": "Create a new custom setting"
},
{
"name": "Update Custom Setting",
"description": "Update a custom setting"
},
{
"name": "Delete Custom Setting",
"description": "Delete a custom setting"
},
{
"name": "List Object Categories",
"description": "List all object categories"
},
{
"name": "Get Object Category",
"description": "Get a specific object category"
},
{
"name": "Create Object Category",
"description": "Create a new object category"
},
{
"name": "Update Object Category",
"description": "Update an object category"
},
{
"name": "Delete Object Category",
"description": "Delete an object category"
},
{
"name": "Get Report Run",
"description": "Get a report run by ID"
},
{
"name": "Trigger Report Run",
"description": "Trigger a new report run"
},
{
"name": "Create Draft Hires",
"description": "Create bulk draft hires"
}
],
"operationCount": 19,
"operationCount": 86,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
@@ -9384,6 +9652,81 @@
"integrationType": "hr",
"tags": ["hiring"]
},
{
"type": "rootly",
"slug": "rootly",
"name": "Rootly",
"description": "Manage incidents, alerts, and on-call with Rootly",
"longDescription": "Integrate Rootly incident management into workflows. Create and manage incidents, alerts, services, severities, and retrospectives.",
"bgColor": "#6C72C8",
"iconName": "RootlyIcon",
"docsUrl": "https://docs.sim.ai/tools/rootly",
"operations": [
{
"name": "Create Incident",
"description": "Create a new incident in Rootly with optional severity, services, and teams."
},
{
"name": "Get Incident",
"description": "Retrieve a single incident by ID from Rootly."
},
{
"name": "Update Incident",
"description": "Update an existing incident in Rootly (status, severity, summary, etc.)."
},
{
"name": "List Incidents",
"description": "List incidents from Rootly with optional filtering by status, severity, and more."
},
{
"name": "Create Alert",
"description": "Create a new alert in Rootly for on-call notification and routing."
},
{
"name": "List Alerts",
"description": "List alerts from Rootly with optional filtering by status, source, and services."
},
{
"name": "Add Incident Event",
"description": "Add a timeline event to an existing incident in Rootly."
},
{
"name": "List Services",
"description": "List services from Rootly with optional search filtering."
},
{
"name": "List Severities",
"description": "List severity levels configured in Rootly."
},
{
"name": "List Teams",
"description": "List teams (groups) configured in Rootly."
},
{
"name": "List Environments",
"description": "List environments configured in Rootly."
},
{
"name": "List Incident Types",
"description": "List incident types configured in Rootly."
},
{
"name": "List Functionalities",
"description": "List functionalities configured in Rootly."
},
{
"name": "List Retrospectives",
"description": "List incident retrospectives (post-mortems) from Rootly."
}
],
"operationCount": 14,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
"category": "tools",
"integrationType": "developer-tools",
"tags": ["incident-management", "monitoring"]
},
{
"type": "s3",
"slug": "s3",

View File

@@ -0,0 +1,42 @@
import { notFound } from 'next/navigation'
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatPrice,
formatTokenCount,
getModelBySlug,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export default async function Image({
params,
}: {
params: Promise<{ provider: string; model: string }>
}) {
const { provider: providerSlug, model: modelSlug } = await params
const provider = getProviderBySlug(providerSlug)
const model = getModelBySlug(providerSlug, modelSlug)
if (!provider || !model) {
notFound()
}
return createModelsOgImage({
eyebrow: `${provider.name} model`,
title: model.displayName,
subtitle: `${provider.name} pricing, context window, and feature support generated from Sim's model registry.`,
pills: [
`Input ${formatPrice(model.pricing.input)}/1M`,
`Output ${formatPrice(model.pricing.output)}/1M`,
model.contextWindow ? `${formatTokenCount(model.contextWindow)} context` : 'Unknown context',
model.capabilityTags[0] ?? 'Capabilities tracked',
],
domainLabel: `sim.ai${model.href}`,
})
}

View File

@@ -0,0 +1,390 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
Breadcrumbs,
CapabilityTags,
DetailItem,
ModelCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
ALL_CATALOG_MODELS,
buildModelCapabilityFacts,
buildModelFaqs,
formatPrice,
formatTokenCount,
formatUpdatedAt,
getModelBySlug,
getPricingBounds,
getProviderBySlug,
getRelatedModels,
} from '@/app/(landing)/models/utils'
const baseUrl = getBaseUrl()
export async function generateStaticParams() {
return ALL_CATALOG_MODELS.map((model) => ({
provider: model.providerSlug,
model: model.slug,
}))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ provider: string; model: string }>
}): Promise<Metadata> {
const { provider: providerSlug, model: modelSlug } = await params
const provider = getProviderBySlug(providerSlug)
const model = getModelBySlug(providerSlug, modelSlug)
if (!provider || !model) {
return {}
}
return {
title: `${model.displayName} Pricing, Context Window, and Features`,
description: `${model.displayName} by ${provider.name}: pricing, cached input cost, output cost, context window, and capability support. Explore the full generated model page on Sim.`,
keywords: [
model.displayName,
`${model.displayName} pricing`,
`${model.displayName} context window`,
`${model.displayName} features`,
`${provider.name} ${model.displayName}`,
`${provider.name} model pricing`,
...model.capabilityTags,
],
openGraph: {
title: `${model.displayName} Pricing, Context Window, and Features | Sim`,
description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`,
url: `${baseUrl}${model.href}`,
type: 'website',
images: [
{
url: `${baseUrl}${model.href}/opengraph-image`,
width: 1200,
height: 630,
alt: `${model.displayName} on Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${model.displayName} | Sim`,
description: model.summary,
images: [
{ url: `${baseUrl}${model.href}/opengraph-image`, alt: `${model.displayName} on Sim` },
],
},
alternates: {
canonical: `${baseUrl}${model.href}`,
},
}
}
export default async function ModelPage({
params,
}: {
params: Promise<{ provider: string; model: string }>
}) {
const { provider: providerSlug, model: modelSlug } = await params
const provider = getProviderBySlug(providerSlug)
const model = getModelBySlug(providerSlug, modelSlug)
if (!provider || !model) {
notFound()
}
const faqs = buildModelFaqs(provider, model)
const capabilityFacts = buildModelCapabilityFacts(model)
const pricingBounds = getPricingBounds(model.pricing)
const relatedModels = getRelatedModels(model, 6)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
{
'@type': 'ListItem',
position: 4,
name: model.displayName,
item: `${baseUrl}${model.href}`,
},
],
}
const productJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: model.displayName,
brand: provider.name,
category: 'AI language model',
description: model.summary,
sku: model.id,
offers: {
'@type': 'AggregateOffer',
priceCurrency: 'USD',
lowPrice: pricingBounds.lowPrice.toString(),
highPrice: pricingBounds.highPrice.toString(),
},
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: provider.name, href: provider.href },
{ label: model.displayName },
]}
/>
<section aria-labelledby='model-heading' className='mb-14'>
<div className='mb-6 flex items-start gap-4'>
<ProviderIcon
provider={provider}
className='h-16 w-16 rounded-3xl'
iconClassName='h-8 w-8'
/>
<div className='min-w-0'>
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
{provider.name} model
</p>
<h1
id='model-heading'
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
>
{model.displayName}
</h1>
<p className='mt-2 break-all text-[13px] text-[var(--landing-text-muted)]'>
Model ID: {model.id}
</p>
</div>
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary} {model.bestFor}
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<Link
href={provider.href}
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Explore {provider.name} models
</Link>
<a
href='https://sim.ai'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Build with this model
</a>
</div>
</section>
<section
aria-label='Model stats'
className='mb-16 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'
>
<StatCard label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<StatCard
label='Cached input'
value={
model.pricing.cachedInput !== undefined
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
compact
/>
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
<StatCard
label='Context window'
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
compact
/>
</section>
<div className='grid grid-cols-1 gap-16 lg:grid-cols-[1fr_320px]'>
<div className='min-w-0 space-y-16'>
<section aria-labelledby='pricing-heading'>
<h2
id='pricing-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Pricing and limits
</h2>
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Pricing below is generated directly from the provider registry in Sim. All amounts
are listed per one million tokens.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<DetailItem label='Input price' value={`${formatPrice(model.pricing.input)}/1M`} />
<DetailItem
label='Cached input'
value={
model.pricing.cachedInput !== undefined
? `${formatPrice(model.pricing.cachedInput)}/1M`
: 'N/A'
}
/>
<DetailItem
label='Output price'
value={`${formatPrice(model.pricing.output)}/1M`}
/>
<DetailItem label='Updated' value={formatUpdatedAt(model.pricing.updatedAt)} />
<DetailItem
label='Context window'
value={
model.contextWindow
? `${formatTokenCount(model.contextWindow)} tokens`
: 'Unknown'
}
/>
<DetailItem
label='Max output'
value={
model.capabilities.maxOutputTokens
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
: 'Standard defaults'
}
/>
<DetailItem label='Provider' value={provider.name} />
<DetailItem label='Best for' value={model.bestFor} />
</div>
</section>
<section aria-labelledby='capabilities-heading'>
<h2
id='capabilities-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Capabilities
</h2>
<p className='mb-6 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These capability flags are generated from the provider and model definitions tracked
in Sim.
</p>
<CapabilityTags tags={model.capabilityTags} />
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{capabilityFacts.map((item) => (
<DetailItem key={item.label} label={item.label} value={item.value} />
))}
</div>
</section>
{relatedModels.length > 0 && (
<section aria-labelledby='related-models-heading'>
<h2
id='related-models-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Related {provider.name} models
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Browse comparable models from the same provider to compare pricing, context
window, and capability coverage.
</p>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{relatedModels.map((entry) => (
<ModelCard key={entry.id} provider={provider} model={entry} />
))}
</div>
</section>
)}
<section
aria-labelledby='model-faq-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='model-faq-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqs} />
</div>
</section>
</div>
<aside className='space-y-5' aria-label='Model details'>
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
Quick details
</h2>
<div className='space-y-3'>
<DetailItem label='Display name' value={model.displayName} />
<DetailItem label='Provider' value={provider.name} />
<DetailItem
label='Context tracked'
value={model.contextWindow ? 'Yes' : 'Partial'}
/>
<DetailItem
label='Pricing updated'
value={formatUpdatedAt(model.pricing.updatedAt)}
/>
</div>
</div>
<div className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-5'>
<h2 className='mb-4 font-[500] text-[16px] text-[var(--landing-text)]'>
Browse more
</h2>
<div className='space-y-2'>
<Link
href={provider.href}
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
All {provider.name} models
</Link>
<Link
href='/models'
className='block rounded-xl px-3 py-2 text-[14px] text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] hover:text-[var(--landing-text)]'
>
Full models directory
</Link>
</div>
</div>
</aside>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,43 @@
import { notFound } from 'next/navigation'
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatPrice,
formatTokenCount,
getCheapestProviderModel,
getLargestContextProviderModel,
getProviderBySlug,
} from '@/app/(landing)/models/utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export default async function Image({ params }: { params: Promise<{ provider: string }> }) {
const { provider: providerSlug } = await params
const provider = getProviderBySlug(providerSlug)
if (!provider || provider.models.length === 0) {
notFound()
}
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return createModelsOgImage({
eyebrow: `${provider.name} on Sim`,
title: `${provider.name} models`,
subtitle: `Browse ${provider.modelCount} tracked ${provider.name} models with pricing, context windows, default model selection, and model capability coverage.`,
pills: [
`${provider.modelCount} tracked`,
provider.defaultModelDisplayName || 'Dynamic default',
cheapestModel ? `From ${formatPrice(cheapestModel.pricing.input)}/1M` : 'Pricing tracked',
largestContextModel?.contextWindow
? `${formatTokenCount(largestContextModel.contextWindow)} context`
: 'Context tracked',
],
domainLabel: `sim.ai${provider.href}`,
})
}

View File

@@ -0,0 +1,294 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import {
Breadcrumbs,
CapabilityTags,
ModelCard,
ProviderCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
buildProviderFaqs,
getProviderBySlug,
getProviderCapabilitySummary,
MODEL_PROVIDERS_WITH_CATALOGS,
TOP_MODEL_PROVIDERS,
} from '@/app/(landing)/models/utils'
const baseUrl = getBaseUrl()
export async function generateStaticParams() {
return MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
provider: provider.slug,
}))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ provider: string }>
}): Promise<Metadata> {
const { provider: providerSlug } = await params
const provider = getProviderBySlug(providerSlug)
if (!provider || provider.models.length === 0) {
return {}
}
const providerFaqs = buildProviderFaqs(provider)
return {
title: `${provider.name} Models`,
description: `Browse ${provider.modelCount} ${provider.name} models tracked in Sim. Compare pricing, context windows, default model selection, and capabilities for ${provider.name}'s AI model lineup.`,
keywords: [
`${provider.name} models`,
`${provider.name} pricing`,
`${provider.name} context window`,
`${provider.name} model list`,
`${provider.name} AI models`,
...provider.models.slice(0, 6).map((model) => model.displayName),
],
openGraph: {
title: `${provider.name} Models | Sim`,
description: `Explore ${provider.modelCount} ${provider.name} models with pricing and capability details.`,
url: `${baseUrl}${provider.href}`,
type: 'website',
images: [
{
url: `${baseUrl}${provider.href}/opengraph-image`,
width: 1200,
height: 630,
alt: `${provider.name} Models on Sim`,
},
],
},
twitter: {
card: 'summary_large_image',
title: `${provider.name} Models | Sim`,
description: providerFaqs[0]?.answer ?? provider.summary,
images: [
{
url: `${baseUrl}${provider.href}/opengraph-image`,
alt: `${provider.name} Models on Sim`,
},
],
},
alternates: {
canonical: `${baseUrl}${provider.href}`,
},
}
}
export default async function ProviderModelsPage({
params,
}: {
params: Promise<{ provider: string }>
}) {
const { provider: providerSlug } = await params
const provider = getProviderBySlug(providerSlug)
if (!provider || provider.models.length === 0) {
notFound()
}
const faqs = buildProviderFaqs(provider)
const capabilitySummary = getProviderCapabilitySummary(provider)
const relatedProviders = MODEL_PROVIDERS_WITH_CATALOGS.filter(
(entry) => entry.id !== provider.id && TOP_MODEL_PROVIDERS.includes(entry.name)
).slice(0, 4)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
{ '@type': 'ListItem', position: 3, name: provider.name, item: `${baseUrl}${provider.href}` },
],
}
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: `${provider.name} Models`,
description: `List of ${provider.modelCount} ${provider.name} models tracked in Sim.`,
url: `${baseUrl}${provider.href}`,
numberOfItems: provider.modelCount,
itemListElement: provider.models.map((model, index) => ({
'@type': 'ListItem',
position: index + 1,
url: `${baseUrl}${model.href}`,
name: model.displayName,
})),
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-12 sm:px-8 md:px-12'>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: provider.name },
]}
/>
<section aria-labelledby='provider-heading' className='mb-14'>
<div className='mb-6 flex items-center gap-4'>
<ProviderIcon
provider={provider}
className='h-16 w-16 rounded-3xl'
iconClassName='h-8 w-8'
/>
<div>
<p className='text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
Provider
</p>
<h1
id='provider-heading'
className='font-[500] text-[38px] text-[var(--landing-text)] leading-tight sm:text-[48px]'
>
{provider.name} models
</h1>
</div>
</div>
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.summary} Browse every {provider.name} model page generated from Sim&apos;s
provider registry with human-readable names, pricing, context windows, and capability
metadata.
</p>
<div className='mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
<StatCard label='Models tracked' value={provider.modelCount.toString()} />
<StatCard
label='Default model'
value={provider.defaultModelDisplayName || 'Dynamic'}
compact
/>
<StatCard
label='Metadata coverage'
value={provider.contextInformationAvailable ? 'Tracked' : 'Partial'}
compact
/>
<StatCard
label='Featured models'
value={provider.featuredModels.length.toString()}
compact
/>
</div>
<div className='mt-6'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
</section>
<section aria-labelledby='provider-models-heading' className='mb-16'>
<h2
id='provider-models-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
All {provider.name} models
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Every model below links to a dedicated SEO page with exact pricing, context window,
capability support, and related model recommendations.
</p>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{provider.models.map((model) => (
<ModelCard key={model.id} provider={provider} model={model} />
))}
</div>
</section>
<section
aria-labelledby='lineup-snapshot-heading'
className='mb-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='lineup-snapshot-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Lineup snapshot
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
A quick view of the strongest differentiators in the {provider.name} model lineup based
on the metadata currently tracked in Sim.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{capabilitySummary.map((item) => (
<StatCard key={item.label} label={item.label} value={item.value} compact />
))}
</div>
</section>
{relatedProviders.length > 0 && (
<section aria-labelledby='related-providers-heading' className='mb-16'>
<h2
id='related-providers-heading'
className='mb-2 font-[500] text-[28px] text-[var(--landing-text)]'
>
Compare with other providers
</h2>
<p className='mb-8 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Explore similar provider hubs to compare model lineups, pricing surfaces, and
long-context coverage across the broader AI ecosystem.
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
{relatedProviders.map((entry) => (
<ProviderCard key={entry.id} provider={entry} />
))}
</div>
</section>
)}
<section
aria-labelledby='provider-faq-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2
id='provider-faq-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqs} />
</div>
</section>
</div>
</>
)
}

View File

@@ -0,0 +1,291 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { Input } from '@/components/emcn'
import { SearchIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import {
CapabilityTags,
DetailItem,
ModelCard,
ProviderIcon,
StatCard,
} from '@/app/(landing)/models/components/model-primitives'
import {
type CatalogProvider,
MODEL_PROVIDERS_WITH_CATALOGS,
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
export function ModelDirectory() {
const [query, setQuery] = useState('')
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const providerOptions = useMemo(
() =>
MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
id: provider.id,
name: provider.name,
count: provider.modelCount,
})),
[]
)
const normalizedQuery = query.trim().toLowerCase()
const { filteredProviders, filteredDynamicProviders, visibleModelCount } = useMemo(() => {
const filteredProviders = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => {
const providerMatchesSearch =
normalizedQuery.length > 0 && provider.searchText.includes(normalizedQuery)
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
if (!providerMatchesFilter) {
return null
}
const models =
normalizedQuery.length === 0
? provider.models
: provider.models.filter(
(model) =>
model.searchText.includes(normalizedQuery) ||
(providerMatchesSearch && normalizedQuery.length > 0)
)
if (!providerMatchesSearch && models.length === 0) {
return null
}
return {
...provider,
models: providerMatchesSearch && normalizedQuery.length > 0 ? provider.models : models,
}
}).filter((provider): provider is CatalogProvider => provider !== null)
const filteredDynamicProviders = MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS.filter((provider) => {
const providerMatchesFilter = !activeProviderId || provider.id === activeProviderId
if (!providerMatchesFilter) {
return false
}
if (!normalizedQuery) {
return true
}
return provider.searchText.includes(normalizedQuery)
})
const visibleModelCount = filteredProviders.reduce(
(count, provider) => count + provider.models.length,
0
)
return {
filteredProviders,
filteredDynamicProviders,
visibleModelCount,
}
}, [activeProviderId, normalizedQuery])
const hasResults = filteredProviders.length > 0 || filteredDynamicProviders.length > 0
return (
<div>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
<div className='relative max-w-[560px] flex-1'>
<SearchIcon
aria-hidden='true'
className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-[var(--landing-text-muted)]'
/>
<Input
type='search'
placeholder='Search models, providers, capabilities, or pricing details'
value={query}
onChange={(event) => setQuery(event.target.value)}
className='h-11 border-[var(--landing-border)] bg-[var(--landing-bg-card)] pl-10 text-[var(--landing-text)] placeholder:text-[var(--landing-text-muted)]'
aria-label='Search AI models'
/>
</div>
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
Showing {visibleModelCount.toLocaleString('en-US')} of{' '}
{TOTAL_MODELS.toLocaleString('en-US')} models
{activeProviderId ? ' in one provider' : ''}.
</p>
</div>
<div className='mb-10 flex flex-wrap gap-2'>
<FilterButton
isActive={activeProviderId === null}
onClick={() => setActiveProviderId(null)}
label={`All providers (${MODEL_PROVIDERS_WITH_CATALOGS.length})`}
/>
{providerOptions.map((provider) => (
<FilterButton
key={provider.id}
isActive={activeProviderId === provider.id}
onClick={() =>
setActiveProviderId(activeProviderId === provider.id ? null : provider.id)
}
label={`${provider.name} (${provider.count})`}
/>
))}
</div>
{!hasResults ? (
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-6 py-12 text-center'>
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>No matches found</h3>
<p className='mt-2 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
Try a provider name like OpenAI or Anthropic, or search for capabilities like
&nbsp;structured outputs, reasoning, or deep research.
</p>
</div>
) : (
<div className='space-y-10'>
{filteredProviders.map((provider) => (
<section
key={provider.id}
aria-labelledby={`${provider.id}-heading`}
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<div className='mb-6 flex flex-col gap-5 border-[var(--landing-border)] border-b pb-6 lg:flex-row lg:items-start lg:justify-between'>
<div className='min-w-0'>
<div className='mb-3 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div>
<p className='text-[12px] text-[var(--landing-text-muted)]'>Provider</p>
<h2
id={`${provider.id}-heading`}
className='font-[500] text-[24px] text-[var(--landing-text)]'
>
{provider.name}
</h2>
</div>
</div>
<p className='max-w-[720px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<Link
href={provider.href}
className='mt-3 inline-flex text-[#555] text-[13px] transition-colors hover:text-[var(--landing-text-muted)]'
>
View provider page
</Link>
</div>
<div className='grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-3'>
<StatCard label='Models' value={provider.models.length.toString()} />
<StatCard
label='Default'
value={provider.defaultModelDisplayName || 'Dynamic'}
compact
/>
<StatCard
label='Context info'
value={provider.contextInformationAvailable ? 'Tracked' : 'Limited'}
compact
/>
</div>
</div>
<div className='mb-6'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{provider.models.map((model) => (
<ModelCard key={model.id} provider={provider} model={model} />
))}
</div>
</section>
))}
{filteredDynamicProviders.length > 0 && (
<section
aria-labelledby='dynamic-catalogs-heading'
className='rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<div className='mb-6'>
<h2
id='dynamic-catalogs-heading'
className='font-[500] text-[24px] text-[var(--landing-text)]'
>
Dynamic model catalogs
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These providers are supported by Sim, but their model lists are loaded dynamically
at runtime rather than hard-coded into the public catalog.
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4'>
{filteredDynamicProviders.map((provider) => (
<article
key={provider.id}
className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] p-5'
>
<div className='mb-4 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div className='min-w-0'>
<h3 className='font-[500] text-[16px] text-[var(--landing-text)]'>
{provider.name}
</h3>
<p className='text-[12px] text-[var(--landing-text-muted)]'>
{provider.id}
</p>
</div>
</div>
<p className='text-[13px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<div className='mt-4 space-y-3 text-[13px]'>
<DetailItem
label='Default'
value={provider.defaultModelDisplayName || 'Selected at runtime'}
/>
<DetailItem label='Catalog source' value='Loaded dynamically inside Sim' />
</div>
<div className='mt-4'>
<CapabilityTags tags={provider.providerCapabilityTags} />
</div>
</article>
))}
</div>
</section>
)}
</div>
)}
</div>
)
}
function FilterButton({
isActive,
onClick,
label,
}: {
isActive: boolean
onClick: () => void
label: string
}) {
return (
<button
type='button'
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
isActive
? 'border-[#555] bg-[#333] text-[var(--landing-text)]'
: 'border-[var(--landing-border)] bg-transparent text-[var(--landing-text-muted)] hover:border-[var(--landing-border-strong)] hover:text-[var(--landing-text)]'
)}
>
{label}
</button>
)
}

View File

@@ -0,0 +1,214 @@
import Link from 'next/link'
import { Badge } from '@/components/emcn'
import {
type CatalogModel,
type CatalogProvider,
formatPrice,
formatTokenCount,
formatUpdatedAt,
} from '@/app/(landing)/models/utils'
export function Breadcrumbs({ items }: { items: Array<{ label: string; href?: string }> }) {
return (
<nav
aria-label='Breadcrumb'
className='mb-10 flex flex-wrap items-center gap-2 text-[#555] text-[13px]'
>
{items.map((item, index) => (
<span key={`${item.label}-${index}`} className='inline-flex items-center gap-2'>
{item.href ? (
<Link
href={item.href}
className='transition-colors hover:text-[var(--landing-text-muted)]'
>
{item.label}
</Link>
) : (
<span className='text-[var(--landing-text-muted)]'>{item.label}</span>
)}
{index < items.length - 1 ? <span aria-hidden='true'>/</span> : null}
</span>
))}
</nav>
)
}
export function ProviderIcon({
provider,
className = 'h-12 w-12 rounded-2xl',
iconClassName = 'h-6 w-6',
}: {
provider: Pick<CatalogProvider, 'icon' | 'name'>
className?: string
iconClassName?: string
}) {
const Icon = provider.icon
return (
<span
className={`flex items-center justify-center border border-[var(--landing-border)] bg-[var(--landing-bg)] ${className}`}
>
{Icon ? (
<Icon className={iconClassName} />
) : (
<span className='font-[500] text-[14px] text-[var(--landing-text)]'>
{provider.name.slice(0, 2).toUpperCase()}
</span>
)}
</span>
)
}
export function StatCard({
label,
value,
compact = false,
}: {
label: string
value: string
compact?: boolean
}) {
return (
<div className='rounded-2xl border border-[var(--landing-border)] bg-[var(--landing-bg-elevated)] px-4 py-3'>
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
{label}
</p>
<p
className={`mt-1 font-[500] text-[var(--landing-text)] ${
compact ? 'break-all text-[12px] leading-snug' : 'text-[18px]'
}`}
>
{value}
</p>
</div>
)
}
export function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] px-3 py-2'>
<p className='text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.08em]'>
{label}
</p>
<p className='mt-1 break-words font-[500] text-[12px] text-[var(--landing-text)] leading-snug'>
{value}
</p>
</div>
)
}
export function CapabilityTags({ tags }: { tags: string[] }) {
if (tags.length === 0) {
return null
}
return (
<div className='flex flex-wrap gap-2'>
{tags.map((tag) => (
<Badge
key={tag}
className='border-[var(--landing-border)] bg-transparent px-2 py-1 text-[11px] text-[var(--landing-text-muted)]'
>
{tag}
</Badge>
))}
</div>
)
}
export function ProviderCard({ provider }: { provider: CatalogProvider }) {
return (
<Link
href={provider.href}
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<div className='mb-4 flex items-center gap-3'>
<ProviderIcon provider={provider} />
<div className='min-w-0'>
<h3 className='font-[500] text-[18px] text-[var(--landing-text)]'>{provider.name}</h3>
<p className='text-[12px] text-[var(--landing-text-muted)]'>
{provider.modelCount} models tracked
</p>
</div>
</div>
<p className='mb-4 flex-1 text-[14px] text-[var(--landing-text-muted)] leading-relaxed'>
{provider.description}
</p>
<div className='mb-4 grid grid-cols-2 gap-3'>
<DetailItem label='Default' value={provider.defaultModelDisplayName || 'Dynamic'} />
<DetailItem
label='Catalog'
value={provider.contextInformationAvailable ? 'Tracked metadata' : 'Partial metadata'}
/>
</div>
<CapabilityTags tags={provider.providerCapabilityTags.slice(0, 4)} />
<p className='mt-4 text-[#555] text-[13px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
Explore provider
</p>
</Link>
)
}
export function ModelCard({
provider,
model,
showProvider = false,
}: {
provider: CatalogProvider
model: CatalogModel
showProvider?: boolean
}) {
return (
<Link
href={model.href}
className='group flex h-full flex-col rounded-lg border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-4 transition-colors hover:border-[var(--landing-border-strong)] hover:bg-[var(--landing-bg-elevated)]'
>
<div className='mb-4 flex items-start gap-3'>
<ProviderIcon
provider={provider}
className='h-10 w-10 rounded-xl'
iconClassName='h-5 w-5'
/>
<div className='min-w-0 flex-1'>
{showProvider ? (
<p className='mb-1 text-[12px] text-[var(--landing-text-muted)]'>{provider.name}</p>
) : null}
<h3 className='break-all font-[500] text-[16px] text-[var(--landing-text)] leading-snug'>
{model.displayName}
</h3>
<p className='mt-1 break-all text-[12px] text-[var(--landing-text-muted)]'>{model.id}</p>
</div>
</div>
<p className='mb-3 line-clamp-3 flex-1 text-[12px] text-[var(--landing-text-muted)] leading-relaxed'>
{model.summary}
</p>
<div className='flex flex-wrap items-center gap-1.5'>
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{`Input ${formatPrice(model.pricing.input)}/1M`}
</Badge>
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{`Output ${formatPrice(model.pricing.output)}/1M`}
</Badge>
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{model.contextWindow
? `${formatTokenCount(model.contextWindow)} context`
: 'Unknown context'}
</Badge>
{model.capabilityTags[0] ? (
<Badge className='border-0 bg-[#333] text-[11px] text-[var(--landing-text-muted)]'>
{model.capabilityTags[0]}
</Badge>
) : null}
<span className='ml-auto text-[#555] text-[12px] transition-colors group-hover:text-[var(--landing-text-muted)]'>
{`Updated ${formatUpdatedAt(model.pricing.updatedAt)}`}
</span>
</div>
</Link>
)
}

View File

@@ -0,0 +1,42 @@
import { getNavBlogPosts } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import Footer from '@/app/(home)/components/footer/footer'
import Navbar from '@/app/(home)/components/navbar/navbar'
export default async function ModelsLayout({ children }: { children: React.ReactNode }) {
const blogPosts = await getNavBlogPosts()
const url = getBaseUrl()
const orgJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Sim',
url,
logo: `${url}/logo/primary/small.png`,
sameAs: ['https://x.com/simdotai'],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Sim',
url,
}
return (
<div className='dark flex min-h-screen flex-col bg-[var(--landing-bg)] font-[430] font-season text-[var(--landing-text)]'>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(orgJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
<header>
<Navbar blogPosts={blogPosts} />
</header>
<main className='relative flex-1'>{children}</main>
<Footer />
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { ImageResponse } from 'next/og'
const size = {
width: 1200,
height: 630,
}
const TITLE_FONT_SIZE = {
large: 64,
medium: 56,
small: 48,
} as const
function getTitleFontSize(title: string): number {
if (title.length > 42) return TITLE_FONT_SIZE.small
if (title.length > 26) return TITLE_FONT_SIZE.medium
return TITLE_FONT_SIZE.large
}
async function loadGoogleFont(
font: string,
weights: string,
text: string
): Promise<ArrayBuffer | null> {
try {
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weights}&text=${encodeURIComponent(text)}`
const css = await (await fetch(url)).text()
const resource = css.match(/src: url\(([^)]+)\) format\('(opentype|truetype|woff2?)'\)/)
if (resource) {
const response = await fetch(resource[1])
if (response.status === 200) {
return await response.arrayBuffer()
}
}
} catch {
return null
}
return null
}
function SimLogoFull() {
return (
<svg height='28' viewBox='720 440 1020 320' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M875.791 577.171C875.791 581.922 873.911 586.483 870.576 589.842L870.098 590.323C866.764 593.692 862.234 595.575 857.517 595.575H750.806C740.978 595.575 733 603.6 733 613.498V728.902C733 738.799 740.978 746.826 750.806 746.826H865.382C875.209 746.826 883.177 738.799 883.177 728.902V620.853C883.177 616.448 884.912 612.222 888.008 609.104C891.093 605.997 895.29 604.249 899.664 604.249H1008.16C1017.99 604.249 1025.96 596.224 1025.96 586.327V470.923C1025.96 461.025 1017.99 453 1008.16 453H893.586C883.759 453 875.791 461.025 875.791 470.923V577.171ZM910.562 477.566H991.178C996.922 477.566 1001.57 482.254 1001.57 488.029V569.22C1001.57 574.995 996.922 579.683 991.178 579.683H910.562C904.828 579.683 900.173 574.995 900.173 569.22V488.029C900.173 482.254 904.828 477.566 910.562 477.566Z'
fill='#33C482'
/>
<path
d='M1008.3 624.59H923.113C912.786 624.59 904.414 633.022 904.414 643.423V728.171C904.414 738.572 912.786 747.004 923.113 747.004H1008.3C1018.63 747.004 1027 738.572 1027 728.171V643.423C1027 633.022 1018.63 624.59 1008.3 624.59Z'
fill='#33C482'
/>
<path
d='M1210.54 515.657C1226.65 515.657 1240.59 518.51 1252.31 524.257H1252.31C1264.3 529.995 1273.63 538.014 1280.26 548.319H1280.26C1287.19 558.635 1290.78 570.899 1291.08 585.068L1291.1 586.089H1249.11L1249.09 585.115C1248.8 574.003 1245.18 565.493 1238.32 559.451C1231.45 553.399 1221.79 550.308 1209.21 550.308C1196.3 550.308 1186.48 553.113 1179.61 558.588C1172.76 564.046 1169.33 571.499 1169.33 581.063C1169.33 588.092 1171.88 593.978 1177.01 598.783C1182.17 603.618 1189.99 607.399 1200.56 610.061H1200.56L1238.77 619.451C1257.24 623.65 1271.21 630.571 1280.57 640.293L1281.01 640.739C1290.13 650.171 1294.64 662.97 1294.64 679.016C1294.64 692.923 1290.88 705.205 1283.34 715.822L1283.33 715.834C1275.81 726.134 1265.44 734.14 1252.26 739.866L1252.25 739.871C1239.36 745.302 1224.12 748 1206.54 748C1180.9 748 1160.36 741.696 1145.02 728.984C1129.67 716.258 1122 699.269 1122 678.121V677.121H1163.99V678.121C1163.99 688.869 1167.87 697.367 1175.61 703.722L1176.34 704.284C1184.04 709.997 1194.37 712.902 1207.43 712.902C1222.13 712.902 1233.3 710.087 1241.07 704.588C1248.8 698.812 1252.64 691.21 1252.64 681.699C1252.64 674.769 1250.5 669.057 1246.25 664.49L1246.23 664.478L1246.22 664.464C1242.28 659.929 1234.83 656.119 1223.64 653.152L1185.43 644.208L1185.42 644.204C1166.05 639.407 1151.49 632.035 1141.83 622.012L1141.83 622.006L1141.82 622C1132.43 611.94 1127.78 598.707 1127.78 582.405C1127.78 568.81 1131.23 556.976 1138.17 546.949L1138.18 546.941L1138.19 546.933C1145.41 536.936 1155.18 529.225 1167.48 523.793L1167.48 523.79C1180.07 518.36 1194.43 515.657 1210.54 515.657ZM1323.39 521.979C1331.68 525.008 1337.55 526.482 1343.51 526.482C1349.48 526.482 1355.64 525.005 1364.49 521.973L1365.82 521.52V742.633H1322.05V521.489L1323.39 521.979ZM1642.01 515.657C1667.11 515.657 1686.94 523.031 1701.39 537.876C1715.83 552.716 1723 572.968 1723 598.507V742.633H1680.12V608.794C1680.12 591.666 1675.72 578.681 1667.07 569.681L1667.06 569.669L1667.04 569.656C1658.67 560.359 1647.26 555.675 1632.68 555.675C1622.47 555.675 1613.47 558.022 1605.64 562.69L1605.63 562.696C1598.11 567.064 1592.17 573.475 1587.8 581.968C1583.44 590.448 1581.25 600.424 1581.25 611.925V742.633H1537.92V608.347C1537.92 591.208 1533.67 578.376 1525.31 569.68L1525.31 569.674L1525.3 569.668C1516.93 560.664 1505.52 556.122 1490.93 556.122C1480.72 556.122 1471.72 558.469 1463.89 563.138L1463.88 563.144C1456.36 567.511 1450.41 573.922 1446.05 582.415L1446.05 582.422L1446.04 582.428C1441.69 590.602 1439.5 600.423 1439.5 611.925V742.633H1395.72V521.919H1435.05V554.803C1439.92 544.379 1447.91 535.465 1458.37 528.356C1470.71 519.875 1485.58 515.657 1502.93 515.657C1522.37 515.657 1538.61 520.931 1551.55 531.538C1560.38 538.771 1567.1 547.628 1571.72 558.091C1576.05 547.619 1582.83 538.757 1592.07 531.524C1605.61 520.93 1622.28 515.657 1642.01 515.657ZM1343.49 452C1351.45 452 1358.23 454.786 1363.75 460.346C1369.27 465.905 1372.04 472.721 1372.04 480.73C1372.04 488.452 1369.27 495.254 1363.77 501.096L1363.76 501.105L1363.75 501.115C1358.23 506.675 1351.45 509.461 1343.49 509.461C1335.81 509.461 1329.05 506.669 1323.25 501.134L1323.23 501.115L1323.21 501.096C1317.71 495.254 1314.94 488.452 1314.94 480.73C1314.94 472.721 1317.7 465.905 1323.23 460.346L1323.24 460.337L1323.25 460.327C1329.05 454.792 1335.81 452 1343.49 452Z'
fill='#fafafa'
/>
</svg>
)
}
interface ModelsOgImageProps {
eyebrow: string
title: string
subtitle: string
pills?: string[]
domainLabel?: string
}
export async function createModelsOgImage({
eyebrow,
title,
subtitle,
pills = [],
domainLabel = 'sim.ai/models',
}: ModelsOgImageProps) {
const text = `${eyebrow}${title}${subtitle}${pills.join('')}${domainLabel}`
const [regularFontData, mediumFontData] = await Promise.all([
loadGoogleFont('Geist', '400', text),
loadGoogleFont('Geist', '500', text),
])
return new ImageResponse(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '56px 64px',
background: '#121212',
fontFamily: 'Geist',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<span
style={{
fontSize: 22,
fontWeight: 500,
color: '#71717a',
letterSpacing: '-0.01em',
}}
>
{eyebrow}
</span>
<span
style={{
fontSize: getTitleFontSize(title),
fontWeight: 500,
color: '#fafafa',
lineHeight: 1.08,
letterSpacing: '-0.03em',
maxWidth: '1000px',
}}
>
{title}
</span>
<span
style={{
fontSize: 28,
fontWeight: 400,
color: '#a1a1aa',
lineHeight: 1.35,
maxWidth: '980px',
}}
>
{subtitle}
</span>
{pills.length > 0 ? (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 4 }}>
{pills.slice(0, 4).map((pill) => (
<div
key={pill}
style={{
display: 'flex',
alignItems: 'center',
borderRadius: 9999,
border: '1px solid #2f2f2f',
background: '#1b1b1b',
padding: '10px 16px',
color: '#d4d4d8',
fontSize: 20,
fontWeight: 500,
}}
>
{pill}
</div>
))}
</div>
) : null}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<SimLogoFull />
<span
style={{
fontSize: 20,
fontWeight: 400,
color: '#71717a',
}}
>
{domainLabel}
</span>
</div>
</div>,
{
...size,
fonts: [
...(regularFontData
? [
{
name: 'Geist',
data: regularFontData,
style: 'normal' as const,
weight: 400 as const,
},
]
: []),
...(mediumFontData
? [
{
name: 'Geist',
data: mediumFontData,
style: 'normal' as const,
weight: 500 as const,
},
]
: []),
],
}
)
}

View File

@@ -0,0 +1,29 @@
import { createModelsOgImage } from '@/app/(landing)/models/og-utils'
import {
formatTokenCount,
MAX_CONTEXT_WINDOW,
TOTAL_MODEL_PROVIDERS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
export const runtime = 'edge'
export const contentType = 'image/png'
export const size = {
width: 1200,
height: 630,
}
export default async function Image() {
return createModelsOgImage({
eyebrow: 'Sim model directory',
title: 'AI Models Directory',
subtitle:
'Browse tracked AI models with pricing, context windows, and workflow-ready capability details.',
pills: [
`${TOTAL_MODELS} models`,
`${TOTAL_MODEL_PROVIDERS} providers`,
`${formatTokenCount(MAX_CONTEXT_WINDOW)} max context`,
],
domainLabel: 'sim.ai/models',
})
}

View File

@@ -0,0 +1,293 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
import { ModelDirectory } from '@/app/(landing)/models/components/model-directory'
import { ModelCard, ProviderCard } from '@/app/(landing)/models/components/model-primitives'
import {
getPricingBounds,
MODEL_CATALOG_PROVIDERS,
MODEL_PROVIDERS_WITH_CATALOGS,
TOP_MODEL_PROVIDERS,
TOTAL_MODEL_PROVIDERS,
TOTAL_MODELS,
} from '@/app/(landing)/models/utils'
const baseUrl = getBaseUrl()
const faqItems = [
{
question: 'What is the Sim AI models directory?',
answer:
'The Sim AI models directory is a public catalog of the language models and providers tracked inside Sim. It shows provider coverage, model IDs, pricing per one million tokens, context windows, and supported capabilities such as reasoning controls, structured outputs, and deep research.',
},
{
question: 'Can I compare models from multiple providers in one place?',
answer:
'Yes. This page organizes every tracked model by provider and lets you search across providers, model names, and capabilities. You can quickly compare OpenAI, Anthropic, Google, xAI, Mistral, Groq, Cerebras, Fireworks, Bedrock, and more from a single directory.',
},
{
question: 'Are these model prices shown per million tokens?',
answer:
'Yes. Input, cached input, and output prices on this page are shown per one million tokens based on the provider metadata tracked in Sim.',
},
{
question: 'Does Sim support providers with dynamic model catalogs too?',
answer:
'Yes. Some providers such as OpenRouter, Fireworks, Ollama, and vLLM load their model lists dynamically at runtime. Those providers are still shown here even when their full public model list is not hard-coded into the catalog.',
},
]
export const metadata: Metadata = {
title: 'AI Models Directory',
description: `Browse ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers. Compare pricing, context windows, and capabilities for OpenAI, Anthropic, Google, xAI, Mistral, Bedrock, Groq, and more.`,
keywords: [
'AI models directory',
'LLM model list',
'model pricing',
'context window comparison',
'OpenAI models',
'Anthropic models',
'Google Gemini models',
'xAI Grok models',
'Mistral models',
...TOP_MODEL_PROVIDERS.map((provider) => `${provider} models`),
],
openGraph: {
title: 'AI Models Directory | Sim',
description: `Explore ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers with pricing, context windows, and capability details.`,
url: `${baseUrl}/models`,
type: 'website',
images: [
{
url: `${baseUrl}/models/opengraph-image`,
width: 1200,
height: 630,
alt: 'Sim AI Models Directory',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'AI Models Directory | Sim',
description: `Search ${TOTAL_MODELS}+ AI models across ${TOTAL_MODEL_PROVIDERS} providers.`,
images: [{ url: `${baseUrl}/models/opengraph-image`, alt: 'Sim AI Models Directory' }],
},
alternates: {
canonical: `${baseUrl}/models`,
},
}
export default function ModelsPage() {
const flatModels = MODEL_CATALOG_PROVIDERS.flatMap((provider) =>
provider.models.map((model) => ({ provider, model }))
)
const featuredProviders = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 6)
const featuredModels = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
provider.featuredModels[0] ? [{ provider, model: provider.featuredModels[0] }] : []
).slice(0, 6)
const heroProviders = ['openai', 'anthropic', 'azure-openai', 'google', 'bedrock']
.map((providerId) => MODEL_CATALOG_PROVIDERS.find((provider) => provider.id === providerId))
.filter(
(provider): provider is (typeof MODEL_CATALOG_PROVIDERS)[number] => provider !== undefined
)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{ '@type': 'ListItem', position: 2, name: 'Models', item: `${baseUrl}/models` },
],
}
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Sim AI Models Directory',
description: `Directory of ${TOTAL_MODELS} AI models tracked in Sim across ${TOTAL_MODEL_PROVIDERS} providers.`,
url: `${baseUrl}/models`,
numberOfItems: TOTAL_MODELS,
itemListElement: flatModels.map(({ provider, model }, index) => {
const { lowPrice, highPrice } = getPricingBounds(model.pricing)
return {
'@type': 'ListItem',
position: index + 1,
item: {
'@type': 'Product',
name: model.displayName,
url: `${baseUrl}${model.href}`,
description: model.summary,
brand: provider.name,
category: 'AI language model',
offers: {
'@type': 'AggregateOffer',
priceCurrency: 'USD',
lowPrice: lowPrice.toString(),
highPrice: highPrice.toString(),
},
},
}
}),
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqItems.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: item.answer,
},
})),
}
return (
<>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className='mx-auto max-w-[1280px] px-6 py-16 sm:px-8 md:px-12'>
<section aria-labelledby='models-heading' className='mb-14'>
<div className='max-w-[840px]'>
<p className='mb-3 text-[12px] text-[var(--landing-text-muted)] uppercase tracking-[0.16em]'>
Public model directory
</p>
<h1
id='models-heading'
className='text-balance font-[500] text-[40px] text-[var(--landing-text)] leading-tight sm:text-[56px]'
>
Browse AI models by provider, pricing, and capabilities
</h1>
<p className='mt-5 max-w-[760px] text-[18px] text-[var(--landing-text-muted)] leading-relaxed'>
Explore every model tracked in Sim across providers like{' '}
{heroProviders.map((provider, index, allProviders) => {
const Icon = provider.icon
return (
<span key={provider.id}>
<span className='inline-flex items-center gap-1 whitespace-nowrap align-[0.02em]'>
{Icon ? (
<span
aria-hidden='true'
className='relative top-[0.02em] inline-flex shrink-0 text-[var(--landing-text)]'
>
<Icon className='h-[0.82em] w-[0.82em]' />
</span>
) : null}
<span>{provider.name}</span>
</span>
{index < allProviders.length - 1 ? ', ' : ''}
</span>
)
})}
{
' and more. Compare model IDs, token pricing, context windows, and features such as reasoning, structured outputs, and deep research from one clean catalog.'
}
</p>
</div>
<div className='mt-8 flex flex-wrap gap-3'>
<a
href='https://sim.ai'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--white)] bg-[var(--white)] px-3 font-[430] text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]'
>
Start building free
</a>
<Link
href='/integrations'
className='inline-flex h-[34px] items-center rounded-[6px] border border-[var(--landing-border-strong)] px-3 font-[430] text-[14px] text-[var(--landing-text)] transition-colors hover:bg-[var(--landing-bg-elevated)]'
>
Explore integrations
</Link>
</div>
</section>
<section aria-labelledby='providers-heading' className='mb-16'>
<div className='mb-6'>
<h2
id='providers-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Browse by provider
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Each provider has its own generated SEO page with model lineup details, featured
models, provider FAQs, and internal links to individual model pages.
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3'>
{featuredProviders.map((provider) => (
<ProviderCard key={provider.id} provider={provider} />
))}
</div>
</section>
<section aria-labelledby='featured-models-heading' className='mb-16'>
<div className='mb-6'>
<h2
id='featured-models-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
Featured model pages
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
These pages are generated directly from the model registry and target high-intent
search queries around pricing, context windows, and model capabilities.
</p>
</div>
<div className='grid grid-cols-1 gap-4 xl:grid-cols-2'>
{featuredModels.map(({ provider, model }) => (
<ModelCard key={model.id} provider={provider} model={model} showProvider />
))}
</div>
</section>
<section aria-labelledby='all-models-heading'>
<div className='mb-6'>
<h2
id='all-models-heading'
className='font-[500] text-[28px] text-[var(--landing-text)]'
>
All models
</h2>
<p className='mt-2 max-w-[760px] text-[15px] text-[var(--landing-text-muted)] leading-relaxed'>
Search the full catalog by provider, model ID, or capability. Use it to compare
providers, sanity-check pricing, and quickly understand which models fit the workflow
you&apos;re building. All pricing is shown per one million tokens using the metadata
currently tracked in Sim.
</p>
</div>
<ModelDirectory />
</section>
<section
aria-labelledby='faq-heading'
className='mt-16 rounded-3xl border border-[var(--landing-border)] bg-[var(--landing-bg-card)] p-6 sm:p-8'
>
<h2 id='faq-heading' className='font-[500] text-[28px] text-[var(--landing-text)]'>
Frequently asked questions
</h2>
<div className='mt-3'>
<LandingFAQ faqs={faqItems} />
</div>
</section>
</div>
</>
)
}

View File

@@ -0,0 +1,790 @@
import type { ComponentType } from 'react'
import { type ModelCapabilities, PROVIDER_DEFINITIONS } from '@/providers/models'
const PROVIDER_PREFIXES: Record<string, string[]> = {
'azure-openai': ['azure/'],
'azure-anthropic': ['azure-anthropic/'],
vertex: ['vertex/'],
bedrock: ['bedrock/'],
cerebras: ['cerebras/'],
fireworks: ['fireworks/'],
groq: ['groq/'],
openrouter: ['openrouter/'],
vllm: ['vllm/'],
}
const PROVIDER_NAME_OVERRIDES: Record<string, string> = {
deepseek: 'DeepSeek',
vllm: 'vLLM',
xai: 'xAI',
}
const TOKEN_REPLACEMENTS: Record<string, string> = {
ai: 'AI',
aws: 'AWS',
gpt: 'GPT',
oss: 'OSS',
llm: 'LLM',
xai: 'xAI',
openai: 'OpenAI',
anthropic: 'Anthropic',
azure: 'Azure',
gemini: 'Gemini',
vertex: 'Vertex',
groq: 'Groq',
mistral: 'Mistral',
deepseek: 'DeepSeek',
cerebras: 'Cerebras',
ollama: 'Ollama',
bedrock: 'Bedrock',
google: 'Google',
moonshotai: 'Moonshot AI',
qwen: 'Qwen',
glm: 'GLM',
kimi: 'Kimi',
nova: 'Nova',
llama: 'Llama',
meta: 'Meta',
cohere: 'Cohere',
amazon: 'Amazon',
opus: 'Opus',
sonnet: 'Sonnet',
haiku: 'Haiku',
flash: 'Flash',
preview: 'Preview',
latest: 'Latest',
mini: 'Mini',
nano: 'Nano',
pro: 'Pro',
plus: 'Plus',
plusplus: 'PlusPlus',
code: 'Code',
codex: 'Codex',
instant: 'Instant',
versatile: 'Versatile',
instruct: 'Instruct',
guard: 'Guard',
safeguard: 'Safeguard',
medium: 'Medium',
small: 'Small',
large: 'Large',
lite: 'Lite',
premier: 'Premier',
premierer: 'Premier',
micro: 'Micro',
reasoning: 'Reasoning',
non: 'Non',
distill: 'Distill',
chat: 'Chat',
text: 'Text',
embedding: 'Embedding',
router: 'Router',
}
export interface PricingInfo {
input: number
cachedInput?: number
output: number
updatedAt: string
}
export interface CatalogFaq {
question: string
answer: string
}
export interface CapabilityFact {
label: string
value: string
}
export interface CatalogModel {
id: string
slug: string
href: string
displayName: string
shortId: string
providerId: string
providerName: string
providerSlug: string
contextWindow: number | null
pricing: PricingInfo
capabilities: ModelCapabilities
capabilityTags: string[]
summary: string
bestFor: string
searchText: string
}
export interface CatalogProvider {
id: string
slug: string
href: string
name: string
description: string
summary: string
defaultModel: string
defaultModelDisplayName: string
icon?: ComponentType<{ className?: string }>
contextInformationAvailable: boolean
providerCapabilityTags: string[]
modelCount: number
models: CatalogModel[]
featuredModels: CatalogModel[]
searchText: string
}
export function formatTokenCount(value?: number | null): string {
if (value == null) {
return 'Unknown'
}
if (value >= 1000000) {
return `${trimTrailingZeros((value / 1000000).toFixed(2))}M`
}
if (value >= 1000) {
return `${trimTrailingZeros((value / 1000).toFixed(0))}k`
}
return value.toLocaleString('en-US')
}
export function formatPrice(price?: number | null): string {
if (price === undefined || price === null) {
return 'N/A'
}
const maximumFractionDigits = price > 0 && price < 0.001 ? 4 : 3
return `$${trimTrailingZeros(
new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits,
}).format(price)
)}`
}
export function formatUpdatedAt(date: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(date))
} catch {
return date
}
}
export function formatCapabilityBoolean(
value: boolean | undefined,
{
positive = 'Supported',
negative = 'Not supported',
}: {
positive?: string
negative?: string
} = {}
): string {
return value ? positive : negative
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/--+/g, '-')
}
function getProviderPrefixes(providerId: string): string[] {
return PROVIDER_PREFIXES[providerId] ?? [`${providerId}/`]
}
function stripProviderPrefix(providerId: string, modelId: string): string {
for (const prefix of getProviderPrefixes(providerId)) {
if (modelId.startsWith(prefix)) {
return modelId.slice(prefix.length)
}
}
return modelId
}
function stripTechnicalSuffixes(value: string): string {
return value
.replace(/-\d{8}-v\d+:\d+$/i, '')
.replace(/-v\d+:\d+$/i, '')
.replace(/-\d{8}$/i, '')
}
function tokenizeModelName(value: string): string[] {
return value
.replace(/[./:_]+/g, '-')
.split('-')
.filter(Boolean)
}
function mergeVersionTokens(tokens: string[]): string[] {
const merged: string[] = []
for (let index = 0; index < tokens.length; index += 1) {
const current = tokens[index]
const next = tokens[index + 1]
if (/^\d{1,2}$/.test(current) && /^\d{1,2}$/.test(next)) {
merged.push(`${current}.${next}`)
index += 1
continue
}
merged.push(current)
}
return merged
}
function formatModelToken(token: string): string {
const normalized = token.toLowerCase()
if (TOKEN_REPLACEMENTS[normalized]) {
return TOKEN_REPLACEMENTS[normalized]
}
if (/^\d+b$/i.test(token)) {
return `${token.slice(0, -1)}B`
}
if (/^\d+e$/i.test(token)) {
return `${token.slice(0, -1)}E`
}
if (/^o\d+$/i.test(token)) {
return token.toLowerCase()
}
if (/^r\d+$/i.test(token)) {
return token.toUpperCase()
}
if (/^v\d+$/i.test(token)) {
return token.toUpperCase()
}
if (/^\d+\.\d+$/.test(token)) {
return token
}
if (/^[a-z]{3,}\d+$/i.test(token)) {
const [, prefix, version] = token.match(/^([a-z]{3,})(\d+)$/i) ?? []
if (prefix && version) {
return `${formatModelToken(prefix)} ${version}`
}
}
if (/^[a-z]\d+[a-z]$/i.test(token)) {
return token.toUpperCase()
}
if (/^\d+$/.test(token)) {
return token
}
return token.charAt(0).toUpperCase() + token.slice(1)
}
function formatModelDisplayName(providerId: string, modelId: string): string {
const shortId = stripProviderPrefix(providerId, modelId)
const normalized = stripTechnicalSuffixes(shortId)
const tokens = mergeVersionTokens(tokenizeModelName(normalized))
const displayName = tokens
.map(formatModelToken)
.join(' ')
.split(/\s+/)
.filter(
(word, index, words) => index === 0 || word.toLowerCase() !== words[index - 1].toLowerCase()
)
.join(' ')
return displayName.replace(/^GPT (\d[\w.]*)/i, 'GPT-$1').replace(/\bGpt\b/g, 'GPT')
}
function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
const tags: string[] = []
if (capabilities.temperature) {
tags.push(`Temperature ${capabilities.temperature.min}-${capabilities.temperature.max}`)
}
if (capabilities.toolUsageControl) {
tags.push('Tool choice')
}
if (capabilities.nativeStructuredOutputs) {
tags.push('Structured outputs')
}
if (capabilities.computerUse) {
tags.push('Computer use')
}
if (capabilities.deepResearch) {
tags.push('Deep research')
}
if (capabilities.reasoningEffort) {
tags.push(`Reasoning ${capabilities.reasoningEffort.values.join(', ')}`)
}
if (capabilities.verbosity) {
tags.push(`Verbosity ${capabilities.verbosity.values.join(', ')}`)
}
if (capabilities.thinking) {
tags.push(`Thinking ${capabilities.thinking.levels.join(', ')}`)
}
if (capabilities.maxOutputTokens) {
tags.push(`Max output ${formatTokenCount(capabilities.maxOutputTokens)}`)
}
if (capabilities.memory === false) {
tags.push('Memory off')
}
return tags
}
function buildBestForLine(model: {
pricing: PricingInfo
capabilities: ModelCapabilities
contextWindow: number | null
}): string {
const { pricing, capabilities, contextWindow } = model
if (capabilities.deepResearch) {
return 'Best for multi-step research workflows and agent-led web investigation.'
}
if (capabilities.reasoningEffort || capabilities.thinking) {
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
}
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
}
if (contextWindow && contextWindow >= 1000000) {
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
}
if (capabilities.nativeStructuredOutputs) {
return 'Best for production workflows that need reliable typed outputs.'
}
return 'Best for general-purpose AI workflows inside Sim.'
}
function buildModelSummary(
providerName: string,
displayName: string,
pricing: PricingInfo,
contextWindow: number | null,
capabilityTags: string[]
): string {
const parts = [
`${displayName} is a ${providerName} model tracked in Sim.`,
contextWindow ? `It supports a ${formatTokenCount(contextWindow)} token context window.` : null,
`Pricing starts at ${formatPrice(pricing.input)}/1M input tokens and ${formatPrice(pricing.output)}/1M output tokens.`,
capabilityTags.length > 0
? `Key capabilities include ${capabilityTags.slice(0, 3).join(', ')}.`
: null,
]
return parts.filter(Boolean).join(' ')
}
function getProviderDisplayName(providerId: string, providerName: string): string {
return PROVIDER_NAME_OVERRIDES[providerId] ?? providerName
}
function computeModelRelevanceScore(model: CatalogModel): number {
return (
(model.capabilities.reasoningEffort ? 10 : 0) +
(model.capabilities.thinking ? 10 : 0) +
(model.capabilities.deepResearch ? 8 : 0) +
(model.capabilities.nativeStructuredOutputs ? 4 : 0) +
(model.contextWindow ?? 0) / 100000
)
}
function compareModelsByRelevance(a: CatalogModel, b: CatalogModel): number {
return computeModelRelevanceScore(b) - computeModelRelevanceScore(a)
}
const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
const providerSlug = slugify(provider.id)
const providerDisplayName = getProviderDisplayName(provider.id, provider.name)
const providerCapabilityTags = buildCapabilityTags(provider.capabilities ?? {})
const models: CatalogModel[] = provider.models.map((model) => {
const shortId = stripProviderPrefix(provider.id, model.id)
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
const capabilityTags = buildCapabilityTags(mergedCapabilities)
const displayName = formatModelDisplayName(provider.id, model.id)
const modelSlug = slugify(shortId)
const href = `/models/${providerSlug}/${modelSlug}`
return {
id: model.id,
slug: modelSlug,
href,
displayName,
shortId,
providerId: provider.id,
providerName: providerDisplayName,
providerSlug,
contextWindow: model.contextWindow ?? null,
pricing: model.pricing,
capabilities: mergedCapabilities,
capabilityTags,
summary: buildModelSummary(
providerDisplayName,
displayName,
model.pricing,
model.contextWindow ?? null,
capabilityTags
),
bestFor: buildBestForLine({
pricing: model.pricing,
capabilities: mergedCapabilities,
contextWindow: model.contextWindow ?? null,
}),
searchText: [
provider.name,
providerDisplayName,
provider.id,
provider.description,
model.id,
shortId,
displayName,
capabilityTags.join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
}
})
const defaultModelDisplayName =
models.find((model) => model.id === provider.defaultModel)?.displayName ||
(provider.defaultModel ? formatModelDisplayName(provider.id, provider.defaultModel) : 'Dynamic')
const featuredModels = [...models].sort(compareModelsByRelevance).slice(0, 6)
return {
id: provider.id,
slug: providerSlug,
href: `/models/${providerSlug}`,
name: providerDisplayName,
description: provider.description,
summary: `${providerDisplayName} has ${models.length} tracked model${models.length === 1 ? '' : 's'} in Sim with pricing, context window, and capability metadata.`,
defaultModel: provider.defaultModel,
defaultModelDisplayName,
icon: provider.icon,
contextInformationAvailable: provider.contextInformationAvailable !== false,
providerCapabilityTags,
modelCount: models.length,
models,
featuredModels,
searchText: [
provider.name,
providerDisplayName,
provider.id,
provider.description,
provider.defaultModel,
defaultModelDisplayName,
providerCapabilityTags.join(' '),
models.map((model) => model.displayName).join(' '),
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
} satisfies CatalogProvider
})
function assertUniqueGeneratedRoutes(providers: CatalogProvider[]): void {
const seenProviderHrefs = new Map<string, string>()
const seenModelHrefs = new Map<string, string>()
for (const provider of providers) {
const existingProvider = seenProviderHrefs.get(provider.href)
if (existingProvider) {
throw new Error(
`Duplicate provider route detected: ${provider.href} for ${provider.id} and ${existingProvider}`
)
}
seenProviderHrefs.set(provider.href, provider.id)
for (const model of provider.models) {
const existingModel = seenModelHrefs.get(model.href)
if (existingModel) {
throw new Error(
`Duplicate model route detected: ${model.href} for ${model.id} and ${existingModel}`
)
}
seenModelHrefs.set(model.href, model.id)
}
}
}
assertUniqueGeneratedRoutes(rawProviders)
export const MODEL_CATALOG_PROVIDERS: CatalogProvider[] = rawProviders
export const MODEL_PROVIDERS_WITH_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
(provider) => provider.models.length > 0
)
export const MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS = MODEL_CATALOG_PROVIDERS.filter(
(provider) => provider.models.length === 0
)
export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap(
(provider) => provider.models
)
export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length
export const TOTAL_MODELS = ALL_CATALOG_MODELS.length
export const MAX_CONTEXT_WINDOW = Math.max(
...ALL_CATALOG_MODELS.map((model) => model.contextWindow ?? 0)
)
export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map(
(provider) => provider.name
)
export function getPricingBounds(pricing: PricingInfo): { lowPrice: number; highPrice: number } {
return {
lowPrice: Math.min(
pricing.input,
pricing.output,
...(pricing.cachedInput !== undefined ? [pricing.cachedInput] : [])
),
highPrice: Math.max(pricing.input, pricing.output),
}
}
export function getProviderBySlug(providerSlug: string): CatalogProvider | null {
return MODEL_CATALOG_PROVIDERS.find((provider) => provider.slug === providerSlug) ?? null
}
export function getModelBySlug(providerSlug: string, modelSlug: string): CatalogModel | null {
const provider = getProviderBySlug(providerSlug)
if (!provider) {
return null
}
return provider.models.find((model) => model.slug === modelSlug) ?? null
}
export function getRelatedModels(targetModel: CatalogModel, limit = 6): CatalogModel[] {
const provider = MODEL_PROVIDERS_WITH_CATALOGS.find(
(entry) => entry.id === targetModel.providerId
)
if (!provider) {
return []
}
const targetTokens = new Set(tokenizeModelName(stripTechnicalSuffixes(targetModel.shortId)))
return provider.models
.filter((model) => model.id !== targetModel.id)
.map((model) => {
const modelTokens = tokenizeModelName(stripTechnicalSuffixes(model.shortId))
const sharedTokenCount = modelTokens.filter((token) => targetTokens.has(token)).length
const sharedCapabilityCount = model.capabilityTags.filter((tag) =>
targetModel.capabilityTags.includes(tag)
).length
return {
model,
score: sharedTokenCount * 2 + sharedCapabilityCount + (model.contextWindow ?? 0) / 1000000,
}
})
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ model }) => model)
}
export function buildProviderFaqs(provider: CatalogProvider): CatalogFaq[] {
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return [
{
question: `What ${provider.name} models are available in Sim?`,
answer: `Sim currently tracks ${provider.modelCount} ${provider.name} model${provider.modelCount === 1 ? '' : 's'} including ${provider.models
.slice(0, 6)
.map((model) => model.displayName)
.join(', ')}${provider.modelCount > 6 ? ', and more' : ''}.`,
},
{
question: `What is the default ${provider.name} model in Sim?`,
answer: provider.defaultModel
? `${provider.defaultModelDisplayName} is the default ${provider.name} model in Sim.`
: `${provider.name} does not have a fixed default model in the public catalog because models are loaded dynamically.`,
},
{
question: `What is the cheapest ${provider.name} model tracked in Sim?`,
answer: cheapestModel
? `${cheapestModel.displayName} currently has the lowest listed input price at ${formatPrice(
cheapestModel.pricing.input
)}/1M tokens.`
: `Sim does not currently expose a fixed public pricing table for ${provider.name} models on this page.`,
},
{
question: `Which ${provider.name} model has the largest context window?`,
answer: largestContextModel?.contextWindow
? `${largestContextModel.displayName} currently has the largest listed context window at ${formatTokenCount(
largestContextModel.contextWindow
)} tokens.`
: `Context window details are not fully available for every ${provider.name} model in the public catalog.`,
},
]
}
export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel): CatalogFaq[] {
return [
{
question: `What is ${model.displayName}?`,
answer: `${model.displayName} is a ${provider.name} model available in Sim. ${model.summary}`,
},
{
question: `How much does ${model.displayName} cost?`,
answer: `${model.displayName} is listed at ${formatPrice(model.pricing.input)}/1M input tokens${model.pricing.cachedInput !== undefined ? `, ${formatPrice(model.pricing.cachedInput)}/1M cached input tokens` : ''}, and ${formatPrice(model.pricing.output)}/1M output tokens.`,
},
{
question: `What is the context window for ${model.displayName}?`,
answer: model.contextWindow
? `${model.displayName} supports a listed context window of ${formatTokenCount(model.contextWindow)} tokens in Sim.`
: `A public context window value is not currently tracked for ${model.displayName}.`,
},
{
question: `What capabilities does ${model.displayName} support?`,
answer:
model.capabilityTags.length > 0
? `${model.displayName} supports ${model.capabilityTags.join(', ')}.`
: `${model.displayName} is available in Sim, but no extra public capability flags are currently tracked for this model.`,
},
]
}
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
const { capabilities } = model
return [
{
label: 'Temperature',
value: capabilities.temperature
? `${capabilities.temperature.min} to ${capabilities.temperature.max}`
: 'Not configurable',
},
{
label: 'Reasoning effort',
value: capabilities.reasoningEffort
? capabilities.reasoningEffort.values.join(', ')
: 'Not supported',
},
{
label: 'Verbosity',
value: capabilities.verbosity ? capabilities.verbosity.values.join(', ') : 'Not supported',
},
{
label: 'Thinking levels',
value: capabilities.thinking
? `${capabilities.thinking.levels.join(', ')}${
capabilities.thinking.default ? ` (default: ${capabilities.thinking.default})` : ''
}`
: 'Not supported',
},
{
label: 'Structured outputs',
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
},
{
label: 'Tool choice',
value: formatCapabilityBoolean(capabilities.toolUsageControl),
},
{
label: 'Computer use',
value: formatCapabilityBoolean(capabilities.computerUse),
},
{
label: 'Deep research',
value: formatCapabilityBoolean(capabilities.deepResearch),
},
{
label: 'Memory support',
value: capabilities.memory === false ? 'Disabled' : 'Supported',
},
{
label: 'Max output tokens',
value: capabilities.maxOutputTokens
? formatTokenCount(capabilities.maxOutputTokens)
: 'Standard defaults',
},
]
}
export function getCheapestProviderModel(provider: CatalogProvider): CatalogModel | null {
return [...provider.models].sort((a, b) => a.pricing.input - b.pricing.input)[0] ?? null
}
export function getLargestContextProviderModel(provider: CatalogProvider): CatalogModel | null {
return (
[...provider.models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0))[0] ?? null
)
}
export function getProviderCapabilitySummary(provider: CatalogProvider): CapabilityFact[] {
const reasoningCount = provider.models.filter(
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
).length
const structuredCount = provider.models.filter(
(model) => model.capabilities.nativeStructuredOutputs
).length
const deepResearchCount = provider.models.filter(
(model) => model.capabilities.deepResearch
).length
const cheapestModel = getCheapestProviderModel(provider)
const largestContextModel = getLargestContextProviderModel(provider)
return [
{
label: 'Reasoning-capable models',
value: reasoningCount > 0 ? `${reasoningCount} tracked` : 'None tracked',
},
{
label: 'Structured outputs',
value: structuredCount > 0 ? `${structuredCount} tracked` : 'None tracked',
},
{
label: 'Deep research models',
value: deepResearchCount > 0 ? `${deepResearchCount} tracked` : 'None tracked',
},
{
label: 'Lowest input price',
value: cheapestModel
? `${cheapestModel.displayName} at ${formatPrice(cheapestModel.pricing.input)}/1M`
: 'Not available',
},
{
label: 'Largest context window',
value: largestContextModel?.contextWindow
? `${largestContextModel.displayName} at ${formatTokenCount(largestContextModel.contextWindow)}`
: 'Not available',
},
]
}

View File

@@ -7,7 +7,10 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import {
getCanonicalScopesForProvider,
getServiceAccountProviderForProviderId,
} from '@/lib/oauth/utils'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -36,7 +39,8 @@ function toCredentialResponse(
displayName: string,
providerId: string,
updatedAt: Date,
scope: string | null
scope: string | null,
credentialType: 'oauth' | 'service_account' = 'oauth'
) {
const storedScope = scope?.trim()
// Some providers (e.g. Box) don't return scopes in their token response,
@@ -52,6 +56,7 @@ function toCredentialResponse(
id,
name: displayName,
provider: providerId,
type: credentialType,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes,
@@ -149,6 +154,7 @@ export async function GET(request: NextRequest) {
displayName: credential.displayName,
providerId: credential.providerId,
accountId: credential.accountId,
updatedAt: credential.updatedAt,
accountProviderId: account.providerId,
accountScope: account.scope,
accountUpdatedAt: account.updatedAt,
@@ -159,6 +165,49 @@ export async function GET(request: NextRequest) {
.limit(1)
if (platformCredential) {
if (platformCredential.type === 'service_account') {
if (
workflowId &&
(!effectiveWorkspaceId || platformCredential.workspaceId !== effectiveWorkspaceId)
) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
if (!workflowId) {
const [membership] = await db
.select({ id: credentialMember.id })
.from(credentialMember)
.where(
and(
eq(credentialMember.credentialId, platformCredential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.limit(1)
if (!membership) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
return NextResponse.json(
{
credentials: [
toCredentialResponse(
platformCredential.id,
platformCredential.displayName,
platformCredential.providerId || 'google-service-account',
platformCredential.updatedAt,
null,
'service_account'
),
],
},
{ status: 200 }
)
}
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) {
return NextResponse.json({ credentials: [] }, { status: 200 })
}
@@ -238,14 +287,52 @@ export async function GET(request: NextRequest) {
)
)
return NextResponse.json(
{
credentials: credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
),
},
{ status: 200 }
const results = credentialsData.map((row) =>
toCredentialResponse(row.id, row.displayName, row.providerId, row.updatedAt, row.scope)
)
const saProviderId = getServiceAccountProviderForProviderId(providerParam)
if (saProviderId) {
const serviceAccountCreds = await db
.select({
id: credential.id,
displayName: credential.displayName,
providerId: credential.providerId,
updatedAt: credential.updatedAt,
})
.from(credential)
.innerJoin(
credentialMember,
and(
eq(credentialMember.credentialId, credential.id),
eq(credentialMember.userId, requesterUserId),
eq(credentialMember.status, 'active')
)
)
.where(
and(
eq(credential.workspaceId, effectiveWorkspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, saProviderId)
)
)
for (const sa of serviceAccountCreds) {
results.push(
toCredentialResponse(
sa.id,
sa.displayName,
sa.providerId || saProviderId,
sa.updatedAt,
null,
'service_account'
)
)
}
}
return NextResponse.json({ credentials: results }, { status: 200 })
}
return NextResponse.json({ credentials: [] }, { status: 200 })

View File

@@ -11,6 +11,8 @@ const {
mockGetCredential,
mockRefreshTokenIfNeeded,
mockGetOAuthToken,
mockResolveOAuthAccountId,
mockGetServiceAccountToken,
mockAuthorizeCredentialUse,
mockCheckSessionOrInternalAuth,
mockLogger,
@@ -29,6 +31,8 @@ const {
mockGetCredential: vi.fn(),
mockRefreshTokenIfNeeded: vi.fn(),
mockGetOAuthToken: vi.fn(),
mockResolveOAuthAccountId: vi.fn(),
mockGetServiceAccountToken: vi.fn(),
mockAuthorizeCredentialUse: vi.fn(),
mockCheckSessionOrInternalAuth: vi.fn(),
mockLogger: logger,
@@ -40,6 +44,8 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
resolveOAuthAccountId: mockResolveOAuthAccountId,
getServiceAccountToken: mockGetServiceAccountToken,
}))
vi.mock('@sim/logger', () => ({
@@ -50,6 +56,10 @@ vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: vi.fn(),
@@ -62,6 +72,7 @@ import { GET, POST } from '@/app/api/auth/oauth/token/route'
describe('OAuth Token API Routes', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveOAuthAccountId.mockResolvedValue(null)
})
/**

View File

@@ -4,7 +4,13 @@ import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import {
getCredential,
getOAuthToken,
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -18,6 +24,8 @@ const tokenRequestSchema = z
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
scopes: z.array(z.string()).optional(),
impersonateEmail: z.string().email().optional(),
})
.refine(
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
@@ -63,7 +71,14 @@ export async function POST(request: NextRequest) {
)
}
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
const {
credentialId,
credentialAccountUserId,
providerId,
workflowId,
scopes,
impersonateEmail,
} = parseResult.data
if (credentialAccountUserId && providerId) {
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
@@ -112,6 +127,31 @@ export async function POST(request: NextRequest) {
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
const resolved = await resolveOAuthAccountId(credentialId)
if (resolved?.credentialType === 'service_account' && resolved.credentialId) {
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,
requireWorkflowIdForInternal: false,
callerUserId,
})
if (!authz.ok) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
try {
const accessToken = await getServiceAccountToken(
resolved.credentialId,
scopes ?? [],
impersonateEmail
)
return NextResponse.json({ accessToken }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Service account token error:`, error)
return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 })
}
}
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId: workflowId ?? undefined,

View File

@@ -160,7 +160,12 @@ describe('OAuth Utils', () => {
describe('refreshAccessTokenIfNeeded', () => {
it('should return valid access token without refresh if not expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'valid-token',
@@ -169,7 +174,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
const token = await refreshAccessTokenIfNeeded('credential-id', 'test-user-id', 'request-id')
@@ -179,7 +184,12 @@ describe('OAuth Utils', () => {
})
it('should refresh token when expired', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -188,7 +198,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockUpdateChain()
@@ -215,7 +225,12 @@ describe('OAuth Utils', () => {
})
it('should return null if refresh fails', async () => {
const mockCredentialRow = { type: 'oauth', accountId: 'account-id' }
const mockResolvedCredential = {
id: 'credential-id',
type: 'oauth',
accountId: 'account-id',
workspaceId: 'workspace-id',
}
const mockAccountRow = {
id: 'account-id',
accessToken: 'expired-token',
@@ -224,7 +239,7 @@ describe('OAuth Utils', () => {
providerId: 'google',
userId: 'test-user-id',
}
mockSelectChain([mockCredentialRow])
mockSelectChain([mockResolvedCredential])
mockSelectChain([mockAccountRow])
mockRefreshOAuthToken.mockResolvedValueOnce(null)

View File

@@ -1,7 +1,9 @@
import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential, credentialSetMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { decryptSecret } from '@/lib/core/security/encryption'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
@@ -11,6 +13,16 @@ import {
const logger = createLogger('OAuthUtilsAPI')
export class ServiceAccountTokenError extends Error {
constructor(
public readonly statusCode: number,
public readonly errorDescription: string
) {
super(errorDescription)
this.name = 'ServiceAccountTokenError'
}
}
interface AccountInsertData {
id: string
userId: string
@@ -25,16 +37,26 @@ interface AccountInsertData {
accessTokenExpiresAt?: Date
}
export interface ResolvedCredential {
accountId: string
workspaceId?: string
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
}
/**
* Resolves a credential ID to its underlying account ID.
* If `credentialId` matches a `credential` row, returns its `accountId` and `workspaceId`.
* For service_account credentials, returns credentialId and type instead of accountId.
* Otherwise assumes `credentialId` is already a raw `account.id` (legacy).
*/
export async function resolveOAuthAccountId(
credentialId: string
): Promise<{ accountId: string; workspaceId?: string; usedCredentialTable: boolean } | null> {
): Promise<ResolvedCredential | null> {
const [credentialRow] = await db
.select({
id: credential.id,
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
@@ -44,6 +66,16 @@ export async function resolveOAuthAccountId(
.limit(1)
if (credentialRow) {
if (credentialRow.type === 'service_account') {
return {
accountId: '',
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
usedCredentialTable: true,
}
}
if (credentialRow.type !== 'oauth' || !credentialRow.accountId) {
return null
}
@@ -57,6 +89,124 @@ export async function resolveOAuthAccountId(
return { accountId: credentialId, usedCredentialTable: false }
}
/**
* Userinfo scopes are excluded because service accounts don't represent a user
* and cannot request user identity information. Google rejects token requests
* that include these scopes for service account credentials.
*/
const SA_EXCLUDED_SCOPES = new Set([
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
])
/**
* Generates a short-lived access token for a Google service account credential
* using the two-legged OAuth JWT flow (RFC 7523).
*
* @param impersonateEmail - Optional. Required for Google Workspace APIs (Gmail, Drive, Calendar, etc.)
* where the service account must impersonate a domain user via domain-wide delegation.
* Not needed for project-scoped APIs like BigQuery or Vertex AI where the service account
* authenticates directly with its own IAM permissions.
*/
export async function getServiceAccountToken(
credentialId: string,
scopes: string[],
impersonateEmail?: string
): Promise<string> {
const [credentialRow] = await db
.select({
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!credentialRow?.encryptedServiceAccountKey) {
throw new Error('Service account key not found')
}
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const keyData = JSON.parse(decrypted) as {
client_email: string
private_key: string
token_uri?: string
}
const filteredScopes = scopes.filter((s) => !SA_EXCLUDED_SCOPES.has(s))
const now = Math.floor(Date.now() / 1000)
const ALLOWED_TOKEN_URIS = new Set(['https://oauth2.googleapis.com/token'])
const tokenUri =
keyData.token_uri && ALLOWED_TOKEN_URIS.has(keyData.token_uri)
? keyData.token_uri
: 'https://oauth2.googleapis.com/token'
const header = { alg: 'RS256', typ: 'JWT' }
const payload: Record<string, unknown> = {
iss: keyData.client_email,
scope: filteredScopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
}
if (impersonateEmail) {
payload.sub = impersonateEmail
}
logger.info('Service account JWT payload', {
iss: keyData.client_email,
sub: impersonateEmail || '(none)',
scopes: filteredScopes.join(' '),
aud: tokenUri,
})
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url')
const signingInput = `${toBase64Url(header)}.${toBase64Url(payload)}`
const signer = createSign('RSA-SHA256')
signer.update(signingInput)
const signature = signer.sign(keyData.private_key, 'base64url')
const jwt = `${signingInput}.${signature}`
const response = await fetch(tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
if (!response.ok) {
const errorBody = await response.text()
logger.error('Service account token exchange failed', {
status: response.status,
body: errorBody,
})
let description = `Token exchange failed: ${response.status}`
try {
const parsed = JSON.parse(errorBody) as { error_description?: string }
if (parsed.error_description) {
const raw = parsed.error_description
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
description = 'Invalid account credentials.'
} else {
description = raw
}
}
} catch {
// use default description
}
throw new ServiceAccountTokenError(response.status, description)
}
const tokenData = (await response.json()) as { access_token: string }
return tokenData.access_token
}
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -81,19 +231,13 @@ export async function safeAccountInsert(
}
/**
* Get a credential by ID and verify it belongs to the user
* Get a credential by resolved account ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
async function getCredentialByAccountId(requestId: string, accountId: string, userId: string) {
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, resolved.accountId), eq(account.userId, userId)))
.where(and(eq(account.id, accountId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
@@ -103,10 +247,22 @@ export async function getCredential(requestId: string, credentialId: string, use
return {
...credentials[0],
resolvedCredentialId: resolved.accountId,
resolvedCredentialId: accountId,
}
}
/**
* Get a credential by ID and verify it belongs to the user.
*/
export async function getCredential(requestId: string, credentialId: string, userId: string) {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
logger.warn(`[${requestId}] Credential is not an OAuth credential`)
return undefined
}
return getCredentialByAccountId(requestId, resolved.accountId, userId)
}
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
@@ -196,19 +352,36 @@ export async function getOAuthToken(userId: string, providerId: string): Promise
}
/**
* Refreshes an OAuth token if needed based on credential information
* Refreshes an OAuth token if needed based on credential information.
* Also handles service account credentials by generating a JWT-based token.
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Request ID for log correlation
* @param scopes Optional scopes for service account token generation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId: string
requestId: string,
scopes?: string[],
impersonateEmail?: string
): Promise<string | null> {
// Get the credential directly using the getCredential helper
const credential = await getCredential(requestId, credentialId, userId)
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return null
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
logger.info(`[${requestId}] Using service account token for credential`)
return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail)
}
// Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query
const credential = await getCredentialByAccountId(requestId, resolved.accountId, userId)
if (!credential) {
return null

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { getCredentialActorContext } from '@/lib/credentials/access'
import {
syncPersonalEnvCredentialsForUser,
@@ -17,12 +18,19 @@ const updateCredentialSchema = z
.object({
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).nullish(),
serviceAccountJson: z.string().min(1).optional(),
})
.strict()
.refine((data) => data.displayName !== undefined || data.description !== undefined, {
message: 'At least one field must be provided',
path: ['displayName'],
})
.refine(
(data) =>
data.displayName !== undefined ||
data.description !== undefined ||
data.serviceAccountJson !== undefined,
{
message: 'At least one field must be provided',
path: ['displayName'],
}
)
async function getCredentialResponse(credentialId: string, userId: string) {
const [row] = await db
@@ -106,12 +114,37 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
updates.description = parseResult.data.description ?? null
}
if (parseResult.data.displayName !== undefined && access.credential.type === 'oauth') {
if (
parseResult.data.displayName !== undefined &&
(access.credential.type === 'oauth' || access.credential.type === 'service_account')
) {
updates.displayName = parseResult.data.displayName
}
if (
parseResult.data.serviceAccountJson !== undefined &&
access.credential.type === 'service_account'
) {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(parseResult.data.serviceAccountJson)
} catch {
return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 })
}
if (
parsed.type !== 'service_account' ||
typeof parsed.client_email !== 'string' ||
typeof parsed.private_key !== 'string' ||
typeof parsed.project_id !== 'string'
) {
return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 })
}
const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson)
updates.encryptedServiceAccountKey = encrypted
}
if (Object.keys(updates).length === 0) {
if (access.credential.type === 'oauth') {
if (access.credential.type === 'oauth' || access.credential.type === 'service_account') {
return NextResponse.json(
{
error: 'No updatable fields provided.',
@@ -134,6 +167,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const row = await getCredentialResponse(id, session.user.id)
return NextResponse.json({ credential: row }, { status: 200 })
} catch (error) {
if (error instanceof Error && error.message.includes('unique')) {
return NextResponse.json(
{ error: 'A service account credential with this name already exists in the workspace' },
{ status: 409 }
)
}
logger.error('Failed to update credential', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
@@ -14,7 +15,7 @@ import { isValidEnvVarName } from '@/executor/constants'
const logger = createLogger('CredentialsAPI')
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal'])
const credentialTypeSchema = z.enum(['oauth', 'env_workspace', 'env_personal', 'service_account'])
function normalizeEnvKeyInput(raw: string): string {
const trimmed = raw.trim()
@@ -29,6 +30,56 @@ const listCredentialsSchema = z.object({
credentialId: z.string().optional(),
})
const serviceAccountJsonSchema = z
.string()
.min(1, 'Service account JSON key is required')
.transform((val, ctx) => {
try {
const parsed = JSON.parse(val)
if (parsed.type !== 'service_account') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must have type "service_account"',
})
return z.NEVER
}
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid client_email',
})
return z.NEVER
}
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid private_key',
})
return z.NEVER
}
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'JSON key must contain a valid project_id',
})
return z.NEVER
}
return parsed as {
type: 'service_account'
client_email: string
private_key: string
project_id: string
[key: string]: unknown
}
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON format',
})
return z.NEVER
}
})
const createCredentialSchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
@@ -39,6 +90,7 @@ const createCredentialSchema = z
accountId: z.string().trim().min(1).optional(),
envKey: z.string().trim().min(1).optional(),
envOwnerUserId: z.string().trim().min(1).optional(),
serviceAccountJson: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.type === 'oauth') {
@@ -66,6 +118,17 @@ const createCredentialSchema = z
return
}
if (data.type === 'service_account') {
if (!data.serviceAccountJson) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'serviceAccountJson is required for service account credentials',
path: ['serviceAccountJson'],
})
}
return
}
const normalizedEnvKey = data.envKey ? normalizeEnvKeyInput(data.envKey) : ''
if (!normalizedEnvKey) {
ctx.addIssue({
@@ -87,14 +150,16 @@ const createCredentialSchema = z
interface ExistingCredentialSourceParams {
workspaceId: string
type: 'oauth' | 'env_workspace' | 'env_personal'
type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
accountId?: string | null
envKey?: string | null
envOwnerUserId?: string | null
displayName?: string | null
providerId?: string | null
}
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
const { workspaceId, type, accountId, envKey, envOwnerUserId } = params
const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
if (type === 'oauth' && accountId) {
const [row] = await db
@@ -142,6 +207,22 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
return row ?? null
}
if (type === 'service_account' && displayName && providerId) {
const [row] = await db
.select()
.from(credential)
.where(
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'service_account'),
eq(credential.providerId, providerId),
eq(credential.displayName, displayName)
)
)
.limit(1)
return row ?? null
}
return null
}
@@ -288,6 +369,7 @@ export async function POST(request: NextRequest) {
accountId,
envKey,
envOwnerUserId,
serviceAccountJson,
} = parseResult.data
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
@@ -301,6 +383,7 @@ export async function POST(request: NextRequest) {
let resolvedAccountId: string | null = accountId ?? null
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
let resolvedEnvOwnerUserId: string | null = null
let resolvedEncryptedServiceAccountKey: string | null = null
if (type === 'oauth') {
const [accountRow] = await db
@@ -335,6 +418,33 @@ export async function POST(request: NextRequest) {
resolvedDisplayName =
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'service_account') {
if (!serviceAccountJson) {
return NextResponse.json(
{ error: 'serviceAccountJson is required for service account credentials' },
{ status: 400 }
)
}
const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
if (!jsonParseResult.success) {
return NextResponse.json(
{ error: jsonParseResult.error.errors[0]?.message || 'Invalid service account JSON' },
{ status: 400 }
)
}
const parsed = jsonParseResult.data
resolvedProviderId = 'google-service-account'
resolvedAccountId = null
resolvedEnvOwnerUserId = null
if (!resolvedDisplayName) {
resolvedDisplayName = parsed.client_email
}
const { encrypted } = await encryptSecret(serviceAccountJson)
resolvedEncryptedServiceAccountKey = encrypted
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
@@ -363,6 +473,8 @@ export async function POST(request: NextRequest) {
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
displayName: resolvedDisplayName,
providerId: resolvedProviderId,
})
if (existingCredential) {
@@ -441,12 +553,13 @@ export async function POST(request: NextRequest) {
accountId: resolvedAccountId,
envKey: resolvedEnvKey,
envOwnerUserId: resolvedEnvOwnerUserId,
encryptedServiceAccountKey: resolvedEncryptedServiceAccountKey,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
})
if (type === 'env_workspace' && workspaceRow?.ownerId) {
if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) {
const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
if (workspaceUserIds.length > 0) {
for (const memberUserId of workspaceUserIds) {

View File

@@ -342,7 +342,7 @@ describe('Function Execute API Route', () => {
code: 'return "Email sent to user"',
params: {
email: {
from: 'Waleed Latif <waleed@sim.ai>',
from: 'Dr. Shaw <shaw@high-flying.ai>',
to: 'User <user@example.com>',
},
},
@@ -378,7 +378,7 @@ describe('Function Execute API Route', () => {
async () => {
const emailData = {
id: '123',
from: 'Waleed Latif <waleed@sim.ai>',
from: 'Dr. Shaw <shaw@high-flying.ai>',
to: 'User <user@example.com>',
subject: 'Test Email',
bodyText: 'Hello world',

View File

@@ -6,7 +6,11 @@ import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { refreshTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import {
getServiceAccountToken,
refreshTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import type { StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -365,6 +369,14 @@ async function resolveVertexCredential(requestId: string, credentialId: string):
throw new Error(`Vertex AI credential not found: ${credentialId}`)
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
const accessToken = await getServiceAccountToken(resolved.credentialId, [
'https://www.googleapis.com/auth/cloud-platform',
])
logger.info(`[${requestId}] Successfully resolved Vertex AI service account credential`)
return accessToken
}
const credential = await db.query.account.findFirst({
where: eq(account.id, resolved.accountId),
})

View File

@@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleDriveFileAPI')
@@ -26,6 +27,7 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId || !fileId) {
logger.warn(`[${requestId}] Missing required parameters`)
@@ -46,7 +48,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-drive'),
impersonateEmail
)
if (!accessToken) {
@@ -157,6 +161,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ file }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching file from Google Drive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -4,7 +4,8 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleDriveFilesAPI')
@@ -85,6 +86,7 @@ export async function GET(request: NextRequest) {
const query = searchParams.get('query') || ''
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
@@ -100,7 +102,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId!,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-drive'),
impersonateEmail
)
if (!accessToken) {
@@ -175,6 +179,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ files }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching files from Google Drive`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -26,6 +32,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const labelId = searchParams.get('labelId')
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId || !labelId) {
logger.warn(`[${requestId}] Missing required parameters`)
@@ -58,29 +65,40 @@ export async function GET(request: NextRequest) {
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
let accessToken: string | null = null
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
accessToken = await getServiceAccountToken(
resolved.credentialId,
getScopesForService('gmail'),
impersonateEmail
)
} else {
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId,
getScopesForService('gmail')
)
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
@@ -127,6 +145,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ label: formattedLabel }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Gmail label:`, error)
return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 })
}

View File

@@ -6,7 +6,13 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import {
getServiceAccountToken,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
ServiceAccountTokenError,
} from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GmailLabelsAPI')
@@ -33,6 +39,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query')
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
@@ -62,29 +69,40 @@ export async function GET(request: NextRequest) {
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
let accessToken: string | null = null
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
accessToken = await getServiceAccountToken(
resolved.credentialId,
getScopesForService('gmail'),
impersonateEmail
)
} else {
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId,
getScopesForService('gmail')
)
}
const accountRow = credentials[0]
logger.info(
`[${requestId}] Using credential: ${accountRow.id}, provider: ${accountRow.providerId}`
)
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
@@ -139,6 +157,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ labels: filteredLabels }, { status: 200 })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Gmail labels:`, error)
return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 })
}

View File

@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryDatasetsAPI')
@@ -20,7 +21,7 @@ export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, projectId } = body
const { credential, workflowId, projectId, impersonateEmail } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -43,7 +44,9 @@ export async function POST(request: Request) {
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-bigquery'),
impersonateEmail
)
if (!accessToken) {
logger.error('Failed to get access token', {
@@ -91,6 +94,9 @@ export async function POST(request: Request) {
return NextResponse.json({ datasets })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error processing BigQuery datasets request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message },

View File

@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryTablesAPI')
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, projectId, datasetId } = body
const { credential, workflowId, projectId, datasetId, impersonateEmail } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -40,7 +41,9 @@ export async function POST(request: Request) {
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-bigquery'),
impersonateEmail
)
if (!accessToken) {
logger.error('Failed to get access token', {
@@ -85,6 +88,9 @@ export async function POST(request: Request) {
return NextResponse.json({ tables })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error processing BigQuery tables request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve BigQuery tables', details: (error as Error).message },

View File

@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleCalendarAPI')
@@ -28,6 +29,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
@@ -41,7 +43,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-calendar'),
impersonateEmail
)
if (!accessToken) {
@@ -98,6 +102,10 @@ export async function GET(request: NextRequest) {
})),
})
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Google calendars`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -3,7 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -40,6 +41,7 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const spreadsheetId = searchParams.get('spreadsheetId')
const workflowId = searchParams.get('workflowId') || undefined
const impersonateEmail = searchParams.get('impersonateEmail') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
@@ -59,7 +61,9 @@ export async function GET(request: NextRequest) {
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-sheets'),
impersonateEmail
)
if (!accessToken) {
@@ -114,6 +118,10 @@ export async function GET(request: NextRequest) {
})),
})
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
logger.warn(`[${requestId}] Service account token error`, { message: error.message })
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}

View File

@@ -2,7 +2,8 @@ import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getScopesForService } from '@/lib/oauth/utils'
import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleTasksTaskListsAPI')
@@ -12,7 +13,7 @@ export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
const { credential, workflowId, impersonateEmail } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -30,7 +31,9 @@ export async function POST(request: Request) {
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
requestId,
getScopesForService('google-tasks'),
impersonateEmail
)
if (!accessToken) {
logger.error('Failed to get access token', {
@@ -70,6 +73,9 @@ export async function POST(request: Request) {
return NextResponse.json({ taskLists })
} catch (error) {
if (error instanceof ServiceAccountTokenError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error('Error processing Google Tasks task lists request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message },

View File

@@ -1,3 +1,4 @@
import type { SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager'
import {
CreateSecretCommand,
DeleteSecretCommand,
@@ -61,7 +62,7 @@ export async function listSecrets(
})
const response = await client.send(command)
const secrets = (response.SecretList ?? []).map((secret) => ({
const secrets = (response.SecretList ?? []).map((secret: SecretListEntry) => ({
name: secret.Name ?? '',
arn: secret.ARN ?? '',
description: secret.Description ?? null,
@@ -69,7 +70,7 @@ export async function listSecrets(
lastChangedDate: secret.LastChangedDate?.toISOString() ?? null,
lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null,
rotationEnabled: secret.RotationEnabled ?? false,
tags: secret.Tags?.map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [],
tags: secret.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [],
}))
return {

View File

@@ -28,6 +28,13 @@ vi.mock('@sim/db', () => ({
db: {
select: (...args: unknown[]) => mockDbSelect(...args),
insert: (...args: unknown[]) => mockDbInsert(...args),
transaction: vi.fn(async (fn: (tx: Record<string, unknown>) => Promise<void>) => {
const tx = {
select: (...args: unknown[]) => mockDbSelect(...args),
insert: (...args: unknown[]) => mockDbInsert(...args),
}
await fn(tx)
}),
},
}))
@@ -87,6 +94,18 @@ vi.mock('@/lib/core/telemetry', () => ({
},
}))
vi.mock('@/lib/workflows/defaults', () => ({
buildDefaultWorkflowArtifacts: vi.fn().mockReturnValue({
workflowState: { blocks: {}, edges: [], loops: {}, parallels: {} },
subBlockValues: {},
startBlockId: 'start-block-id',
}),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
saveWorkflowToNormalizedTables: vi.fn().mockResolvedValue({ success: true }),
}))
import { POST } from '@/app/api/workflows/route'
describe('Workflows API Route - POST ordering', () => {

View File

@@ -8,6 +8,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
@@ -247,24 +249,30 @@ export async function POST(req: NextRequest) {
// Silently fail
})
await db.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: folderId || null,
sortOrder,
name,
description,
color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts()
await db.transaction(async (tx) => {
await tx.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: folderId || null,
sortOrder,
name,
description,
color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
})
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`)
recordAudit({
workspaceId,
@@ -290,6 +298,8 @@ export async function POST(req: NextRequest) {
sortOrder,
createdAt: now,
updatedAt: now,
startBlockId,
subBlockValues,
})
} catch (error) {
if (error instanceof z.ZodError) {

View File

@@ -173,6 +173,9 @@ async function createWorkspace(
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
}
logger.info(
@@ -181,15 +184,6 @@ async function createWorkspace(
: `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
)
})
if (!skipDefaultWorkflow) {
const { workflowState } = buildDefaultWorkflowArtifacts()
const seedResult = await saveWorkflowToNormalizedTables(workflowId, workflowState)
if (!seedResult.success) {
throw new Error(seedResult.error || 'Failed to seed default workflow state')
}
}
} catch (error) {
logger.error(`Failed to create workspace ${workspaceId}:`, error)
throw error

View File

@@ -9,7 +9,7 @@ export async function GET() {
## Overview
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that execute real workflows across systems, and manage them with full observability. SOC2 and HIPAA compliant.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. Teams connect their tools and data, build agents that execute real workflows across systems, and manage them with full observability. SOC2 compliant.
## Product Details
@@ -17,7 +17,7 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
- **Category**: AI Agent Platform / Agentic Workflow Orchestration
- **Deployment**: Cloud (SaaS) and Self-hosted options
- **Pricing**: Free tier, Pro ($25/month, 6K credits), Max ($100/month, 25K credits), Team plans available, Enterprise (custom)
- **Compliance**: SOC2 Type II, HIPAA compliant
- **Compliance**: SOC2 Type II
## Core Concepts

View File

@@ -7,7 +7,7 @@ export async function GET() {
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 and HIPAA compliant.
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
## Core Pages

View File

@@ -14,7 +14,7 @@ export const metadata: Metadata = {
description:
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.',
keywords:
'AI agents, agentic workforce, open-source AI agent platform, agentic workflows, LLM orchestration, AI automation, knowledge base, workflow builder, AI integrations, SOC2 compliant, HIPAA compliant, enterprise AI',
'AI agents, agentic workforce, open-source AI agent platform, agentic workflows, LLM orchestration, AI automation, knowledge base, workflow builder, AI integrations, SOC2 compliant, enterprise AI',
authors: [{ name: 'Sim' }],
creator: 'Sim',
publisher: 'Sim',

View File

@@ -2,6 +2,7 @@ import type { MetadataRoute } from 'next'
import { getAllPostMeta } from '@/lib/blog/registry'
import { getBaseUrl } from '@/lib/core/utils/urls'
import integrations from '@/app/(landing)/integrations/data/integrations.json'
import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
@@ -29,6 +30,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
url: `${baseUrl}/integrations`,
lastModified: now,
},
{
url: `${baseUrl}/models`,
lastModified: now,
},
{
url: `${baseUrl}/changelog`,
lastModified: now,
@@ -54,5 +59,15 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
lastModified: now,
}))
return [...staticPages, ...blogPages, ...integrationPages]
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
url: `${baseUrl}${provider.href}`,
lastModified: now,
}))
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
url: `${baseUrl}${model.href}`,
lastModified: new Date(model.pricing.updatedAt),
}))
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
}

View File

@@ -0,0 +1,72 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/auth/auth-client', () => ({
client: { oauth2: { link: vi.fn() } },
useSession: vi.fn(() => ({ data: null, isPending: false, error: null })),
}))
vi.mock('@/lib/credentials/client-state', () => ({
writeOAuthReturnContext: vi.fn(),
}))
vi.mock('@/hooks/queries/credentials', () => ({
useCreateCredentialDraft: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
}))
vi.mock('@/lib/oauth', () => ({
getCanonicalScopesForProvider: vi.fn(() => []),
getProviderIdFromServiceId: vi.fn((id: string) => id),
OAUTH_PROVIDERS: {},
parseProvider: vi.fn((p: string) => ({ baseProvider: p, variant: null })),
}))
vi.mock('@/lib/oauth/utils', () => ({
getScopeDescription: vi.fn((s: string) => s),
}))
import { getDefaultCredentialName } from '@/app/workspace/[workspaceId]/components/oauth-modal'
describe('getDefaultCredentialName', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses the user name when available', () => {
expect(getDefaultCredentialName('Waleed', 'Google Drive', 0)).toBe("Waleed's Google Drive 1")
})
it('increments the number based on existing credential count', () => {
expect(getDefaultCredentialName('Waleed', 'Google Drive', 2)).toBe("Waleed's Google Drive 3")
})
it('falls back to "My" when user name is null', () => {
expect(getDefaultCredentialName(null, 'Slack', 0)).toBe('My Slack 1')
})
it('falls back to "My" when user name is undefined', () => {
expect(getDefaultCredentialName(undefined, 'Gmail', 1)).toBe('My Gmail 2')
})
it('falls back to "My" when user name is empty string', () => {
expect(getDefaultCredentialName('', 'GitHub', 0)).toBe('My GitHub 1')
})
it('falls back to "My" when user name is whitespace-only', () => {
expect(getDefaultCredentialName(' ', 'Notion', 0)).toBe('My Notion 1')
})
it('trims whitespace from user name', () => {
expect(getDefaultCredentialName(' Waleed ', 'Linear', 0)).toBe("Waleed's Linear 1")
})
it('works with zero existing credentials', () => {
expect(getDefaultCredentialName('Alice', 'Jira', 0)).toBe("Alice's Jira 1")
})
it('works with many existing credentials', () => {
expect(getDefaultCredentialName('Bob', 'Slack', 9)).toBe("Bob's Slack 10")
})
})

View File

@@ -0,0 +1,304 @@
'use client'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check } from 'lucide-react'
import {
Badge,
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { client, useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
const logger = createLogger('OAuthModal')
const EMPTY_SCOPES: string[] = []
/**
* Generates a default credential display name.
* Format: "{User}'s {Provider} {N}" or "My {Provider} {N}" when no user name is available.
*/
export function getDefaultCredentialName(
userName: string | null | undefined,
providerName: string,
credentialCount: number
): string {
const trimmed = userName?.trim()
const num = credentialCount + 1
if (trimmed) {
return `${trimmed}'s ${providerName} ${num}`
}
return `My ${providerName} ${num}`
}
interface OAuthModalBaseProps {
isOpen: boolean
onClose: () => void
provider: OAuthProvider
serviceId: string
}
type OAuthModalConnectProps = OAuthModalBaseProps & {
mode: 'connect'
workspaceId: string
credentialCount: number
} & (
| { workflowId: string; knowledgeBaseId?: never }
| { workflowId?: never; knowledgeBaseId: string }
)
interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
mode: 'reauthorize'
toolName: string
requiredScopes?: string[]
newScopes?: string[]
onConnect?: () => Promise<void> | void
}
export type OAuthModalProps = OAuthModalConnectProps | OAuthModalReauthorizeProps
export function OAuthModal(props: OAuthModalProps) {
const { isOpen, onClose, provider, serviceId, mode } = props
const isConnect = mode === 'connect'
const credentialCount = isConnect ? props.credentialCount : 0
const workspaceId = isConnect ? props.workspaceId : ''
const workflowId = isConnect ? props.workflowId : undefined
const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
const toolName = !isConnect ? props.toolName : ''
const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
const onConnectOverride = !isConnect ? props.onConnect : undefined
const { data: session } = useSession()
const [error, setError] = useState<string | null>(null)
const createDraft = useCreateCredentialDraft()
const { providerName, ProviderIcon } = useMemo(() => {
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
let name = baseProviderConfig?.name || provider
let Icon = baseProviderConfig?.icon || (() => null)
if (baseProviderConfig) {
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
if (key === serviceId || service.providerId === provider) {
name = service.name
Icon = service.icon
break
}
}
}
return { providerName: name, ProviderIcon: Icon }
}, [provider, serviceId])
const providerId = getProviderIdFromServiceId(serviceId)
const [displayName, setDisplayName] = useState(() =>
isConnect ? getDefaultCredentialName(session?.user?.name, providerName, credentialCount) : ''
)
const newScopesSet = useMemo(
() =>
new Set(
newScopes.filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
),
[newScopes]
)
const displayScopes = useMemo(() => {
if (isConnect) {
return getCanonicalScopesForProvider(providerId).filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
}
const filtered = requiredScopes.filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
return filtered.sort((a, b) => {
const aIsNew = newScopesSet.has(a)
const bIsNew = newScopesSet.has(b)
if (aIsNew && !bIsNew) return -1
if (!aIsNew && bIsNew) return 1
return 0
})
}, [isConnect, providerId, requiredScopes, newScopesSet])
const handleClose = () => {
setError(null)
onClose()
}
const handleConnect = async () => {
setError(null)
try {
if (isConnect) {
const trimmedName = displayName.trim()
if (!trimmedName) {
setError('Display name is required.')
return
}
await createDraft.mutateAsync({
workspaceId,
providerId,
displayName: trimmedName,
})
const baseContext = {
displayName: trimmedName,
providerId,
preCount: credentialCount,
workspaceId,
requestedAt: Date.now(),
}
const returnContext: OAuthReturnContext = knowledgeBaseId
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
writeOAuthReturnContext(returnContext)
}
if (!isConnect && onConnectOverride) {
await onConnectOverride()
onClose()
return
}
if (!isConnect) {
logger.info('Linking OAuth2:', {
providerId,
requiredScopes,
hasNewScopes: newScopes.length > 0,
})
}
if (providerId === 'trello') {
if (!isConnect) onClose()
window.location.href = '/api/auth/trello/authorize'
return
}
if (providerId === 'shopify') {
if (!isConnect) onClose()
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
return
}
await client.oauth2.link({ providerId, callbackURL: window.location.href })
handleClose()
} catch (err) {
logger.error('Failed to initiate OAuth connection', { error: err })
setError('Failed to connect. Please try again.')
}
}
const isPending = isConnect && createDraft.isPending
const isConnectDisabled = isConnect ? !displayName.trim() || Boolean(isPending) : false
const subtitle = isConnect
? `Grant access to use ${providerName} in your ${knowledgeBaseId ? 'knowledge base' : 'workflow'}`
: `The "${toolName}" tool requires access to your account`
return (
<Modal open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<ModalContent size='md'>
<ModalHeader>Connect {providerName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-lg bg-[var(--surface-5)]'>
<ProviderIcon className='h-[18px] w-[18px]' />
</div>
<div className='flex-1'>
<p className='font-medium text-[var(--text-primary)] text-small'>
Connect your {providerName} account
</p>
<p className='text-[var(--text-tertiary)] text-caption'>{subtitle}</p>
</div>
</div>
{displayScopes.length > 0 && (
<div className='rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='border-[var(--border-1)] border-b px-3.5 py-2.5'>
<h4 className='font-medium text-[var(--text-primary)] text-caption'>
Permissions requested
</h4>
</div>
<ul className='max-h-[200px] space-y-2.5 overflow-y-auto px-3.5 py-3'>
{displayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-2.5'>
<div className='mt-0.5 flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
</div>
<div className='flex flex-1 items-center gap-2 text-[var(--text-primary)] text-caption'>
<span>{getScopeDescription(scope)}</span>
{!isConnect && newScopesSet.has(scope) && (
<Badge variant='amber' size='sm'>
New
</Badge>
)}
</div>
</li>
))}
</ul>
</div>
)}
{isConnect && (
<div>
<Label>
Display name <span className='text-[var(--text-muted)]'>*</span>
</Label>
<Input
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isPending) void handleConnect()
}}
placeholder={`My ${providerName} account`}
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
)}
{error && <p className='text-[var(--text-error)] text-caption'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={Boolean(isPending)}>
Cancel
</Button>
<Button variant='primary' onClick={handleConnect} disabled={isConnectDisabled}>
{isPending ? 'Connecting...' : 'Connect'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -21,8 +21,8 @@ import {
} from '@/components/emcn'
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { getDependsOnFields } from '@/blocks/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
@@ -553,7 +553,8 @@ export function AddConnectorModal({ open, onOpenChange, knowledgeBaseId }: AddCo
connectorConfig &&
connectorConfig.auth.mode === 'oauth' &&
connectorProviderId && (
<ConnectCredentialModal
<OAuthModal
mode='connect'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()

View File

@@ -35,9 +35,8 @@ import {
type OAuthProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
import { EditConnectorModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal'
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
import type { ConnectorData, SyncLogData } from '@/hooks/queries/kb/connectors'
import {
@@ -520,7 +519,8 @@ function ConnectorCard({
)}
{showOAuthModal && serviceId && providerId && !connector.credentialId && (
<ConnectCredentialModal
<OAuthModal
mode='connect'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()
@@ -535,7 +535,8 @@ function ConnectorCard({
)}
{showOAuthModal && serviceId && providerId && connector.credentialId && (
<OAuthRequiredModal
<OAuthModal
mode='reauthorize'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()

View File

@@ -23,6 +23,7 @@ import {
} from '@/components/emcn'
import { Input as UiInput } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
import { cn } from '@/lib/core/utils/cn'
import {
clearPendingCredentialCreateRequest,
PENDING_CREDENTIAL_CREATE_REQUEST_EVENT,
@@ -91,6 +92,13 @@ export function IntegrationsManager() {
| { type: 'kb-connectors'; knowledgeBaseId: string }
| undefined
>(undefined)
const [saJsonInput, setSaJsonInput] = useState('')
const [saDisplayName, setSaDisplayName] = useState('')
const [saDescription, setSaDescription] = useState('')
const [saError, setSaError] = useState<string | null>(null)
const [saIsSubmitting, setSaIsSubmitting] = useState(false)
const [saDragActive, setSaDragActive] = useState(false)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -110,7 +118,7 @@ export function IntegrationsManager() {
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
const oauthCredentials = useMemo(
() => credentials.filter((c) => c.type === 'oauth'),
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
[credentials]
)
@@ -348,11 +356,7 @@ export function IntegrationsManager() {
const isSelectedAdmin = selectedCredential?.role === 'admin'
const selectedOAuthServiceConfig = useMemo(() => {
if (
!selectedCredential ||
selectedCredential.type !== 'oauth' ||
!selectedCredential.providerId
) {
if (!selectedCredential?.providerId) {
return null
}
@@ -366,6 +370,10 @@ export function IntegrationsManager() {
setCreateError(null)
setCreateStep(1)
setServiceSearch('')
setSaJsonInput('')
setSaDisplayName('')
setSaDescription('')
setSaError(null)
pendingReturnOriginRef.current = undefined
}
@@ -456,25 +464,30 @@ export function IntegrationsManager() {
setDeleteError(null)
try {
if (!credentialToDelete.accountId || !credentialToDelete.providerId) {
const errorMessage =
'Cannot disconnect: missing account information. Please try reconnecting this credential first.'
setDeleteError(errorMessage)
logger.error('Cannot disconnect OAuth credential: missing accountId or providerId')
return
}
await disconnectOAuthService.mutateAsync({
provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId,
providerId: credentialToDelete.providerId,
serviceId: credentialToDelete.providerId,
accountId: credentialToDelete.accountId,
})
await refetchCredentials()
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId: credentialToDelete.providerId, workspaceId },
if (credentialToDelete.type === 'service_account') {
await deleteCredential.mutateAsync(credentialToDelete.id)
await refetchCredentials()
} else {
if (!credentialToDelete.accountId || !credentialToDelete.providerId) {
const errorMessage =
'Cannot disconnect: missing account information. Please try reconnecting this credential first.'
setDeleteError(errorMessage)
logger.error('Cannot disconnect OAuth credential: missing accountId or providerId')
return
}
await disconnectOAuthService.mutateAsync({
provider: credentialToDelete.providerId.split('-')[0] || credentialToDelete.providerId,
providerId: credentialToDelete.providerId,
serviceId: credentialToDelete.providerId,
accountId: credentialToDelete.accountId,
})
)
await refetchCredentials()
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId: credentialToDelete.providerId, workspaceId },
})
)
}
if (selectedCredentialId === credentialToDelete.id) {
setSelectedCredentialId(null)
@@ -624,6 +637,117 @@ export function IntegrationsManager() {
setShowCreateModal(true)
}, [])
const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(raw)
} catch {
return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' }
}
if (parsed.type !== 'service_account') {
return { valid: false, error: 'JSON key must have "type": "service_account".' }
}
if (!parsed.client_email || typeof parsed.client_email !== 'string') {
return { valid: false, error: 'Missing "client_email" field.' }
}
if (!parsed.private_key || typeof parsed.private_key !== 'string') {
return { valid: false, error: 'Missing "private_key" field.' }
}
if (!parsed.project_id || typeof parsed.project_id !== 'string') {
return { valid: false, error: 'Missing "project_id" field.' }
}
return { valid: true }
}
const handleCreateServiceAccount = async () => {
setSaError(null)
const trimmed = saJsonInput.trim()
if (!trimmed) {
setSaError('Paste the service account JSON key.')
return
}
const validation = validateServiceAccountJson(trimmed)
if (!validation.valid) {
setSaError(validation.error ?? 'Invalid JSON')
return
}
setSaIsSubmitting(true)
try {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
displayName: saDisplayName.trim() || undefined,
description: saDescription.trim() || undefined,
serviceAccountJson: trimmed,
})
setShowCreateModal(false)
resetCreateForm()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to add service account'
setSaError(message)
logger.error('Failed to create service account credential', error)
} finally {
setSaIsSubmitting(false)
}
}
const readSaJsonFile = useCallback(
(file: File) => {
if (!file.name.endsWith('.json')) {
setSaError('Only .json files are supported')
return
}
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target?.result
if (typeof text === 'string') {
setSaJsonInput(text)
setSaError(null)
try {
const parsed = JSON.parse(text)
if (parsed.client_email && !saDisplayName.trim()) {
setSaDisplayName(parsed.client_email)
}
} catch {
// validation will catch this on submit
}
}
}
reader.readAsText(file)
},
[saDisplayName]
)
const handleSaFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
readSaJsonFile(file)
event.target.value = ''
}
const handleSaDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(true)
}, [])
const handleSaDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(false)
}, [])
const handleSaDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setSaDragActive(false)
const file = event.dataTransfer.files[0]
if (file) readSaJsonFile(file)
},
[readSaJsonFile]
)
const filteredServices = useMemo(() => {
if (!serviceSearch.trim()) return oauthServiceOptions
const q = serviceSearch.toLowerCase()
@@ -700,7 +824,7 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : (
) : selectedOAuthService?.authType !== 'service_account' ? (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
@@ -827,6 +951,160 @@ export function IntegrationsManager() {
</Button>
</ModalFooter>
</>
) : (
<>
<ModalHeader>
<div className='flex items-center gap-2.5'>
<button
type='button'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
className='flex h-6 w-6 items-center justify-center rounded-[4px] text-[var(--text-muted)] hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
aria-label='Back'
>
</button>
<span>
Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
</span>
</div>
</ModalHeader>
<ModalBody>
{saError && (
<div className='mb-3'>
<Badge variant='red' size='lg' dot className='max-w-full'>
{saError}
</Badge>
</div>
)}
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
{selectedOAuthService &&
createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Add {selectedOAuthService?.name || 'service account'}
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedOAuthService?.description || 'Paste or upload the JSON key file'}
</p>
<a
href='https://docs.sim.ai/credentials/google-service-account'
target='_blank'
rel='noopener noreferrer'
className='text-[12px] text-[var(--accent)] hover:underline'
>
View setup guide
</a>
</div>
</div>
<div>
<Label>
JSON Key<span className='ml-1'>*</span>
</Label>
<div
onDragOver={handleSaDragOver}
onDragLeave={handleSaDragLeave}
onDrop={handleSaDrop}
className={cn(
'relative mt-1.5 rounded-md border-2 border-dashed transition-colors',
saDragActive
? 'border-[var(--accent)] bg-[var(--accent)]/5'
: 'border-transparent'
)}
>
{saDragActive && (
<div className='pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md bg-[var(--accent)]/5'>
<p className='font-medium text-[13px] text-[var(--accent)]'>
Drop JSON key file here
</p>
</div>
)}
<Textarea
value={saJsonInput}
onChange={(event) => {
setSaJsonInput(event.target.value)
setSaError(null)
if (!saDisplayName.trim()) {
try {
const parsed = JSON.parse(event.target.value)
if (parsed.client_email) setSaDisplayName(parsed.client_email)
} catch {
// not valid yet
}
}
}}
placeholder='Paste your service account JSON key here or drag & drop a .json file...'
autoComplete='off'
data-lpignore='true'
className={cn(
'min-h-[120px] resize-none border-0 font-mono text-[12px]',
saDragActive && 'opacity-30'
)}
autoFocus
/>
</div>
<div className='mt-1.5'>
<label className='inline-flex cursor-pointer items-center gap-1.5 text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'>
<input
type='file'
accept='.json'
onChange={handleSaFileUpload}
className='hidden'
/>
Or upload a .json file
</label>
</div>
</div>
<div>
<Label>Display name</Label>
<Input
value={saDisplayName}
onChange={(event) => setSaDisplayName(event.target.value)}
placeholder='Auto-populated from client_email'
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
<div>
<Label>Description</Label>
<Textarea
value={saDescription}
onChange={(event) => setSaDescription(event.target.value)}
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-1.5 min-h-[80px] resize-none'
/>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setCreateStep(1)
setSaError(null)
}}
>
Back
</Button>
<Button
variant='primary'
onClick={handleCreateServiceAccount}
disabled={!saJsonInput.trim() || saIsSubmitting}
>
{saIsSubmitting ? 'Adding...' : 'Add Service Account'}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
@@ -869,9 +1147,11 @@ export function IntegrationsManager() {
<Button
variant='destructive'
onClick={handleConfirmDelete}
disabled={disconnectOAuthService.isPending}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
{disconnectOAuthService.isPending ? 'Disconnecting...' : 'Disconnect'}
{disconnectOAuthService.isPending || deleteCredential.isPending
? 'Disconnecting...'
: 'Disconnect'}
</Button>
</ModalFooter>
</ModalContent>
@@ -920,10 +1200,14 @@ export function IntegrationsManager() {
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<p className='truncate font-medium text-[var(--text-primary)] text-base'>
{resolveProviderLabel(selectedCredential.providerId) || 'Unknown service'}
{selectedOAuthServiceConfig?.name ||
resolveProviderLabel(selectedCredential.providerId) ||
'Unknown service'}
</p>
<Badge variant='gray-secondary' size='sm'>
oauth
{selectedOAuthServiceConfig?.authType === 'service_account'
? 'service account'
: 'oauth'}
</Badge>
{selectedCredential.role && (
<Badge variant='gray-secondary' size='sm'>
@@ -931,7 +1215,9 @@ export function IntegrationsManager() {
</Badge>
)}
</div>
<p className='text-[var(--text-muted)] text-small'>Connected service</p>
<p className='text-[var(--text-muted)] text-small'>
{selectedOAuthServiceConfig?.description || 'Connected service'}
</p>
</div>
</div>
@@ -1116,15 +1402,17 @@ export function IntegrationsManager() {
<div className='flex items-center gap-2'>
{isSelectedAdmin && (
<>
<Button
variant='default'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
{selectedOAuthServiceConfig?.authType !== 'service_account' && (
<Button
variant='default'
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
)}
{(workspaceUserOptions.length > 0 || isShareingWithWorkspace) && (
<Button
variant='default'
@@ -1138,7 +1426,7 @@ export function IntegrationsManager() {
<Button
variant='ghost'
onClick={() => handleDeleteClick(selectedCredential)}
disabled={disconnectOAuthService.isPending}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
Disconnect
</Button>
@@ -1234,7 +1522,11 @@ export function IntegrationsManager() {
<Button
variant='ghost'
onClick={() => handleDeleteClick(credential)}
disabled={disconnectOAuthService.isPending}
disabled={
credential.type === 'service_account'
? deleteCredential.isPending
: disconnectOAuthService.isPending
}
>
Disconnect
</Button>

View File

@@ -21,7 +21,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Constants for ComboBox component behavior
*/
const DEFAULT_MODEL = 'claude-sonnet-4-5'
const DEFAULT_MODEL = 'claude-sonnet-4-6'
const ZOOM_FACTOR_BASE = 0.96
const MIN_ZOOM = 0.1
const MAX_ZOOM = 1
@@ -234,7 +234,7 @@ export const ComboBox = memo(function ComboBox({
/**
* Determines the default option value to use.
* Priority: explicit defaultValue > claude-sonnet-4-5 for model field > first option
* Priority: explicit defaultValue > claude-sonnet-4-6 for model field > first option
*/
const defaultOptionValue = useMemo(() => {
if (defaultValue !== undefined) {
@@ -246,11 +246,13 @@ export const ComboBox = memo(function ComboBox({
// Default not available (e.g. provider disabled) — fall through to other fallbacks
}
// For model field, default to claude-sonnet-4-5 if available
// For model field, default to claude-sonnet-4-6 if available
if (subBlockId === 'model') {
const claudeSonnet45 = evaluatedOptions.find((opt) => getOptionValue(opt) === DEFAULT_MODEL)
if (claudeSonnet45) {
return getOptionValue(claudeSonnet45)
const defaultModelOption = evaluatedOptions.find(
(opt) => getOptionValue(opt) === DEFAULT_MODEL
)
if (defaultModelOption) {
return getOptionValue(defaultModelOption)
}
}

View File

@@ -1,222 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check } from 'lucide-react'
import {
Button,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { client } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import {
getCanonicalScopesForProvider,
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
const logger = createLogger('ConnectCredentialModal')
interface ConnectCredentialModalBaseProps {
isOpen: boolean
onClose: () => void
provider: OAuthProvider
serviceId: string
workspaceId: string
/** Number of existing credentials for this provider — used to detect a successful new connection. */
credentialCount: number
}
export type ConnectCredentialModalProps = ConnectCredentialModalBaseProps &
(
| { workflowId: string; knowledgeBaseId?: never }
| { workflowId?: never; knowledgeBaseId: string }
)
export function ConnectCredentialModal({
isOpen,
onClose,
provider,
serviceId,
workspaceId,
workflowId,
knowledgeBaseId,
credentialCount,
}: ConnectCredentialModalProps) {
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState<string | null>(null)
const createDraft = useCreateCredentialDraft()
const { providerName, ProviderIcon } = useMemo(() => {
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
let name = baseProviderConfig?.name || provider
let Icon = baseProviderConfig?.icon || (() => null)
if (baseProviderConfig) {
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
if (key === serviceId || service.providerId === provider) {
name = service.name
Icon = service.icon
break
}
}
}
return { providerName: name, ProviderIcon: Icon }
}, [provider, serviceId])
const providerId = getProviderIdFromServiceId(serviceId)
const displayScopes = useMemo(
() =>
getCanonicalScopesForProvider(providerId).filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
),
[providerId]
)
const handleClose = () => {
setDisplayName('')
setError(null)
onClose()
}
const handleConnect = async () => {
const trimmedName = displayName.trim()
if (!trimmedName) {
setError('Display name is required.')
return
}
setError(null)
try {
await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmedName })
const baseContext = {
displayName: trimmedName,
providerId,
preCount: credentialCount,
workspaceId,
requestedAt: Date.now(),
}
const returnContext: OAuthReturnContext = knowledgeBaseId
? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId }
: { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
writeOAuthReturnContext(returnContext)
if (providerId === 'trello') {
window.location.href = '/api/auth/trello/authorize'
return
}
if (providerId === 'shopify') {
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
return
}
await client.oauth2.link({ providerId, callbackURL: window.location.href })
handleClose()
} catch (err) {
logger.error('Failed to initiate OAuth connection', { error: err })
setError('Failed to connect. Please try again.')
}
}
const isPending = createDraft.isPending
return (
<Modal open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<ModalContent size='md'>
<ModalHeader>Connect {providerName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-[8px] bg-[var(--surface-5)]'>
<ProviderIcon className='h-[18px] w-[18px]' />
</div>
<div>
<p className='font-medium text-[13px] text-[var(--text-primary)]'>
Connect your {providerName} account
</p>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Grant access to use {providerName} in your workflow
</p>
</div>
</div>
{displayScopes.length > 0 && (
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='border-[var(--border-1)] border-b px-3.5 py-2.5'>
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
Permissions requested
</h4>
</div>
<ul className='max-h-[200px] space-y-2.5 overflow-y-auto px-3.5 py-3'>
{displayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-2.5'>
<div className='mt-0.5 flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
</div>
<div className='flex flex-1 items-center gap-2 text-[12px] text-[var(--text-primary)]'>
<span>{getScopeDescription(scope)}</span>
</div>
</li>
))}
</ul>
</div>
)}
<div>
<Label>
Display name <span className='text-[var(--text-muted)]'>*</span>
</Label>
<Input
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isPending) void handleConnect()
}}
placeholder={`My ${providerName} account`}
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
/>
</div>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isPending}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleConnect}
disabled={!displayName.trim() || isPending}
>
{isPending ? 'Connecting...' : 'Connect'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -1,190 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check } from 'lucide-react'
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { client } from '@/lib/auth/auth-client'
import {
getProviderIdFromServiceId,
getScopeDescription,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
const logger = createLogger('OAuthRequiredModal')
export interface OAuthRequiredModalProps {
isOpen: boolean
onClose: () => void
provider: OAuthProvider
toolName: string
requiredScopes?: string[]
serviceId: string
newScopes?: string[]
onConnect?: () => Promise<void> | void
}
export function OAuthRequiredModal({
isOpen,
onClose,
provider,
toolName,
requiredScopes = [],
serviceId,
newScopes = [],
onConnect,
}: OAuthRequiredModalProps) {
const [error, setError] = useState<string | null>(null)
const { baseProvider } = parseProvider(provider)
const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
let providerName = baseProviderConfig?.name || provider
let ProviderIcon = baseProviderConfig?.icon || (() => null)
if (baseProviderConfig) {
for (const [key, service] of Object.entries(baseProviderConfig.services)) {
if (key === serviceId || service.providerId === provider) {
providerName = service.name
ProviderIcon = service.icon
break
}
}
}
const newScopesSet = useMemo(
() =>
new Set(
(newScopes || []).filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
),
[newScopes]
)
const displayScopes = useMemo(() => {
const filtered = requiredScopes.filter(
(scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
)
return filtered.sort((a, b) => {
const aIsNew = newScopesSet.has(a)
const bIsNew = newScopesSet.has(b)
if (aIsNew && !bIsNew) return -1
if (!aIsNew && bIsNew) return 1
return 0
})
}, [requiredScopes, newScopesSet])
const handleConnectDirectly = async () => {
setError(null)
try {
if (onConnect) {
await onConnect()
onClose()
return
}
const providerId = getProviderIdFromServiceId(serviceId)
logger.info('Linking OAuth2:', {
providerId,
requiredScopes,
hasNewScopes: newScopes.length > 0,
})
if (providerId === 'trello') {
onClose()
window.location.href = '/api/auth/trello/authorize'
return
}
if (providerId === 'shopify') {
onClose()
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
return
}
await client.oauth2.link({
providerId,
callbackURL: window.location.href,
})
onClose()
} catch (err) {
logger.error('Error initiating OAuth flow:', { error: err })
setError('Failed to connect. Please try again.')
}
}
return (
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()}>
<ModalContent size='md'>
<ModalHeader>Connect {providerName}</ModalHeader>
<ModalBody>
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3.5'>
<div className='flex h-[40px] w-[40px] flex-shrink-0 items-center justify-center rounded-lg bg-[var(--surface-5)]'>
<ProviderIcon className='h-[18px] w-[18px]' />
</div>
<div className='flex-1'>
<p className='font-medium text-[var(--text-primary)] text-small'>
Connect your {providerName} account
</p>
<p className='text-[var(--text-tertiary)] text-caption'>
The "{toolName}" tool requires access to your account
</p>
</div>
</div>
{displayScopes.length > 0 && (
<div className='rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)]'>
<div className='border-[var(--border-1)] border-b px-3.5 py-2.5'>
<h4 className='font-medium text-[var(--text-primary)] text-caption'>
Permissions requested
</h4>
</div>
<ul className='max-h-[200px] space-y-2.5 overflow-y-auto px-3.5 py-3'>
{displayScopes.map((scope) => (
<li key={scope} className='flex items-start gap-2.5'>
<div className='mt-0.5 flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<Check className='h-[10px] w-[10px] text-[var(--text-primary)]' />
</div>
<div className='flex flex-1 items-center gap-2 text-[var(--text-primary)] text-caption'>
<span>{getScopeDescription(scope)}</span>
{newScopesSet.has(scope) && (
<Badge variant='amber' size='sm'>
New
</Badge>
)}
</div>
</li>
))}
</ul>
</div>
)}
{error && <p className='text-[var(--text-error)] text-caption'>{error}</p>}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={onClose}>
Cancel
</Button>
<Button variant='primary' type='button' onClick={handleConnectDirectly}>
Connect
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -16,8 +16,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { SubBlockConfig } from '@/blocks/types'
@@ -98,8 +97,10 @@ export function CredentialSelector({
)
const provider = effectiveProviderId
const isTriggerMode = subBlock.mode === 'trigger'
const {
data: credentials = [],
data: rawCredentials = [],
isFetching: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(effectiveProviderId, {
@@ -108,11 +109,24 @@ export function CredentialSelector({
workflowId: activeWorkflowId || undefined,
})
const credentials = useMemo(
() =>
isTriggerMode
? rawCredentials.filter((cred) => cred.type !== 'service_account')
: rawCredentials,
[rawCredentials, isTriggerMode]
)
const selectedCredential = useMemo(
() => credentials.find((cred) => cred.id === selectedId),
[credentials, selectedId]
)
const isServiceAccount = useMemo(
() => selectedCredential?.type === 'service_account',
[selectedCredential]
)
const selectedCredentialSet = useMemo(
() => credentialSets.find((cs) => cs.id === selectedCredentialSetId),
[credentialSets, selectedCredentialSetId]
@@ -151,6 +165,7 @@ export function CredentialSelector({
const needsUpdate =
hasSelection &&
!isServiceAccount &&
missingRequiredScopes.length > 0 &&
!effectiveDisabled &&
!isPreview &&
@@ -230,6 +245,7 @@ export function CredentialSelector({
const credentialItems = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
}))
credentialItems.push({
label:
@@ -237,6 +253,7 @@ export function CredentialSelector({
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='h-3 w-3' />,
})
groups.push({
@@ -250,6 +267,7 @@ export function CredentialSelector({
const options = credentials.map((cred) => ({
label: cred.name,
value: cred.id,
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
}))
options.push({
@@ -258,6 +276,7 @@ export function CredentialSelector({
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='h-3 w-3' />,
})
return { comboboxOptions: options, comboboxGroups: undefined }
@@ -265,6 +284,7 @@ export function CredentialSelector({
credentials,
provider,
effectiveProviderId,
getProviderIcon,
getProviderName,
canUseCredentialSets,
credentialSets,
@@ -300,6 +320,7 @@ export function CredentialSelector({
selectedCredentialProvider,
isCredentialSetSelected,
selectedCredentialSet,
isServiceAccount,
])
const handleComboboxChange = useCallback(
@@ -378,7 +399,8 @@ export function CredentialSelector({
)}
{showConnectModal && (
<ConnectCredentialModal
<OAuthModal
mode='connect'
isOpen={showConnectModal}
onClose={() => setShowConnectModal(false)}
provider={provider}
@@ -390,7 +412,8 @@ export function CredentialSelector({
)}
{showOAuthModal && (
<OAuthRequiredModal
<OAuthModal
mode='reauthorize'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()

View File

@@ -15,8 +15,7 @@ import {
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/connect-credential-modal'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
import { useWorkflowMap } from '@/hooks/queries/workflows'
@@ -245,7 +244,8 @@ export function ToolCredentialSelector({
)}
{showConnectModal && (
<ConnectCredentialModal
<OAuthModal
mode='connect'
isOpen={showConnectModal}
onClose={() => setShowConnectModal(false)}
provider={provider}
@@ -257,7 +257,8 @@ export function ToolCredentialSelector({
)}
{showOAuthModal && (
<OAuthRequiredModal
<OAuthModal
mode='reauthorize'
isOpen={showOAuthModal}
onClose={() => {
consumeOAuthReturnContext()

View File

@@ -51,6 +51,7 @@ import { getAllBlocks } from '@/blocks'
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import {
type CustomTool as CustomToolDefinition,
useCustomTools,
@@ -88,6 +89,7 @@ import {
evaluateSubBlockCondition,
isCanonicalPair,
resolveCanonicalMode,
resolveDependencyValue,
type SubBlockCondition,
} from '@/tools/params-resolver'
@@ -482,6 +484,42 @@ export const ToolInput = memo(function ToolInput({
? (value as StoredTool[])
: []
// Look up credential type for reactive condition filtering (e.g. service account detection).
// Uses canonical resolution so the active field (basic vs advanced) is respected.
const toolCredentialId = useMemo(() => {
const allBlocks = getAllBlocks()
for (const tool of selectedTools) {
const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type)
if (!blockConfig?.subBlocks) continue
const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks)
const scopedOverrides: CanonicalModeOverrides = {}
if (canonicalModeOverrides) {
for (const [key, val] of Object.entries(canonicalModeOverrides)) {
const prefix = `${tool.type}:`
if (key.startsWith(prefix) && val) {
scopedOverrides[key.slice(prefix.length)] = val as 'basic' | 'advanced'
}
}
}
const reactiveSubBlock = blockConfig.subBlocks.find(
(sb: { reactiveCondition?: unknown }) => sb.reactiveCondition
)
const reactiveCond = reactiveSubBlock?.reactiveCondition as
| { watchFields: string[]; requiredType: string }
| undefined
if (!reactiveCond) continue
for (const field of reactiveCond.watchFields) {
const val = resolveDependencyValue(field, tool.params || {}, toolCanonical, scopedOverrides)
if (val && typeof val === 'string') return val
}
}
return undefined
}, [selectedTools, canonicalModeOverrides])
const { data: toolCredential } = useWorkspaceCredential(
toolCredentialId,
Boolean(toolCredentialId)
)
const hasReferenceOnlyCustomTools = selectedTools.some(
(tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code
)
@@ -1637,7 +1675,11 @@ export const ToolInput = memo(function ToolInput({
? mcpToolParams
: toolParams?.userInputParameters || []
const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks
? subBlocksResult!.subBlocks
? subBlocksResult!.subBlocks.filter(
(sb) =>
!sb.reactiveCondition ||
toolCredential?.type === sb.reactiveCondition.requiredType
)
: []
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)

View File

@@ -9,6 +9,7 @@ import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useDependsOnGate } from './use-depends-on-gate'
import { useSubBlockValue } from './use-sub-block-value'
/**
* Resolves all selector configuration from a sub-block's declarative properties.
@@ -39,6 +40,8 @@ export function useSelectorSetup(
opts
)
const [impersonateUserEmail] = useSubBlockValue<string | null>(blockId, 'impersonateUserEmail')
const resolvedDependencyValues = useMemo(() => {
const resolved: Record<string, unknown> = {}
for (const [key, value] of Object.entries(dependencyValues)) {
@@ -75,8 +78,18 @@ export function useSelectorSetup(
}
}
if (context.oauthCredential && impersonateUserEmail) {
context.impersonateUserEmail = impersonateUserEmail
}
return context
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
}, [
resolvedDependencyValues,
canonicalIndex,
workflowId,
subBlock.mimeType,
impersonateUserEmail,
])
return {
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,

View File

@@ -1,17 +1,77 @@
import { useCallback, useMemo } from 'react'
import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockHidden,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
import type { BlockConfig, SubBlockConfig, SubBlockType } from '@/blocks/types'
import { useWorkspaceCredential } from '@/hooks/queries/credentials'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
/**
* Evaluates reactive conditions for subblocks. Always calls the same hooks
* regardless of whether a reactive condition exists (Rules of Hooks).
*
* Returns a Set of subblock IDs that should be hidden.
*/
function useReactiveConditions(
subBlocks: SubBlockConfig[],
blockId: string,
activeWorkflowId: string | null,
canonicalModeOverrides?: CanonicalModeOverrides
): Set<string> {
const reactiveSubBlock = useMemo(() => subBlocks.find((sb) => sb.reactiveCondition), [subBlocks])
const reactiveCond = reactiveSubBlock?.reactiveCondition
const canonicalIndex = useMemo(() => buildCanonicalIndex(subBlocks), [subBlocks])
// Resolve watchFields through canonical index to get the active credential value
const watchedCredentialId = useSubBlockStore(
useCallback(
(state) => {
if (!reactiveCond || !activeWorkflowId) return ''
const blockValues = state.workflowValues[activeWorkflowId]?.[blockId] ?? {}
for (const field of reactiveCond.watchFields) {
const val = resolveDependencyValue(
field,
blockValues,
canonicalIndex,
canonicalModeOverrides
)
if (val && typeof val === 'string') return val
}
return ''
},
[reactiveCond, activeWorkflowId, blockId, canonicalIndex, canonicalModeOverrides]
)
)
// Always call useWorkspaceCredential (stable hook count), disable when not needed
const { data: credential } = useWorkspaceCredential(
watchedCredentialId || undefined,
Boolean(reactiveCond && watchedCredentialId)
)
return useMemo(() => {
const hidden = new Set<string>()
if (!reactiveSubBlock || !reactiveCond) return hidden
const conditionMet = credential?.type === reactiveCond.requiredType
if (!conditionMet) {
hidden.add(reactiveSubBlock.id)
}
return hidden
}, [reactiveSubBlock, reactiveCond, credential?.type])
}
/**
* Custom hook for computing subblock layout in the editor panel.
* Determines which subblocks should be visible based on mode, conditions, and feature flags.
@@ -39,6 +99,14 @@ export function useEditorSubblockLayout(
)
const { config: permissionConfig } = usePermissionConfig()
// Evaluate reactive conditions (hooks-based, must be called before useMemo)
const hiddenByReactiveCondition = useReactiveConditions(
config?.subBlocks || [],
blockId,
activeWorkflowId,
blockDataFromStore?.canonicalModes
)
return useMemo(() => {
// Guard against missing config or block selection
if (!config || !Array.isArray((config as any).subBlocks) || !blockId) {
@@ -100,17 +168,26 @@ export function useEditorSubblockLayout(
const effectiveAdvanced = displayAdvancedMode
const canonicalModeOverrides = blockData?.canonicalModes
// Expose canonical mode overrides to condition functions so they can
// react to basic/advanced credential toggles (e.g. SERVICE_ACCOUNT_SUBBLOCKS).
if (canonicalModeOverrides) {
rawValues.__canonicalModes = canonicalModeOverrides
}
const visibleSubBlocks = (config.subBlocks || []).filter((block) => {
if (block.hidden) return false
// Filter by reactive condition (evaluated via hooks before useMemo)
if (hiddenByReactiveCondition.has(block.id)) return false
// Hide skill-input subblock when skills are disabled via permissions
if (block.type === 'skill-input' && permissionConfig.disableSkills) return false
// Check required feature if specified - declarative feature gating
if (!isSubBlockFeatureEnabled(block)) return false
// Hide tool API key fields when hosted
if (isSubBlockHiddenByHostedKey(block)) return false
// Hide tool API key fields when hosted or when env var is set
if (isSubBlockHidden(block)) return false
// Special handling for trigger-config type (legacy trigger configuration UI)
if (block.type === ('trigger-config' as SubBlockType)) {
@@ -158,6 +235,7 @@ export function useEditorSubblockLayout(
activeWorkflowId,
isSnapshotView,
blockDataFromStore,
hiddenByReactiveCondition,
permissionConfig.disableSkills,
])
}

View File

@@ -17,7 +17,7 @@ import {
evaluateSubBlockCondition,
hasAdvancedValues,
isSubBlockFeatureEnabled,
isSubBlockHiddenByHostedKey,
isSubBlockHidden,
isSubBlockVisibleForMode,
resolveDependencyValue,
} from '@/lib/workflows/subblocks/visibility'
@@ -980,7 +980,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
if (block.hidden) return false
if (block.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(block)) return false
if (isSubBlockHiddenByHostedKey(block)) return false
if (isSubBlockHidden(block)) return false
const isPureTriggerBlock = config?.triggers?.enabled && config.category === 'triggers'

View File

@@ -23,6 +23,7 @@ import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/creden
import type { OAuthProvider } from '@/lib/oauth'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
CommandList,
@@ -97,11 +98,6 @@ const LazyChat = lazy(() =>
default: mod.Chat,
}))
)
const LazyOAuthRequiredModal = lazy(() =>
import(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
).then((mod) => ({ default: mod.OAuthRequiredModal }))
)
const logger = createLogger('Workflow')
@@ -2236,6 +2232,10 @@ const WorkflowContent = React.memo(
return
}
if (hydration.phase === 'creating') {
return
}
// If already loading (state-loading phase), skip
if (hydration.phase === 'state-loading' && hydration.workflowId === currentId) {
return
@@ -2303,6 +2303,10 @@ const WorkflowContent = React.memo(
return
}
if (hydration.phase === 'creating') {
return
}
// If no workflows exist after loading, redirect to workspace root
if (workflowCount === 0) {
logger.info('No workflows found, redirecting to workspace root')
@@ -4127,20 +4131,19 @@ const WorkflowContent = React.memo(
{(!embedded || sandbox) && <Panel workspaceId={sandbox ? workspaceId : undefined} />}
{!embedded && !sandbox && oauthModal && (
<Suspense fallback={null}>
<LazyOAuthRequiredModal
isOpen={true}
onClose={() => {
consumeOAuthReturnContext()
setOauthModal(null)
}}
provider={oauthModal.provider}
toolName={oauthModal.providerName}
serviceId={oauthModal.serviceId}
requiredScopes={oauthModal.requiredScopes}
newScopes={oauthModal.newScopes}
/>
</Suspense>
<OAuthModal
mode='reauthorize'
isOpen={true}
onClose={() => {
consumeOAuthReturnContext()
setOauthModal(null)
}}
provider={oauthModal.provider}
toolName={oauthModal.providerName}
serviceId={oauthModal.serviceId}
requiredScopes={oauthModal.requiredScopes}
newScopes={oauthModal.newScopes}
/>
)}
</div>
)

View File

@@ -32,6 +32,7 @@ import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -135,29 +136,23 @@ export function FolderItem({
const isEditingRef = useRef(false)
const handleCreateWorkflowInFolder = useCallback(async () => {
try {
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
const handleCreateWorkflowInFolder = useCallback(() => {
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
const id = crypto.randomUUID()
const result = await createWorkflowMutation.mutateAsync({
workspaceId,
folderId: folder.id,
name,
color,
id: crypto.randomUUID(),
})
createWorkflowMutation.mutate({
workspaceId,
folderId: folder.id,
name,
color,
id,
})
if (result.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
expandFolder()
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
)
}
} catch (error) {
logger.error('Failed to create workflow in folder:', error)
}
useWorkflowRegistry.getState().markWorkflowCreating(id)
expandFolder()
router.push(`/workspace/${workspaceId}/w/${id}`)
window.dispatchEvent(new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: id } }))
}, [createWorkflowMutation, workspaceId, folder.id, router, expandFolder])
const handleCreateFolderInFolder = useCallback(async () => {

View File

@@ -1,13 +1,11 @@
import { useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')
interface UseWorkflowOperationsProps {
workspaceId: string
}
@@ -25,30 +23,24 @@ export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProp
[workflows, workspaceId]
)
const handleCreateWorkflow = useCallback(async (): Promise<string | null> => {
try {
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
const handleCreateWorkflow = useCallback((): Promise<string | null> => {
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
const name = generateCreativeWorkflowName()
const color = getNextWorkflowColor()
const id = crypto.randomUUID()
const result = await createWorkflowMutation.mutateAsync({
workspaceId,
name,
color,
id: crypto.randomUUID(),
})
createWorkflowMutation.mutate({
workspaceId,
name,
color,
id,
})
if (result.id) {
router.push(`/workspace/${workspaceId}/w/${result.id}`)
return result.id
}
return null
} catch (error) {
logger.error('Error creating workflow:', error)
return null
}
useWorkflowRegistry.getState().markWorkflowCreating(id)
router.push(`/workspace/${workspaceId}/w/${id}`)
return Promise.resolve(id)
}, [createWorkflowMutation, workspaceId, router])
return {

View File

@@ -39,6 +39,7 @@ export function useWorkspaceManagement({
const {
data: workspaces = [],
isLoading: isWorkspacesLoading,
isFetching: isWorkspacesFetching,
refetch: refetchWorkspaces,
} = useWorkspacesQuery(Boolean(sessionUserId))
@@ -71,6 +72,9 @@ export function useWorkspaceManagement({
const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
if (!matchingWorkspace) {
if (isWorkspacesFetching) {
return
}
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
const fallbackWorkspace = workspaces[0]
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
@@ -78,7 +82,7 @@ export function useWorkspaceManagement({
}
hasValidatedRef.current = true
}, [workspaces, isWorkspacesLoading])
}, [workspaces, isWorkspacesLoading, isWorkspacesFetching])
const refreshWorkspaceList = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })

View File

@@ -15,6 +15,7 @@ import { useParams } from 'next/navigation'
import type { Socket } from 'socket.io-client'
import { getEnv } from '@/lib/core/config/env'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { useWorkflowRegistry as useWorkflowRegistryStore } from '@/stores/workflows/registry/store'
const logger = createLogger('SocketContext')
@@ -387,13 +388,11 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
{ useWorkflowRegistry },
{ useWorkflowStore },
{ useSubBlockStore },
{ useWorkflowDiffStore },
] = await Promise.all([
import('@/stores/operation-queue/store'),
import('@/stores/workflows/registry/store'),
import('@/stores/workflows/workflow/store'),
import('@/stores/workflows/subblock/store'),
import('@/stores/workflow-diff/store'),
])
const { activeWorkflowId } = useWorkflowRegistry.getState()
@@ -542,9 +541,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
}
}, [user?.id, authFailed])
const hydrationPhase = useWorkflowRegistryStore((s) => s.hydration.phase)
useEffect(() => {
if (!socket || !isConnected || !urlWorkflowId) return
if (hydrationPhase === 'creating') return
// Skip if already in the correct room
if (currentWorkflowId === urlWorkflowId) return
@@ -562,7 +565,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
workflowId: urlWorkflowId,
tabSessionId: getTabSessionId(),
})
}, [socket, isConnected, urlWorkflowId, currentWorkflowId])
}, [socket, isConnected, urlWorkflowId, currentWorkflowId, hydrationPhase])
const joinWorkflow = useCallback(
(workflowId: string) => {

View File

@@ -337,7 +337,7 @@ describe.concurrent('Blocks Module', () => {
expect(modelSubBlock).toBeDefined()
expect(modelSubBlock?.type).toBe('combobox')
expect(modelSubBlock?.required).toBe(true)
expect(modelSubBlock?.defaultValue).toBe('claude-sonnet-4-5')
expect(modelSubBlock?.defaultValue).toBe('claude-sonnet-4-6')
})
it('should have LLM tool access', () => {

View File

@@ -1,9 +1,12 @@
import { createLogger } from '@sim/logger'
import { AgentIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { getApiKeyCondition, getModelOptions, RESPONSE_FORMAT_WAND_CONFIG } from '@/blocks/utils'
import {
getModelOptions,
getProviderCredentialSubBlocks,
RESPONSE_FORMAT_WAND_CONFIG,
} from '@/blocks/utils'
import {
getBaseModelProviders,
getMaxTemperature,
@@ -12,7 +15,6 @@ import {
getModelsWithReasoningEffort,
getModelsWithThinking,
getModelsWithVerbosity,
getProviderModels,
getReasoningEffortValuesForModel,
getThinkingLevelsForModel,
getVerbosityValuesForModel,
@@ -23,9 +25,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { ToolResponse } from '@/tools/types'
const logger = createLogger('AgentBlock')
const VERTEX_MODELS = getProviderModels('vertex')
const BEDROCK_MODELS = getProviderModels('bedrock')
const AZURE_MODELS = [...getProviderModels('azure-openai'), ...getProviderModels('azure-anthropic')]
const MODELS_WITH_REASONING_EFFORT = getModelsWithReasoningEffort()
const MODELS_WITH_VERBOSITY = getModelsWithVerbosity()
const MODELS_WITH_THINKING = getModelsWithThinking()
@@ -131,37 +130,9 @@ Return ONLY the JSON array.`,
type: 'combobox',
placeholder: 'Type or select a model...',
required: true,
defaultValue: 'claude-sonnet-4-5',
defaultValue: 'claude-sonnet-4-6',
options: getModelOptions,
},
{
id: 'vertexCredential',
title: 'Google Cloud Account',
type: 'oauth-input',
serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('vertex-ai'),
placeholder: 'Select Google Cloud account',
required: true,
condition: {
field: 'model',
value: VERTEX_MODELS,
},
},
{
id: 'manualCredential',
title: 'Google Cloud Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
condition: {
field: 'model',
value: VERTEX_MODELS,
},
},
{
id: 'reasoningEffort',
title: 'Reasoning Effort',
@@ -318,100 +289,7 @@ Return ONLY the JSON array.`,
},
},
{
id: 'azureEndpoint',
title: 'Azure Endpoint',
type: 'short-input',
password: true,
placeholder: 'https://your-resource.services.ai.azure.com',
connectionDroppable: false,
condition: {
field: 'model',
value: AZURE_MODELS,
},
},
{
id: 'azureApiVersion',
title: 'Azure API Version',
type: 'short-input',
placeholder: 'Enter API version',
connectionDroppable: false,
condition: {
field: 'model',
value: AZURE_MODELS,
},
},
{
id: 'vertexProject',
title: 'Vertex AI Project',
type: 'short-input',
placeholder: 'your-gcp-project-id',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: VERTEX_MODELS,
},
},
{
id: 'vertexLocation',
title: 'Vertex AI Location',
type: 'short-input',
placeholder: 'us-central1',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: VERTEX_MODELS,
},
},
{
id: 'bedrockAccessKeyId',
title: 'AWS Access Key ID',
type: 'short-input',
password: true,
placeholder: 'Enter your AWS Access Key ID',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: BEDROCK_MODELS,
},
},
{
id: 'bedrockSecretKey',
title: 'AWS Secret Access Key',
type: 'short-input',
password: true,
placeholder: 'Enter your AWS Secret Access Key',
connectionDroppable: false,
required: true,
condition: {
field: 'model',
value: BEDROCK_MODELS,
},
},
{
id: 'bedrockRegion',
title: 'AWS Region',
type: 'short-input',
placeholder: 'us-east-1',
connectionDroppable: false,
condition: {
field: 'model',
value: BEDROCK_MODELS,
},
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
condition: getApiKeyCondition(),
},
...getProviderCredentialSubBlocks(),
{
id: 'tools',
title: 'Tools',
@@ -580,7 +458,7 @@ Return ONLY the JSON array.`,
],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'claude-sonnet-4-5'
const model = params.model || 'claude-sonnet-4-6'
if (!model) {
throw new Error('No model selected')
}
@@ -661,7 +539,7 @@ Return ONLY the JSON array.`,
apiKey: { type: 'string', description: 'Provider API key' },
azureEndpoint: { type: 'string', description: 'Azure endpoint URL' },
azureApiVersion: { type: 'string', description: 'Azure API version' },
oauthCredential: { type: 'string', description: 'OAuth credential for Vertex AI' },
vertexCredential: { type: 'string', description: 'OAuth credential for Vertex AI' },
vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' },
vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' },
bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' },

Some files were not shown because too many files have changed in this diff Show More