Files
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

243 lines
6.9 KiB
TypeScript

// twenty-shared by source path rather than by package name: the consuming
// repository runs this from a sparse checkout with no workspace install, so
// 'twenty-shared/ai' would not resolve there.
import { isAiSdkPackage } from '../../../../twenty-shared/src/ai/utils/is-ai-sdk-package.util';
import { isDataResidency } from '../../../../twenty-shared/src/ai/utils/is-data-residency.util';
import {
type CatalogSpec,
type CatalogSpecModel,
type CatalogSpecModelOverrides,
type CatalogSpecProvider,
} from '../types/catalog-spec.type';
// Structural, so the catalog needs no schema here and this file can run from a
// checkout that has not installed the monorepo.
type CanonicalModel = { name: string; label?: string } & Record<
string,
unknown
>;
type CanonicalProvider = { models?: CanonicalModel[] } & Record<
string,
unknown
>;
export type CanonicalCatalog = Record<string, CanonicalProvider>;
const OVERRIDABLE_FIELDS = [
'label',
'isDeprecated',
'contextWindowTokens',
'maxOutputTokens',
'inputCostPerMillionTokens',
'outputCostPerMillionTokens',
'cachedInputCostPerMillionTokens',
'cacheCreationCostPerMillionTokens',
] as const satisfies readonly (keyof CatalogSpecModelOverrides)[];
const NUMERIC_FIELDS = [
'contextWindowTokens',
'maxOutputTokens',
'inputCostPerMillionTokens',
'outputCostPerMillionTokens',
'cachedInputCostPerMillionTokens',
'cacheCreationCostPerMillionTokens',
] as const satisfies readonly (keyof CatalogSpecModelOverrides)[];
const PROVIDER_CREDENTIAL_FIELDS = [
'label',
'apiKey',
'baseUrl',
'region',
'authType',
'accessKeyId',
'secretAccessKey',
'sessionToken',
] as const;
const asSpecModel = (entry: string | CatalogSpecModel): CatalogSpecModel =>
typeof entry === 'string' ? { model: entry } : entry;
const indexCanonicalModels = (
catalog: CanonicalCatalog,
): Map<string, CanonicalModel> => {
const models = new Map<string, CanonicalModel>();
for (const provider of Object.values(catalog)) {
for (const model of provider.models ?? []) {
models.set(model.name, model);
}
}
return models;
};
const projectModel = ({
canonicalModel,
specModel,
provider,
}: {
canonicalModel: CanonicalModel;
specModel: CatalogSpecModel;
provider: CatalogSpecProvider;
}): CanonicalModel => {
const overrides = Object.fromEntries(
OVERRIDABLE_FIELDS.filter((field) => specModel[field] !== undefined).map(
(field) => [field, specModel[field]],
),
);
const routeFields = {
...(provider.dataResidency === undefined
? {}
: { dataResidency: provider.dataResidency }),
...(provider.zeroDataRetention === undefined
? {}
: { zeroDataRetention: provider.zeroDataRetention }),
};
return {
...canonicalModel,
...routeFields,
...overrides,
name: specModel.as ?? canonicalModel.name,
// The suffix marks the route, so it is appended to whichever name the model
// ends up with rather than to the catalog's.
label: `${specModel.label ?? canonicalModel.label ?? canonicalModel.name}${provider.labelSuffix ?? ''}`,
};
};
const assertProviderIsUsable = ({
provider,
seenNames,
}: {
provider: CatalogSpecProvider;
seenNames: Set<string>;
}): void => {
// Providers key an object, so a repeat would drop the first one's credentials
// and models on the floor, and `__proto__` would drop its own.
if (seenNames.has(provider.name) || provider.name === '__proto__') {
throw new Error(`Provider "${provider.name}" is repeated or reserved`);
}
if (!isAiSdkPackage(provider.npm)) {
throw new Error(
`Provider "${provider.name}" names an unsupported SDK package: ${provider.npm}`,
);
}
if (
provider.dataResidency !== undefined &&
!isDataResidency(provider.dataResidency)
) {
throw new Error(
`Provider "${provider.name}" names an unsupported data residency: ${provider.dataResidency}`,
);
}
};
const assertNumbersAreUsable = ({
provider,
specModel,
}: {
provider: CatalogSpecProvider;
specModel: CatalogSpecModel;
}): void => {
for (const field of NUMERIC_FIELDS) {
const value = specModel[field];
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
throw new Error(
`${provider.name}/${specModel.model} sets ${field} to ${value}, which is not a usable number`,
);
}
}
};
// A spec naming a model the catalog does not carry is the failure this whole
// pipeline exists to prevent: it publishes a route to nothing, and the tier
// chains then fall through to a neighbouring rung in silence.
const resolveModels = ({
provider,
canonicalCatalog,
canonicalModels,
}: {
provider: CatalogSpecProvider;
canonicalCatalog: CanonicalCatalog;
canonicalModels: Map<string, CanonicalModel>;
}): { canonicalModel: CanonicalModel; specModel: CatalogSpecModel }[] => {
if (!Array.isArray(provider.models)) {
const { vendor } = provider.models;
const vendorModels = canonicalCatalog[vendor]?.models ?? [];
if (vendorModels.length === 0) {
throw new Error(
`Provider "${provider.name}" serves every model from "${vendor}", which the catalog does not carry`,
);
}
return vendorModels.map((canonicalModel) => ({
canonicalModel,
specModel: { model: canonicalModel.name },
}));
}
const specModels = provider.models.map(asSpecModel);
const unknown = specModels
.filter(({ model }) => !canonicalModels.has(model))
.map(({ model }) => `${provider.name}/${model}`);
if (unknown.length > 0) {
throw new Error(
`The catalog does not carry: ${unknown.join(', ')}. Check the model names, or wait for the catalog sync to pick them up.`,
);
}
return specModels.map((specModel) => {
assertNumbersAreUsable({ provider, specModel });
const canonicalModel = canonicalModels.get(specModel.model);
if (canonicalModel === undefined) {
throw new Error(`The catalog does not carry ${specModel.model}`);
}
return { canonicalModel, specModel };
});
};
export const projectCatalog = ({
canonicalCatalog,
spec,
}: {
canonicalCatalog: CanonicalCatalog;
spec: CatalogSpec;
}): CanonicalCatalog => {
const canonicalModels = indexCanonicalModels(canonicalCatalog);
const projected: CanonicalCatalog = {};
const seenNames = new Set<string>();
for (const provider of spec.providers) {
assertProviderIsUsable({ provider, seenNames });
seenNames.add(provider.name);
const credentials = Object.fromEntries(
PROVIDER_CREDENTIAL_FIELDS.filter(
(field) => provider[field] !== undefined,
).map((field) => [field, provider[field]]),
);
projected[provider.name] = {
npm: provider.npm,
label: provider.label ?? provider.name,
...credentials,
models: resolveModels({
provider,
canonicalCatalog,
canonicalModels,
}).map((resolved) => projectModel({ ...resolved, provider })),
};
}
return projected;
};