diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md index 98a94592ab..a7cae67e8f 100644 --- a/.agents/skills/component-refactoring/SKILL.md +++ b/.agents/skills/component-refactoring/SKILL.md @@ -63,7 +63,7 @@ pnpm analyze-component --json ```typescript // ❌ Before: Complex state logic in component -const Configuration: FC = () => { +function Configuration() { const [modelConfig, setModelConfig] = useState(...) const [datasetConfigs, setDatasetConfigs] = useState(...) const [completionParams, setCompletionParams] = useState({}) @@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => { } // Component becomes cleaner -const Configuration: FC = () => { +function Configuration() { const { modelConfig, setModelConfig } = useModelConfig(appId) return
...
} @@ -189,8 +189,6 @@ const Template = useMemo(() => { **Dify Convention**: - This skill is for component decomposition, not query/mutation design. -- When refactoring data fetching, follow `web/AGENTS.md`. -- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - Do not introduce deprecated `useInvalid` / `useReset`. - Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state. diff --git a/.agents/skills/component-refactoring/references/complexity-patterns.md b/.agents/skills/component-refactoring/references/complexity-patterns.md index 5a0a268f38..2873630d4b 100644 --- a/.agents/skills/component-refactoring/references/complexity-patterns.md +++ b/.agents/skills/component-refactoring/references/complexity-patterns.md @@ -60,8 +60,10 @@ const Template = useMemo(() => { **After** (complexity: ~3): ```typescript +import type { ComponentType } from 'react' + // Define lookup table outside component -const TEMPLATE_MAP: Record>> = { +const TEMPLATE_MAP: Record>> = { [AppModeEnum.CHAT]: { [LanguagesSupported[1]]: TemplateChatZh, [LanguagesSupported[7]]: TemplateChatJa, diff --git a/.agents/skills/component-refactoring/references/component-splitting.md b/.agents/skills/component-refactoring/references/component-splitting.md index 78a3389100..81c007e005 100644 --- a/.agents/skills/component-refactoring/references/component-splitting.md +++ b/.agents/skills/component-refactoring/references/component-splitting.md @@ -65,10 +65,10 @@ interface ConfigurationHeaderProps { onPublish: () => void } -const ConfigurationHeader: FC = ({ +function ConfigurationHeader({ isAdvancedMode, onPublish, -}) => { +}: ConfigurationHeaderProps) { const { t } = useTranslation() return ( @@ -136,7 +136,7 @@ const AppInfo = () => { } // ✅ After: Separate view components -const AppInfoExpanded: FC = ({ appDetail, onAction }) => { +function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) { return (
{/* Clean, focused expanded view */} @@ -144,7 +144,7 @@ const AppInfoExpanded: FC = ({ appDetail, onAction }) => { ) } -const AppInfoCollapsed: FC = ({ appDetail, onAction }) => { +function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) { return (
{/* Clean, focused collapsed view */} @@ -203,12 +203,12 @@ interface AppInfoModalsProps { onSuccess: () => void } -const AppInfoModals: FC = ({ +function AppInfoModals({ appDetail, activeModal, onClose, onSuccess, -}) => { +}: AppInfoModalsProps) { const handleEdit = async (data) => { /* logic */ } const handleDuplicate = async (data) => { /* logic */ } const handleDelete = async () => { /* logic */ } @@ -296,7 +296,7 @@ interface OperationItemProps { onAction: (id: string) => void } -const OperationItem: FC = ({ operation, onAction }) => { +function OperationItem({ operation, onAction }: OperationItemProps) { return (
{operation.icon} @@ -435,7 +435,7 @@ interface ChildProps { onSubmit: () => void } -const Child: FC = ({ value, onChange, onSubmit }) => { +function Child({ value, onChange, onSubmit }: ChildProps) { return (
onChange(e.target.value)} /> diff --git a/.agents/skills/component-refactoring/references/hook-extraction.md b/.agents/skills/component-refactoring/references/hook-extraction.md index 0d567eb2a6..6fad2c8885 100644 --- a/.agents/skills/component-refactoring/references/hook-extraction.md +++ b/.agents/skills/component-refactoring/references/hook-extraction.md @@ -112,13 +112,13 @@ export const useModelConfig = ({ ```typescript // Before: 50+ lines of state management -const Configuration: FC = () => { +function Configuration() { const [modelConfig, setModelConfig] = useState(...) // ... lots of related state and effects } // After: Clean component -const Configuration: FC = () => { +function Configuration() { const { modelConfig, setModelConfig, @@ -159,8 +159,6 @@ const Configuration: FC = () => { When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. -- Follow `web/AGENTS.md` first. -- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - Do not introduce deprecated `useInvalid` / `useReset`. - Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index de6b58f26d..dd7d204678 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -23,7 +23,7 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS - `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter 3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved. 4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved. -5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern. +5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern. ## Local Rules diff --git a/.agents/skills/frontend-code-review/references/performance.md b/.agents/skills/frontend-code-review/references/performance.md index 2d60072f5c..0c33db46d0 100644 --- a/.agents/skills/frontend-code-review/references/performance.md +++ b/.agents/skills/frontend-code-review/references/performance.md @@ -9,18 +9,18 @@ Category: Performance When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks. -## Complex prop memoization +## Complex prop stability -IsUrgent: True +IsUrgent: False Category: Performance ### Description -Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders. +Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization. Update this file when adding, editing, or removing Performance rules so the catalog remains accurate. -Wrong: +Risky: ```tsx ``` -Right: +Better when stable identity matters: ```tsx const config = useMemo(() => ({ diff --git a/.agents/skills/frontend-query-mutation/SKILL.md b/.agents/skills/frontend-query-mutation/SKILL.md deleted file mode 100644 index 49888bdb66..0000000000 --- a/.agents/skills/frontend-query-mutation/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: frontend-query-mutation -description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions() directly or extract a helper or use-* hook, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers. ---- - -# Frontend Query & Mutation - -## Intent - -- Keep contract as the single source of truth in `web/contract/*`. -- Prefer contract-shaped `queryOptions()` and `mutationOptions()`. -- Keep invalidation and mutation flow knowledge in the service layer. -- Keep abstractions minimal to preserve TypeScript inference. - -## Workflow - -1. Identify the change surface. - - Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape. - - Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and legacy migrations. - - Read both references when a task spans contract shape and runtime behavior. -2. Implement the smallest abstraction that fits the task. - - Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site. - - Extract a small shared query helper only when multiple call sites share the same extra options. - - Create `web/service/use-{domain}.ts` only for orchestration or shared domain behavior. -3. Preserve Dify conventions. - - Keep contract inputs in `{ params, query?, body? }` shape. - - Bind invalidation in the service-layer mutation definition. - - Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required. - -## Files Commonly Touched - -- `web/contract/console/*.ts` -- `web/contract/marketplace.ts` -- `web/contract/router.ts` -- `web/service/client.ts` -- `web/service/use-*.ts` -- component and hook call sites using `consoleQuery` or `marketplaceQuery` - -## References - -- Use `references/contract-patterns.md` for contract shape, router registration, query and mutation helpers, and anti-patterns that degrade inference. -- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, and legacy migration rules. - -Treat this skill as the single query and mutation entry point for Dify frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs. diff --git a/.agents/skills/frontend-query-mutation/agents/openai.yaml b/.agents/skills/frontend-query-mutation/agents/openai.yaml deleted file mode 100644 index 87f7ae6ea4..0000000000 --- a/.agents/skills/frontend-query-mutation/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Frontend Query & Mutation" - short_description: "Dify TanStack Query and oRPC patterns" - default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, conditional queries, invalidation, or legacy query/mutation migrations." diff --git a/.agents/skills/frontend-query-mutation/references/contract-patterns.md b/.agents/skills/frontend-query-mutation/references/contract-patterns.md deleted file mode 100644 index 08016ed2cc..0000000000 --- a/.agents/skills/frontend-query-mutation/references/contract-patterns.md +++ /dev/null @@ -1,98 +0,0 @@ -# Contract Patterns - -## Table of Contents - -- Intent -- Minimal structure -- Core workflow -- Query usage decision rule -- Mutation usage decision rule -- Anti-patterns -- Contract rules -- Type export - -## Intent - -- Keep contract as the single source of truth in `web/contract/*`. -- Default query usage to call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract. -- Keep abstractions minimal and preserve TypeScript inference. - -## Minimal Structure - -```text -web/contract/ -├── base.ts -├── router.ts -├── marketplace.ts -└── console/ - ├── billing.ts - └── ...other domains -web/service/client.ts -``` - -## Core Workflow - -1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`. - - Use `base.route({...}).output(type<...>())` as the baseline. - - Add `.input(type<...>())` only when the request has `params`, `query`, or `body`. - - For `GET` without input, omit `.input(...)`; do not use `.input(type())`. -2. Register contract in `web/contract/router.ts`. - - Import directly from domain files and nest by API prefix. -3. Consume from UI call sites via oRPC query utilities. - -```typescript -import { useQuery } from '@tanstack/react-query' -import { consoleQuery } from '@/service/client' - -const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({ - staleTime: 5 * 60 * 1000, - throwOnError: true, - select: invoice => invoice.url, -})) -``` - -## Query Usage Decision Rule - -1. Default to direct `*.queryOptions(...)` usage at the call site. -2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook. -3. Create `web/service/use-{domain}.ts` only for orchestration. - - Combine multiple queries or mutations. - - Share domain-level derived state or invalidation helpers. - -```typescript -const invoicesBaseQueryOptions = () => - consoleQuery.billing.invoices.queryOptions({ retry: false }) - -const invoiceQuery = useQuery({ - ...invoicesBaseQueryOptions(), - throwOnError: true, -}) -``` - -## Mutation Usage Decision Rule - -1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`. -2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic. - -## Anti-Patterns - -- Do not wrap `useQuery` with `options?: Partial`. -- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case. -- Do not create thin `use-*` passthrough hooks for a single endpoint. -- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection. - -## Contract Rules - -- Input structure: always use `{ params, query?, body? }`. -- No-input `GET`: omit `.input(...)`; do not use `.input(type())`. -- Path params: use `{paramName}` in the path and match it in the `params` object. -- Router nesting: group by API prefix, for example `/billing/*` becomes `billing: {}`. -- No barrel files: import directly from specific files. -- Types: import from `@/types/` and use the `type()` helper. -- Mutations: prefer `mutationOptions`; use explicit `mutationKey` mainly for defaults, filtering, and devtools. - -## Type Export - -```typescript -export type ConsoleInputs = InferContractRouterInputs -``` diff --git a/.agents/skills/frontend-query-mutation/references/runtime-rules.md b/.agents/skills/frontend-query-mutation/references/runtime-rules.md deleted file mode 100644 index 73d6fbdded..0000000000 --- a/.agents/skills/frontend-query-mutation/references/runtime-rules.md +++ /dev/null @@ -1,130 +0,0 @@ -# Runtime Rules - -## Table of Contents - -- Conditional queries -- Cache invalidation -- Key API guide -- `mutate` vs `mutateAsync` -- Legacy migration - -## Conditional Queries - -Prefer contract-shaped `queryOptions(...)`. -When required input is missing, prefer `input: skipToken` instead of placeholder params or non-null assertions. -Use `enabled` only for extra business gating after the input itself is already valid. - -```typescript -import { skipToken, useQuery } from '@tanstack/react-query' - -// Disable the query by skipping input construction. -function useAccessMode(appId: string | undefined) { - return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({ - input: appId - ? { params: { appId } } - : skipToken, - })) -} - -// Avoid runtime-only guards that bypass type checking. -function useBadAccessMode(appId: string | undefined) { - return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({ - input: { params: { appId: appId! } }, - enabled: !!appId, - })) -} -``` - -## Cache Invalidation - -Bind invalidation in the service-layer mutation definition. -Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate. - -Use: - -- `.key()` for namespace or prefix invalidation -- `.queryKey(...)` only for exact cache reads or writes such as `getQueryData` and `setQueryData` -- `queryClient.invalidateQueries(...)` in mutation `onSuccess` - -Do not use deprecated `useInvalid` from `use-base.ts`. - -```typescript -// Service layer owns cache invalidation. -export const useUpdateAccessMode = () => { - const queryClient = useQueryClient() - - return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(), - }) - }, - })) -} - -// Component only adds UI behavior. -updateAccessMode({ appId, mode }, { - onSuccess: () => toast.success('...'), -}) - -// Avoid putting invalidation knowledge in the component. -mutate({ appId, mode }, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(), - }) - }, -}) -``` - -## Key API Guide - -- `.key(...)` - - Use for partial matching operations. - - Prefer it for invalidation, refetch, and cancel patterns. - - Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })` -- `.queryKey(...)` - - Use for a specific query's full key. - - Prefer it for exact cache addressing and direct reads or writes. -- `.mutationKey(...)` - - Use for a specific mutation's full key. - - Prefer it for mutation defaults registration, mutation-status filtering, and devtools grouping. - -## `mutate` vs `mutateAsync` - -Prefer `mutate` by default. -Use `mutateAsync` only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies. - -Rules: - -- Event handlers should usually call `mutate(...)` with `onSuccess` or `onError`. -- Every `await mutateAsync(...)` must be wrapped in `try/catch`. -- Do not use `mutateAsync` when callbacks already express the flow clearly. - -```typescript -// Default case. -mutation.mutate(data, { - onSuccess: result => router.push(result.url), -}) - -// Promise semantics are required. -try { - const order = await createOrder.mutateAsync(orderData) - await confirmPayment.mutateAsync({ orderId: order.id, token }) - router.push(`/orders/${order.id}`) -} -catch (error) { - toast.error(error instanceof Error ? error.message : 'Unknown error') -} -``` - -## Legacy Migration - -When touching old code, migrate it toward these rules: - -| Old pattern | New pattern | -|---|---| -| `useInvalid(key)` in service layer | `queryClient.invalidateQueries(...)` inside mutation `onSuccess` | -| component-triggered invalidation after mutation | move invalidation into the service-layer mutation definition | -| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` | -| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` | diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 105c979c58..21c46d75bc 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -5,7 +5,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com # Dify Frontend Testing Skill -This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. +This skill enables Codex to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. > **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`). @@ -24,35 +24,27 @@ Apply this skill when the user: **Do NOT apply** when: - User is asking about backend/API tests (Python/pytest) -- User is asking about E2E tests (Playwright/Cypress) +- User is asking about E2E tests (Cucumber + Playwright under `e2e/`) - User is only asking conceptual questions without code context ## Quick Reference -### Tech Stack - -| Tool | Version | Purpose | -|------|---------|---------| -| Vitest | 4.0.16 | Test runner | -| React Testing Library | 16.0 | Component testing | -| jsdom | - | Test environment | -| nock | 14.0 | HTTP mocking | -| TypeScript | 5.x | Type safety | - ### Key Commands +Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`. + ```bash # Run all tests pnpm test # Watch mode -pnpm test:watch +pnpm test --watch # Run specific file pnpm test path/to/file.spec.tsx # Generate coverage report -pnpm test:coverage +pnpm test --coverage # Analyze component complexity pnpm analyze-component @@ -228,7 +220,10 @@ Every test should clearly separate: ### 2. Black-Box Testing - Test observable behavior, not implementation details -- Use semantic queries (getByRole, getByLabelText) +- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`) +- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`. +- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment. +- Do not assert decorative icons by test id. Assert the named control that contains them, or mark decorative icons `aria-hidden`. - Avoid testing internal state directly - **Prefer pattern matching over hardcoded strings** in assertions: diff --git a/.agents/skills/frontend-testing/references/mocking.md b/.agents/skills/frontend-testing/references/mocking.md index 8c2f1c0c58..7723e4df21 100644 --- a/.agents/skills/frontend-testing/references/mocking.md +++ b/.agents/skills/frontend-testing/references/mocking.md @@ -56,7 +56,7 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details. | Location | Purpose | |----------|---------| -| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) | +| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) | | `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) | | `web/__mocks__/` | Reusable mock factories shared across multiple test files | | Test file | Test-specific mocks, inline with `vi.mock()` | @@ -216,28 +216,21 @@ describe('Component', () => { }) ``` -### 5. HTTP Mocking with Nock +### 5. HTTP and `fetch` Mocking ```typescript -import nock from 'nock' - -const GITHUB_HOST = 'https://api.github.com' -const GITHUB_PATH = '/repos/owner/repo' - -const mockGithubApi = (status: number, body: Record, delayMs = 0) => { - return nock(GITHUB_HOST) - .get(GITHUB_PATH) - .delay(delayMs) - .reply(status, body) -} - describe('GithubComponent', () => { - afterEach(() => { - nock.cleanAll() + beforeEach(() => { + vi.clearAllMocks() }) it('should display repo info', async () => { - mockGithubApi(200, { name: 'dify', stars: 1000 }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'dify', stars: 1000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) render() @@ -247,7 +240,12 @@ describe('GithubComponent', () => { }) it('should handle API error', async () => { - mockGithubApi(500, { message: 'Server error' }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ) render() @@ -258,6 +256,8 @@ describe('GithubComponent', () => { }) ``` +Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses. Do not introduce an HTTP interception dependency such as `nock` or MSW unless it is already declared in the workspace or adding it is part of the task. + ### 6. Context Providers ```typescript @@ -332,7 +332,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => { 1. **Don't mock Zustand store modules** - Use real stores with `setState()` 1. Don't mock components you can import directly 1. Don't create overly simplified mocks that miss conditional logic -1. Don't forget to clean up nock after each test +1. Don't leave HTTP mocks or service mock state leaking between tests 1. Don't use `any` types in mocks without necessity ### Mock Decision Tree diff --git a/.agents/skills/frontend-testing/references/workflow.md b/.agents/skills/frontend-testing/references/workflow.md index bc4ed8285a..27755d42a7 100644 --- a/.agents/skills/frontend-testing/references/workflow.md +++ b/.agents/skills/frontend-testing/references/workflow.md @@ -227,12 +227,12 @@ Failing tests compound: **Fix failures immediately before proceeding.** -## Integration with Claude's Todo Feature +## Integration with Codex's Todo Feature -When using Claude for multi-file testing: +When using Codex for multi-file testing: -1. **Ask Claude to create a todo list** before starting -1. **Request one file at a time** or ensure Claude processes incrementally +1. **Create a todo list** before starting +1. **Process one file at a time** 1. **Verify each test passes** before asking for the next 1. **Mark todos complete** as you progress diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md new file mode 100644 index 0000000000..ac77112993 --- /dev/null +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -0,0 +1,71 @@ +--- +name: how-to-write-component +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. +--- + +# How To Write A Component + +Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation. + +## Core Defaults + +- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. +- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them. +- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature. +- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`. + +## Ownership + +- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home. +- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing. +- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children. +- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. +- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. +- Prefer uncontrolled DOM state and CSS variables before adding controlled props. + +## Components, Props, And Types + +- Type component signatures directly; do not use `FC` or `React.FC`. +- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. +- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. +- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them. +- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary. +- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks. + +## Queries And Mutations + +- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape. +- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. +- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it. +- Keep feature hooks for real orchestration, workflow state, or shared domain behavior. +- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid. +- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows. +- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. +- Do not use deprecated `useInvalid` or `useReset`. +- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`. + +## Component Boundaries + +- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner. +- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer. +- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary. +- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. +- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. +- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. +- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. + +## You Might Not Need An Effect + +- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration. +- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive. +- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known. +- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render. +- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary. +- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components. +- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow. + +## Navigation And Performance + +- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. +- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/.agents/skills/tailwind-css-rules/SKILL.md b/.agents/skills/tailwind-css-rules/SKILL.md new file mode 100644 index 0000000000..3528548036 --- /dev/null +++ b/.agents/skills/tailwind-css-rules/SKILL.md @@ -0,0 +1,367 @@ +--- +name: tailwind-css-rules +description: Tailwind CSS v4.1+ rules and best practices. Use when writing, reviewing, refactoring, or upgrading Tailwind CSS classes and styles, especially v4 utility migrations, layout spacing, typography, responsive variants, dark mode, gradients, CSS variables, and component styling. +--- + +# Tailwind CSS Rules and Best Practices + +## Core Principles + +- **Always use Tailwind CSS v4.1+** - Ensure the codebase is using the latest version +- **Do not use deprecated or removed utilities** - ALWAYS use the replacement +- **Never use `@apply`** - Use CSS variables, the `--spacing()` function, or framework components instead +- **Check for redundant classes** - Remove any classes that aren't necessary +- **Group elements logically** to simplify responsive tweaks later + +## Upgrading to Tailwind CSS v4 + +### Before Upgrading + +- **Always read the upgrade documentation first** - Read https://tailwindcss.com/docs/upgrade-guide and https://tailwindcss.com/blog/tailwindcss-v4 before starting an upgrade. +- Ensure the git repository is in a clean state before starting + +### Upgrade Process + +1. Run the upgrade command: `npx @tailwindcss/upgrade@latest` for both major and minor updates +2. The tool will convert JavaScript config files to the new CSS format +3. Review all changes extensively to clean up any false positives +4. Test thoroughly across your application + +## Breaking Changes Reference + +### Removed Utilities (NEVER use these in v4) + +| ❌ Deprecated | ✅ Replacement | +| ----------------------- | ------------------------------------------------- | +| `bg-opacity-*` | Use opacity modifiers like `bg-black/50` | +| `text-opacity-*` | Use opacity modifiers like `text-black/50` | +| `border-opacity-*` | Use opacity modifiers like `border-black/50` | +| `divide-opacity-*` | Use opacity modifiers like `divide-black/50` | +| `ring-opacity-*` | Use opacity modifiers like `ring-black/50` | +| `placeholder-opacity-*` | Use opacity modifiers like `placeholder-black/50` | +| `flex-shrink-*` | `shrink-*` | +| `flex-grow-*` | `grow-*` | +| `overflow-ellipsis` | `text-ellipsis` | +| `decoration-slice` | `box-decoration-slice` | +| `decoration-clone` | `box-decoration-clone` | + +### Renamed Utilities + +Use the v4 name when migrating code that still carries Tailwind v3 semantics. Do not blanket-replace existing v4 classes: classes such as `rounded-sm`, `shadow-sm`, `ring-1`, and `ring-2` are valid in this codebase when they intentionally represent the current design scale. + +| ❌ v3 pattern | ✅ v4 pattern | +| ------------------- | -------------------------------------------------- | +| `bg-gradient-*` | `bg-linear-*` | +| old shadow scale | verify against the current Tailwind/design scale | +| old blur scale | verify against the current Tailwind/design scale | +| old radius scale | use the Dify radius token mapping when applicable | +| `outline-none` | `outline-hidden` | +| bare `ring` utility | use an explicit ring width such as `ring-1`/`ring-2`/`ring-3` | + +For Figma radius tokens, follow `packages/dify-ui/AGENTS.md`. For example, `--radius/xs` maps to `rounded-sm`; do not rewrite it to `rounded-xs`. + +## Layout and Spacing Rules + +### Flexbox and Grid Spacing + +#### Always use gap utilities for internal spacing + +Gap provides consistent spacing without edge cases (no extra space on last items). It's cleaner and more maintainable than margins on children. + +```html + +
+
Item 1
+
Item 2
+
Item 3
+ +
+ + +
+
Item 1
+
Item 2
+
Item 3
+
+``` + +#### Gap vs Space utilities + +- **Never use `space-x-*` or `space-y-*` in flex/grid layouts** - always use gap +- Space utilities add margins to children and have issues with wrapped items +- Gap works correctly with flex-wrap and all flex directions + +```html + +
+ +
+ + +
+ +
+``` + +### General Spacing Guidelines + +- **Prefer top and left margins** over bottom and right margins (unless conditionally rendered) +- **Use padding on parent containers** instead of bottom margins on the last child +- **Always use `min-h-dvh` instead of `min-h-screen`** - `min-h-screen` is buggy on mobile Safari +- **Prefer `size-*` utilities** over separate `w-*` and `h-*` when setting equal dimensions +- For max-widths, prefer the container scale (e.g., `max-w-2xs` over `max-w-72`) + +## Typography Rules + +### Line Heights + +- **Never use `leading-*` classes** - Always use line height modifiers with text size +- **Always use fixed line heights from the spacing scale** - Don't use named values + +```html + +

Text with separate line height

+

Text with named line height

+ + +

Text with line height modifier

+

Text with specific line height

+``` + +### Font Size Reference + +Be precise with font sizes - know the actual pixel values: + +- `text-xs` = 12px +- `text-sm` = 14px +- `text-base` = 16px +- `text-lg` = 18px +- `text-xl` = 20px + +## Color and Opacity + +### Opacity Modifiers + +**Never use `bg-opacity-*`, `text-opacity-*`, etc.** - use the opacity modifier syntax: + +```html + +
Old opacity syntax
+ + +
Modern opacity syntax
+``` + +## Responsive Design + +### Breakpoint Optimization + +- **Check for redundant classes across breakpoints** +- **Only add breakpoint variants when values change** + +```html + +
+ +
+ + +
+ +
+``` + +## Dark Mode + +### Dark Mode Best Practices + +- Use the plain `dark:` variant pattern +- Put light mode styles first, then dark mode styles +- Ensure `dark:` variant comes before other variants + +```html + +
+ +
+``` + +## Gradient Utilities + +- **ALWAYS Use `bg-linear-*` instead of `bg-gradient-*` utilities** - The gradient utilities were renamed in v4 +- Use the new `bg-radial` or `bg-radial-[]` to create radial gradients +- Use the new `bg-conic` or `bg-conic-*` to create conic gradients + +```html + +
+
+
+ + +
+``` + +## Working with CSS Variables + +### Accessing Theme Values + +Tailwind CSS v4 exposes all theme values as CSS variables: + +```css +/* Access colors, and other theme values */ +.custom-element { + background: var(--color-red-500); + border-radius: var(--radius-lg); +} +``` + +### The `--spacing()` Function + +Use the dedicated `--spacing()` function for spacing calculations: + +```css +.custom-class { + margin-top: calc(100vh - --spacing(16)); +} +``` + +### Extending theme values + +Use CSS to extend theme values: + +```css +@import "tailwindcss"; + +@theme { + --color-mint-500: oklch(0.72 0.11 178); +} +``` + +```html +
+ +
+``` + +## New v4 Features + +### Container Queries + +Use the `@container` class and size variants: + +```html +
+
+ +
+ +
+
+
+``` + +### Container Query Units + +Use container-based units like `cqw` for responsive sizing: + +```html +
+

Responsive to container width

+
+``` + +### Text Shadows (v4.1) + +Use text-shadow-\* utilities from text-shadow-2xs to text-shadow-lg: + +```html + +

Large shadow

+

Small shadow with opacity

+``` + +### Masking (v4.1) + +Use the new composable mask utilities for image and gradient masks: + +```html + +
Top fade
+
Bottom gradient
+
+ Fade from white to black +
+ + +
+ Radial mask +
+``` + +## Component Patterns + +### Avoiding Utility Inheritance + +Don't add utilities to parents that you override in children: + +```html + +
+

Centered Heading

+
Left-aligned content
+
+ + +
+

Centered Heading

+
Left-aligned content
+
+``` + +### Component Extraction + +- Extract repeated patterns into framework components, not CSS classes +- Keep utility classes in templates/JSX +- Use data attributes for complex state-based styling + +## CSS Best Practices + +### Nesting Guidelines + +- Use nesting when styling both parent and children +- Avoid empty parent selectors + +```css +/* ✅ Good nesting - parent has styles */ +.card { + padding: --spacing(4); + + > .card-title { + font-weight: bold; + } +} + +/* ❌ Avoid empty parents */ +ul { + > li { + /* Parent has no styles */ + } +} +``` + +## Common Pitfalls to Avoid + +1. **Using old opacity utilities** - Always use `/opacity` syntax like `bg-red-500/60` +2. **Redundant breakpoint classes** - Only specify changes +3. **Space utilities in flex/grid** - Always use gap +4. **Leading utilities** - Use line-height modifiers like `text-sm/6` +5. **Arbitrary values** - Use the design scale +6. **@apply directive** - Use components or CSS variables +7. **min-h-screen on mobile** - Use min-h-dvh +8. **Separate width/height** - Use size utilities when equal +9. **Arbitrary values** - Always use Tailwind's predefined scale whenever possible (e.g., use `ml-4` over `ml-[16px]`) diff --git a/.codex b/.codex new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/actions/setup-web/action.yml b/.github/actions/setup-web/action.yml index 085b39ebfb..2979c3ddb8 100644 --- a/.github/actions/setup-web/action.yml +++ b/.github/actions/setup-web/action.yml @@ -1,8 +1,13 @@ name: Setup Web Environment +description: Set up Node.js, Vite+, pnpm, and web dependencies runs: using: composite steps: + - name: Setup pnpm + uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 + with: + run_install: false - name: Setup Vite+ uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0 with: diff --git a/.github/scripts/check-hotfix-cherry-picks.sh b/.github/scripts/check-hotfix-cherry-picks.sh new file mode 100644 index 0000000000..11dc024ccf --- /dev/null +++ b/.github/scripts/check-hotfix-cherry-picks.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_SHA=${BASE_SHA:-} +HEAD_SHA=${HEAD_SHA:-} +MAIN_REF=${MAIN_REF:-origin/main} +REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x." + +error() { + printf 'ERROR: %s\n' "$1" >&2 +} + +if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then + error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT" + exit 2 +fi + +if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then + error "Base commit '$BASE_SHA' is not available in the local git checkout." + exit 2 +fi + +if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then + error "Head commit '$HEAD_SHA' is not available in the local git checkout." + exit 2 +fi + +if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then + error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT" + exit 2 +fi + +failed=0 +checked=0 + +while IFS= read -r commit_sha; do + [[ -n "$commit_sha" ]] || continue + + checked=$((checked + 1)) + subject=$(git log -1 --format=%s "$commit_sha") + source_sha=$( + git log -1 --format=%B "$commit_sha" \ + | sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \ + | tail -n 1 + ) + + if [[ -z "$source_sha" ]]; then + error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT" + failed=1 + continue + fi + + if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then + error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT" + failed=1 + continue + fi + + if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then + error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT" + failed=1 + fi +done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA") + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +if [[ "$checked" -eq 0 ]]; then + echo "No PR commits to check." +else + echo "Verified $checked PR commit(s) include cherry-pick provenance from main." +fi diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index bd47abc710..a08e7aacae 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -99,7 +99,7 @@ jobs: - name: Set up dotenvs run: | cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env + cp docker/envs/middleware.env.example docker/middleware.env - name: Expose Service Ports run: sh .github/workflows/expose_service_ports.sh diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 76fbd18f47..b0cd652c43 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -116,6 +116,16 @@ jobs: if: github.event_name != 'merge_group' uses: ./.github/actions/setup-web + - name: Generate API docs + if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true' + run: | + cd api + uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json + + - name: Generate frontend contracts + if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true' + run: pnpm --dir packages/contracts gen-api-contract-from-openapi + - name: ESLint autofix if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true' run: | diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 65f0149a74..9d3ccb34b2 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare middleware env run: | cd docker - cp middleware.env.example middleware.env + cp envs/middleware.env.example middleware.env - name: Set up Middlewares uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 @@ -87,7 +87,7 @@ jobs: - name: Prepare middleware env for MySQL run: | cd docker - cp middleware.env.example middleware.env + cp envs/middleware.env.example middleware.env sed -i 's/DB_TYPE=postgresql/DB_TYPE=mysql/' middleware.env sed -i 's/DB_HOST=db_postgres/DB_HOST=db_mysql/' middleware.env sed -i 's/DB_PORT=5432/DB_PORT=3306/' middleware.env diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml new file mode 100644 index 0000000000..594b10c743 --- /dev/null +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -0,0 +1,49 @@ +name: Hotfix Cherry-Pick Provenance + +on: + pull_request: + branches: + - 'hotfix/**' + - 'lts/**' + types: + - opened + - edited + - reopened + - ready_for_review + - synchronize + +permissions: + contents: read + +concurrency: + group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + check-cherry-pick-provenance: + name: Require cherry-pick provenance + runs-on: depot-ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Fetch PR base, PR head, and main + env: + BASE_REF: ${{ github.base_ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git fetch --no-tags --prune origin \ + "+refs/heads/main:refs/remotes/origin/main" \ + "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \ + "+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head" + + - name: Load checker from main + run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh" + + - name: Check PR commits + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MAIN_REF: origin/main + run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f59cc6be48..aefcf1b5ac 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,6 +9,6 @@ jobs: pull-requests: write runs-on: depot-ubuntu-24.04 steps: - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: sync-labels: true diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 8071d6204d..f624e8f872 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -57,7 +57,7 @@ jobs: - '.github/workflows/api-tests.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose-template.yaml' - 'docker/generate_docker_compose' @@ -84,7 +84,7 @@ jobs: - 'pnpm-workspace.yaml' - '.nvmrc' - 'docker/docker-compose.middleware.yaml' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - '.github/workflows/web-e2e.yml' - '.github/actions/setup-web/**' vdb: @@ -94,7 +94,7 @@ jobs: - '.github/workflows/vdb-tests.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - 'docker/docker-compose.yaml' - 'docker/docker-compose-template.yaml' - 'docker/generate_docker_compose' @@ -116,7 +116,7 @@ jobs: - '.github/workflows/db-migration-test.yml' - '.github/workflows/expose_service_ports.sh' - 'docker/.env.example' - - 'docker/middleware.env.example' + - 'docker/envs/middleware.env.example' - 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose-template.yaml' - 'docker/generate_docker_compose' diff --git a/.github/workflows/pyrefly-diff-comment.yml b/.github/workflows/pyrefly-diff-comment.yml index 7f82942e7e..8e16baf933 100644 --- a/.github/workflows/pyrefly-diff-comment.yml +++ b/.github/workflows/pyrefly-diff-comment.yml @@ -77,10 +77,28 @@ jobs: } if (diff.trim()) { - await github.rest.issues.createComment({ + const body = '### Pyrefly Diff\n
\nbase → PR\n\n```diff\n' + diff + '\n```\n
'; + const marker = '### Pyrefly Diff'; + const { data: comments } = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, - body: '### Pyrefly Diff\n
\nbase → PR\n\n```diff\n' + diff + '\n```\n
', }); + const existing = comments.find((comment) => comment.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } } diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index 0cf54e3585..386bd25751 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -103,9 +103,26 @@ jobs: ].join('\n') : '### Pyrefly Diff\nNo changes detected.'; - await github.rest.issues.createComment({ + const marker = '### Pyrefly Diff'; + const { data: comments } = await github.rest.issues.listComments({ issue_number: prNumber, owner: context.repo.owner, repo: context.repo.repo, - body, }); + const existing = comments.find((comment) => comment.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + comment_id: existing.id, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } else { + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); + } diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index d2b892d9aa..4ce121ba60 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -109,6 +109,8 @@ jobs: - name: Web tsslint if: steps.changed-files.outputs.any_changed == 'true' working-directory: ./web + env: + NODE_OPTIONS: --max-old-space-size=4096 run: vp run lint:tss - name: Web type check diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 7bb6fc1bbd..4e738df684 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 + uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index 5c241af5c5..1405eb4eeb 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -51,7 +51,7 @@ jobs: - name: Set up dotenvs run: | cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env + cp docker/envs/middleware.env.example docker/middleware.env - name: Expose Service Ports run: sh .github/workflows/expose_service_ports.sh diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 38ec96f00f..cdcdcb27d7 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -48,7 +48,7 @@ jobs: - name: Set up dotenvs run: | cp docker/.env.example docker/.env - cp docker/middleware.env.example docker/middleware.env + cp docker/envs/middleware.env.example docker/middleware.env - name: Expose Service Ports run: sh .github/workflows/expose_service_ports.sh diff --git a/.gitignore b/.gitignore index dc3b3f284f..881f841da1 100644 --- a/.gitignore +++ b/.gitignore @@ -250,5 +250,5 @@ scripts/stress-test/reports/ # Code Agent Folder .qoder/* - +.context/* .eslintcache diff --git a/AGENTS.md b/AGENTS.md index 8be2daef95..c6ebf7ae8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ The codebase is split into: - **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design - **Frontend Web** (`/web`): Next.js application using TypeScript and React - **Docker deployment** (`/docker`): Containerized deployment configurations +- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent ## Backend Workflow diff --git a/Makefile b/Makefile index d8c9df5208..d65209c2ff 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web API_IMAGE=$(DOCKER_REGISTRY)/dify-api VERSION=latest +DOCKER_DIR=docker +DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env +DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example +DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev # Default target - show help .DEFAULT_GOAL := help @@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api # Step 1: Prepare Docker middleware prepare-docker: @echo "🐳 Setting up Docker middleware..." - @cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists" - @cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d + @if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \ + cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \ + echo "Docker middleware.env created"; \ + else \ + echo "Docker middleware.env already exists"; \ + fi + @cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d @echo "✅ Docker middleware started" # Step 2: Prepare web environment @@ -39,12 +48,18 @@ prepare-api: # Clean dev environment dev-clean: @echo "⚠️ Stopping Docker containers..." - @cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down + @if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \ + cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \ + else \ + echo "Docker middleware.env does not exist, skipping compose down"; \ + fi @echo "🗑️ Removing volumes..." @rm -rf docker/volumes/db + @rm -rf docker/volumes/mysql @rm -rf docker/volumes/redis @rm -rf docker/volumes/plugin_daemon @rm -rf docker/volumes/weaviate + @rm -rf docker/volumes/sandbox/dependencies @rm -rf api/storage @echo "✅ Cleanup complete" @@ -68,16 +83,15 @@ lint: @echo "✅ Linting complete" type-check: - @echo "📝 Running type checks (basedpyright + pyrefly + mypy)..." - @./dev/basedpyright-check $(PATH_TO_CHECK) - @./dev/pyrefly-check-local - @uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped . + @echo "📝 Running type checks (pyrefly + mypy)..." + @./dev/pyrefly-check-local $(PATH_TO_CHECK) + @uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped . @echo "✅ Type checks complete" type-check-core: - @echo "📝 Running core type checks (basedpyright + mypy)..." - @./dev/basedpyright-check $(PATH_TO_CHECK) - @uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --check-untyped-defs --disable-error-code=import-untyped . + @echo "📝 Running core type checks (pyrefly + mypy)..." + @./dev/pyrefly-check-local $(PATH_TO_CHECK) + @uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped . @echo "✅ Core type checks complete" test: @@ -132,14 +146,14 @@ help: @echo " make prepare-docker - Set up Docker middleware" @echo " make prepare-web - Set up web environment" @echo " make prepare-api - Set up API environment" - @echo " make dev-clean - Stop Docker middleware containers" + @echo " make dev-clean - Stop Docker middleware containers and remove dev data" @echo "" @echo "Backend Code Quality:" @echo " make format - Format code with ruff" @echo " make check - Check code with ruff" @echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)" - @echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)" - @echo " make type-check-core - Run core type checks (basedpyright, mypy)" + @echo " make type-check - Run type checks (pyrefly, mypy)" + @echo " make type-check-core - Run core type checks (pyrefly, mypy)" @echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/)" @echo "" @echo "Docker Build Targets:" diff --git a/README.md b/README.md index 778028fc76..b6cbb0e126 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Star Dify on GitHub and be instantly notified of new releases. ### Custom configurations -If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments). +If you need to customize the configuration, edit `docker/.env`. The essential startup defaults live in [`docker/.env.example`](docker/.env.example), and optional advanced variables are split under `docker/envs/` by theme. After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments). ### Metrics Monitoring with Grafana diff --git a/api/.env.example b/api/.env.example index f6f65011ea..34be400e87 100644 --- a/api/.env.example +++ b/api/.env.example @@ -34,7 +34,7 @@ TRIGGER_URL=http://localhost:5001 FILES_ACCESS_TIMEOUT=300 # Collaboration mode toggle -ENABLE_COLLABORATION_MODE=false +ENABLE_COLLABORATION_MODE=true # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -88,6 +88,10 @@ REDIS_HEALTH_CHECK_INTERVAL=30 CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BACKEND=redis +# Ops trace retry configuration +OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60 +OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5 + # Database configuration DB_TYPE=postgresql DB_USERNAME=postgres @@ -98,6 +102,8 @@ DB_DATABASE=dify SQLALCHEMY_POOL_PRE_PING=true SQLALCHEMY_POOL_TIMEOUT=30 +# Connection pool reset behavior on return +SQLALCHEMY_POOL_RESET_ON_RETURN=rollback # Storage configuration # use for store upload files, private keys... @@ -381,7 +387,7 @@ VIKINGDB_ACCESS_KEY=your-ak VIKINGDB_SECRET_KEY=your-sk VIKINGDB_REGION=cn-shanghai VIKINGDB_HOST=api-vikingdb.xxx.volces.com -VIKINGDB_SCHEMA=http +VIKINGDB_SCHEME=http VIKINGDB_CONNECTION_TIMEOUT=30 VIKINGDB_SOCKET_TIMEOUT=30 @@ -432,8 +438,6 @@ UPLOAD_FILE_EXTENSION_BLACKLIST= # Model configuration MULTIMODAL_SEND_FORMAT=base64 -PROMPT_GENERATION_MAX_TOKENS=512 -CODE_GENERATION_MAX_TOKENS=1024 PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false # Mail configuration, support: resend, smtp, sendgrid @@ -553,7 +557,7 @@ MAX_VARIABLE_SIZE=204800 # GraphEngine Worker Pool Configuration # Minimum number of workers per GraphEngine instance (default: 1) -GRAPH_ENGINE_MIN_WORKERS=1 +GRAPH_ENGINE_MIN_WORKERS=3 # Maximum number of workers per GraphEngine instance (default: 10) GRAPH_ENGINE_MAX_WORKERS=10 # Queue depth threshold that triggers worker scale up (default: 3) diff --git a/api/AGENTS.md b/api/AGENTS.md index 8e5d9f600d..7cd60b0281 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -193,6 +193,10 @@ Before opening a PR / submitting: - Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic. - Services: coordinate repositories, providers, background tasks; keep side effects explicit. - Document non-obvious behaviour with concise docstrings and comments. +- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`. + In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response + DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`, + and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns. ### Miscellaneous diff --git a/api/Dockerfile b/api/Dockerfile index 6098652573..8425578953 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -24,7 +24,8 @@ RUN apt-get update \ # Install Python dependencies (workspace members under providers/vdb/) COPY pyproject.toml uv.lock ./ COPY providers ./providers -RUN uv sync --locked --no-dev +# Trust the checked-in lock during image builds; dev-only path sources live outside the api/ context. +RUN uv sync --frozen --no-dev # production stage FROM base AS production diff --git a/api/README.md b/api/README.md index a075bc0fa9..b41ee99b09 100644 --- a/api/README.md +++ b/api/README.md @@ -99,7 +99,7 @@ The scripts resolve paths relative to their location, so you can run them from a ./dev/reformat # Run all formatters and linters uv run ruff check --fix ./ # Fix linting issues uv run ruff format ./ # Format code - uv run basedpyright . # Type checking + uv run pyrefly check # Type checking ``` ## Generate TS stub diff --git a/api/app_factory.py b/api/app_factory.py index 48e50ceae9..565c7fefd8 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -117,7 +117,7 @@ def create_flask_app_with_configs() -> DifyApp: logger.warning("Failed to add trace headers to response", exc_info=True) return response - # Capture the decorator's return value to avoid pyright reportUnusedFunction + # Capture the decorator return values so static checkers do not treat the hooks as unused. _ = before_request _ = add_trace_headers @@ -181,7 +181,6 @@ def initialize_extensions(app: DifyApp): ext_import_modules, ext_orjson, ext_forward_refs, - ext_set_secretkey, ext_compress, ext_code_based_extension, ext_database, @@ -189,6 +188,7 @@ def initialize_extensions(app: DifyApp): ext_migrate, ext_redis, ext_storage, + ext_set_secretkey, ext_logstore, # Initialize logstore after storage, before celery ext_celery, ext_login, diff --git a/api/commands/plugin.py b/api/commands/plugin.py index 8bd5392d7b..8ad2321b07 100644 --- a/api/commands/plugin.py +++ b/api/commands/plugin.py @@ -185,9 +185,9 @@ def transform_datasource_credentials(environment: str): firecrawl_plugin_id = "langgenius/firecrawl_datasource" jina_plugin_id = "langgenius/jina_datasource" if environment == "online": - notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id) # pyright: ignore[reportPrivateUsage] - firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id) # pyright: ignore[reportPrivateUsage] - jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id) # pyright: ignore[reportPrivateUsage] + notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id) + firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id) + jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id) else: notion_plugin_unique_identifier = None firecrawl_plugin_unique_identifier = None diff --git a/api/commands/system.py b/api/commands/system.py index 39b2e991ed..7755d3b5bc 100644 --- a/api/commands/system.py +++ b/api/commands/system.py @@ -14,6 +14,7 @@ from libs.rsa import generate_key_pair from models import Tenant from models.model import App, AppMode, Conversation from models.provider import Provider, ProviderModel +from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider logger = logging.getLogger(__name__) @@ -23,13 +24,16 @@ DB_UPGRADE_LOCK_TTL_SECONDS = 60 @click.command( "reset-encrypt-key-pair", help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. " - "After the reset, all LLM credentials will become invalid, " - "requiring re-entry." + "After the reset, all LLM credentials and tool provider credentials " + "(builtin / API / MCP) will be purged, requiring re-entry. " "Only support SELF_HOSTED mode.", ) @click.confirmation_option( prompt=click.style( - "Are you sure you want to reset encrypt key pair? This operation cannot be rolled back!", fg="red" + "Are you sure you want to reset encrypt key pair? " + "This will also purge builtin / API / MCP tool provider records for every tenant. " + "This operation cannot be rolled back!", + fg="red", ) ) def reset_encrypt_key_pair(): @@ -53,6 +57,13 @@ def reset_encrypt_key_pair(): session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id)) session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id)) + # Purge tool provider records that hold credentials encrypted under the + # tenant key. Leaving them in place causes /console/api/workspaces/current/ + # tool-providers to 500 because decryption fails on stale ciphertext (#35396). + session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id)) + session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id)) + session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id)) + click.echo( click.style( f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.", diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index 8a6a921a4e..b3a93d9773 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -23,6 +23,12 @@ class EnterpriseFeatureConfig(BaseSettings): ge=1, description="Maximum timeout in seconds for enterprise requests", default=5 ) + ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK: bool = Field( + default=False, + description="If disabled, credential policy check is only performed when saving workflows." + "This helps gain runtime performance by trading off consistency.", + ) + class EnterpriseTelemetryConfig(BaseSettings): """ diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 52e33c1789..a752d9d103 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings): """ SECRET_KEY: str = Field( - description="Secret key for secure session cookie signing." - "Make sure you are changing this key for your deployment with a strong key." - "Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.", + description="Secret key for secure session cookie signing. " + "Leave empty to let Dify generate a persistent key in the storage directory, " + "or set a strong value via the `SECRET_KEY` environment variable.", default="", ) @@ -761,7 +761,7 @@ class WorkflowConfig(BaseSettings): # GraphEngine Worker Pool Configuration GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field( description="Minimum number of workers per GraphEngine instance", - default=1, + default=3, ) GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field( @@ -1137,6 +1137,18 @@ class MultiModalTransferConfig(BaseSettings): ) +class OpsTraceConfig(BaseSettings): + OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field( + description="Maximum retry attempts for transient ops trace provider dispatch failures.", + default=60, + ) + + OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field( + description="Delay in seconds between transient ops trace provider dispatch retry attempts.", + default=5, + ) + + class CeleryBeatConfig(BaseSettings): CELERY_BEAT_SCHEDULER_TIME: int = Field( description="Interval in days for Celery Beat scheduler execution, default to 1 day", @@ -1298,7 +1310,7 @@ class PositionConfig(BaseSettings): class CollaborationConfig(BaseSettings): ENABLE_COLLABORATION_MODE: bool = Field( description="Whether to enable collaboration mode features across the workspace", - default=False, + default=True, ) @@ -1417,6 +1429,7 @@ class FeatureConfig( ModelLoadBalanceConfig, ModerationConfig, MultiModalTransferConfig, + OpsTraceConfig, PositionConfig, RagEtlConfig, RepositoryConfig, diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py index c392b8840f..865bb48c67 100644 --- a/api/configs/middleware/__init__.py +++ b/api/configs/middleware/__init__.py @@ -1,5 +1,5 @@ import os -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, cast from urllib.parse import parse_qsl, quote_plus from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field @@ -50,28 +50,30 @@ from .vdb.vastbase_vector_config import VastbaseVectorConfig from .vdb.vikingdb_config import VikingDBConfig from .vdb.weaviate_config import WeaviateConfig +_VALID_STORAGE_TYPE = Literal[ + "opendal", + "s3", + "aliyun-oss", + "azure-blob", + "baidu-obs", + "clickzetta-volume", + "google-storage", + "huawei-obs", + "oci-storage", + "tencent-cos", + "volcengine-tos", + "supabase", + "local", +] + class StorageConfig(BaseSettings): - STORAGE_TYPE: Literal[ - "opendal", - "s3", - "aliyun-oss", - "azure-blob", - "baidu-obs", - "clickzetta-volume", - "google-storage", - "huawei-obs", - "oci-storage", - "tencent-cos", - "volcengine-tos", - "supabase", - "local", - ] = Field( + STORAGE_TYPE: _VALID_STORAGE_TYPE = Field( description="Type of storage to use." " Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', " "'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', " "'volcengine-tos', 'supabase'. Default is 'opendal'.", - default="opendal", + default=cast(_VALID_STORAGE_TYPE, "opendal"), ) STORAGE_LOCAL_PATH: str = Field( @@ -114,7 +116,7 @@ class SQLAlchemyEngineOptionsDict(TypedDict): pool_pre_ping: bool connect_args: dict[str, str] pool_use_lifo: bool - pool_reset_on_return: None + pool_reset_on_return: Literal["commit", "rollback", None] pool_timeout: int @@ -223,6 +225,11 @@ class DatabaseConfig(BaseSettings): default=30, ) + SQLALCHEMY_POOL_RESET_ON_RETURN: Literal["commit", "rollback", None] = Field( + description="Connection pool reset behavior on return. Options: 'commit', 'rollback', or None", + default="rollback", + ) + RETRIEVAL_SERVICE_EXECUTORS: NonNegativeInt = Field( description="Number of processes for the retrieval service, default to CPU cores.", default=os.cpu_count() or 1, @@ -252,7 +259,7 @@ class DatabaseConfig(BaseSettings): "pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING, "connect_args": connect_args, "pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO, - "pool_reset_on_return": None, + "pool_reset_on_return": self.SQLALCHEMY_POOL_RESET_ON_RETURN, "pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT, } return result diff --git a/api/configs/secret_key.py b/api/configs/secret_key.py new file mode 100644 index 0000000000..f8c33f6a2c --- /dev/null +++ b/api/configs/secret_key.py @@ -0,0 +1,38 @@ +"""SECRET_KEY persistence helpers for runtime setup.""" + +from __future__ import annotations + +import secrets + +from extensions.ext_storage import storage + +GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key" + + +def resolve_secret_key(secret_key: str) -> str: + """Return an explicit SECRET_KEY or a generated key persisted in storage.""" + if secret_key: + return secret_key + + return _load_or_create_secret_key() + + +def _load_or_create_secret_key() -> str: + try: + persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip() + if persisted_key: + return persisted_key + except FileNotFoundError: + pass + + generated_key = secrets.token_urlsafe(48) + + try: + storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode()) + except Exception as exc: + raise ValueError( + f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. " + "Set SECRET_KEY explicitly or make storage writable." + ) from exc + + return generated_key diff --git a/api/constants/recommended_apps.json b/api/constants/recommended_apps.json index 3779fb0180..3d728f1b2e 100644 --- a/api/constants/recommended_apps.json +++ b/api/constants/recommended_apps.json @@ -19,7 +19,7 @@ "name": "Website Generator" }, "app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -35,7 +35,7 @@ "name": "Investment Analysis Report Copilot" }, "app_id": "a23b57fa-85da-49c0-a571-3aff375976c1", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n", "is_listed": true, @@ -51,7 +51,7 @@ "name": "Workflow Planning Assistant " }, "app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ", "is_listed": true, @@ -67,7 +67,7 @@ "name": "Automated Email Reply " }, "app_id": "e9d92058-7d20-4904-892f-75d90bef7587", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ", "is_listed": true, @@ -83,7 +83,7 @@ "name": "Book Translation " }, "app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ", "is_listed": true, @@ -99,7 +99,7 @@ "name": "Python bug fixer" }, "app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e", - "category": "Programming", + "categories": ["Programming"], "copyright": null, "description": null, "is_listed": true, @@ -115,7 +115,7 @@ "name": "Code Interpreter" }, "app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Code interpreter, clarifying the syntax and semantics of the code.", "is_listed": true, @@ -131,7 +131,7 @@ "name": "SVG Logo Design " }, "app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALL·E 3. ", "is_listed": true, @@ -147,7 +147,7 @@ "name": "Long Story Generator (Iteration) " }, "app_id": "5efb98d7-176b-419c-b6ef-50767391ab62", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ", "is_listed": true, @@ -163,7 +163,7 @@ "name": "Text Summarization Workflow" }, "app_id": "f00c4531-6551-45ee-808f-1d7903099515", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", "is_listed": true, @@ -179,7 +179,7 @@ "name": "YouTube Channel Data Analysis" }, "app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638", - "category": "Agent", + "categories": ["Agent"], "copyright": "Dify.AI", "description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ", "is_listed": true, @@ -195,7 +195,7 @@ "name": "Article Grading Bot" }, "app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f", - "category": "Writing", + "categories": ["Writing"], "copyright": null, "description": "Assess the quality of articles and text based on user defined criteria. ", "is_listed": true, @@ -211,7 +211,7 @@ "name": "SEO Blog Generator" }, "app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.", "is_listed": true, @@ -227,7 +227,7 @@ "name": "SQL Creator" }, "app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.", "is_listed": true, @@ -243,7 +243,7 @@ "name": "Sentiment Analysis " }, "app_id": "f06bf86b-d50c-4895-a942-35112dbe4189", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.", "is_listed": true, @@ -259,7 +259,7 @@ "name": "Strategic Consulting Expert" }, "app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2", - "category": "Assistant", + "categories": ["Assistant"], "copyright": "Copyright 2023 Dify", "description": "I can answer your questions related to strategic marketing.", "is_listed": true, @@ -275,7 +275,7 @@ "name": "Code Converter" }, "app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a", - "category": "Programming", + "categories": ["Programming"], "copyright": "Copyright 2023 Dify", "description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.", "is_listed": true, @@ -291,7 +291,7 @@ "name": "Question Classifier + Knowledge + Chatbot " }, "app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", "is_listed": true, @@ -307,7 +307,7 @@ "name": "AI Front-end interviewer" }, "app_id": "127efead-8944-4e20-ba9d-12402eb345e0", - "category": "HR", + "categories": ["HR"], "copyright": "Copyright 2023 Dify", "description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.", "is_listed": true, @@ -323,7 +323,7 @@ "name": "Knowledge Retrieval + Chatbot " }, "app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Basic Workflow Template, A chatbot with a knowledge base. ", "is_listed": true, @@ -339,7 +339,7 @@ "name": "Email Assistant Workflow " }, "app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.", "is_listed": true, @@ -355,7 +355,7 @@ "name": "Customer Review Analysis Workflow " }, "app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a", - "category": "Workflow", + "categories": ["Workflow"], "copyright": null, "description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.", "is_listed": true, diff --git a/api/controllers/API_SCHEMA_GUIDE.md b/api/controllers/API_SCHEMA_GUIDE.md new file mode 100644 index 0000000000..6cfbab4b1c --- /dev/null +++ b/api/controllers/API_SCHEMA_GUIDE.md @@ -0,0 +1,211 @@ +# API Schema Guide + +This guide describes the expected Flask-RESTX + Pydantic pattern for controller request payloads, query +parameters, response schemas, and Swagger documentation. + +## Principles + +- Use Pydantic `BaseModel` for request bodies and query parameters. +- Use `fields.base.ResponseModel` for response DTOs. +- Keep runtime validation and Swagger documentation wired to the same Pydantic model. +- Prefer explicit validation and serialization in controller methods over Flask-RESTX marshalling. +- Do not add new Flask-RESTX `fields.*` dictionaries, `Namespace.model(...)` exports, or `@marshal_with(...)` for migrated or new endpoints. +- Do not use `@ns.expect(...)` for GET query parameters. Flask-RESTX documents that as a request body. + +## Naming + +- Request body models: use a `Payload` suffix. + - Example: `WorkflowRunPayload`, `DatasourceVariablesPayload`. +- Query parameter models: use a `Query` suffix. + - Example: `WorkflowRunListQuery`, `MessageListQuery`. +- Response models: use a `Response` suffix and inherit from `ResponseModel`. + - Example: `WorkflowRunDetailResponse`, `WorkflowRunNodeExecutionListResponse`. +- Use `ListResponse` or `PaginationResponse` for wrapper responses. + - Example: `WorkflowRunNodeExecutionListResponse`, `WorkflowRunPaginationResponse`. +- Keep these models near the controller when they are endpoint-specific. Move them to `fields/*_fields.py` only when shared by multiple controllers. + +## Registering Models For Swagger + +Use helpers from `controllers.common.schema`. + +```python +from controllers.common.schema import ( + query_params_from_model, + register_response_schema_models, + register_schema_models, +) +from libs.helper import dump_response +``` + +Register request payload and query models with `register_schema_models(...)`: + +```python +register_schema_models( + console_ns, + WorkflowRunPayload, + WorkflowRunListQuery, +) +``` + +Register response models with `register_response_schema_models(...)`: + +```python +register_response_schema_models( + console_ns, + WorkflowRunDetailResponse, + WorkflowRunPaginationResponse, +) +``` + +Response models are registered in Pydantic serialization mode. This matters when a response model uses +`validation_alias` to read internal object attributes but emits public API field names. For example, a response model +can validate from `inputs_dict` while documenting and serializing `inputs`. + +## Request Bodies + +For non-GET request bodies: + +1. Define a Pydantic `Payload` model. +2. Register it with `register_schema_models(...)`. +3. Use `@ns.expect(ns.models[Payload.__name__])` for Swagger documentation. +4. Validate from `ns.payload or {}` inside the controller. + +```python +class DraftWorkflowNodeRunPayload(BaseModel): + inputs: dict[str, Any] + query: str = "" + + +register_schema_models(console_ns, DraftWorkflowNodeRunPayload) + + +@console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__]) +def post(self, app_model: App, node_id: str): + payload = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {}) + result = service.run(..., inputs=payload.inputs, query=payload.query) + return dump_response(WorkflowRunNodeExecutionResponse, result) +``` + +## Query Parameters + +For GET query parameters: + +1. Define a Pydantic `Query` model. +2. Register it with `register_schema_models(...)` if it is referenced elsewhere in docs, or only use + `query_params_from_model(...)` if a body schema is not needed. +3. Use `@ns.doc(params=query_params_from_model(QueryModel))`. +4. Validate from `request.args.to_dict(flat=True)` or an explicit dict when type coercion is needed. + +```python +class WorkflowRunListQuery(BaseModel): + last_id: str | None = Field(default=None, description="Last run ID for pagination") + limit: int = Field(default=20, ge=1, le=100, description="Number of items per page (1-100)") + + +@console_ns.doc(params=query_params_from_model(WorkflowRunListQuery)) +def get(self, app_model: App): + query = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) + result = service.list(..., limit=query.limit, last_id=query.last_id) + return dump_response(WorkflowRunPaginationResponse, result) +``` + +Do not do this for GET query parameters: + +```python +@console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__]) +def get(...): + ... +``` + +That documents a GET request body and is not the expected contract. + +## Responses + +Response models should inherit from `ResponseModel`: + +```python +class WorkflowRunNodeExecutionResponse(ResponseModel): + id: str + inputs: Any = Field(default=None, validation_alias="inputs_dict") + process_data: Any = Field(default=None, validation_alias="process_data_dict") + outputs: Any = Field(default=None, validation_alias="outputs_dict") +``` + +Document response models with `@ns.response(...)`: + +```python +@console_ns.response( + 200, + "Node run started successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], +) +def post(...): + ... +``` + +Serialize explicitly: + +```python +return dump_response(WorkflowRunNodeExecutionResponse, workflow_node_execution) +``` + +`dump_response(...)` is the preferred response serialization helper for a single Pydantic response DTO. It validates +with `from_attributes=True` and returns `model_dump(mode="json")`, so SQLAlchemy models, plain objects, dictionaries, +Pydantic aliases, computed fields, and `datetime` values are serialized consistently. + +For wrapper responses, pass a dictionary with the public wrapper fields: + +```python +return dump_response( + WorkflowRunPaginationResponse, + { + "data": workflow_runs, + "page": page, + "limit": limit, + "has_more": has_more, + }, +) +``` + +If the service can return `None`, translate that into the expected HTTP error before validation: + +```python +workflow_run = service.get_workflow_run(...) +if workflow_run is None: + raise NotFound("Workflow run not found") + +return dump_response(WorkflowRunDetailResponse, workflow_run) +``` + +Use manual `model_validate(...).model_dump(...)` only when the endpoint needs behavior that `dump_response(...)` does +not provide, such as returning a non-dict payload, intentionally excluding fields, or composing a `(body, status)` tuple. + +## Legacy Flask-RESTX Patterns + +Avoid adding these patterns to new or migrated endpoints: + +- `ns.model(...)` for new request/response DTOs. +- Module-level exported RESTX model objects such as `workflow_run_detail_model`. +- `fields.Nested({...})` with raw inline dict field maps. +- `@marshal_with(...)` for response serialization. +- `@ns.expect(...)` for GET query params. + +Existing legacy field dictionaries may remain where an endpoint has not yet been migrated. Keep that compatibility local +to the legacy area and avoid importing RESTX model objects from controllers. + +## Verifying Swagger + +For schema and documentation changes, run focused tests and generate Swagger JSON: + +```bash +uv run --project . pytest tests/unit_tests/controllers/common/test_schema.py +uv run --project . pytest tests/unit_tests/commands/test_generate_swagger_specs.py tests/unit_tests/controllers/test_swagger.py +uv run --project . dev/generate_swagger_specs.py --output-dir /tmp/dify-openapi-check +``` + +Inspect affected endpoints with `jq`. Check that: + +- GET parameters are `in: query`. +- Request bodies appear only where the endpoint has a body. +- Responses reference the expected `*Response` schema. +- Response schemas use public serialized names, not internal validation aliases like `inputs_dict`. diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py index 5d6f4efb95..98fe2ce67b 100644 --- a/api/controllers/common/human_input.py +++ b/api/controllers/common/human_input.py @@ -1,6 +1,21 @@ +import json + from pydantic import BaseModel, JsonValue class HumanInputFormSubmitPayload(BaseModel): inputs: dict[str, JsonValue] action: str + + +def stringify_form_default_values(values: dict[str, object]) -> dict[str, str]: + """Serialize default values into strings expected by human-input form clients.""" + result: dict[str, str] = {} + for key, value in values.items(): + if value is None: + result[key] = "" + elif isinstance(value, (dict, list)): + result[key] = json.dumps(value, ensure_ascii=False) + else: + result[key] = str(value) + return result diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 8d112c203b..a1b37d3df4 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -1,6 +1,14 @@ -"""Helpers for registering Pydantic models with Flask-RESTX namespaces.""" +"""Helpers for registering Pydantic models with Flask-RESTX namespaces. +Flask-RESTX treats `SchemaModel` bodies as opaque JSON schemas; it does not +promote Pydantic's nested `$defs` into top-level Swagger `definitions`. +These helpers keep that translation centralized so models registered through +`register_schema_models` emit resolvable Swagger 2.0 references. +""" + +from collections.abc import Mapping from enum import StrEnum +from typing import Any, Literal, NotRequired, TypedDict from flask_restx import Namespace from pydantic import BaseModel, TypeAdapter @@ -8,10 +16,89 @@ from pydantic import BaseModel, TypeAdapter DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" -def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: - """Register a single BaseModel with a namespace for Swagger documentation.""" +QueryParamDoc = TypedDict( + "QueryParamDoc", + { + "in": NotRequired[str], + "type": NotRequired[str], + "items": NotRequired[dict[str, object]], + "required": NotRequired[bool], + "description": NotRequired[str], + "enum": NotRequired[list[object]], + "default": NotRequired[object], + "minimum": NotRequired[int | float], + "maximum": NotRequired[int | float], + "minLength": NotRequired[int], + "maxLength": NotRequired[int], + "minItems": NotRequired[int], + "maxItems": NotRequired[int], + }, +) - namespace.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) + +def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None: + """Register a JSON schema and promote any nested Pydantic `$defs`.""" + + schema = _swagger_2_compatible_schema(schema) + nested_definitions = schema.get("$defs") + schema_to_register = dict(schema) + if isinstance(nested_definitions, dict): + schema_to_register.pop("$defs") + + namespace.schema_model(name, schema_to_register) + + if not isinstance(nested_definitions, dict): + return + + for nested_name, nested_schema in nested_definitions.items(): + if isinstance(nested_schema, dict): + _register_json_schema(namespace, nested_name, nested_schema) + + +JsonSchemaMode = Literal["validation", "serialization"] + + +def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode: JsonSchemaMode) -> None: + _register_json_schema( + namespace, + model.__name__, + model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode), + ) + + +def _swagger_2_compatible_schema(value: Any) -> Any: + if isinstance(value, list): + return [_swagger_2_compatible_schema(item) for item in value] + + if not isinstance(value, dict): + return value + + converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()} + any_of = value.get("anyOf") + if not isinstance(any_of, list): + return converted + + non_null_candidates = [ + candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null" + ] + has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of) + if not has_null_candidate or len(non_null_candidates) != 1: + return converted + + non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0])) + if not isinstance(non_null_schema, dict): + return converted + + converted.pop("anyOf", None) + converted.update(non_null_schema) + converted["x-nullable"] = True + return converted + + +def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: + """Register a BaseModel and its nested schema definitions for Swagger documentation.""" + + _register_schema_model(namespace, model, mode="validation") def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: @@ -21,6 +108,19 @@ def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> No register_schema_model(namespace, model) +def register_response_schema_model(namespace: Namespace, model: type[BaseModel]) -> None: + """Register a BaseModel using its serialized response shape.""" + + _register_schema_model(namespace, model, mode="serialization") + + +def register_response_schema_models(namespace: Namespace, *models: type[BaseModel]) -> None: + """Register multiple response BaseModels using their serialized response shape.""" + + for model in models: + register_response_schema_model(namespace, model) + + def get_or_create_model(model_name: str, field_def): # Import lazily to avoid circular imports between console controllers and schema helpers. from controllers.console import console_ns @@ -34,15 +134,114 @@ def get_or_create_model(model_name: str, field_def): def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None: """Register multiple StrEnum with a namespace.""" for model in models: - namespace.schema_model( - model.__name__, TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + _register_json_schema( + namespace, + model.__name__, + TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), ) +def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]: + """Build Flask-RESTX query parameter docs from a flat Pydantic model. + + `Namespace.expect()` treats Pydantic schema models as request bodies, so GET + endpoints should keep runtime validation on the Pydantic model and feed this + derived mapping to `Namespace.doc(params=...)` for Swagger documentation. + """ + + schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + properties = schema.get("properties", {}) + if not isinstance(properties, Mapping): + return {} + + required = schema.get("required", []) + required_names = set(required) if isinstance(required, list) else set() + + params: dict[str, QueryParamDoc] = {} + for name, property_schema in properties.items(): + if not isinstance(name, str) or not isinstance(property_schema, Mapping): + continue + + params[name] = _query_param_from_property(property_schema, required=name in required_names) + + return params + + +def _query_param_from_property(property_schema: Mapping[str, Any], *, required: bool) -> QueryParamDoc: + param_schema = _nullable_property_schema(property_schema) + param_doc: QueryParamDoc = {"in": "query", "required": required} + + description = param_schema.get("description") + if isinstance(description, str): + param_doc["description"] = description + + schema_type = param_schema.get("type") + if isinstance(schema_type, str) and schema_type in {"array", "boolean", "integer", "number", "string"}: + param_doc["type"] = schema_type + if schema_type == "array": + items = param_schema.get("items") + if isinstance(items, Mapping): + item_type = items.get("type") + if isinstance(item_type, str): + param_doc["items"] = {"type": item_type} + + enum = param_schema.get("enum") + if isinstance(enum, list): + param_doc["enum"] = enum + + default = param_schema.get("default") + if default is not None: + param_doc["default"] = default + + minimum = param_schema.get("minimum") + if isinstance(minimum, int | float): + param_doc["minimum"] = minimum + + maximum = param_schema.get("maximum") + if isinstance(maximum, int | float): + param_doc["maximum"] = maximum + + min_length = param_schema.get("minLength") + if isinstance(min_length, int): + param_doc["minLength"] = min_length + + max_length = param_schema.get("maxLength") + if isinstance(max_length, int): + param_doc["maxLength"] = max_length + + min_items = param_schema.get("minItems") + if isinstance(min_items, int): + param_doc["minItems"] = min_items + + max_items = param_schema.get("maxItems") + if isinstance(max_items, int): + param_doc["maxItems"] = max_items + + return param_doc + + +def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str, Any]: + any_of = property_schema.get("anyOf") + if not isinstance(any_of, list): + return property_schema + + non_null_candidates = [ + candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null" + ] + + if len(non_null_candidates) == 1: + return {**property_schema, **non_null_candidates[0]} + + return property_schema + + __all__ = [ "DEFAULT_REF_TEMPLATE_SWAGGER_2_0", "get_or_create_model", + "query_params_from_model", "register_enum_models", + "register_response_schema_model", + "register_response_schema_models", "register_schema_model", "register_schema_models", ] diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 980e828945..f5aeb17ba2 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -33,7 +33,6 @@ for module_name in RESOURCE_MODULES: # Ensure resource modules are imported so route decorators are evaluated. # Import other controllers from . import ( - admin, apikey, extension, feature, @@ -117,7 +116,7 @@ from .explore import ( saved_message, trial, ) -from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport] +from .socketio import workflow as socketio_workflow # Import tag controllers from .tag import tags @@ -142,7 +141,6 @@ api.add_namespace(console_ns) __all__ = [ "account", "activate", - "admin", "advanced_prompt_template", "agent", "agent_providers", diff --git a/api/controllers/console/admin.py b/api/controllers/console/admin.py index dce394be97..0cfa1fb25b 100644 --- a/api/controllers/console/admin.py +++ b/api/controllers/console/admin.py @@ -1,72 +1,11 @@ -import csv -import io from collections.abc import Callable from functools import wraps -from typing import cast from flask import request -from flask_restx import Resource -from pydantic import BaseModel, Field, field_validator -from sqlalchemy import select -from werkzeug.exceptions import BadRequest, NotFound, Unauthorized +from werkzeug.exceptions import Unauthorized from configs import dify_config -from constants.languages import supported_language -from controllers.console import console_ns -from controllers.console.wraps import only_edition_cloud -from core.db.session_factory import session_factory -from extensions.ext_database import db from libs.token import extract_access_token -from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp -from services.billing_service import BillingService, LangContentDict - -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - -class InsertExploreAppPayload(BaseModel): - app_id: str = Field(...) - desc: str | None = None - copyright: str | None = None - privacy_policy: str | None = None - custom_disclaimer: str | None = None - language: str = Field(...) - category: str = Field(...) - position: int = Field(...) - can_trial: bool = Field(default=False) - trial_limit: int = Field(default=0) - - @field_validator("language") - @classmethod - def validate_language(cls, value: str) -> str: - return supported_language(value) - - -class InsertExploreBannerPayload(BaseModel): - category: str = Field(...) - title: str = Field(...) - description: str = Field(...) - img_src: str = Field(..., alias="img-src") - language: str = Field(default="en-US") - link: str = Field(...) - sort: int = Field(...) - - @field_validator("language") - @classmethod - def validate_language(cls, value: str) -> str: - return supported_language(value) - - model_config = {"populate_by_name": True} - - -console_ns.schema_model( - InsertExploreAppPayload.__name__, - InsertExploreAppPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - -console_ns.schema_model( - InsertExploreBannerPayload.__name__, - InsertExploreBannerPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: @@ -84,361 +23,3 @@ def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: return view(*args, **kwargs) return decorated - - -@console_ns.route("/admin/insert-explore-apps") -class InsertExploreAppListApi(Resource): - @console_ns.doc("insert_explore_app") - @console_ns.doc(description="Insert or update an app in the explore list") - @console_ns.expect(console_ns.models[InsertExploreAppPayload.__name__]) - @console_ns.response(200, "App updated successfully") - @console_ns.response(201, "App inserted successfully") - @console_ns.response(404, "App not found") - @only_edition_cloud - @admin_required - def post(self): - payload = InsertExploreAppPayload.model_validate(console_ns.payload) - - app = db.session.execute(select(App).where(App.id == payload.app_id)).scalar_one_or_none() - if not app: - raise NotFound(f"App '{payload.app_id}' is not found") - - site = app.site - if not site: - desc = payload.desc or "" - copy_right = payload.copyright or "" - privacy_policy = payload.privacy_policy or "" - custom_disclaimer = payload.custom_disclaimer or "" - else: - desc = site.description or payload.desc or "" - copy_right = site.copyright or payload.copyright or "" - privacy_policy = site.privacy_policy or payload.privacy_policy or "" - custom_disclaimer = site.custom_disclaimer or payload.custom_disclaimer or "" - - with session_factory.create_session() as session: - recommended_app = session.execute( - select(RecommendedApp).where(RecommendedApp.app_id == payload.app_id) - ).scalar_one_or_none() - - if not recommended_app: - recommended_app = RecommendedApp( - app_id=app.id, - description=desc, - copyright=copy_right, - privacy_policy=privacy_policy, - custom_disclaimer=custom_disclaimer, - language=payload.language, - category=payload.category, - position=payload.position, - ) - - db.session.add(recommended_app) - if payload.can_trial: - trial_app = db.session.execute( - select(TrialApp).where(TrialApp.app_id == payload.app_id) - ).scalar_one_or_none() - if not trial_app: - db.session.add( - TrialApp( - app_id=payload.app_id, - tenant_id=app.tenant_id, - trial_limit=payload.trial_limit, - ) - ) - else: - trial_app.trial_limit = payload.trial_limit - - app.is_public = True - db.session.commit() - - return {"result": "success"}, 201 - else: - recommended_app.description = desc - recommended_app.copyright = copy_right - recommended_app.privacy_policy = privacy_policy - recommended_app.custom_disclaimer = custom_disclaimer - recommended_app.language = payload.language - recommended_app.category = payload.category - recommended_app.position = payload.position - - if payload.can_trial: - trial_app = db.session.execute( - select(TrialApp).where(TrialApp.app_id == payload.app_id) - ).scalar_one_or_none() - if not trial_app: - db.session.add( - TrialApp( - app_id=payload.app_id, - tenant_id=app.tenant_id, - trial_limit=payload.trial_limit, - ) - ) - else: - trial_app.trial_limit = payload.trial_limit - app.is_public = True - - db.session.commit() - - return {"result": "success"}, 200 - - -@console_ns.route("/admin/insert-explore-apps/") -class InsertExploreAppApi(Resource): - @console_ns.doc("delete_explore_app") - @console_ns.doc(description="Remove an app from the explore list") - @console_ns.doc(params={"app_id": "Application ID to remove"}) - @console_ns.response(204, "App removed successfully") - @only_edition_cloud - @admin_required - def delete(self, app_id): - with session_factory.create_session() as session: - recommended_app = session.execute( - select(RecommendedApp).where(RecommendedApp.app_id == str(app_id)) - ).scalar_one_or_none() - - if not recommended_app: - return {"result": "success"}, 204 - - with session_factory.create_session() as session: - app = session.execute(select(App).where(App.id == recommended_app.app_id)).scalar_one_or_none() - - if app: - app.is_public = False - - with session_factory.create_session() as session: - installed_apps = ( - session.execute( - select(InstalledApp).where( - InstalledApp.app_id == recommended_app.app_id, - InstalledApp.tenant_id != InstalledApp.app_owner_tenant_id, - ) - ) - .scalars() - .all() - ) - - for installed_app in installed_apps: - session.delete(installed_app) - - trial_app = session.execute( - select(TrialApp).where(TrialApp.app_id == recommended_app.app_id) - ).scalar_one_or_none() - if trial_app: - session.delete(trial_app) - - db.session.delete(recommended_app) - db.session.commit() - - return {"result": "success"}, 204 - - -@console_ns.route("/admin/insert-explore-banner") -class InsertExploreBannerApi(Resource): - @console_ns.doc("insert_explore_banner") - @console_ns.doc(description="Insert an explore banner") - @console_ns.expect(console_ns.models[InsertExploreBannerPayload.__name__]) - @console_ns.response(201, "Banner inserted successfully") - @only_edition_cloud - @admin_required - def post(self): - payload = InsertExploreBannerPayload.model_validate(console_ns.payload) - - banner = ExporleBanner( - content={ - "category": payload.category, - "title": payload.title, - "description": payload.description, - "img-src": payload.img_src, - }, - link=payload.link, - sort=payload.sort, - language=payload.language, - ) - db.session.add(banner) - db.session.commit() - - return {"result": "success"}, 201 - - -@console_ns.route("/admin/delete-explore-banner/") -class DeleteExploreBannerApi(Resource): - @console_ns.doc("delete_explore_banner") - @console_ns.doc(description="Delete an explore banner") - @console_ns.doc(params={"banner_id": "Banner ID to delete"}) - @console_ns.response(204, "Banner deleted successfully") - @only_edition_cloud - @admin_required - def delete(self, banner_id): - banner = db.session.execute(select(ExporleBanner).where(ExporleBanner.id == banner_id)).scalar_one_or_none() - if not banner: - raise NotFound(f"Banner '{banner_id}' is not found") - - db.session.delete(banner) - db.session.commit() - - return {"result": "success"}, 204 - - -class LangContentPayload(BaseModel): - lang: str = Field(..., description="Language tag: 'zh' | 'en' | 'jp'") - title: str = Field(...) - subtitle: str | None = Field(default=None) - body: str = Field(...) - title_pic_url: str | None = Field(default=None) - - -class UpsertNotificationPayload(BaseModel): - notification_id: str | None = Field(default=None, description="Omit to create; supply UUID to update") - contents: list[LangContentPayload] = Field(..., min_length=1) - start_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-01T00:00:00Z") - end_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-20T23:59:59Z") - frequency: str = Field(default="once", description="'once' | 'every_page_load'") - status: str = Field(default="active", description="'active' | 'inactive'") - - -class BatchAddNotificationAccountsPayload(BaseModel): - notification_id: str = Field(...) - user_email: list[str] = Field(..., description="List of account email addresses") - - -console_ns.schema_model( - UpsertNotificationPayload.__name__, - UpsertNotificationPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - -console_ns.schema_model( - BatchAddNotificationAccountsPayload.__name__, - BatchAddNotificationAccountsPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - - -@console_ns.route("/admin/upsert_notification") -class UpsertNotificationApi(Resource): - @console_ns.doc("upsert_notification") - @console_ns.doc( - description=( - "Create or update an in-product notification. " - "Supply notification_id to update an existing one; omit it to create a new one. " - "Pass at least one language variant in contents (zh / en / jp)." - ) - ) - @console_ns.expect(console_ns.models[UpsertNotificationPayload.__name__]) - @console_ns.response(200, "Notification upserted successfully") - @only_edition_cloud - @admin_required - def post(self): - payload = UpsertNotificationPayload.model_validate(console_ns.payload) - result = BillingService.upsert_notification( - contents=[cast(LangContentDict, c.model_dump()) for c in payload.contents], - frequency=payload.frequency, - status=payload.status, - notification_id=payload.notification_id, - start_time=payload.start_time, - end_time=payload.end_time, - ) - return {"result": "success", "notification_id": result.get("notificationId")}, 200 - - -@console_ns.route("/admin/batch_add_notification_accounts") -class BatchAddNotificationAccountsApi(Resource): - @console_ns.doc("batch_add_notification_accounts") - @console_ns.doc( - description=( - "Register target accounts for a notification by email address. " - 'JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. ' - "File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) " - "plus a 'notification_id' field. " - "Emails that do not match any account are silently skipped." - ) - ) - @console_ns.response(200, "Accounts added successfully") - @only_edition_cloud - @admin_required - def post(self): - from models.account import Account - - if "file" in request.files: - notification_id = request.form.get("notification_id", "").strip() - if not notification_id: - raise BadRequest("notification_id is required.") - emails = self._parse_emails_from_file() - else: - payload = BatchAddNotificationAccountsPayload.model_validate(console_ns.payload) - notification_id = payload.notification_id - emails = payload.user_email - - if not emails: - raise BadRequest("No valid email addresses provided.") - - # Resolve emails → account IDs in chunks to avoid large IN-clause - account_ids: list[str] = [] - chunk_size = 500 - for i in range(0, len(emails), chunk_size): - chunk = emails[i : i + chunk_size] - rows = db.session.execute(select(Account.id, Account.email).where(Account.email.in_(chunk))).all() - account_ids.extend(str(row.id) for row in rows) - - if not account_ids: - raise BadRequest("None of the provided emails matched an existing account.") - - # Send to dify-saas in batches of 1000 - total_count = 0 - batch_size = 1000 - for i in range(0, len(account_ids), batch_size): - batch = account_ids[i : i + batch_size] - result = BillingService.batch_add_notification_accounts( - notification_id=notification_id, - account_ids=batch, - ) - total_count += result.get("count", 0) - - return { - "result": "success", - "emails_provided": len(emails), - "accounts_matched": len(account_ids), - "count": total_count, - }, 200 - - @staticmethod - def _parse_emails_from_file() -> list[str]: - """Parse email addresses from an uploaded CSV or TXT file.""" - file = request.files["file"] - if not file.filename: - raise BadRequest("Uploaded file has no filename.") - - filename_lower = file.filename.lower() - if not filename_lower.endswith((".csv", ".txt")): - raise BadRequest("Invalid file type. Only CSV (.csv) and TXT (.txt) files are allowed.") - - try: - content = file.read().decode("utf-8") - except UnicodeDecodeError: - try: - file.seek(0) - content = file.read().decode("gbk") - except UnicodeDecodeError: - raise BadRequest("Unable to decode the file. Please use UTF-8 or GBK encoding.") - - emails: list[str] = [] - if filename_lower.endswith(".csv"): - reader = csv.reader(io.StringIO(content)) - for row in reader: - for cell in row: - cell = cell.strip() - if cell: - emails.append(cell) - else: - for line in content.splitlines(): - line = line.strip() - if line: - emails.append(line) - - # Deduplicate while preserving order - seen: set[str] = set() - unique_emails: list[str] = [] - for email in emails: - if email.lower() not in seen: - seen.add(email.lower()) - unique_emails.append(email) - - return unique_emails diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index b03d9b4a4c..6463b022b5 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import Forbidden from controllers.common.schema import register_schema_models from extensions.ext_database import db from fields.base import ResponseModel +from libs.helper import to_timestamp from libs.login import current_account_with_tenant, login_required from models.dataset import Dataset from models.enums import ApiTokenType @@ -21,12 +22,6 @@ from . import console_ns from .wraps import account_initialization_required, edit_permission_required, setup_required -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class ApiKeyItem(ResponseModel): id: str type: str @@ -37,7 +32,7 @@ class ApiKeyItem(ResponseModel): @field_validator("last_used_at", "created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class ApiKeyList(ResponseModel): diff --git a/api/controllers/console/app/advanced_prompt_template.py b/api/controllers/console/app/advanced_prompt_template.py index ed66da1be5..ad21671176 100644 --- a/api/controllers/console/app/advanced_prompt_template.py +++ b/api/controllers/console/app/advanced_prompt_template.py @@ -34,7 +34,7 @@ class AdvancedPromptTemplateList(Resource): @login_required @account_initialization_required def get(self): - args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) prompt_args: AdvancedPromptTemplateArgs = { "app_mode": args.app_mode, "model_mode": args.model_mode, diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index cfdb9cf417..c05600ced5 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -2,6 +2,7 @@ from flask import request from flask_restx import Resource, fields from pydantic import BaseModel, Field, field_validator +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -10,8 +11,6 @@ from libs.login import login_required from models.model import AppMode from services.agent_service import AgentService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AgentLogQuery(BaseModel): message_id: str = Field(..., description="Message UUID") @@ -23,9 +22,7 @@ class AgentLogQuery(BaseModel): return uuid_value(value) -console_ns.schema_model( - AgentLogQuery.__name__, AgentLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, AgentLogQuery) @console_ns.route("/apps//agent/logs") @@ -44,6 +41,6 @@ class AgentLogApi(Resource): @get_app_model(mode=[AppMode.AGENT_CHAT]) def get(self, app_model): """Get agent logs""" - args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AgentLogQuery.model_validate(request.args.to_dict(flat=True)) return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id) diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index 528785931e..cfeaec4af9 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -1,4 +1,5 @@ from typing import Any, Literal +from uuid import UUID from flask import abort, make_response, request from flask_restx import Resource @@ -33,8 +34,6 @@ from services.annotation_service import ( UpsertAnnotationArgs, ) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AnnotationReplyPayload(BaseModel): score_threshold: float = Field(..., description="Score threshold for annotation matching") @@ -87,17 +86,6 @@ class AnnotationFilePayload(BaseModel): return uuid_value(value) -def reg(model: type[BaseModel]) -> None: - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(AnnotationReplyPayload) -reg(AnnotationSettingUpdatePayload) -reg(AnnotationListQuery) -reg(CreateAnnotationPayload) -reg(UpdateAnnotationPayload) -reg(AnnotationReplyStatusQuery) -reg(AnnotationFilePayload) register_schema_models( console_ns, Annotation, @@ -105,6 +93,13 @@ register_schema_models( AnnotationExportList, AnnotationHitHistory, AnnotationHitHistoryList, + AnnotationReplyPayload, + AnnotationSettingUpdatePayload, + AnnotationListQuery, + CreateAnnotationPayload, + UpdateAnnotationPayload, + AnnotationReplyStatusQuery, + AnnotationFilePayload, ) @@ -121,8 +116,7 @@ class AnnotationReplyActionApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def post(self, app_id, action: Literal["enable", "disable"]): - app_id = str(app_id) + def post(self, app_id: UUID, action: Literal["enable", "disable"]): args = AnnotationReplyPayload.model_validate(console_ns.payload) match action: case "enable": @@ -131,9 +125,9 @@ class AnnotationReplyActionApi(Resource): "embedding_provider_name": args.embedding_provider_name, "embedding_model_name": args.embedding_model_name, } - result = AppAnnotationService.enable_app_annotation(enable_args, app_id) + result = AppAnnotationService.enable_app_annotation(enable_args, str(app_id)) case "disable": - result = AppAnnotationService.disable_app_annotation(app_id) + result = AppAnnotationService.disable_app_annotation(str(app_id)) return result, 200 @@ -148,9 +142,8 @@ class AppAnnotationSettingDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id): - app_id = str(app_id) - result = AppAnnotationService.get_app_annotation_setting_by_app_id(app_id) + def get(self, app_id: UUID): + result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id)) return result, 200 @@ -166,14 +159,13 @@ class AppAnnotationSettingUpdateApi(Resource): @login_required @account_initialization_required @edit_permission_required - def post(self, app_id, annotation_setting_id): - app_id = str(app_id) + def post(self, app_id: UUID, annotation_setting_id): annotation_setting_id = str(annotation_setting_id) args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload) setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold} - result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, setting_args) + result = AppAnnotationService.update_app_annotation_setting(str(app_id), annotation_setting_id, setting_args) return result, 200 @@ -189,7 +181,7 @@ class AnnotationReplyActionStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def get(self, app_id, job_id, action): + def get(self, app_id: UUID, job_id, action): job_id = str(job_id) app_annotation_job_key = f"{action}_app_annotation_job_{str(job_id)}" cache_result = redis_client.get(app_annotation_job_key) @@ -217,14 +209,13 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id): - args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + def get(self, app_id: UUID): + args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page limit = args.limit keyword = args.keyword - app_id = str(app_id) - annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_id, page, limit, keyword) + annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(str(app_id), page, limit, keyword) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) response = AnnotationList( data=annotation_models, @@ -246,8 +237,7 @@ class AnnotationApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def post(self, app_id): - app_id = str(app_id) + def post(self, app_id: UUID): args = CreateAnnotationPayload.model_validate(console_ns.payload) upsert_args: UpsertAnnotationArgs = {} if args.answer is not None: @@ -258,15 +248,14 @@ class AnnotationApi(Resource): upsert_args["message_id"] = args.message_id if args.question is not None: upsert_args["question"] = args.question - annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, app_id) + annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, str(app_id)) return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") @setup_required @login_required @account_initialization_required @edit_permission_required - def delete(self, app_id): - app_id = str(app_id) + def delete(self, app_id: UUID): # Use request.args.getlist to get annotation_ids array directly annotation_ids = request.args.getlist("annotation_id") @@ -280,11 +269,11 @@ class AnnotationApi(Resource): "message": "annotation_ids are required if the parameter is provided.", }, 400 - result = AppAnnotationService.delete_app_annotations_in_batch(app_id, annotation_ids) + result = AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids) return result, 204 # If no annotation_ids are provided, handle clearing all annotations else: - AppAnnotationService.clear_all_annotations(app_id) + AppAnnotationService.clear_all_annotations(str(app_id)) return {"result": "success"}, 204 @@ -303,9 +292,8 @@ class AnnotationExportApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id): - app_id = str(app_id) - annotation_list = AppAnnotationService.export_annotation_list_by_app_id(app_id) + def get(self, app_id: UUID): + annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id)) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) response_data = AnnotationExportList(data=annotation_models).model_dump(mode="json") @@ -331,26 +319,22 @@ class AnnotationUpdateDeleteApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def post(self, app_id, annotation_id): - app_id = str(app_id) - annotation_id = str(annotation_id) + def post(self, app_id: UUID, annotation_id: UUID): args = UpdateAnnotationPayload.model_validate(console_ns.payload) update_args: UpdateAnnotationArgs = {} if args.answer is not None: update_args["answer"] = args.answer if args.question is not None: update_args["question"] = args.question - annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_id, annotation_id) + annotation = AppAnnotationService.update_app_annotation_directly(update_args, str(app_id), str(annotation_id)) return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") @setup_required @login_required @account_initialization_required @edit_permission_required - def delete(self, app_id, annotation_id): - app_id = str(app_id) - annotation_id = str(annotation_id) - AppAnnotationService.delete_app_annotation(app_id, annotation_id) + def delete(self, app_id: UUID, annotation_id: UUID): + AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id)) return {"result": "success"}, 204 @@ -371,11 +355,9 @@ class AnnotationBatchImportApi(Resource): @annotation_import_rate_limit @annotation_import_concurrency_limit @edit_permission_required - def post(self, app_id): + def post(self, app_id: UUID): from configs import dify_config - app_id = str(app_id) - # check file if "file" not in request.files: raise NoFileUploadedError() @@ -391,9 +373,9 @@ class AnnotationBatchImportApi(Resource): raise ValueError("Invalid file type. Only CSV files are allowed") # Check file size before processing - file.seek(0, 2) # Seek to end of file - file_size = file.tell() - file.seek(0) # Reset to beginning + file.stream.seek(0, 2) # Seek to end of file + file_size = file.stream.tell() + file.stream.seek(0) # Reset to beginning max_size_bytes = dify_config.ANNOTATION_IMPORT_FILE_SIZE_LIMIT * 1024 * 1024 if file_size > max_size_bytes: @@ -406,7 +388,7 @@ class AnnotationBatchImportApi(Resource): if file_size == 0: raise ValueError("The uploaded file is empty") - return AppAnnotationService.batch_import_app_annotations(app_id, file) + return AppAnnotationService.batch_import_app_annotations(str(app_id), file) @console_ns.route("/apps//annotations/batch-import-status/") @@ -421,8 +403,7 @@ class AnnotationBatchImportStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required - def get(self, app_id, job_id): - job_id = str(job_id) + def get(self, app_id: UUID, job_id: UUID): indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}" cache_result = redis_client.get(indexing_cache_key) if cache_result is None: @@ -456,13 +437,11 @@ class AnnotationHitHistoryListApi(Resource): @login_required @account_initialization_required @edit_permission_required - def get(self, app_id, annotation_id): + def get(self, app_id: UUID, annotation_id: UUID): page = request.args.get("page", default=1, type=int) limit = request.args.get("limit", default=20, type=int) - app_id = str(app_id) - annotation_id = str(annotation_id) annotation_hit_history_list, total = AppAnnotationService.get_annotation_hit_histories( - app_id, annotation_id, page, limit + str(app_id), str(annotation_id), page, limit ) history_models = TypeAdapter(list[AnnotationHitHistory]).validate_python( annotation_hit_history_list, from_attributes=True diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a736fc8bc8..045325f283 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,4 +1,5 @@ import logging +import re import uuid from datetime import datetime from typing import Any, Literal @@ -8,6 +9,7 @@ from flask_restx import Resource from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator from sqlalchemy import select from sqlalchemy.orm import Session +from werkzeug.datastructures import MultiDict from werkzeug.exceptions import BadRequest from controllers.common.helpers import FileInfo @@ -23,6 +25,7 @@ from controllers.console.wraps import ( is_admin_or_owner_required, setup_required, ) +from core.db.session_factory import session_factory from core.ops.ops_trace_manager import OpsTraceManager from core.rag.entities import PreProcessingRule, Rule, Segmentation from core.rag.retrieval.retrieval_methods import RetrievalMethod @@ -30,12 +33,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES from extensions.ext_database import db from fields.base import ResponseModel from graphon.enums import WorkflowExecutionStatus -from libs.helper import build_icon_url +from libs.helper import build_icon_url, to_timestamp from libs.login import current_account_with_tenant, login_required from models import App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService -from services.app_service import AppService +from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -57,6 +60,7 @@ ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "co register_enum_models(console_ns, IconType) _logger = logging.getLogger(__name__) +_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") class AppListQuery(BaseModel): @@ -66,22 +70,19 @@ class AppListQuery(BaseModel): default="all", description="App mode filter" ) name: str | None = Field(default=None, description="Filter by app name") - tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs") + tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") is_created_by_me: bool | None = Field(default=None, description="Filter by creator") @field_validator("tag_ids", mode="before") @classmethod - def validate_tag_ids(cls, value: str | list[str] | None) -> list[str] | None: + def validate_tag_ids(cls, value: list[str] | None) -> list[str] | None: if not value: return None - if isinstance(value, str): - items = [item.strip() for item in value.split(",") if item.strip()] - elif isinstance(value, list): - items = [str(item).strip() for item in value if item and str(item).strip()] - else: - raise TypeError("Unsupported tag_ids type.") + if not isinstance(value, list): + raise ValueError("Unsupported tag_ids type.") + items = [str(item).strip() for item in value if item and str(item).strip()] if not items: return None @@ -91,6 +92,26 @@ class AppListQuery(BaseModel): raise ValueError("Invalid UUID format in tag_ids.") from exc +def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]: + normalized: dict[str, str | list[str]] = {} + indexed_tag_ids: list[tuple[int, str]] = [] + + for key in query_args: + match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key) + if match: + indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key)) + continue + + value = query_args.get(key) + if value is not None: + normalized[key] = value + + if indexed_tag_ids: + normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)] + + return normalized + + class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) @@ -156,12 +177,6 @@ class AppTracePayload(BaseModel): type JSONValue = Any -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class Tag(ResponseModel): id: str name: str @@ -178,7 +193,7 @@ class WorkflowPartial(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class ModelConfigPartial(ResponseModel): @@ -192,7 +207,7 @@ class ModelConfigPartial(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class ModelConfig(ResponseModel): @@ -253,7 +268,7 @@ class ModelConfig(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class Site(ResponseModel): @@ -296,7 +311,7 @@ class Site(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class DeletedTool(ResponseModel): @@ -339,7 +354,7 @@ class AppPartial(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AppDetail(ResponseModel): @@ -369,7 +384,7 @@ class AppDetail(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AppDetailWithSite(AppDetail): @@ -455,12 +470,19 @@ class AppListApi(Resource): """Get app list""" current_user, current_tenant_id = current_account_with_tenant() - args = AppListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore - args_dict = args.model_dump() + args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) + params = AppListParams( + page=args.page, + limit=args.limit, + mode=args.mode, + name=args.name, + tag_ids=args.tag_ids, + is_created_by_me=args.is_created_by_me, + ) # get app list app_service = AppService() - app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, args_dict) + app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params) if not app_pagination: empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json"), 200 @@ -524,9 +546,17 @@ class AppListApi(Resource): """Create app""" current_user, current_tenant_id = current_account_with_tenant() args = CreateAppPayload.model_validate(console_ns.payload) + params = CreateAppParams( + name=args.name, + description=args.description, + mode=args.mode, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) app_service = AppService() - app = app_service.create_app(current_tenant_id, args.model_dump(), current_user) + app = app_service.create_app(current_tenant_id, params, current_user) app_detail = AppDetail.model_validate(app, from_attributes=True) return app_detail.model_dump(mode="json"), 201 @@ -680,7 +710,7 @@ class AppExportApi(Resource): @edit_permission_required def get(self, app_model): """Export app""" - args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) payload = AppExportResponse( data=AppDslService.export_dsl( @@ -819,9 +849,11 @@ class AppTraceApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_id): + @get_app_model + def get(self, app_model): """Get app trace""" - app_trace_config = OpsTraceManager.get_app_tracing_config(app_id=app_id) + with session_factory.create_session() as session: + app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session) return app_trace_config @@ -835,12 +867,13 @@ class AppTraceApi(Resource): @login_required @account_initialization_required @edit_permission_required - def post(self, app_id): + @get_app_model + def post(self, app_model): # add app trace args = AppTracePayload.model_validate(console_ns.payload) OpsTraceManager.update_app_tracing_config( - app_id=app_id, + app_id=app_model.id, enabled=args.enabled, tracing_provider=args.tracing_provider, ) diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index e91dc9cfe5..b653016319 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -2,7 +2,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_enum_models, register_schema_models from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( account_initialization_required, @@ -33,6 +33,7 @@ class AppImportPayload(BaseModel): app_id: str | None = Field(None) +register_enum_models(console_ns, ImportStatus) register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 91fbe4a85a..5b673f3394 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -173,7 +173,7 @@ class TextModesApi(Resource): @account_initialization_required def get(self, app_model): try: - args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) response = AudioService.transcript_tts_voices( tenant_id=app_model.tenant_id, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index fe274e4c9a..6a20296cff 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import InternalServerError, NotFound import services +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( AppUnavailableError, @@ -37,7 +38,6 @@ from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class BaseMessagePayload(BaseModel): @@ -65,13 +65,7 @@ class ChatMessagePayload(BaseMessagePayload): return uuid_value(value) -console_ns.schema_model( - CompletionMessagePayload.__name__, - CompletionMessagePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - ChatMessagePayload.__name__, ChatMessagePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload) # define completion message api for user diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index b2b1049f0c..c7347933cb 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -39,8 +39,6 @@ from models.model import AppMode from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class BaseConversationQuery(BaseModel): keyword: str | None = Field(default=None, description="Search keyword") @@ -70,15 +68,6 @@ class ChatConversationQuery(BaseConversationQuery): ) -console_ns.schema_model( - CompletionConversationQuery.__name__, - CompletionConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - ChatConversationQuery.__name__, - ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) - register_schema_models( console_ns, CompletionConversationQuery, @@ -89,6 +78,8 @@ register_schema_models( ConversationWithSummaryPaginationResponse, ConversationDetailResponse, ResultResponse, + CompletionConversationQuery, + ChatConversationQuery, ) @@ -107,7 +98,7 @@ class CompletionConversationApi(Resource): @edit_permission_required def get(self, app_model): current_user, _ = current_account_with_tenant() - args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) query = sa.select(Conversation).where( Conversation.app_id == app_model.id, Conversation.mode == "completion", Conversation.is_deleted.is_(False) @@ -221,7 +212,7 @@ class ChatConversationApi(Resource): @edit_permission_required def get(self, app_model): current_user, _ = current_account_with_tenant() - args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) subquery = ( sa.select(Conversation.id.label("conversation_id"), EndUser.session_id.label("from_end_user_session_id")) diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 9c8b095b9f..5951f7405a 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -16,6 +16,7 @@ from controllers.console.wraps import account_initialization_required, setup_req from extensions.ext_database import db from fields._value_type_serializer import serialize_value_type from fields.base import ResponseModel +from libs.helper import to_timestamp from libs.login import login_required from models import ConversationVariable from models.model import AppMode @@ -25,12 +26,6 @@ class ConversationVariablesQuery(BaseModel): conversation_id: str = Field(..., description="Conversation ID to filter variables") -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class ConversationVariableResponse(ResponseModel): id: str name: str @@ -65,7 +60,7 @@ class ConversationVariableResponse(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class PaginatedConversationVariableResponse(ResponseModel): @@ -100,7 +95,7 @@ class ConversationVariablesApi(Resource): @account_initialization_required @get_app_model(mode=AppMode.ADVANCED_CHAT) def get(self, app_model): - args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) stmt = ( select(ConversationVariable) diff --git a/api/controllers/console/app/generator.py b/api/controllers/console/app/generator.py index c720a5e074..d4f501d34c 100644 --- a/api/controllers/console/app/generator.py +++ b/api/controllers/console/app/generator.py @@ -3,6 +3,7 @@ from collections.abc import Sequence from flask_restx import Resource from pydantic import BaseModel, Field +from controllers.common.schema import register_enum_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( CompletionRequestError, @@ -19,13 +20,12 @@ from core.helper.code_executor.python3.python3_code_provider import Python3CodeP from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload from core.llm_generator.llm_generator import LLMGenerator from extensions.ext_database import db +from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.errors.invoke import InvokeError from libs.login import current_account_with_tenant, login_required from models import App from services.workflow_service import WorkflowService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class InstructionGeneratePayload(BaseModel): flow_id: str = Field(..., description="Workflow/Flow ID") @@ -41,16 +41,16 @@ class InstructionTemplatePayload(BaseModel): type: str = Field(..., description="Instruction template type") -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(RuleGeneratePayload) -reg(RuleCodeGeneratePayload) -reg(RuleStructuredOutputPayload) -reg(InstructionGeneratePayload) -reg(InstructionTemplatePayload) -reg(ModelConfig) +register_enum_models(console_ns, LLMMode) +register_schema_models( + console_ns, + RuleGeneratePayload, + RuleCodeGeneratePayload, + RuleStructuredOutputPayload, + InstructionGeneratePayload, + InstructionTemplatePayload, + ModelConfig, +) @console_ns.route("/rule-generate") diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index d517f695b8..13f6e098ba 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -13,6 +13,7 @@ from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from extensions.ext_database import db from fields.base import ResponseModel +from libs.helper import to_timestamp from libs.login import current_account_with_tenant, login_required from models.enums import AppMCPServerStatus from models.model import AppMCPServer @@ -30,12 +31,6 @@ class MCPServerUpdatePayload(BaseModel): status: str | None = Field(default=None, description="Server status") -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class AppMCPServerResponse(ResponseModel): id: str name: str @@ -59,7 +54,7 @@ class AppMCPServerResponse(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse) diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 44e19b57db..4b596b992f 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -37,10 +37,9 @@ from fields.conversation_fields import ( JSONValue, MessageFile, format_files_contained, - to_timestamp, ) from graphon.model_runtime.errors.invoke import InvokeError -from libs.helper import uuid_value +from libs.helper import to_timestamp, uuid_value from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.login import current_account_with_tenant, login_required from models.enums import FeedbackFromSource, FeedbackRating @@ -144,9 +143,7 @@ class MessageDetailResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class MessageInfiniteScrollPaginationResponse(ResponseModel): diff --git a/api/controllers/console/app/ops_trace.py b/api/controllers/console/app/ops_trace.py index cbcf513162..41acf39541 100644 --- a/api/controllers/console/app/ops_trace.py +++ b/api/controllers/console/app/ops_trace.py @@ -5,14 +5,15 @@ from flask_restx import Resource, fields from pydantic import BaseModel, Field from werkzeug.exceptions import BadRequest +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist +from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required from libs.login import login_required +from models import App from services.ops_service import OpsService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class TraceProviderQuery(BaseModel): tracing_provider: str = Field(..., description="Tracing provider name") @@ -23,13 +24,7 @@ class TraceConfigPayload(BaseModel): tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data") -console_ns.schema_model( - TraceProviderQuery.__name__, - TraceProviderQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - TraceConfigPayload.__name__, TraceConfigPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload) @console_ns.route("/apps//trace-config") @@ -49,11 +44,14 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def get(self, app_id): + @get_app_model + def get(self, app_model: App): args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore try: - trace_config = OpsService.get_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) + trace_config = OpsService.get_tracing_app_config( + app_id=app_model.id, tracing_provider=args.tracing_provider + ) if not trace_config: return {"has_not_configured": True} return trace_config @@ -71,13 +69,14 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def post(self, app_id): + @get_app_model + def post(self, app_model: App): """Create a new trace app configuration""" args = TraceConfigPayload.model_validate(console_ns.payload) try: result = OpsService.create_tracing_app_config( - app_id=app_id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config + app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config ) if not result: raise TracingConfigIsExist() @@ -96,13 +95,14 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def patch(self, app_id): + @get_app_model + def patch(self, app_model: App): """Update an existing trace app configuration""" args = TraceConfigPayload.model_validate(console_ns.payload) try: result = OpsService.update_tracing_app_config( - app_id=app_id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config + app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config ) if not result: raise TracingConfigNotExist() @@ -119,12 +119,13 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required - def delete(self, app_id): + @get_app_model + def delete(self, app_model: App): """Delete an existing trace app configuration""" - args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) try: - result = OpsService.delete_tracing_app_config(app_id=app_id, tracing_provider=args.tracing_provider) + result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider) if not result: raise TracingConfigNotExist() return {"result": "success"}, 204 diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index ffa28b1c95..d23b2837c9 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -5,6 +5,7 @@ from flask import abort, jsonify, request from flask_restx import Resource, fields from pydantic import BaseModel, Field, field_validator +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -15,8 +16,6 @@ from libs.helper import convert_datetime_to_date from libs.login import current_account_with_tenant, login_required from models import AppMode -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class StatisticTimeRangeQuery(BaseModel): start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") @@ -30,10 +29,7 @@ class StatisticTimeRangeQuery(BaseModel): return value -console_ns.schema_model( - StatisticTimeRangeQuery.__name__, - StatisticTimeRangeQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, StatisticTimeRangeQuery) @console_ns.route("/apps//statistics/daily-messages") @@ -54,7 +50,7 @@ class DailyMessageStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -111,7 +107,7 @@ class DailyConversationStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -167,7 +163,7 @@ class DailyTerminalsStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -224,7 +220,7 @@ class DailyTokenCostStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -284,7 +280,7 @@ class AverageSessionInteractionStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("c.created_at") sql_query = f"""SELECT @@ -360,7 +356,7 @@ class UserSatisfactionRateStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("m.created_at") sql_query = f"""SELECT @@ -426,7 +422,7 @@ class AverageResponseTimeStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT @@ -482,7 +478,7 @@ class TokensPerSecondStatistic(Resource): @account_initialization_required def get(self, app_model): account, _ = current_account_with_tenant() - args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) converted_created_at = convert_datetime_to_date("created_at") sql_query = f"""SELECT diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 68dd8b7a8d..8d065ece67 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -1,19 +1,24 @@ import json import logging from collections.abc import Sequence -from typing import Any +from datetime import datetime +from typing import Any, NotRequired, TypedDict from flask import abort, request -from flask_restx import Resource, fields, marshal, marshal_with -from pydantic import BaseModel, Field, ValidationError, field_validator +from flask_restx import Resource, fields +from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload +from controllers.common.schema import ( + register_response_schema_model, + register_response_schema_models, + register_schema_models, +) from controllers.console import console_ns from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync -from controllers.console.app.workflow_run import workflow_run_node_execution_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError @@ -22,6 +27,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY from core.app.entities.app_invoke_entities import InvokeFrom from core.app.file_access import DatabaseFileAccessController +from core.helper import encrypter from core.helper.trace_id_helper import get_external_trace_id from core.plugin.impl.exc import PluginInvokeError from core.trigger.constants import TRIGGER_SCHEDULE_NODE_TYPE @@ -34,17 +40,18 @@ from core.trigger.debug.event_selectors import ( from extensions.ext_database import db from extensions.ext_redis import redis_client from factories import file_factory, variable_factory -from fields.member_fields import simple_account_fields -from fields.online_user_fields import online_user_list_fields -from fields.workflow_fields import workflow_fields, workflow_pagination_fields +from fields.base import ResponseModel +from fields.member_fields import SimpleAccount +from fields.workflow_run_fields import WorkflowRunNodeExecutionResponse from graphon.enums import NodeType from graphon.file import File from graphon.file import helpers as file_helpers from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.utils.encoders import jsonable_encoder +from graphon.variables import SecretVariable, SegmentType, VariableBase from libs import helper from libs.datetime_utils import naive_utc_now -from libs.helper import TimestampField, uuid_value +from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value from libs.login import current_account_with_tenant, login_required from models import App from models.model import AppMode @@ -56,48 +63,22 @@ from services.errors.llm import InvokeRateLimitError from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService logger = logging.getLogger(__name__) + _file_access_controller = DatabaseFileAccessController() LISTENING_RETRY_IN = 2000 -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" + RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published" MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000 WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50 +ENVIRONMENT_VARIABLE_SUPPORTED_TYPES = (SegmentType.STRING, SegmentType.NUMBER, SegmentType.SECRET) -# Register models for flask_restx to avoid dict type issues in Swagger -# Register in dependency order: base models first, then dependent models -# Base models -simple_account_model = console_ns.model("SimpleAccount", simple_account_fields) - -from fields.workflow_fields import pipeline_variable_fields, serialize_value_type - -conversation_variable_model = console_ns.model( - "ConversationVariable", - { - "id": fields.String, - "name": fields.String, - "value_type": fields.String(attribute=serialize_value_type), - "value": fields.Raw, - "description": fields.String, - }, -) - -pipeline_variable_model = console_ns.model("PipelineVariable", pipeline_variable_fields) - -# Workflow model with nested dependencies -workflow_fields_copy = workflow_fields.copy() -workflow_fields_copy["created_by"] = fields.Nested(simple_account_model, attribute="created_by_account") -workflow_fields_copy["updated_by"] = fields.Nested( - simple_account_model, attribute="updated_by_account", allow_null=True -) -workflow_fields_copy["conversation_variables"] = fields.List(fields.Nested(conversation_variable_model)) -workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipeline_variable_model)) -workflow_model = console_ns.model("Workflow", workflow_fields_copy) - -# Workflow pagination model -workflow_pagination_fields_copy = workflow_pagination_fields.copy() -workflow_pagination_fields_copy["items"] = fields.List(fields.Nested(workflow_model), attribute="items") -workflow_pagination_model = console_ns.model("WorkflowPagination", workflow_pagination_fields_copy) +class EnvironmentVariableResponseDict(TypedDict): + value_type: str + id: NotRequired[str] + name: NotRequired[str] + value: NotRequired[Any] + description: NotRequired[str | None] class SyncDraftWorkflowPayload(BaseModel): @@ -168,6 +149,110 @@ class WorkflowOnlineUsersPayload(BaseModel): return list(dict.fromkeys(app_id.strip() for app_id in app_ids if app_id.strip())) +class WorkflowConversationVariableResponse(ResponseModel): + id: str + name: str + value_type: str + value: Any = Field(json_schema_extra={"type": "object"}) + description: str + + @field_validator("value_type", mode="before") + @classmethod + def _serialize_value_type(cls, value: Any) -> str: + if hasattr(value, "exposed_type"): + return str(value.exposed_type()) + return str(value) + + +class PipelineVariableResponse(ResponseModel): + label: str + variable: str + type: str + belong_to_node_id: str + max_length: int | None = None + required: bool + unit: str | None = None + default_value: Any = Field(default=None, json_schema_extra={"type": "object"}) + options: list[str] | None = None + placeholder: str | None = None + tooltips: str | None = None + allowed_file_types: list[str] | None = None + allowed_file_extensions: list[str] | None = Field( + default=None, validation_alias=AliasChoices("allowed_file_extensions", "allow_file_extension") + ) + allowed_file_upload_methods: list[str] | None = Field( + default=None, validation_alias=AliasChoices("allowed_file_upload_methods", "allow_file_upload_methods") + ) + + +class WorkflowEnvironmentVariableResponse(ResponseModel): + value_type: str + id: str + name: str + value: Any = Field(json_schema_extra={"type": "object"}) + description: str + + +class WorkflowResponse(ResponseModel): + id: str + graph: dict[str, Any] = Field(validation_alias=AliasChoices("graph_dict", "graph")) + features: dict[str, Any] = Field(validation_alias=AliasChoices("features_dict", "features")) + hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash")) + version: str + marked_name: str + marked_comment: str + created_by: SimpleAccount | None = Field( + default=None, validation_alias=AliasChoices("created_by_account", "created_by") + ) + created_at: int + updated_by: SimpleAccount | None = Field( + default=None, validation_alias=AliasChoices("updated_by_account", "updated_by") + ) + updated_at: int + tool_published: bool + environment_variables: list[WorkflowEnvironmentVariableResponse] + conversation_variables: list[WorkflowConversationVariableResponse] + rag_pipeline_variables: list[PipelineVariableResponse] + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int: + timestamp = to_timestamp(value) + if timestamp is None: + raise ValueError("timestamp is required") + return timestamp + + @field_validator("environment_variables", mode="before") + @classmethod + def _serialize_environment_variables(cls, value: Any) -> list[Any]: + if value is None: + return [] + + return [_serialize_environment_variable(item) for item in value] + + +class WorkflowPaginationResponse(ResponseModel): + items: list[WorkflowResponse] + page: int + limit: int + has_more: bool + + +class WorkflowOnlineUser(ResponseModel): + user_id: str + username: str + avatar: str | None = None + + +class WorkflowOnlineUsersByApp(ResponseModel): + app_id: str + users: list[WorkflowOnlineUser] + + +class WorkflowOnlineUsersResponse(ResponseModel): + data: list[WorkflowOnlineUsersByApp] + + class DraftWorkflowTriggerRunPayload(BaseModel): node_id: str @@ -176,25 +261,36 @@ class DraftWorkflowTriggerRunAllPayload(BaseModel): node_ids: list[str] -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(SyncDraftWorkflowPayload) -reg(AdvancedChatWorkflowRunPayload) -reg(IterationNodeRunPayload) -reg(LoopNodeRunPayload) -reg(DraftWorkflowRunPayload) -reg(DraftWorkflowNodeRunPayload) -reg(PublishWorkflowPayload) -reg(DefaultBlockConfigQuery) -reg(ConvertToWorkflowPayload) -reg(WorkflowListQuery) -reg(WorkflowUpdatePayload) -reg(WorkflowFeaturesPayload) -reg(WorkflowOnlineUsersPayload) -reg(DraftWorkflowTriggerRunPayload) -reg(DraftWorkflowTriggerRunAllPayload) +register_schema_models( + console_ns, + SyncDraftWorkflowPayload, + AdvancedChatWorkflowRunPayload, + IterationNodeRunPayload, + LoopNodeRunPayload, + DraftWorkflowRunPayload, + DraftWorkflowNodeRunPayload, + PublishWorkflowPayload, + DefaultBlockConfigQuery, + ConvertToWorkflowPayload, + WorkflowListQuery, + WorkflowUpdatePayload, + WorkflowFeaturesPayload, + WorkflowOnlineUsersPayload, + DraftWorkflowTriggerRunPayload, + DraftWorkflowTriggerRunAllPayload, +) +register_response_schema_model(console_ns, WorkflowRunNodeExecutionResponse) +register_response_schema_models( + console_ns, + WorkflowConversationVariableResponse, + PipelineVariableResponse, + WorkflowEnvironmentVariableResponse, + WorkflowResponse, + WorkflowPaginationResponse, + WorkflowOnlineUser, + WorkflowOnlineUsersByApp, + WorkflowOnlineUsersResponse, +) # TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing @@ -216,18 +312,56 @@ def _parse_file(workflow: Workflow, files: list[dict] | None = None) -> Sequence return file_objs +def _serialize_environment_variable(value: Any) -> EnvironmentVariableResponseDict | Any: + match value: + case SecretVariable(): + return { + "id": value.id, + "name": value.name, + "value": encrypter.full_mask_token(), + "value_type": value.value_type.value, + "description": value.description, + } + + case VariableBase(): + return { + "id": value.id, + "name": value.name, + "value": value.value, + "value_type": str(value.value_type.exposed_type()), + "description": value.description, + } + + case dict(): + value_type_str = value.get("value_type") + if not isinstance(value_type_str, str): + raise TypeError( + f"unexpected type for value_type field, value={value_type_str}, type={type(value_type_str)}" + ) + value_type = SegmentType(value_type_str).exposed_type() + if value_type not in ENVIRONMENT_VARIABLE_SUPPORTED_TYPES: + raise ValueError(f"Unsupported environment variable value type: {value_type}") + return value + + case _: + return value + + @console_ns.route("/apps//workflows/draft") class DraftWorkflowApi(Resource): @console_ns.doc("get_draft_workflow") @console_ns.doc(description="Get draft workflow for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Draft workflow retrieved successfully", workflow_model) + @console_ns.response( + 200, + "Draft workflow retrieved successfully", + console_ns.models[WorkflowResponse.__name__], + ) @console_ns.response(404, "Draft workflow not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_model) @edit_permission_required def get(self, app_model: App): """ @@ -240,8 +374,8 @@ class DraftWorkflowApi(Resource): if not workflow: raise DraftWorkflowNotExist() - # return workflow, if not found, return None (initiate graph by frontend) - return workflow + # return workflow, if not found, return 404 + return dump_response(WorkflowResponse, workflow) @setup_required @login_required @@ -540,9 +674,12 @@ class HumanInputDeliveryTestPayload(BaseModel): ) -reg(HumanInputFormPreviewPayload) -reg(HumanInputFormSubmitPayload) -reg(HumanInputDeliveryTestPayload) +register_schema_models( + console_ns, + HumanInputFormPreviewPayload, + HumanInputFormSubmitPayload, + HumanInputDeliveryTestPayload, +) @console_ns.route("/apps//advanced-chat/workflows/draft/human-input/nodes//form/preview") @@ -760,14 +897,17 @@ class DraftWorkflowNodeRunApi(Resource): @console_ns.doc(description="Run draft workflow node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) @console_ns.expect(console_ns.models[DraftWorkflowNodeRunPayload.__name__]) - @console_ns.response(200, "Node run started successfully", workflow_run_node_execution_model) + @console_ns.response( + 200, + "Node run started successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @console_ns.response(403, "Permission denied") @console_ns.response(404, "Node not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_node_execution_model) @edit_permission_required def post(self, app_model: App, node_id: str): """ @@ -799,7 +939,9 @@ class DraftWorkflowNodeRunApi(Resource): files=files, ) - return workflow_node_execution + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/apps//workflows/publish") @@ -807,13 +949,15 @@ class PublishedWorkflowApi(Resource): @console_ns.doc("get_published_workflow") @console_ns.doc(description="Get published workflow for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Published workflow retrieved successfully", workflow_model) - @console_ns.response(404, "Published workflow not found") + @console_ns.response( + 200, + "Published workflow retrieved successfully, or null if not found", + console_ns.models[WorkflowResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_model) @edit_permission_required def get(self, app_model: App): """ @@ -824,7 +968,10 @@ class PublishedWorkflowApi(Resource): workflow = workflow_service.get_published_workflow(app_model=app_model) # return workflow, if not found, return None - return workflow + if workflow is None: + return None + + return dump_response(WorkflowResponse, workflow) @console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__]) @setup_required @@ -902,7 +1049,7 @@ class DefaultBlockConfigApi(Resource): """ Get default block config """ - args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True)) filters = None if args.q: @@ -983,7 +1130,11 @@ class PublishedAllWorkflowApi(Resource): @console_ns.doc("get_all_published_workflows") @console_ns.doc(description="Get all published workflows for an application") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Published workflows retrieved successfully", workflow_pagination_model) + @console_ns.response( + 200, + "Published workflows retrieved successfully", + console_ns.models[WorkflowPaginationResponse.__name__], + ) @setup_required @login_required @account_initialization_required @@ -995,7 +1146,7 @@ class PublishedAllWorkflowApi(Resource): """ current_user, _ = current_account_with_tenant() - args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page limit = args.limit user_id = args.user_id @@ -1015,14 +1166,14 @@ class PublishedAllWorkflowApi(Resource): user_id=user_id, named_only=named_only, ) - serialized_workflows = marshal(workflows, workflow_fields_copy) - - return { - "items": serialized_workflows, - "page": page, - "limit": limit, - "has_more": has_more, - } + return WorkflowPaginationResponse.model_validate( + { + "items": workflows, + "page": page, + "limit": limit, + "has_more": has_more, + } + ).model_dump(mode="json") @console_ns.route("/apps//workflows//restore") @@ -1068,14 +1219,13 @@ class WorkflowByIdApi(Resource): @console_ns.doc(description="Update workflow by ID") @console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Workflow ID"}) @console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__]) - @console_ns.response(200, "Workflow updated successfully", workflow_model) + @console_ns.response(200, "Workflow updated successfully", console_ns.models[WorkflowResponse.__name__]) @console_ns.response(404, "Workflow not found") @console_ns.response(403, "Permission denied") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_model) @edit_permission_required def patch(self, app_model: App, workflow_id: str): """ @@ -1109,7 +1259,7 @@ class WorkflowByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - return workflow + return dump_response(WorkflowResponse, workflow) @setup_required @login_required @@ -1143,14 +1293,17 @@ class DraftWorkflowNodeLastRunApi(Resource): @console_ns.doc("get_draft_workflow_node_last_run") @console_ns.doc(description="Get last run result for draft workflow node") @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"}) - @console_ns.response(200, "Node last run retrieved successfully", workflow_run_node_execution_model) + @console_ns.response( + 200, + "Node last run retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @console_ns.response(404, "Node last run not found") @console_ns.response(403, "Permission denied") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_node_execution_model) def get(self, app_model: App, node_id: str): srv = WorkflowService() workflow = srv.get_draft_workflow(app_model) @@ -1163,7 +1316,7 @@ class DraftWorkflowNodeLastRunApi(Resource): ) if node_exec is None: raise NotFound("last run not found") - return node_exec + return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//workflows/draft/trigger/run") @@ -1391,12 +1544,16 @@ class DraftWorkflowTriggerRunAllApi(Resource): @console_ns.route("/apps/workflows/online-users") class WorkflowOnlineUsersApi(Resource): @console_ns.expect(console_ns.models[WorkflowOnlineUsersPayload.__name__]) + @console_ns.response( + 200, + "Workflow online users retrieved successfully", + console_ns.models[WorkflowOnlineUsersResponse.__name__], + ) @console_ns.doc("get_workflow_online_users") @console_ns.doc(description="Get workflow online users") @setup_required @login_required @account_initialization_required - @marshal_with(online_user_list_fields) def post(self): args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {}) @@ -1439,10 +1596,18 @@ class WorkflowOnlineUsersApi(Resource): if not isinstance(user_info, dict): continue + user_id = user_info.get("user_id") + username = user_info.get("username") + if not isinstance(user_id, str) or not isinstance(username, str): + continue + avatar = user_info.get("avatar") + if avatar is not None and not isinstance(avatar, str): + avatar = None + if isinstance(avatar, str) and avatar and not avatar.startswith(("http://", "https://")): try: - user_info["avatar"] = file_helpers.get_signed_file_url(avatar) + avatar = file_helpers.get_signed_file_url(avatar) except Exception as exc: logger.warning( "Failed to sign workflow online user avatar; using original value. " @@ -1452,7 +1617,7 @@ class WorkflowOnlineUsersApi(Resource): exc, ) - users.append(user_info) + users.append({"user_id": user_id, "username": username, "avatar": avatar}) results.append({"app_id": app_id, "users": users}) - return {"data": results} + return WorkflowOnlineUsersResponse.model_validate({"data": results}).model_dump(mode="json") diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index 4b39590235..dec183a300 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -16,6 +16,7 @@ from fields.base import ResponseModel from fields.end_user_fields import SimpleEndUser from fields.member_fields import SimpleAccount from graphon.enums import WorkflowExecutionStatus +from libs.helper import to_timestamp from libs.login import login_required from models import App from models.model import AppMode @@ -82,9 +83,7 @@ class WorkflowRunForLogResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value + return to_timestamp(value) class WorkflowRunForArchivedLogResponse(ResponseModel): @@ -117,9 +116,7 @@ class WorkflowAppLogPartialResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value + return to_timestamp(value) class WorkflowArchivedLogPartialResponse(ResponseModel): @@ -133,9 +130,7 @@ class WorkflowArchivedLogPartialResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value + return to_timestamp(value) class WorkflowAppLogPaginationResponse(ResponseModel): @@ -185,7 +180,7 @@ class WorkflowAppLogApi(Resource): """ Get workflow app logs """ - args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # get paginate workflow app logs workflow_app_service = WorkflowAppService() @@ -228,7 +223,7 @@ class WorkflowArchivedLogApi(Resource): """ Get workflow archived logs """ - args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) workflow_app_service = WorkflowAppService() with sessionmaker(db.engine, expire_on_commit=False).begin() as session: diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index e7c3e982a6..f011f576fd 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -1,29 +1,22 @@ import logging +from datetime import datetime -from flask_restx import Resource, marshal_with -from pydantic import BaseModel, Field, TypeAdapter +from flask_restx import Resource +from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_validator -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from fields.base import ResponseModel from fields.member_fields import AccountWithRole -from fields.workflow_comment_fields import ( - workflow_comment_basic_fields, - workflow_comment_create_fields, - workflow_comment_detail_fields, - workflow_comment_reply_create_fields, - workflow_comment_reply_update_fields, - workflow_comment_resolve_fields, - workflow_comment_update_fields, -) +from libs.helper import build_avatar_url, dump_response, to_timestamp from libs.login import current_user, login_required from models import App from services.account_service import TenantService from services.workflow_comment_service import WorkflowCommentService logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class WorkflowCommentCreatePayload(BaseModel): @@ -52,24 +45,159 @@ class WorkflowCommentMentionUsersPayload(BaseModel): users: list[AccountWithRole] -for model in ( +class WorkflowCommentAccount(ResponseModel): + id: str + name: str + email: str + avatar: str | None = Field(default=None, exclude=True) + + @computed_field(return_type=str | None) # type: ignore[prop-decorator] + @property + def avatar_url(self) -> str | None: + return build_avatar_url(self.avatar) + + +class WorkflowCommentReply(ResponseModel): + id: str + content: str + created_by: str + created_by_account: WorkflowCommentAccount | None = None + created_at: int | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentMention(ResponseModel): + mentioned_user_id: str + mentioned_user_account: WorkflowCommentAccount | None = None + reply_id: str | None = None + + +class WorkflowCommentBasic(ResponseModel): + id: str + position_x: float + position_y: float + content: str + created_by: str + created_by_account: WorkflowCommentAccount | None = None + created_at: int | None = None + updated_at: int | None = None + resolved: bool + resolved_at: int | None = None + resolved_by: str | None = None + resolved_by_account: WorkflowCommentAccount | None = None + reply_count: int + mention_count: int + participants: list[WorkflowCommentAccount] + + @field_validator("created_at", "updated_at", "resolved_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentBasicList(ResponseModel): + data: list[WorkflowCommentBasic] + + +class WorkflowCommentDetail(ResponseModel): + id: str + position_x: float + position_y: float + content: str + created_by: str + created_by_account: WorkflowCommentAccount | None = None + created_at: int | None = None + updated_at: int | None = None + resolved: bool + resolved_at: int | None = None + resolved_by: str | None = None + resolved_by_account: WorkflowCommentAccount | None = None + replies: list[WorkflowCommentReply] + mentions: list[WorkflowCommentMention] + + @field_validator("created_at", "updated_at", "resolved_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentCreate(ResponseModel): + id: str + created_at: int | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentUpdate(ResponseModel): + id: str + updated_at: int | None = None + + @field_validator("updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentResolve(ResponseModel): + id: str + resolved: bool + resolved_at: int | None = None + resolved_by: str | None = None + + @field_validator("resolved_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentReplyCreate(ResponseModel): + id: str + created_at: int | None = None + + @field_validator("created_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class WorkflowCommentReplyUpdate(ResponseModel): + id: str + updated_at: int | None = None + + @field_validator("updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +register_schema_models( + console_ns, + AccountWithRole, + WorkflowCommentMentionUsersPayload, WorkflowCommentCreatePayload, WorkflowCommentUpdatePayload, WorkflowCommentReplyPayload, -): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) -register_schema_models(console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload) - -workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields) -workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields) -workflow_comment_create_model = console_ns.model("WorkflowCommentCreate", workflow_comment_create_fields) -workflow_comment_update_model = console_ns.model("WorkflowCommentUpdate", workflow_comment_update_fields) -workflow_comment_resolve_model = console_ns.model("WorkflowCommentResolve", workflow_comment_resolve_fields) -workflow_comment_reply_create_model = console_ns.model( - "WorkflowCommentReplyCreate", workflow_comment_reply_create_fields ) -workflow_comment_reply_update_model = console_ns.model( - "WorkflowCommentReplyUpdate", workflow_comment_reply_update_fields +register_response_schema_models( + console_ns, + WorkflowCommentAccount, + WorkflowCommentReply, + WorkflowCommentMention, + WorkflowCommentBasic, + WorkflowCommentBasicList, + WorkflowCommentDetail, + WorkflowCommentCreate, + WorkflowCommentUpdate, + WorkflowCommentResolve, + WorkflowCommentReplyCreate, + WorkflowCommentReplyUpdate, ) @@ -80,28 +208,26 @@ class WorkflowCommentListApi(Resource): @console_ns.doc("list_workflow_comments") @console_ns.doc(description="Get all comments for a workflow") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(200, "Comments retrieved successfully", workflow_comment_basic_model) + @console_ns.response(200, "Comments retrieved successfully", console_ns.models[WorkflowCommentBasicList.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_basic_model, envelope="data") def get(self, app_model: App): """Get all comments for a workflow.""" comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id) - return comments + return WorkflowCommentBasicList.model_validate({"data": comments}).model_dump(mode="json") @console_ns.doc("create_workflow_comment") @console_ns.doc(description="Create a new workflow comment") @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__]) - @console_ns.response(201, "Comment created successfully", workflow_comment_create_model) + @console_ns.response(201, "Comment created successfully", console_ns.models[WorkflowCommentCreate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_create_model) @edit_permission_required def post(self, app_model: App): """Create a new workflow comment.""" @@ -117,7 +243,7 @@ class WorkflowCommentListApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return result, 201 + return dump_response(WorkflowCommentCreate, result), 201 @console_ns.route("/apps//workflow/comments/") @@ -127,30 +253,28 @@ class WorkflowCommentDetailApi(Resource): @console_ns.doc("get_workflow_comment") @console_ns.doc(description="Get a specific workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) - @console_ns.response(200, "Comment retrieved successfully", workflow_comment_detail_model) + @console_ns.response(200, "Comment retrieved successfully", console_ns.models[WorkflowCommentDetail.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_detail_model) def get(self, app_model: App, comment_id: str): """Get a specific workflow comment.""" comment = WorkflowCommentService.get_comment( tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id ) - return comment + return dump_response(WorkflowCommentDetail, comment) @console_ns.doc("update_workflow_comment") @console_ns.doc(description="Update a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__]) - @console_ns.response(200, "Comment updated successfully", workflow_comment_update_model) + @console_ns.response(200, "Comment updated successfully", console_ns.models[WorkflowCommentUpdate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_update_model) @edit_permission_required def put(self, app_model: App, comment_id: str): """Update a workflow comment.""" @@ -167,7 +291,7 @@ class WorkflowCommentDetailApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return result + return dump_response(WorkflowCommentUpdate, result) @console_ns.doc("delete_workflow_comment") @console_ns.doc(description="Delete a workflow comment") @@ -197,12 +321,11 @@ class WorkflowCommentResolveApi(Resource): @console_ns.doc("resolve_workflow_comment") @console_ns.doc(description="Resolve a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) - @console_ns.response(200, "Comment resolved successfully", workflow_comment_resolve_model) + @console_ns.response(200, "Comment resolved successfully", console_ns.models[WorkflowCommentResolve.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_resolve_model) @edit_permission_required def post(self, app_model: App, comment_id: str): """Resolve a workflow comment.""" @@ -213,7 +336,7 @@ class WorkflowCommentResolveApi(Resource): user_id=current_user.id, ) - return comment + return dump_response(WorkflowCommentResolve, comment) @console_ns.route("/apps//workflow/comments//replies") @@ -224,12 +347,11 @@ class WorkflowCommentReplyApi(Resource): @console_ns.doc(description="Add a reply to a workflow comment") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"}) @console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__]) - @console_ns.response(201, "Reply created successfully", workflow_comment_reply_create_model) + @console_ns.response(201, "Reply created successfully", console_ns.models[WorkflowCommentReplyCreate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_reply_create_model) @edit_permission_required def post(self, app_model: App, comment_id: str): """Add a reply to a workflow comment.""" @@ -247,7 +369,7 @@ class WorkflowCommentReplyApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return result, 201 + return dump_response(WorkflowCommentReplyCreate, result), 201 @console_ns.route("/apps//workflow/comments//replies/") @@ -258,12 +380,11 @@ class WorkflowCommentReplyDetailApi(Resource): @console_ns.doc(description="Update a comment reply") @console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"}) @console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__]) - @console_ns.response(200, "Reply updated successfully", workflow_comment_reply_update_model) + @console_ns.response(200, "Reply updated successfully", console_ns.models[WorkflowCommentReplyUpdate.__name__]) @login_required @setup_required @account_initialization_required @get_app_model() - @marshal_with(workflow_comment_reply_update_model) @edit_permission_required def put(self, app_model: App, comment_id: str, reply_id: str): """Update a comment reply.""" @@ -284,7 +405,7 @@ class WorkflowCommentReplyDetailApi(Resource): mentioned_user_ids=payload.mentioned_user_ids, ) - return reply + return dump_response(WorkflowCommentReplyUpdate, reply) @console_ns.doc("delete_workflow_comment_reply") @console_ns.doc(description="Delete a comment reply") diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index e32ba5f66c..3c887c33dc 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -8,6 +8,7 @@ from flask_restx import Resource, fields, marshal, marshal_with from pydantic import BaseModel, Field from sqlalchemy.orm import sessionmaker +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( DraftWorkflowNotExist, @@ -33,7 +34,6 @@ from services.workflow_service import WorkflowService logger = logging.getLogger(__name__) _file_access_controller = DatabaseFileAccessController() -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class WorkflowDraftVariableListQuery(BaseModel): @@ -56,33 +56,25 @@ class EnvironmentVariableUpdatePayload(BaseModel): environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow") -console_ns.schema_model( - WorkflowDraftVariableListQuery.__name__, - WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - WorkflowDraftVariableUpdatePayload.__name__, - WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - ConversationVariableUpdatePayload.__name__, - ConversationVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) -console_ns.schema_model( - EnvironmentVariableUpdatePayload.__name__, - EnvironmentVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), +register_schema_models( + console_ns, + WorkflowDraftVariableListQuery, + WorkflowDraftVariableUpdatePayload, + ConversationVariableUpdatePayload, + EnvironmentVariableUpdatePayload, ) def _convert_values_to_json_serializable_object(value: Segment): - if isinstance(value, FileSegment): - return value.value.model_dump() - elif isinstance(value, ArrayFileSegment): - return [i.model_dump() for i in value.value] - elif isinstance(value, SegmentGroup): - return [_convert_values_to_json_serializable_object(i) for i in value.value] - else: - return value.value + match value: + case FileSegment(): + return value.value.model_dump() + case ArrayFileSegment(): + return [i.model_dump() for i in value.value] + case SegmentGroup(): + return [_convert_values_to_json_serializable_object(i) for i in value.value] + case _: + return value.value def _serialize_var_value(variable: WorkflowDraftVariable): @@ -259,7 +251,7 @@ class WorkflowVariableCollectionApi(Resource): """ Get draft workflow """ - args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # fetch draft workflow by app_model workflow_service = WorkflowService() diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 6748d95d6b..97d2003209 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -1,30 +1,28 @@ from datetime import UTC, datetime, timedelta -from typing import Literal, TypedDict, cast +from typing import Literal, cast from flask import request -from flask_restx import Resource, fields, marshal_with +from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.orm import sessionmaker from configs import dify_config +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required from controllers.web.error import NotFoundError from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id from extensions.ext_database import db -from fields.end_user_fields import simple_end_user_fields -from fields.member_fields import simple_account_fields +from fields.base import ResponseModel from fields.workflow_run_fields import ( - advanced_chat_workflow_run_for_list_fields, - advanced_chat_workflow_run_pagination_fields, - workflow_run_count_fields, - workflow_run_detail_fields, - workflow_run_for_list_fields, - workflow_run_node_execution_fields, - workflow_run_node_execution_list_fields, - workflow_run_pagination_fields, + AdvancedChatWorkflowRunPaginationResponse, + WorkflowRunCountResponse, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, ) from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus @@ -52,82 +50,6 @@ def _build_backstage_input_url(form_token: str | None) -> str | None: WORKFLOW_RUN_STATUS_CHOICES = ["running", "succeeded", "failed", "stopped", "partial-succeeded"] EXPORT_SIGNED_URL_EXPIRE_SECONDS = 3600 -# Register models for flask_restx to avoid dict type issues in Swagger -# Register in dependency order: base models first, then dependent models - -# Base models -simple_account_model = console_ns.model("SimpleAccount", simple_account_fields) - -simple_end_user_model = console_ns.model("SimpleEndUser", simple_end_user_fields) - -# Models that depend on simple_account_fields -workflow_run_for_list_fields_copy = workflow_run_for_list_fields.copy() -workflow_run_for_list_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -workflow_run_for_list_model = console_ns.model("WorkflowRunForList", workflow_run_for_list_fields_copy) - -advanced_chat_workflow_run_for_list_fields_copy = advanced_chat_workflow_run_for_list_fields.copy() -advanced_chat_workflow_run_for_list_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -advanced_chat_workflow_run_for_list_model = console_ns.model( - "AdvancedChatWorkflowRunForList", advanced_chat_workflow_run_for_list_fields_copy -) - -workflow_run_detail_fields_copy = workflow_run_detail_fields.copy() -workflow_run_detail_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -workflow_run_detail_fields_copy["created_by_end_user"] = fields.Nested( - simple_end_user_model, attribute="created_by_end_user", allow_null=True -) -workflow_run_detail_model = console_ns.model("WorkflowRunDetail", workflow_run_detail_fields_copy) - -workflow_run_node_execution_fields_copy = workflow_run_node_execution_fields.copy() -workflow_run_node_execution_fields_copy["created_by_account"] = fields.Nested( - simple_account_model, attribute="created_by_account", allow_null=True -) -workflow_run_node_execution_fields_copy["created_by_end_user"] = fields.Nested( - simple_end_user_model, attribute="created_by_end_user", allow_null=True -) -workflow_run_node_execution_model = console_ns.model( - "WorkflowRunNodeExecution", workflow_run_node_execution_fields_copy -) - -# Simple models without nested dependencies -workflow_run_count_model = console_ns.model("WorkflowRunCount", workflow_run_count_fields) - -# Pagination models that depend on list models -advanced_chat_workflow_run_pagination_fields_copy = advanced_chat_workflow_run_pagination_fields.copy() -advanced_chat_workflow_run_pagination_fields_copy["data"] = fields.List( - fields.Nested(advanced_chat_workflow_run_for_list_model), attribute="data" -) -advanced_chat_workflow_run_pagination_model = console_ns.model( - "AdvancedChatWorkflowRunPagination", advanced_chat_workflow_run_pagination_fields_copy -) - -workflow_run_pagination_fields_copy = workflow_run_pagination_fields.copy() -workflow_run_pagination_fields_copy["data"] = fields.List(fields.Nested(workflow_run_for_list_model), attribute="data") -workflow_run_pagination_model = console_ns.model("WorkflowRunPagination", workflow_run_pagination_fields_copy) - -workflow_run_node_execution_list_fields_copy = workflow_run_node_execution_list_fields.copy() -workflow_run_node_execution_list_fields_copy["data"] = fields.List(fields.Nested(workflow_run_node_execution_model)) -workflow_run_node_execution_list_model = console_ns.model( - "WorkflowRunNodeExecutionList", workflow_run_node_execution_list_fields_copy -) - -workflow_run_export_fields = console_ns.model( - "WorkflowRunExport", - { - "status": fields.String(description="Export status: success/failed"), - "presigned_url": fields.String(description="Pre-signed URL for download", required=False), - "presigned_url_expires_at": fields.String(description="Pre-signed URL expiration time", required=False), - }, -) - -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class WorkflowRunListQuery(BaseModel): last_id: str | None = Field(default=None, description="Last run ID for pagination") @@ -136,7 +58,7 @@ class WorkflowRunListQuery(BaseModel): default=None, description="Workflow run status filter" ) triggered_from: Literal["debugging", "app-run"] | None = Field( - default=None, description="Filter by trigger source: debugging or app-run" + default=None, description="Filter by trigger source: debugging or app-run. Default: debugging" ) @field_validator("last_id") @@ -151,9 +73,15 @@ class WorkflowRunCountQuery(BaseModel): status: Literal["running", "succeeded", "failed", "stopped", "partial-succeeded"] | None = Field( default=None, description="Workflow run status filter" ) - time_range: str | None = Field(default=None, description="Time range filter (e.g., 7d, 4h, 30m, 30s)") + time_range: str | None = Field( + default=None, + description=( + "Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), " + "30m (30 minutes), 30s (30 seconds). Filters by created_at field." + ), + ) triggered_from: Literal["debugging", "app-run"] | None = Field( - default=None, description="Filter by trigger source: debugging or app-run" + default=None, description="Filter by trigger source: debugging or app-run. Default: debugging" ) @field_validator("time_range") @@ -164,56 +92,69 @@ class WorkflowRunCountQuery(BaseModel): return time_duration(value) -console_ns.schema_model( - WorkflowRunListQuery.__name__, WorkflowRunListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - WorkflowRunCountQuery.__name__, - WorkflowRunCountQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +class WorkflowRunExportResponse(ResponseModel): + status: str = Field(description="Export status: success/failed") + presigned_url: str | None = Field(default=None, description="Pre-signed URL for download") + presigned_url_expires_at: str | None = Field(default=None, description="Pre-signed URL expiration time") -class HumanInputPauseTypeResponse(TypedDict): +class HumanInputPauseTypeResponse(ResponseModel): type: Literal["human_input"] form_id: str - backstage_input_url: str | None + backstage_input_url: str | None = None -class PausedNodeResponse(TypedDict): +class PausedNodeResponse(ResponseModel): node_id: str node_title: str pause_type: HumanInputPauseTypeResponse -class WorkflowPauseDetailsResponse(TypedDict): - paused_at: str | None +class WorkflowPauseDetailsResponse(ResponseModel): + paused_at: str | None = None paused_nodes: list[PausedNodeResponse] +register_schema_models( + console_ns, + WorkflowRunListQuery, + WorkflowRunCountQuery, +) +register_response_schema_models( + console_ns, + AdvancedChatWorkflowRunPaginationResponse, + WorkflowRunPaginationResponse, + WorkflowRunCountResponse, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunExportResponse, + HumanInputPauseTypeResponse, + PausedNodeResponse, + WorkflowPauseDetailsResponse, +) + + @console_ns.route("/apps//advanced-chat/workflow-runs") class AdvancedChatAppWorkflowRunListApi(Resource): @console_ns.doc("get_advanced_chat_workflow_runs") @console_ns.doc(description="Get advanced chat workflow run list") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params={"last_id": "Last run ID for pagination", "limit": "Number of items per page (1-100)"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunListQuery)) + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[AdvancedChatWorkflowRunPaginationResponse.__name__], ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__]) - @console_ns.response(200, "Workflow runs retrieved successfully", advanced_chat_workflow_run_pagination_model) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT]) - @marshal_with(advanced_chat_workflow_run_pagination_model) def get(self, app_model: App): """ Get advanced chat app workflow run list """ - args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) args: WorkflowRunListArgs = {"limit": args_model.limit} if args_model.last_id is not None: args["last_id"] = args_model.last_id @@ -232,7 +173,9 @@ class AdvancedChatAppWorkflowRunListApi(Resource): app_model=app_model, args=args, triggered_from=triggered_from ) - return result + return AdvancedChatWorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump( + mode="json" + ) @console_ns.route("/apps//workflow-runs//export") @@ -240,7 +183,7 @@ class WorkflowRunExportApi(Resource): @console_ns.doc("get_workflow_run_export_url") @console_ns.doc(description="Generate a download URL for an archived workflow run.") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Export URL generated", workflow_run_export_fields) + @console_ns.response(200, "Export URL generated", console_ns.models[WorkflowRunExportResponse.__name__]) @setup_required @login_required @account_initialization_required @@ -278,11 +221,14 @@ class WorkflowRunExportApi(Resource): expires_in=EXPORT_SIGNED_URL_EXPIRE_SECONDS, ) expires_at = datetime.now(UTC) + timedelta(seconds=EXPORT_SIGNED_URL_EXPIRE_SECONDS) - return { - "status": "success", - "presigned_url": presigned_url, - "presigned_url_expires_at": expires_at.isoformat(), - }, 200 + response = WorkflowRunExportResponse.model_validate( + { + "status": "success", + "presigned_url": presigned_url, + "presigned_url_expires_at": expires_at.isoformat(), + } + ) + return response.model_dump(mode="json"), 200 @console_ns.route("/apps//advanced-chat/workflow-runs/count") @@ -290,32 +236,21 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): @console_ns.doc("get_advanced_chat_workflow_runs_count") @console_ns.doc(description="Get advanced chat workflow runs count statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunCountQuery)) + @console_ns.response( + 200, + "Workflow runs count retrieved successfully", + console_ns.models[WorkflowRunCountResponse.__name__], ) - @console_ns.doc( - params={ - "time_range": ( - "Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), " - "30m (30 minutes), 30s (30 seconds). Filters by created_at field." - ) - } - ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_model) - @console_ns.expect(console_ns.models[WorkflowRunCountQuery.__name__]) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT]) - @marshal_with(workflow_run_count_model) def get(self, app_model: App): """ Get advanced chat workflow runs count statistics """ - args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) args = args_model.model_dump(exclude_none=True) # Default to DEBUGGING if not specified @@ -333,7 +268,7 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): triggered_from=triggered_from, ) - return result + return WorkflowRunCountResponse.model_validate(result).model_dump(mode="json") @console_ns.route("/apps//workflow-runs") @@ -341,25 +276,21 @@ class WorkflowRunListApi(Resource): @console_ns.doc("get_workflow_runs") @console_ns.doc(description="Get workflow run list") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc(params={"last_id": "Last run ID for pagination", "limit": "Number of items per page (1-100)"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunListQuery)) + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[WorkflowRunPaginationResponse.__name__], ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.response(200, "Workflow runs retrieved successfully", workflow_run_pagination_model) - @console_ns.expect(console_ns.models[WorkflowRunListQuery.__name__]) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_pagination_model) def get(self, app_model: App): """ Get workflow run list """ - args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) args: WorkflowRunListArgs = {"limit": args_model.limit} if args_model.last_id is not None: args["last_id"] = args_model.last_id @@ -378,7 +309,7 @@ class WorkflowRunListApi(Resource): app_model=app_model, args=args, triggered_from=triggered_from ) - return result + return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//workflow-runs/count") @@ -386,32 +317,21 @@ class WorkflowRunCountApi(Resource): @console_ns.doc("get_workflow_runs_count") @console_ns.doc(description="Get workflow runs count statistics") @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.doc( - params={"status": "Filter by status (optional): running, succeeded, failed, stopped, partial-succeeded"} + @console_ns.doc(params=query_params_from_model(WorkflowRunCountQuery)) + @console_ns.response( + 200, + "Workflow runs count retrieved successfully", + console_ns.models[WorkflowRunCountResponse.__name__], ) - @console_ns.doc( - params={ - "time_range": ( - "Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), " - "30m (30 minutes), 30s (30 seconds). Filters by created_at field." - ) - } - ) - @console_ns.doc( - params={"triggered_from": "Filter by trigger source (optional): debugging or app-run. Default: debugging"} - ) - @console_ns.response(200, "Workflow runs count retrieved successfully", workflow_run_count_model) - @console_ns.expect(console_ns.models[WorkflowRunCountQuery.__name__]) @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_count_model) def get(self, app_model: App): """ Get workflow runs count statistics """ - args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args_model = WorkflowRunCountQuery.model_validate(request.args.to_dict(flat=True)) args = args_model.model_dump(exclude_none=True) # Default to DEBUGGING for workflow if not specified (backward compatibility) @@ -429,7 +349,7 @@ class WorkflowRunCountApi(Resource): triggered_from=triggered_from, ) - return result + return WorkflowRunCountResponse.model_validate(result).model_dump(mode="json") @console_ns.route("/apps//workflow-runs/") @@ -437,13 +357,16 @@ class WorkflowRunDetailApi(Resource): @console_ns.doc("get_workflow_run_detail") @console_ns.doc(description="Get workflow run detail") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Workflow run detail retrieved successfully", workflow_run_detail_model) + @console_ns.response( + 200, + "Workflow run detail retrieved successfully", + console_ns.models[WorkflowRunDetailResponse.__name__], + ) @console_ns.response(404, "Workflow run not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_detail_model) def get(self, app_model: App, run_id): """ Get workflow run detail @@ -452,8 +375,10 @@ class WorkflowRunDetailApi(Resource): workflow_run_service = WorkflowRunService() workflow_run = workflow_run_service.get_workflow_run(app_model=app_model, run_id=run_id) + if workflow_run is None: + raise NotFoundError("Workflow run not found") - return workflow_run + return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") @console_ns.route("/apps//workflow-runs//node-executions") @@ -461,13 +386,16 @@ class WorkflowRunNodeExecutionListApi(Resource): @console_ns.doc("get_workflow_run_node_executions") @console_ns.doc(description="Get workflow run node execution list") @console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"}) - @console_ns.response(200, "Node executions retrieved successfully", workflow_run_node_execution_list_model) + @console_ns.response( + 200, + "Node executions retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionListResponse.__name__], + ) @console_ns.response(404, "Workflow run not found") @setup_required @login_required @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_run_node_execution_list_model) def get(self, app_model: App, run_id): """ Get workflow run node execution list @@ -482,13 +410,24 @@ class WorkflowRunNodeExecutionListApi(Resource): user=user, ) - return {"data": node_executions} + return WorkflowRunNodeExecutionListResponse.model_validate( + {"data": node_executions}, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/workflow//pause-details") class ConsoleWorkflowPauseDetailsApi(Resource): """Console API for getting workflow pause details.""" + @console_ns.doc("get_workflow_pause_details") + @console_ns.doc(description="Get workflow pause details") + @console_ns.doc(params={"workflow_run_id": "Workflow run ID"}) + @console_ns.response( + 200, + "Workflow pause details retrieved successfully", + console_ns.models[WorkflowPauseDetailsResponse.__name__], + ) + @console_ns.response(404, "Workflow run not found") @setup_required @login_required @account_initialization_required @@ -515,11 +454,8 @@ class ConsoleWorkflowPauseDetailsApi(Resource): # Check if workflow is suspended is_paused = workflow_run.status == WorkflowExecutionStatus.PAUSED if not is_paused: - empty_response: WorkflowPauseDetailsResponse = { - "paused_at": None, - "paused_nodes": [], - } - return empty_response, 200 + empty_response = WorkflowPauseDetailsResponse(paused_at=None, paused_nodes=[]) + return empty_response.model_dump(mode="json"), 200 pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id) pause_reasons = pause_entity.get_pause_reasons() if pause_entity else [] @@ -530,27 +466,25 @@ class ConsoleWorkflowPauseDetailsApi(Resource): # Build response paused_at = pause_entity.paused_at if pause_entity else None paused_nodes: list[PausedNodeResponse] = [] - response: WorkflowPauseDetailsResponse = { - "paused_at": paused_at.isoformat() + "Z" if paused_at else None, - "paused_nodes": paused_nodes, - } for reason in pause_reasons: if isinstance(reason, HumanInputRequired): paused_nodes.append( - { - "node_id": reason.node_id, - "node_title": reason.node_title, - "pause_type": { - "type": "human_input", - "form_id": reason.form_id, - "backstage_input_url": _build_backstage_input_url( - form_tokens_by_form_id.get(reason.form_id) - ), - }, - } + PausedNodeResponse( + node_id=reason.node_id, + node_title=reason.node_title, + pause_type=HumanInputPauseTypeResponse( + type="human_input", + form_id=reason.form_id, + backstage_input_url=_build_backstage_input_url(form_tokens_by_form_id.get(reason.form_id)), + ), + ) ) else: raise AssertionError("unimplemented.") - return response, 200 + response = WorkflowPauseDetailsResponse( + paused_at=paused_at.isoformat() + "Z" if paused_at else None, + paused_nodes=paused_nodes, + ) + return response.model_dump(mode="json"), 200 diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index e48cf42762..ca899d8784 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -3,6 +3,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import sessionmaker +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import account_initialization_required, setup_required @@ -13,8 +14,6 @@ from models.enums import WorkflowRunTriggeredFrom from models.model import AppMode from repositories.factory import DifyAPIRepositoryFactory -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class WorkflowStatisticQuery(BaseModel): start: str | None = Field(default=None, description="Start date and time (YYYY-MM-DD HH:MM)") @@ -28,10 +27,7 @@ class WorkflowStatisticQuery(BaseModel): return value -console_ns.schema_model( - WorkflowStatisticQuery.__name__, - WorkflowStatisticQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, WorkflowStatisticQuery) @console_ns.route("/apps//workflow/statistics/daily-conversations") @@ -53,7 +49,7 @@ class WorkflowDailyRunsStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None @@ -93,7 +89,7 @@ class WorkflowDailyTerminalsStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None @@ -133,7 +129,7 @@ class WorkflowDailyTokenCostStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None @@ -173,7 +169,7 @@ class WorkflowAverageAppInteractionStatistic(Resource): def get(self, app_model): account, _ = current_account_with_tenant() - args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) assert account.timezone is not None diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index a6715fa200..a80b4f5d0c 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -94,7 +94,7 @@ class WebhookTriggerApi(Resource): @console_ns.response(200, "Success", console_ns.models[WebhookTriggerResponse.__name__]) def get(self, app_model: App): """Get webhook trigger for a node""" - args = Parser.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = Parser.model_validate(request.args.to_dict(flat=True)) node_id = args.node_id diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index f7061f820f..0c05cf2fe3 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -63,7 +63,7 @@ class ActivateCheckApi(Resource): console_ns.models[ActivationCheckResponse.__name__], ) def get(self): - args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ActivateCheckQuery.model_validate(request.args.to_dict(flat=True)) workspaceId = args.workspace_id token = args.token diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index 905d0daef0..db0d36af6e 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -1,6 +1,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field +from controllers.common.schema import register_schema_models from libs.login import current_account_with_tenant, login_required from services.auth.api_key_auth_service import ApiKeyAuthService @@ -8,8 +9,6 @@ from .. import console_ns from ..auth.error import ApiKeyAuthFailedError from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ApiKeyAuthBindingPayload(BaseModel): category: str = Field(...) @@ -17,10 +16,7 @@ class ApiKeyAuthBindingPayload(BaseModel): credentials: dict = Field(...) -console_ns.schema_model( - ApiKeyAuthBindingPayload.__name__, - ApiKeyAuthBindingPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_schema_models(console_ns, ApiKeyAuthBindingPayload) @console_ns.route("/api-key-auth/data-source") diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index 1fd781b4fc..e1f3f0eaeb 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -3,7 +3,8 @@ from flask_restx import Resource from pydantic import BaseModel, Field, field_validator from configs import dify_config -from constants.languages import languages +from constants.languages import get_valid_language, languages +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( EmailAlreadyInUseError, @@ -14,17 +15,16 @@ from controllers.console.auth.error import ( PasswordMismatchError, ) from libs.helper import EmailStr, extract_remote_ip +from libs.helper import timezone as validate_timezone_string from libs.password import valid_password from models import Account from services.account_service import AccountService from services.billing_service import BillingService -from services.errors.account import AccountNotFoundError, AccountRegisterError +from services.errors.account import AccountRegisterError from ..error import AccountInFreezeError, EmailSendIpLimitError from ..wraps import email_password_login_enabled, email_register_enabled, setup_required -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class EmailRegisterSendPayload(BaseModel): email: EmailStr = Field(..., description="Email address") @@ -41,15 +41,23 @@ class EmailRegisterResetPayload(BaseModel): token: str = Field(...) new_password: str = Field(...) password_confirm: str = Field(...) + language: str | None = Field(default=None) + timezone: str | None = Field(default=None) @field_validator("new_password", "password_confirm") @classmethod def validate_password(cls, value: str) -> str: return valid_password(value) + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str | None) -> str | None: + if value is None: + return None + return validate_timezone_string(value) -for model in (EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload): - console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) + +register_schema_models(console_ns, EmailRegisterSendPayload, EmailRegisterValidityPayload, EmailRegisterResetPayload) @console_ns.route("/email-register/send-email") @@ -146,26 +154,32 @@ class EmailRegisterResetApi(Resource): if account: raise EmailAlreadyInUseError() - else: - account = self._create_new_account(normalized_email, args.password_confirm) - if not account: - raise AccountNotFoundError() - token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) - AccountService.reset_login_error_rate_limit(normalized_email) + + account = self._create_new_account( + email=normalized_email, + password=args.password_confirm, + timezone=args.timezone, + language=args.language, + ) + token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request)) + AccountService.reset_login_error_rate_limit(normalized_email) return {"result": "success", "data": token_pair.model_dump()} - def _create_new_account(self, email: str, password: str) -> Account | None: - # Create new account if allowed - account = None + def _create_new_account( + self, + email: str, + password: str, + timezone: str | None = None, + language: str | None = None, + ) -> Account: try: - account = AccountService.create_account_and_tenant( + return AccountService.create_account_and_tenant( email=email, name=email, password=password, - interface_language=languages[0], + interface_language=get_valid_language(language), + timezone=timezone, ) except AccountRegisterError: raise AccountInFreezeError() - - return account diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index ed390a5f89..c34dd1ac85 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -28,8 +28,6 @@ from services.entities.auth_entities import ( ) from services.feature_service import FeatureService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ForgotPasswordEmailResponse(BaseModel): result: str = Field(description="Operation result") diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 8216b3d0da..3121470b84 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -3,12 +3,13 @@ import logging import flask_login from flask import make_response, request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import Unauthorized import services from configs import dify_config from constants.languages import get_valid_language +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( AuthenticationFailedError, @@ -33,6 +34,7 @@ from controllers.console.wraps import ( ) from events.tenant_event import tenant_was_created from libs.helper import EmailStr, extract_remote_ip +from libs.helper import timezone as validate_timezone_string from libs.login import current_account_with_tenant from libs.token import ( clear_access_token_from_cookie, @@ -50,7 +52,6 @@ from services.errors.account import AccountRegisterError from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" logger = logging.getLogger(__name__) @@ -69,15 +70,17 @@ class EmailCodeLoginPayload(BaseModel): code: str = Field(...) token: str = Field(...) language: str | None = Field(default=None) + timezone: str | None = Field(default=None) + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str | None) -> str | None: + if value is None: + return None + return validate_timezone_string(value) -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(LoginPayload) -reg(EmailPayload) -reg(EmailCodeLoginPayload) +register_schema_models(console_ns, LoginPayload, EmailPayload, EmailCodeLoginPayload) @console_ns.route("/login") @@ -294,6 +297,7 @@ class EmailCodeLoginApi(Resource): email=user_email, name=user_email, interface_language=get_valid_language(language), + timezone=args.timezone, ) except WorkSpaceNotAllowedCreateError: raise NotAllowedCreateWorkspace() diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index d31fb4a46c..2254fa4981 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -12,7 +12,8 @@ from events.tenant_event import tenant_was_created from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.helper import extract_remote_ip -from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo +from libs.helper import timezone as validate_timezone_string +from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo, decode_oauth_state from libs.token import ( set_access_token_to_cookie, set_csrf_token_to_cookie, @@ -53,6 +54,31 @@ def get_oauth_providers(): return OAUTH_PROVIDERS +def _validated_timezone(value: str | None) -> str | None: + if not value: + return None + try: + return validate_timezone_string(value) + except ValueError: + return None + + +def _validated_language(value: str | None) -> str | None: + if value and value in languages: + return value + return None + + +def _preferred_interface_language(language: str | None = None) -> str: + if language: + return language + + preferred_lang = request.accept_languages.best_match(languages) + if preferred_lang and preferred_lang in languages: + return preferred_lang + return languages[0] + + @console_ns.route("/oauth/login/") class OAuthLogin(Resource): @console_ns.doc("oauth_login") @@ -64,13 +90,19 @@ class OAuthLogin(Resource): @console_ns.response(400, "Invalid provider") def get(self, provider: str): invite_token = request.args.get("invite_token") or None + timezone = _validated_timezone(request.args.get("timezone") or None) + language = _validated_language(request.args.get("language") or None) OAUTH_PROVIDERS = get_oauth_providers() with current_app.app_context(): oauth_provider = OAUTH_PROVIDERS.get(provider) if not oauth_provider: return {"error": "Invalid provider"}, 400 - auth_url = oauth_provider.get_authorization_url(invite_token=invite_token) + auth_url = oauth_provider.get_authorization_url( + invite_token=invite_token, + timezone=timezone, + language=language, + ) return redirect(auth_url) @@ -96,9 +128,10 @@ class OAuthCallback(Resource): code = request.args.get("code") state = request.args.get("state") - invite_token = None - if state: - invite_token = state + oauth_state = decode_oauth_state(state) + invite_token = oauth_state.get("invite_token") + timezone = _validated_timezone(oauth_state.get("timezone")) + language = _validated_language(oauth_state.get("language")) if not code: return {"error": "Authorization code is required"}, 400 @@ -129,7 +162,7 @@ class OAuthCallback(Resource): return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}") try: - account, oauth_new_user = _generate_account(provider, user_info) + account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language) except AccountNotFoundError: return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.") except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError): @@ -184,7 +217,12 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> return account -def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, bool]: +def _generate_account( + provider: str, + user_info: OAuthUserInfo, + timezone: str | None = None, + language: str | None = None, +) -> tuple[Account, bool]: # Get account by openid or email. account = _get_account_by_openid_or_email(provider, user_info) oauth_new_user = False @@ -211,26 +249,19 @@ def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, "30 days and is temporarily unavailable for new account registration" ) ) - else: - raise AccountRegisterError(description=("Invalid email or password")) + raise AccountRegisterError(description=("Invalid email or password")) account_name = user_info.name or "Dify" + interface_language = _preferred_interface_language(language) account = RegisterService.register( email=normalized_email, name=account_name, password=None, open_id=user_info.id, provider=provider, + language=interface_language, + timezone=timezone, ) - # Set interface language - preferred_lang = request.accept_languages.best_match(languages) - if preferred_lang and preferred_lang in languages: - interface_language = preferred_lang - else: - interface_language = languages[0] - account.interface_language = interface_language - db.session.commit() - # Link account AccountService.link_account_integrate(provider, user_info.id, account) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index d001dfba64..0e91779b2c 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -606,63 +606,63 @@ class DatasetIndexingEstimateApi(Resource): # validate args DocumentService.estimate_args_validate(args) extract_settings = [] - if args["info_list"]["data_source_type"] == "upload_file": - file_ids = args["info_list"]["file_info_list"]["file_ids"] - file_details = db.session.scalars( - select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids)) - ).all() + match args["info_list"]["data_source_type"]: + case "upload_file": + file_ids = args["info_list"]["file_info_list"]["file_ids"] + file_details = db.session.scalars( + select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids)) + ).all() + if file_details is None: + raise NotFound("File not found.") - if file_details is None: - raise NotFound("File not found.") - - if file_details: - for file_detail in file_details: + if file_details: + for file_detail in file_details: + extract_setting = ExtractSetting( + datasource_type=DatasourceType.FILE, + upload_file=file_detail, + document_model=args["doc_form"], + ) + extract_settings.append(extract_setting) + case "notion_import": + notion_info_list = args["info_list"]["notion_info_list"] + for notion_info in notion_info_list: + workspace_id = notion_info["workspace_id"] + credential_id = notion_info.get("credential_id") + for page in notion_info["pages"]: + extract_setting = ExtractSetting( + datasource_type=DatasourceType.NOTION, + notion_info=NotionInfo.model_validate( + { + "credential_id": credential_id, + "notion_workspace_id": workspace_id, + "notion_obj_id": page["page_id"], + "notion_page_type": page["type"], + "tenant_id": current_tenant_id, + } + ), + document_model=args["doc_form"], + ) + extract_settings.append(extract_setting) + case "website_crawl": + website_info_list = args["info_list"]["website_info_list"] + for url in website_info_list["urls"]: extract_setting = ExtractSetting( - datasource_type=DatasourceType.FILE, - upload_file=file_detail, - document_model=args["doc_form"], - ) - extract_settings.append(extract_setting) - elif args["info_list"]["data_source_type"] == "notion_import": - notion_info_list = args["info_list"]["notion_info_list"] - for notion_info in notion_info_list: - workspace_id = notion_info["workspace_id"] - credential_id = notion_info.get("credential_id") - for page in notion_info["pages"]: - extract_setting = ExtractSetting( - datasource_type=DatasourceType.NOTION, - notion_info=NotionInfo.model_validate( + datasource_type=DatasourceType.WEBSITE, + website_info=WebsiteInfo.model_validate( { - "credential_id": credential_id, - "notion_workspace_id": workspace_id, - "notion_obj_id": page["page_id"], - "notion_page_type": page["type"], + "provider": website_info_list["provider"], + "job_id": website_info_list["job_id"], + "url": url, "tenant_id": current_tenant_id, + "mode": "crawl", + "only_main_content": website_info_list["only_main_content"], } ), document_model=args["doc_form"], ) extract_settings.append(extract_setting) - elif args["info_list"]["data_source_type"] == "website_crawl": - website_info_list = args["info_list"]["website_info_list"] - for url in website_info_list["urls"]: - extract_setting = ExtractSetting( - datasource_type=DatasourceType.WEBSITE, - website_info=WebsiteInfo.model_validate( - { - "provider": website_info_list["provider"], - "job_id": website_info_list["job_id"], - "url": url, - "tenant_id": current_tenant_id, - "mode": "crawl", - "only_main_content": website_info_list["only_main_content"], - } - ), - document_model=args["doc_form"], - ) - extract_settings.append(extract_setting) - else: - raise ValueError("Data source type not support") + case _: + raise ValueError("Data source type not support") indexing_runner = IndexingRunner() try: response = indexing_runner.indexing_estimate( diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 3372a967d9..dfe8192b89 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -39,6 +39,7 @@ from fields.document_fields import ( from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.invoke import InvokeAuthorizationError from libs.datetime_utils import naive_utc_now +from libs.helper import to_timestamp from libs.login import current_account_with_tenant, login_required from models import DatasetProcessRule, Document, DocumentSegment, UploadFile from models.dataset import DocumentPipelineExecutionLog @@ -71,12 +72,6 @@ from ..wraps import ( logger = logging.getLogger(__name__) -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - def _normalize_enum(value: Any) -> Any: if isinstance(value, str) or value is None: return value @@ -101,7 +96,7 @@ class DatasetResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class DocumentMetadataResponse(ResponseModel): @@ -152,7 +147,7 @@ class DocumentResponse(ResponseModel): @field_validator("created_at", "disabled_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class DocumentWithSegmentsResponse(DocumentResponse): @@ -369,28 +364,31 @@ class DatasetDocumentListApi(Resource): else: sort_logic = asc - if sort == "hit_count": - sub_query = ( - sa.select(DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count")) - .where(DocumentSegment.dataset_id == str(dataset_id)) - .group_by(DocumentSegment.document_id) - .subquery() - ) + match sort: + case "hit_count": + sub_query = ( + sa.select( + DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count") + ) + .where(DocumentSegment.dataset_id == str(dataset_id)) + .group_by(DocumentSegment.document_id) + .subquery() + ) - query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by( - sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)), - sort_logic(Document.position), - ) - elif sort == "created_at": - query = query.order_by( - sort_logic(Document.created_at), - sort_logic(Document.position), - ) - else: - query = query.order_by( - desc(Document.created_at), - desc(Document.position), - ) + query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by( + sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)), + sort_logic(Document.position), + ) + case "created_at": + query = query.order_by( + sort_logic(Document.created_at), + sort_logic(Document.position), + ) + case _: + query = query.order_by( + desc(Document.created_at), + desc(Document.position), + ) paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False) documents = paginated_documents.items diff --git a/api/controllers/console/datasets/hit_testing.py b/api/controllers/console/datasets/hit_testing.py index 36a7a4bb0e..8758f983ee 100644 --- a/api/controllers/console/datasets/hit_testing.py +++ b/api/controllers/console/datasets/hit_testing.py @@ -8,6 +8,7 @@ from pydantic import Field, field_validator from controllers.common.schema import register_schema_models from fields.base import ResponseModel +from libs.helper import to_timestamp from libs.login import login_required from .. import console_ns @@ -19,12 +20,6 @@ from ..wraps import ( ) -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class HitTestingDocument(ResponseModel): id: str | None = None data_source_type: str | None = None @@ -61,7 +56,7 @@ class HitTestingSegment(ResponseModel): @field_validator("disabled_at", "created_at", "indexing_at", "completed_at", "stopped_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class HitTestingChildChunk(ResponseModel): diff --git a/api/controllers/console/datasets/hit_testing_base.py b/api/controllers/console/datasets/hit_testing_base.py index 71ab1513ed..bb725a5f6c 100644 --- a/api/controllers/console/datasets/hit_testing_base.py +++ b/api/controllers/console/datasets/hit_testing_base.py @@ -39,11 +39,8 @@ class HitTestingPayload(BaseModel): class DatasetsHitTestingBase: @staticmethod - def _normalize_hit_testing_query(query: Any) -> str: - """Return the user-visible query string from legacy and current response shapes.""" - if isinstance(query, str): - return query - + def _extract_hit_testing_query(query: Any) -> str: + """Return the query string from the service response shape.""" if isinstance(query, dict): content = query.get("content") if isinstance(content, str): @@ -52,15 +49,15 @@ class DatasetsHitTestingBase: raise ValueError("Invalid hit testing query response") @staticmethod - def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]: - """Coerce nullable collection fields into lists before response validation.""" + def _prepare_hit_testing_records(records: Any) -> list[dict[str, Any]]: + """Ensure collection fields match the API schema before response validation.""" if not isinstance(records, list): - return [] + raise ValueError("Invalid hit testing records response") normalized_records: list[dict[str, Any]] = [] for record in records: if not isinstance(record, dict): - continue + raise ValueError("Invalid hit testing record response") normalized_record = dict(record) segment = normalized_record.get("segment") @@ -118,8 +115,8 @@ class DatasetsHitTestingBase: limit=10, ) return { - "query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")), - "records": DatasetsHitTestingBase._normalize_hit_testing_records( + "query": DatasetsHitTestingBase._extract_hit_testing_query(response.get("query")), + "records": DatasetsHitTestingBase._prepare_hit_testing_records( marshal(response.get("records", []), hit_testing_record_fields) ), } diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py index 7caf5b52ed..a43caa8f56 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_content_preview.py @@ -4,6 +4,7 @@ from flask_restx import ( # type: ignore from pydantic import BaseModel from werkzeug.exceptions import Forbidden +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import account_initialization_required, setup_required @@ -12,8 +13,6 @@ from models import Account from models.dataset import Pipeline from services.rag_pipeline.rag_pipeline import RagPipelineService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class Parser(BaseModel): inputs: dict @@ -21,7 +20,7 @@ class Parser(BaseModel): credential_id: str | None = None -console_ns.schema_model(Parser.__name__, Parser.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) +register_schema_models(console_ns, Parser) @console_ns.route("/rag/pipelines//workflows/published/datasource/nodes//preview") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index ee146e8287..77dbf0be3f 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -3,14 +3,14 @@ import logging from typing import Any, Literal, cast from flask import abort, request -from flask_restx import Resource, marshal_with # type: ignore +from flask_restx import Resource from pydantic import BaseModel, Field, ValidationError from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( ConversationCompletedError, @@ -19,14 +19,8 @@ from controllers.console.app.error import ( ) from controllers.console.app.workflow import ( RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, - workflow_model, - workflow_pagination_model, -) -from controllers.console.app.workflow_run import ( - workflow_run_detail_model, - workflow_run_node_execution_list_model, - workflow_run_node_execution_model, - workflow_run_pagination_model, + WorkflowPaginationResponse, + WorkflowResponse, ) from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import ( @@ -40,9 +34,15 @@ from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from factories import variable_factory +from fields.workflow_run_fields import ( + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, +) from graphon.model_runtime.utils.encoders import jsonable_encoder from libs import helper -from libs.helper import TimestampField, UUIDStrOrEmpty +from libs.helper import TimestampField, UUIDStrOrEmpty, dump_response from libs.login import current_account_with_tenant, current_user, login_required from models import Account from models.dataset import Pipeline @@ -131,16 +131,28 @@ register_schema_models( DatasourceVariablesPayload, RagPipelineRecommendedPluginQuery, ) +register_response_schema_models( + console_ns, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, +) @console_ns.route("/rag/pipelines//workflows/draft") class DraftRagPipelineApi(Resource): + @console_ns.response( + 200, + "Draft workflow retrieved successfully", + console_ns.models[WorkflowResponse.__name__], + ) + @console_ns.response(404, "Draft workflow not found") @setup_required @login_required @account_initialization_required @get_rag_pipeline @edit_permission_required - @marshal_with(workflow_model) def get(self, pipeline: Pipeline): """ Get draft rag pipeline's workflow @@ -152,8 +164,8 @@ class DraftRagPipelineApi(Resource): if not workflow: raise DraftWorkflowNotExist() - # return workflow, if not found, return None (initiate graph by frontend) - return workflow + # return workflow, if not found, return 404 + return dump_response(WorkflowResponse, workflow) @setup_required @login_required @@ -415,12 +427,16 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource): @console_ns.route("/rag/pipelines//workflows/draft/nodes//run") class RagPipelineDraftNodeRunApi(Resource): @console_ns.expect(console_ns.models[NodeRunRequiredPayload.__name__]) + @console_ns.response( + 200, + "Node run started successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @setup_required @login_required @edit_permission_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_node_execution_model) def post(self, pipeline: Pipeline, node_id: str): """ Run draft workflow node @@ -439,7 +455,9 @@ class RagPipelineDraftNodeRunApi(Resource): if workflow_node_execution is None: raise ValueError("Workflow node execution not found") - return workflow_node_execution + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflow-runs/tasks//stop") @@ -463,12 +481,16 @@ class RagPipelineTaskStopApi(Resource): @console_ns.route("/rag/pipelines//workflows/publish") class PublishedRagPipelineApi(Resource): + @console_ns.response( + 200, + "Published workflow retrieved successfully, or null if not exist", + console_ns.models[WorkflowResponse.__name__], + ) @setup_required @login_required @account_initialization_required @edit_permission_required @get_rag_pipeline - @marshal_with(workflow_model) def get(self, pipeline: Pipeline): """ Get published pipeline @@ -481,7 +503,10 @@ class PublishedRagPipelineApi(Resource): workflow = rag_pipeline_service.get_published_workflow(pipeline=pipeline) # return workflow, if not found, return None - return workflow + if workflow is None: + return None + + return dump_response(WorkflowResponse, workflow) @setup_required @login_required @@ -554,12 +579,17 @@ class DefaultRagPipelineBlockConfigApi(Resource): @console_ns.route("/rag/pipelines//workflows") class PublishedAllRagPipelineApi(Resource): + @console_ns.response( + 200, + "Published workflows retrieved successfully", + console_ns.models[WorkflowPaginationResponse.__name__], + ) + @console_ns.response(403, "Permission denied") @setup_required @login_required @account_initialization_required @edit_permission_required @get_rag_pipeline - @marshal_with(workflow_pagination_model) def get(self, pipeline: Pipeline): """ Get published workflows @@ -588,12 +618,14 @@ class PublishedAllRagPipelineApi(Resource): named_only=named_only, ) - return { - "items": workflows, - "page": page, - "limit": limit, - "has_more": has_more, - } + return WorkflowPaginationResponse.model_validate( + { + "items": workflows, + "page": page, + "limit": limit, + "has_more": has_more, + } + ).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflows//restore") @@ -628,12 +660,15 @@ class RagPipelineDraftWorkflowRestoreApi(Resource): @console_ns.route("/rag/pipelines//workflows/") class RagPipelineByIdApi(Resource): + @console_ns.response(200, "Workflow updated successfully", console_ns.models[WorkflowResponse.__name__]) + @console_ns.response(400, "No valid fields to update") + @console_ns.response(403, "Permission denied") + @console_ns.response(404, "Workflow not found") @setup_required @login_required @account_initialization_required @edit_permission_required @get_rag_pipeline - @marshal_with(workflow_model) def patch(self, pipeline: Pipeline, workflow_id: str): """ Update workflow attributes @@ -662,7 +697,7 @@ class RagPipelineByIdApi(Resource): if not workflow: raise NotFound("Workflow not found") - return workflow + return dump_response(WorkflowResponse, workflow) @setup_required @login_required @@ -778,11 +813,15 @@ class DraftRagPipelineSecondStepApi(Resource): @console_ns.route("/rag/pipelines//workflow-runs") class RagPipelineWorkflowRunListApi(Resource): + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[WorkflowRunPaginationResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_pagination_model) def get(self, pipeline: Pipeline): """ Get workflow run list @@ -801,16 +840,20 @@ class RagPipelineWorkflowRunListApi(Resource): rag_pipeline_service = RagPipelineService() result = rag_pipeline_service.get_rag_pipeline_paginate_workflow_runs(pipeline=pipeline, args=args) - return result + return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflow-runs/") class RagPipelineWorkflowRunDetailApi(Resource): + @console_ns.response( + 200, + "Workflow run detail retrieved successfully", + console_ns.models[WorkflowRunDetailResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_detail_model) def get(self, pipeline: Pipeline, run_id): """ Get workflow run detail @@ -819,17 +862,23 @@ class RagPipelineWorkflowRunDetailApi(Resource): rag_pipeline_service = RagPipelineService() workflow_run = rag_pipeline_service.get_rag_pipeline_workflow_run(pipeline=pipeline, run_id=run_id) + if workflow_run is None: + raise NotFound("Workflow run not found") - return workflow_run + return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") @console_ns.route("/rag/pipelines//workflow-runs//node-executions") class RagPipelineWorkflowRunNodeExecutionListApi(Resource): + @console_ns.response( + 200, + "Node executions retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionListResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_node_execution_list_model) def get(self, pipeline: Pipeline, run_id: str): """ Get workflow run node execution list @@ -844,7 +893,9 @@ class RagPipelineWorkflowRunNodeExecutionListApi(Resource): user=user, ) - return {"data": node_executions} + return WorkflowRunNodeExecutionListResponse.model_validate( + {"data": node_executions}, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines/datasource-plugins") @@ -859,11 +910,15 @@ class DatasourceListApi(Resource): @console_ns.route("/rag/pipelines//workflows/draft/nodes//last-run") class RagPipelineWorkflowLastRunApi(Resource): + @console_ns.response( + 200, + "Node last run retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline - @marshal_with(workflow_run_node_execution_model) def get(self, pipeline: Pipeline, node_id: str): rag_pipeline_service = RagPipelineService() workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline) @@ -876,7 +931,7 @@ class RagPipelineWorkflowLastRunApi(Resource): ) if node_exec is None: raise NotFound("last run not found") - return node_exec + return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") @console_ns.route("/rag/pipelines/transform/datasets/") @@ -899,12 +954,16 @@ class RagPipelineTransformApi(Resource): @console_ns.route("/rag/pipelines//workflows/draft/datasource/variables-inspect") class RagPipelineDatasourceVariableApi(Resource): @console_ns.expect(console_ns.models[DatasourceVariablesPayload.__name__]) + @console_ns.response( + 200, + "Datasource variables set successfully", + console_ns.models[WorkflowRunNodeExecutionResponse.__name__], + ) @setup_required @login_required @account_initialization_required @get_rag_pipeline @edit_permission_required - @marshal_with(workflow_run_node_execution_model) def post(self, pipeline: Pipeline): """ Set datasource variables @@ -918,7 +977,9 @@ class RagPipelineDatasourceVariableApi(Resource): args=args, current_user=current_user, ) - return workflow_node_execution + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") @console_ns.route("/rag/pipelines/recommended-plugins") diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index 2d9a997fbf..08c72e45d5 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -16,6 +16,7 @@ from extensions.ext_database import db from fields.base import ResponseModel from graphon.file import helpers as file_helpers from libs.datetime_utils import naive_utc_now +from libs.helper import to_timestamp from libs.login import current_account_with_tenant, login_required from models import App, InstalledApp, RecommendedApp from models.model import IconType @@ -105,9 +106,7 @@ class InstalledAppResponse(ResponseModel): @field_validator("last_used_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value + return to_timestamp(value) class InstalledAppListResponse(ResponseModel): diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 55bd679b48..5821b91489 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -1,11 +1,12 @@ from typing import Any +from uuid import UUID from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, computed_field, field_validator from constants.languages import languages -from controllers.common.schema import register_schema_models +from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required from fields.base import ResponseModel @@ -15,7 +16,7 @@ from services.recommended_app_service import RecommendedAppService class RecommendedAppsQuery(BaseModel): - language: str | None = Field(default=None) + language: str | None = Field(default=None, description="Language code for recommended app localization") class RecommendedAppInfoResponse(ResponseModel): @@ -52,7 +53,7 @@ class RecommendedAppResponse(ResponseModel): copyright: str | None = None privacy_policy: str | None = None custom_disclaimer: str | None = None - category: str | None = None + categories: list[str] = Field(default_factory=list) position: int | None = None is_listed: bool | None = None can_trial: bool | None = None @@ -74,13 +75,13 @@ register_schema_models( @console_ns.route("/explore/apps") class RecommendedAppListApi(Resource): - @console_ns.expect(console_ns.models[RecommendedAppsQuery.__name__]) + @console_ns.doc(params=query_params_from_model(RecommendedAppsQuery)) @console_ns.response(200, "Success", console_ns.models[RecommendedAppListResponse.__name__]) @login_required @account_initialization_required def get(self): # language args - args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) language = args.language if language and language in languages: language_prefix = language @@ -99,6 +100,5 @@ class RecommendedAppListApi(Resource): class RecommendedAppApi(Resource): @login_required @account_initialization_required - def get(self, app_id): - app_id = str(app_id) - return RecommendedAppService.get_recommend_app_detail(app_id) + def get(self, app_id: UUID): + return RecommendedAppService.get_recommend_app_detail(str(app_id)) diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 1456301a24..26b48ec599 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -10,7 +10,7 @@ from werkzeug.exceptions import Forbidden, InternalServerError, NotFound import services from controllers.common.fields import Parameters as ParametersResponse from controllers.common.fields import Site as SiteResponse -from controllers.common.schema import get_or_create_model +from controllers.common.schema import get_or_create_model, register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( AppUnavailableError, @@ -106,7 +106,7 @@ app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model)) app_detail_fields_with_site_copy["site"] = fields.Nested(site_model) app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy) -simple_account_model = get_or_create_model("SimpleAccount", simple_account_fields) +simple_account_model = get_or_create_model("TrialSimpleAccount", simple_account_fields) conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields) pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields) @@ -120,10 +120,6 @@ workflow_fields_copy["rag_pipeline_variables"] = fields.List(fields.Nested(pipel workflow_model = get_or_create_model("TrialWorkflow", workflow_fields_copy) -# Pydantic models for request validation -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - class WorkflowRunRequest(BaseModel): inputs: dict files: list | None = None @@ -153,19 +149,7 @@ class CompletionRequest(BaseModel): retriever_from: str = "explore_app" -# Register schemas for Swagger documentation -console_ns.schema_model( - WorkflowRunRequest.__name__, WorkflowRunRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - ChatRequest.__name__, ChatRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - TextToSpeechRequest.__name__, TextToSpeechRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -console_ns.schema_model( - CompletionRequest.__name__, CompletionRequest.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, WorkflowRunRequest, ChatRequest, TextToSpeechRequest, CompletionRequest) class TrialAppWorkflowRunApi(TrialAppResource): diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 7a6356d052..0c9a93c1cd 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, TypeAdapter, field_validator from constants import HIDDEN_VALUE from fields.base import ResponseModel +from libs.helper import to_timestamp from libs.login import current_account_with_tenant, login_required from models.api_based_extension import APIBasedExtension from services.api_based_extension_service import APIBasedExtensionService @@ -40,12 +41,6 @@ def _mask_api_key(api_key: str) -> str: return api_key[:3] + "******" + api_key[-3:] -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class APIBasedExtensionResponse(ResponseModel): id: str name: str @@ -61,7 +56,7 @@ class APIBasedExtensionResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) register_schema_models(console_ns, APIBasedExtensionPayload, CodeBasedExtensionResponse, APIBasedExtensionResponse) @@ -89,7 +84,7 @@ class CodeBasedExtensionAPI(Resource): @login_required @account_initialization_required def get(self): - query = CodeBasedExtensionQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + query = CodeBasedExtensionQuery.model_validate(request.args.to_dict(flat=True)) return CodeBasedExtensionResponse( module=query.module, diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 109a3cd0d3..5751026040 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -82,7 +82,7 @@ class FileApi(Resource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, source=source, @@ -105,7 +105,8 @@ class FilePreviewApi(Resource): @account_initialization_required def get(self, file_id): file_id = str(file_id) - text = FileService(db.engine).get_file_preview(file_id) + _, tenant_id = current_account_with_tenant() + text = FileService(db.engine).get_file_preview(file_id, tenant_id) return {"content": text} diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index f73e2da54e..346f572ccc 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -25,6 +25,10 @@ class TagBasePayload(BaseModel): type: TagType = Field(description="Tag type") +class TagUpdateRequestPayload(BaseModel): + name: str = Field(description="Tag name", min_length=1, max_length=50) + + class TagBindingPayload(BaseModel): tag_ids: list[str] = Field(description="Tag IDs to bind") target_id: str = Field(description="Target ID to bind tags to") @@ -32,12 +36,7 @@ class TagBindingPayload(BaseModel): class TagBindingRemovePayload(BaseModel): - tag_id: str = Field(description="Tag ID to remove") - target_id: str = Field(description="Target ID to unbind tag from") - type: TagType = Field(description="Tag type") - - -class TagBindingItemDeletePayload(BaseModel): + tag_ids: list[str] = Field(description="Tag IDs to remove", min_length=1) target_id: str = Field(description="Target ID to unbind tag from") type: TagType = Field(description="Tag type") @@ -73,9 +72,9 @@ class TagResponse(ResponseModel): register_schema_models( console_ns, TagBasePayload, + TagUpdateRequestPayload, TagBindingPayload, TagBindingRemovePayload, - TagBindingItemDeletePayload, TagListQueryParam, TagResponse, ) @@ -124,7 +123,7 @@ class TagListApi(Resource): @console_ns.route("/tags/") class TagUpdateDeleteApi(Resource): - @console_ns.expect(console_ns.models[TagBasePayload.__name__]) + @console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__]) @setup_required @login_required @account_initialization_required @@ -135,8 +134,8 @@ class TagUpdateDeleteApi(Resource): if not (current_user.has_edit_permission or current_user.is_dataset_editor): raise Forbidden() - payload = TagBasePayload.model_validate(console_ns.payload or {}) - tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=payload.type), tag_id) + payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {}) + tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id) binding_count = TagService.get_tag_binding_count(tag_id) @@ -184,13 +183,13 @@ def _create_tag_bindings() -> tuple[dict[str, str], int]: return {"result": "success"}, 200 -def _remove_tag_binding() -> tuple[dict[str, str], int]: +def _remove_tag_bindings() -> tuple[dict[str, str], int]: _require_tag_binding_edit_permission() payload = TagBindingRemovePayload.model_validate(console_ns.payload or {}) TagService.delete_tag_binding( TagBindingDeletePayload( - tag_id=payload.tag_id, + tag_ids=payload.tag_ids, target_id=payload.target_id, type=payload.type, ) @@ -211,54 +210,15 @@ class TagBindingCollectionApi(Resource): return _create_tag_bindings() -@console_ns.route("/tag-bindings/") -class TagBindingItemApi(Resource): - """Canonical item resource for tag binding deletion.""" - - @console_ns.doc("delete_tag_binding") - @console_ns.doc(params={"id": "Tag ID"}) - @console_ns.expect(console_ns.models[TagBindingItemDeletePayload.__name__]) - @setup_required - @login_required - @account_initialization_required - def delete(self, id): - _require_tag_binding_edit_permission() - payload = TagBindingItemDeletePayload.model_validate(console_ns.payload or {}) - TagService.delete_tag_binding( - TagBindingDeletePayload( - tag_id=str(id), - target_id=payload.target_id, - type=payload.type, - ) - ) - return {"result": "success"}, 200 - - -@console_ns.route("/tag-bindings/create") -class DeprecatedTagBindingCreateApi(Resource): - """Deprecated verb-based alias for tag binding creation.""" - - @console_ns.doc("create_tag_binding_deprecated") - @console_ns.doc(deprecated=True) - @console_ns.doc(description="Deprecated legacy alias. Use POST /tag-bindings instead.") - @console_ns.expect(console_ns.models[TagBindingPayload.__name__]) - @setup_required - @login_required - @account_initialization_required - def post(self): - return _create_tag_bindings() - - @console_ns.route("/tag-bindings/remove") -class DeprecatedTagBindingRemoveApi(Resource): - """Deprecated verb-based alias for tag binding deletion.""" +class TagBindingRemoveApi(Resource): + """Batch resource for tag binding deletion.""" - @console_ns.doc("delete_tag_binding_deprecated") - @console_ns.doc(deprecated=True) - @console_ns.doc(description="Deprecated legacy alias. Use DELETE /tag-bindings/{id} instead.") + @console_ns.doc("remove_tag_bindings") + @console_ns.doc(description="Remove one or more tag bindings from a target.") @console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__]) @setup_required @login_required @account_initialization_required def post(self): - return _remove_tag_binding() + return _remove_tag_bindings() diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index d69a59ecb7..b1c363433a 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -42,7 +42,7 @@ from fields.base import ResponseModel from fields.member_fields import Account as AccountResponse from graphon.file import helpers as file_helpers from libs.datetime_utils import naive_utc_now -from libs.helper import EmailStr, extract_remote_ip, timezone +from libs.helper import EmailStr, extract_remote_ip, timezone, to_timestamp from libs.login import current_account_with_tenant, login_required from models import AccountIntegrate, InvitationCode from models.account import AccountStatus, InvitationCodeStatus @@ -52,8 +52,6 @@ from services.account_service import AccountService from services.billing_service import BillingService from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class AccountInitPayload(BaseModel): interface_language: str @@ -161,39 +159,32 @@ class CheckEmailUniquePayload(BaseModel): email: EmailStr -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(AccountInitPayload) -reg(AccountNamePayload) -reg(AccountAvatarPayload) -reg(AccountAvatarQuery) -reg(AccountInterfaceLanguagePayload) -reg(AccountInterfaceThemePayload) -reg(AccountTimezonePayload) -reg(AccountPasswordPayload) -reg(AccountDeletePayload) -reg(AccountDeletionFeedbackPayload) -reg(EducationActivatePayload) -reg(EducationAutocompleteQuery) -reg(ChangeEmailSendPayload) -reg(ChangeEmailValidityPayload) -reg(ChangeEmailResetPayload) -reg(CheckEmailUniquePayload) -register_schema_models(console_ns, AccountResponse) +register_schema_models( + console_ns, + AccountResponse, + AccountInitPayload, + AccountNamePayload, + AccountAvatarPayload, + AccountAvatarQuery, + AccountInterfaceLanguagePayload, + AccountInterfaceThemePayload, + AccountTimezonePayload, + AccountPasswordPayload, + AccountDeletePayload, + AccountDeletionFeedbackPayload, + EducationActivatePayload, + EducationAutocompleteQuery, + ChangeEmailSendPayload, + ChangeEmailValidityPayload, + ChangeEmailResetPayload, + CheckEmailUniquePayload, +) def _serialize_account(account) -> dict[str, Any]: return AccountResponse.model_validate(account, from_attributes=True).model_dump(mode="json") -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class AccountIntegrateResponse(ResponseModel): provider: str created_at: int | None = None @@ -203,7 +194,7 @@ class AccountIntegrateResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AccountIntegrateListResponse(ResponseModel): @@ -223,7 +214,7 @@ class EducationStatusResponse(ResponseModel): @field_validator("expire_at", mode="before") @classmethod def _normalize_expire_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class EducationAutocompleteResponse(ResponseModel): @@ -326,7 +317,7 @@ class AccountAvatarApi(Resource): @account_initialization_required def get(self): current_user, current_tenant_id = current_account_with_tenant() - args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = AccountAvatarQuery.model_validate(request.args.to_dict(flat=True)) avatar = args.avatar if avatar.startswith(("http://", "https://")): diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index d4be07382a..925f3e1197 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -20,8 +20,6 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from libs.login import current_account_with_tenant, login_required from services.plugin.endpoint_service import EndpointService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class EndpointCreatePayload(BaseModel): plugin_unique_identifier: str @@ -80,10 +78,6 @@ class EndpointDisableResponse(BaseModel): success: bool = Field(description="Operation success") -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - register_schema_models( console_ns, EndpointCreatePayload, @@ -215,7 +209,7 @@ class EndpointListApi(Resource): def get(self): user, tenant_id = current_account_with_tenant() - args = EndpointListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = EndpointListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page page_size = args.page_size @@ -248,7 +242,7 @@ class EndpointListForSinglePluginApi(Resource): def get(self): user, tenant_id = current_account_with_tenant() - args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = EndpointListForPluginQuery.model_validate(request.args.to_dict(flat=True)) page = args.page page_size = args.page_size diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index e3bf4c95b8..dfc4142085 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -33,8 +33,6 @@ from services.account_service import AccountService, RegisterService, TenantServ from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class MemberInvitePayload(BaseModel): emails: list[str] = Field(default_factory=list) @@ -59,17 +57,23 @@ class OwnerTransferPayload(BaseModel): token: str -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(MemberInvitePayload) -reg(MemberRoleUpdatePayload) -reg(OwnerTransferEmailPayload) -reg(OwnerTransferCheckPayload) -reg(OwnerTransferPayload) register_enum_models(console_ns, TenantAccountRole) -register_schema_models(console_ns, AccountWithRole, AccountWithRoleList) +register_schema_models( + console_ns, + AccountWithRole, + AccountWithRoleList, + MemberInvitePayload, + MemberRoleUpdatePayload, + OwnerTransferEmailPayload, + OwnerTransferCheckPayload, + OwnerTransferPayload, +) + + +def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: + if role != TenantAccountRole.DATASET_OPERATOR: + return True + return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled @console_ns.route("/workspaces/current/members") @@ -112,6 +116,8 @@ class MemberInviteEmailApi(Resource): inviter = current_user if not inviter.current_tenant: raise ValueError("No current tenant") + if not _is_role_enabled(invitee_role, inviter.current_tenant.id): + return {"code": "invalid-role", "message": "Invalid role"}, 400 # Check workspace permission for member invitations from libs.workspace_permission import check_workspace_member_invite_permission @@ -210,6 +216,8 @@ class MemberUpdateRoleApi(Resource): current_user, _ = current_account_with_tenant() if not current_user.current_tenant: raise ValueError("No current tenant") + if not _is_role_enabled(new_role, current_user.current_tenant.id): + return {"code": "invalid-role", "message": "Invalid role"}, 400 member = db.session.get(Account, str(member_id)) if not member: abort(404) @@ -217,11 +225,17 @@ class MemberUpdateRoleApi(Resource): try: assert member is not None, "Member not found" TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user) + except services.errors.account.CannotOperateSelfError as e: + return {"code": "cannot-operate-self", "message": str(e)}, 400 + except services.errors.account.NoPermissionError as e: + return {"code": "forbidden", "message": str(e)}, 403 + except services.errors.account.MemberNotInTenantError as e: + return {"code": "member-not-found", "message": str(e)}, 404 + except services.errors.account.RoleAlreadyAssignedError as e: + return {"code": "role-already-assigned", "message": str(e)}, 400 except Exception as e: raise ValueError(str(e)) - # todo: 403 - return {"result": "success"} diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 4b10561fdb..2f75218c0f 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -5,6 +5,7 @@ from flask import request, send_file from flask_restx import Resource from pydantic import BaseModel, Field, field_validator +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required from graphon.model_runtime.entities.model_entities import ModelType @@ -15,8 +16,6 @@ from libs.login import current_account_with_tenant, login_required from services.billing_service import BillingService from services.model_provider_service import ModelProviderService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ParserModelList(BaseModel): model_type: ModelType | None = None @@ -75,18 +74,17 @@ class ParserPreferredProviderType(BaseModel): preferred_provider_type: Literal["system", "custom"] -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(ParserModelList) -reg(ParserCredentialId) -reg(ParserCredentialCreate) -reg(ParserCredentialUpdate) -reg(ParserCredentialDelete) -reg(ParserCredentialSwitch) -reg(ParserCredentialValidate) -reg(ParserPreferredProviderType) +register_schema_models( + console_ns, + ParserModelList, + ParserCredentialId, + ParserCredentialCreate, + ParserCredentialUpdate, + ParserCredentialDelete, + ParserCredentialSwitch, + ParserCredentialValidate, + ParserPreferredProviderType, +) @console_ns.route("/workspaces/current/model-providers") diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index b2d07ff8f9..7f7d6379c3 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -17,7 +17,6 @@ from services.model_load_balancing_service import ModelLoadBalancingService from services.model_provider_service import ModelProviderService logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class ParserGetDefault(BaseModel): @@ -107,6 +106,12 @@ class ParserParameter(BaseModel): model: str +class ParserSwitch(BaseModel): + model: str + model_type: ModelType + credential_id: str + + register_schema_models( console_ns, ParserGetDefault, @@ -119,6 +124,7 @@ register_schema_models( ParserDeleteCredential, ParserParameter, Inner, + ParserSwitch, ) register_enum_models(console_ns, ModelType) @@ -133,7 +139,7 @@ class DefaultModelApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - args = ParserGetDefault.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserGetDefault.model_validate(request.args.to_dict(flat=True)) model_provider_service = ModelProviderService() default_model_entity = model_provider_service.get_default_model_of_model_type( @@ -261,7 +267,7 @@ class ModelProviderModelCredentialApi(Resource): def get(self, provider: str): _, tenant_id = current_account_with_tenant() - args = ParserGetCredentials.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserGetCredentials.model_validate(request.args.to_dict(flat=True)) model_provider_service = ModelProviderService() current_credential = model_provider_service.get_model_credential( @@ -387,17 +393,6 @@ class ModelProviderModelCredentialApi(Resource): return {"result": "success"}, 204 -class ParserSwitch(BaseModel): - model: str - model_type: ModelType - credential_id: str - - -console_ns.schema_model( - ParserSwitch.__name__, ParserSwitch.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) - - @console_ns.route("/workspaces/current/model-providers//models/credentials/switch") class ModelProviderModelCredentialSwitchApi(Resource): @console_ns.expect(console_ns.models[ParserSwitch.__name__]) @@ -468,9 +463,7 @@ class ParserValidate(BaseModel): credentials: dict[str, Any] -console_ns.schema_model( - ParserValidate.__name__, ParserValidate.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(console_ns, ParserSwitch, ParserValidate) @console_ns.route("/workspaces/current/model-providers//models/credentials/validate") @@ -515,7 +508,7 @@ class ModelProviderModelParameterRuleApi(Resource): @login_required @account_initialization_required def get(self, provider: str): - args = ParserParameter.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserParameter.model_validate(request.args.to_dict(flat=True)) _, tenant_id = current_account_with_tenant() model_provider_service = ModelProviderService() diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index b3e344ccea..a6d4a60beb 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -177,7 +177,7 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes: FileStorage.content_length is not reliable for multipart test uploads and may be zero even when content exists, so the controllers validate against the loaded bytes instead. """ - content = file.read() + content = file.stream.read() if len(content) > max_size: raise ValueError("File size exceeds the maximum allowed size") @@ -211,7 +211,7 @@ class PluginListApi(Resource): @account_initialization_required def get(self): _, tenant_id = current_account_with_tenant() - args = ParserList.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserList.model_validate(request.args.to_dict(flat=True)) try: plugins_with_total = PluginService.list_with_total(tenant_id, args.page, args.page_size) except PluginDaemonClientSideError as e: @@ -261,7 +261,7 @@ class PluginIconApi(Resource): @console_ns.expect(console_ns.models[ParserIcon.__name__]) @setup_required def get(self): - args = ParserIcon.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserIcon.model_validate(request.args.to_dict(flat=True)) try: icon_bytes, mimetype = PluginService.get_asset(args.tenant_id, args.filename) @@ -279,7 +279,7 @@ class PluginAssetApi(Resource): @login_required @account_initialization_required def get(self): - args = ParserAsset.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserAsset.model_validate(request.args.to_dict(flat=True)) _, tenant_id = current_account_with_tenant() try: @@ -421,7 +421,7 @@ class PluginFetchMarketplacePkgApi(Resource): @plugin_permission_required(install_required=True) def get(self): _, tenant_id = current_account_with_tenant() - args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) try: return jsonable_encoder( @@ -446,7 +446,7 @@ class PluginFetchManifestApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserPluginIdentifierQuery.model_validate(request.args.to_dict(flat=True)) try: return jsonable_encoder( @@ -466,7 +466,7 @@ class PluginFetchInstallTasksApi(Resource): def get(self): _, tenant_id = current_account_with_tenant() - args = ParserTasks.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserTasks.model_validate(request.args.to_dict(flat=True)) try: return jsonable_encoder({"tasks": PluginService.fetch_install_tasks(tenant_id, args.page, args.page_size)}) @@ -660,7 +660,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource): current_user, tenant_id = current_account_with_tenant() user_id = current_user.id - args = ParserDynamicOptions.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserDynamicOptions.model_validate(request.args.to_dict(flat=True)) try: options = PluginParameterService.get_dynamic_select_options( @@ -822,7 +822,7 @@ class PluginReadmeApi(Resource): @account_initialization_required def get(self): _, tenant_id = current_account_with_tenant() - args = ParserReadme.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = ParserReadme.model_validate(request.args.to_dict(flat=True)) return jsonable_encoder( {"readme": PluginService.fetch_plugin_readme(tenant_id, args.plugin_unique_identifier, args.language)} ) diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 34c9534de8..cb01a02318 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -874,12 +874,13 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @console_ns.expect(console_ns.models[BuiltinProviderDefaultCredentialPayload.__name__]) @setup_required @login_required + @is_admin_or_owner_required @account_initialization_required def post(self, provider): - current_user, current_tenant_id = current_account_with_tenant() + _, current_tenant_id = current_account_with_tenant() payload = BuiltinProviderDefaultCredentialPayload.model_validate(console_ns.payload or {}) return BuiltinToolManageService.set_default_provider( - tenant_id=current_tenant_id, user_id=current_user.id, provider=provider, id=payload.id + tenant_id=current_tenant_id, provider=provider, id=payload.id ) diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 565099db61..1eb91c472e 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -16,6 +16,7 @@ from controllers.common.errors import ( TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.admin import admin_required from controllers.console.error import AccountNotLinkTenantError @@ -28,7 +29,7 @@ from controllers.console.wraps import ( from enums.cloud_plan import CloudPlan from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import TimestampField +from libs.helper import TimestampField, to_timestamp from libs.login import current_account_with_tenant, login_required from models.account import Tenant, TenantCustomConfigDict, TenantStatus from services.account_service import TenantService @@ -39,7 +40,6 @@ from services.file_service import FileService from services.workspace_service import WorkspaceService logger = logging.getLogger(__name__) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" class WorkspaceListQuery(BaseModel): @@ -86,20 +86,17 @@ class TenantInfoResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None): - if isinstance(value, datetime): - return int(value.timestamp()) - return value + return to_timestamp(value) -def reg(cls: type[BaseModel]): - console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) - - -reg(WorkspaceListQuery) -reg(SwitchWorkspacePayload) -reg(WorkspaceCustomConfigPayload) -reg(WorkspaceInfoPayload) -reg(TenantInfoResponse) +register_schema_models( + console_ns, + WorkspaceListQuery, + SwitchWorkspacePayload, + WorkspaceCustomConfigPayload, + WorkspaceInfoPayload, + TenantInfoResponse, +) provider_fields = { "provider_name": fields.String, @@ -322,7 +319,7 @@ class WebappLogoWorkspaceApi(Resource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, ) diff --git a/api/controllers/files/image_preview.py b/api/controllers/files/image_preview.py index a91e745f80..be7886e831 100644 --- a/api/controllers/files/image_preview.py +++ b/api/controllers/files/image_preview.py @@ -8,13 +8,12 @@ from werkzeug.exceptions import NotFound import services from controllers.common.errors import UnsupportedFileTypeError from controllers.common.file_response import enforce_download_for_html +from controllers.common.schema import register_schema_models from controllers.files import files_ns from extensions.ext_database import db from services.account_service import TenantService from services.file_service import FileService -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class FileSignatureQuery(BaseModel): timestamp: str = Field(..., description="Unix timestamp used in the signature") @@ -26,12 +25,7 @@ class FilePreviewQuery(FileSignatureQuery): as_attachment: bool = Field(default=False, description="Whether to download as attachment") -files_ns.schema_model( - FileSignatureQuery.__name__, FileSignatureQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) -files_ns.schema_model( - FilePreviewQuery.__name__, FilePreviewQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(files_ns, FileSignatureQuery, FilePreviewQuery) @files_ns.route("//image-preview") @@ -58,7 +52,7 @@ class ImagePreviewApi(Resource): def get(self, file_id): file_id = str(file_id) - args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = FileSignatureQuery.model_validate(request.args.to_dict(flat=True)) timestamp = args.timestamp nonce = args.nonce sign = args.sign @@ -100,7 +94,7 @@ class FilePreviewApi(Resource): def get(self, file_id): file_id = str(file_id) - args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = FilePreviewQuery.model_validate(request.args.to_dict(flat=True)) try: generator, upload_file = FileService(db.engine).get_file_generator_by_file_id( diff --git a/api/controllers/files/tool_files.py b/api/controllers/files/tool_files.py index 2f1e2f28bd..8ae16ce7f4 100644 --- a/api/controllers/files/tool_files.py +++ b/api/controllers/files/tool_files.py @@ -7,12 +7,11 @@ from werkzeug.exceptions import Forbidden, NotFound from controllers.common.errors import UnsupportedFileTypeError from controllers.common.file_response import enforce_download_for_html +from controllers.common.schema import register_schema_models from controllers.files import files_ns from core.tools.signature import verify_tool_file_signature from core.tools.tool_file_manager import ToolFileManager -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class ToolFileQuery(BaseModel): timestamp: str = Field(..., description="Unix timestamp") @@ -21,9 +20,7 @@ class ToolFileQuery(BaseModel): as_attachment: bool = Field(default=False, description="Download as attachment") -files_ns.schema_model( - ToolFileQuery.__name__, ToolFileQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(files_ns, ToolFileQuery) @files_ns.route("/tools/.") diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index ed3278a28b..7d588b95dd 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -20,8 +20,6 @@ from ..console.wraps import setup_required from ..files import files_ns from ..inner_api.plugin.wraps import get_user -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - class PluginUploadQuery(BaseModel): timestamp: str = Field(..., description="Unix timestamp for signature verification") @@ -31,9 +29,8 @@ class PluginUploadQuery(BaseModel): user_id: str | None = Field(default=None, description="User identifier") -files_ns.schema_model( - PluginUploadQuery.__name__, PluginUploadQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) -) +register_schema_models(files_ns, PluginUploadQuery) + register_schema_models(files_ns, FileResponse) @@ -69,7 +66,7 @@ class PluginUploadFileApi(Resource): FileTooLargeError: File exceeds size limit UnsupportedFileTypeError: File type not supported """ - args = PluginUploadQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + args = PluginUploadQuery.model_validate(request.args.to_dict(flat=True)) file = request.files.get("file") if file is None: @@ -103,7 +100,7 @@ class PluginUploadFileApi(Resource): tool_file = ToolFileManager().create_file_by_raw( user_id=user.id, tenant_id=tenant_id, - file_binary=file.read(), + file_binary=file.stream.read(), mimetype=mimetype, filename=filename, conversation_id=None, diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index ca4b18cb5e..64b2038f9c 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -22,7 +22,7 @@ from fields.conversation_fields import ( SimpleConversation, ) from graphon.variables.types import SegmentType -from libs.helper import UUIDStrOrEmpty +from libs.helper import UUIDStrOrEmpty, to_timestamp from models.model import App, AppMode, EndUser from services.conversation_service import ConversationService @@ -115,9 +115,7 @@ class ConversationVariableResponse(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value + return to_timestamp(value) class ConversationVariableInfiniteScrollPaginationResponse(ResponseModel): diff --git a/api/controllers/service_api/app/file.py b/api/controllers/service_api/app/file.py index 6f6dadf768..687d34076d 100644 --- a/api/controllers/service_api/app/file.py +++ b/api/controllers/service_api/app/file.py @@ -58,7 +58,7 @@ class FileApi(Resource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=end_user, ) diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 8e5003dbbf..2b38a84b0e 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -7,18 +7,18 @@ paused human input forms in workflow/chatflow runs. import json import logging -from datetime import datetime from flask import Response from flask_restx import Resource from werkzeug.exceptions import BadRequest, NotFound -from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values from controllers.common.schema import register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db +from libs.helper import to_timestamp from models.model import App, EndUser from services.human_input_service import Form, FormNotFoundError, HumanInputService @@ -28,30 +28,14 @@ logger = logging.getLogger(__name__) register_schema_models(service_api_ns, HumanInputFormSubmitPayload) -def _stringify_default_values(values: dict[str, object]) -> dict[str, str]: - result: dict[str, str] = {} - for key, value in values.items(): - if value is None: - result[key] = "" - elif isinstance(value, (dict, list)): - result[key] = json.dumps(value, ensure_ascii=False) - else: - result[key] = str(value) - return result - - -def _to_timestamp(value: datetime) -> int: - return int(value.timestamp()) - - def _jsonify_form_definition(form: Form) -> Response: definition_payload = form.get_definition().model_dump() payload = { "form_content": definition_payload["rendered_content"], "inputs": definition_payload["inputs"], - "resolved_default_values": _stringify_default_values(definition_payload["default_values"]), + "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), "user_actions": definition_payload["user_actions"], - "expiration_time": _to_timestamp(form.expiration_time), + "expiration_time": to_timestamp(form.expiration_time), } return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index cc763fa89c..45d2dda858 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -39,6 +39,7 @@ from graphon.enums import WorkflowExecutionStatus from graphon.graph_engine.manager import GraphEngineManager from graphon.model_runtime.errors.invoke import InvokeError from libs import helper +from libs.helper import to_timestamp from models.model import App, AppMode, EndUser from models.workflow import WorkflowRun from repositories.factory import DifyAPIRepositoryFactory @@ -68,12 +69,6 @@ class WorkflowLogQuery(BaseModel): register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery) -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - def _enum_value(value): return getattr(value, "value", value) @@ -109,7 +104,7 @@ class WorkflowRunResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowRunForLogResponse(ResponseModel): @@ -133,7 +128,7 @@ class WorkflowRunForLogResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowAppLogPartialResponse(ResponseModel): @@ -154,7 +149,7 @@ class WorkflowAppLogPartialResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowAppLogPaginationResponse(ResponseModel): diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index 76519cad0a..d85e46498d 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -2,11 +2,11 @@ from typing import Any, Literal, cast from flask import request from flask_restx import marshal -from pydantic import BaseModel, Field, TypeAdapter, field_validator +from pydantic import BaseModel, Field, TypeAdapter, field_validator, model_validator from werkzeug.exceptions import Forbidden, NotFound import services -from controllers.common.schema import register_schema_models +from controllers.common.schema import register_enum_models, register_schema_models from controllers.console.wraps import edit_permission_required from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError @@ -31,16 +31,12 @@ from services.tag_service import ( TagBindingCreatePayload, TagBindingDeletePayload, TagService, - UpdateTagPayload, +) +from services.tag_service import ( + UpdateTagPayload as UpdateTagServicePayload, ) -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - -service_api_ns.schema_model( - DatasetPermissionEnum.__name__, - TypeAdapter(DatasetPermissionEnum).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), -) +register_enum_models(service_api_ns, DatasetPermissionEnum) class DatasetCreatePayload(BaseModel): @@ -100,9 +96,27 @@ class TagBindingPayload(BaseModel): class TagUnbindingPayload(BaseModel): - tag_id: str + """Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally.""" + + tag_ids: list[str] = Field(default_factory=list) + tag_id: str | None = None target_id: str + @model_validator(mode="before") + @classmethod + def normalize_legacy_tag_id(cls, data: object) -> object: + if not isinstance(data, dict): + return data + if not data.get("tag_ids") and data.get("tag_id"): + return {**data, "tag_ids": [data["tag_id"]]} + return data + + @model_validator(mode="after") + def validate_tag_ids(self) -> "TagUnbindingPayload": + if not self.tag_ids: + raise ValueError("Tag IDs is required.") + return self + class DatasetListQuery(BaseModel): page: int = Field(default=1, description="Page number") @@ -544,7 +558,7 @@ class DatasetTagsApi(DatasetApiResource): payload = TagUpdatePayload.model_validate(service_api_ns.payload or {}) tag_id = payload.tag_id - tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=TagType.KNOWLEDGE), tag_id) + tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id) binding_count = TagService.get_tag_binding_count(tag_id) @@ -601,11 +615,11 @@ class DatasetTagBindingApi(DatasetApiResource): @service_api_ns.route("/datasets/tags/unbinding") class DatasetTagUnbindingApi(DatasetApiResource): @service_api_ns.expect(service_api_ns.models[TagUnbindingPayload.__name__]) - @service_api_ns.doc("unbind_dataset_tag") - @service_api_ns.doc(description="Unbind a tag from a dataset") + @service_api_ns.doc("unbind_dataset_tags") + @service_api_ns.doc(description="Unbind tags from a dataset") @service_api_ns.doc( responses={ - 204: "Tag unbound successfully", + 204: "Tags unbound successfully", 401: "Unauthorized - invalid API token", 403: "Forbidden - insufficient permissions", } @@ -618,7 +632,7 @@ class DatasetTagUnbindingApi(DatasetApiResource): payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag_binding( - TagBindingDeletePayload(tag_id=payload.tag_id, target_id=payload.target_id, type=TagType.KNOWLEDGE) + TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE) ) return "", 204 diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 0b09facf58..e68eeeca25 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -77,9 +77,6 @@ class DocumentTextCreatePayload(BaseModel): return value -DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" - - class DocumentTextUpdate(BaseModel): name: str | None = None text: str | None = None @@ -139,7 +136,7 @@ def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[ if not dataset: raise ValueError("Dataset does not exist.") - if not dataset.indexing_technique and not args["indexing_technique"]: + if not dataset.indexing_technique and not args.get("indexing_technique"): raise ValueError("indexing_technique is required.") embedding_model_provider = payload.embedding_model_provider @@ -435,7 +432,7 @@ class DocumentAddByFileApi(DatasetApiResource): raise ValueError("current_user is required") upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, source="datasets", @@ -509,7 +506,7 @@ def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, source="datasets", diff --git a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py index 2dc98bfbf7..8bc43bccd5 100644 --- a/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/service_api/dataset/rag_pipeline/rag_pipeline_workflow.py @@ -241,7 +241,7 @@ class KnowledgebasePipelineFileUploadApi(DatasetApiResource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=current_user, ) diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index 3ad595f1f4..8ddbc3abb8 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -23,7 +23,7 @@ from controllers.web.wraps import WebApiResource from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError from libs.helper import uuid_value -from models.model import App +from models.model import App, EndUser from services.audio_service import AudioService from services.errors.audio import ( AudioTooLargeServiceError, @@ -69,12 +69,12 @@ class AudioApi(WebApiResource): 500: "Internal Server Error", } ) - def post(self, app_model: App, end_user): + def post(self, app_model: App, end_user: EndUser): """Convert audio to text""" file = request.files["file"] try: - response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user) + response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=end_user.external_user_id) return response except services.errors.app_model_config.AppModelConfigBrokenError: @@ -117,7 +117,7 @@ class TextApi(WebApiResource): 500: "Internal Server Error", } ) - def post(self, app_model: App, end_user): + def post(self, app_model: App, end_user: EndUser): """Convert text to audio""" try: payload = TextToAudioPayload.model_validate(web_ns.payload or {}) diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index 0036c90800..6128490104 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -73,7 +73,7 @@ class FileApi(WebApiResource): try: upload_file = FileService(db.engine).upload_file( filename=file.filename, - content=file.read(), + content=file.stream.read(), mimetype=file.mimetype, user=end_user, source="datasets" if source == "datasets" else None, diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 1ddf2e0717..69297450c9 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -4,7 +4,6 @@ Web App Human Input Form APIs. import json import logging -from datetime import datetime from typing import Any, NotRequired, TypedDict from flask import Response, request @@ -13,12 +12,12 @@ from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config -from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values from controllers.web import web_ns from controllers.web.error import NotFoundError, WebFormRateLimitExceededError from controllers.web.site import serialize_app_site_payload from extensions.ext_database import db -from libs.helper import RateLimiter, extract_remote_ip +from libs.helper import RateLimiter, extract_remote_ip, to_timestamp from models.account import TenantStatus from models.model import App, Site from services.human_input_service import Form, FormNotFoundError, HumanInputService @@ -38,22 +37,6 @@ _FORM_ACCESS_RATE_LIMITER = RateLimiter( ) -def _stringify_default_values(values: dict[str, object]) -> dict[str, str]: - result: dict[str, str] = {} - for key, value in values.items(): - if value is None: - result[key] = "" - elif isinstance(value, (dict, list)): - result[key] = json.dumps(value, ensure_ascii=False) - else: - result[key] = str(value) - return result - - -def _to_timestamp(value: datetime) -> int: - return int(value.timestamp()) - - class FormDefinitionPayload(TypedDict): form_content: Any inputs: Any @@ -69,9 +52,9 @@ def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Re payload: FormDefinitionPayload = { "form_content": definition_payload["rendered_content"], "inputs": definition_payload["inputs"], - "resolved_default_values": _stringify_default_values(definition_payload["default_values"]), + "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), "user_actions": definition_payload["user_actions"], - "expiration_time": _to_timestamp(form.expiration_time), + "expiration_time": to_timestamp(form.expiration_time), } if site_payload is not None: payload["site"] = site_payload diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index c22102c2ba..cba4659483 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -532,7 +532,6 @@ class BaseAgentRunner(AppRunner): file_objs = file_factory.build_from_message_files( message_files=files, tenant_id=self.tenant_id, - config=file_extra_config, access_controller=_file_access_controller, ) if not file_objs: diff --git a/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py b/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py index 4c07445df3..f4bbbe5d8b 100644 --- a/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/prompt_template/manager.py @@ -75,7 +75,7 @@ class PromptTemplateConfigManager: if not config.get("prompt_type"): config["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE - prompt_type_vals = [typ.value for typ in PromptTemplateEntity.PromptType] + prompt_type_vals = list(PromptTemplateEntity.PromptType) if config["prompt_type"] not in prompt_type_vals: raise ValueError(f"prompt_type must be in {prompt_type_vals}") diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index b79d5514b4..cc7bc64439 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -253,7 +253,20 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): ): """ Resume a paused advanced chat execution. + + ``trace_manager`` is transient and excluded from generate-entity serialization, + so resumed executions rebuild it here before persistence layers receive the entity. """ + if application_generate_entity.trace_manager is None: + application_generate_entity = application_generate_entity.model_copy( + update={ + "trace_manager": TraceQueueManager( + app_id=app_model.id, + user_id=user.id if isinstance(user, Account) else user.session_id, + ) + } + ) + return self._generate( workflow=workflow, user=user, diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 82dbf5381d..adbe2241ef 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -9,7 +9,7 @@ from datetime import datetime from threading import Thread from typing import Any, Union -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.orm import Session, sessionmaker from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME @@ -245,49 +245,50 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): """ human_input_responses: list[HumanInputRequiredResponse] = [] for stream_response in generator: - if isinstance(stream_response, ErrorStreamResponse): - raise stream_response.err - elif isinstance(stream_response, HumanInputRequiredResponse): - human_input_responses.append(stream_response) - elif isinstance(stream_response, WorkflowPauseStreamResponse): - return AdvancedChatPausedBlockingResponse( - task_id=stream_response.task_id, - data=AdvancedChatPausedBlockingResponse.Data( - id=self._message_id, - mode=self._conversation_mode, - conversation_id=self._conversation_id, - message_id=self._message_id, - workflow_run_id=stream_response.data.workflow_run_id, - answer=self._task_state.answer, - metadata=self._message_end_to_stream_response().metadata, - created_at=self._message_created_at, - paused_nodes=stream_response.data.paused_nodes, - reasons=stream_response.data.reasons, - status=stream_response.data.status, - elapsed_time=stream_response.data.elapsed_time, - total_tokens=stream_response.data.total_tokens, - total_steps=stream_response.data.total_steps, - ), - ) - elif isinstance(stream_response, MessageEndStreamResponse): - extras = {} - if stream_response.metadata: - extras["metadata"] = stream_response.metadata + match stream_response: + case ErrorStreamResponse(): + raise stream_response.err + case HumanInputRequiredResponse(): + human_input_responses.append(stream_response) + case WorkflowPauseStreamResponse(): + return AdvancedChatPausedBlockingResponse( + task_id=stream_response.task_id, + data=AdvancedChatPausedBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + workflow_run_id=stream_response.data.workflow_run_id, + answer=self._task_state.answer, + metadata=self._message_end_to_stream_response().metadata, + created_at=self._message_created_at, + paused_nodes=stream_response.data.paused_nodes, + reasons=stream_response.data.reasons, + status=stream_response.data.status, + elapsed_time=stream_response.data.elapsed_time, + total_tokens=stream_response.data.total_tokens, + total_steps=stream_response.data.total_steps, + ), + ) + case MessageEndStreamResponse(): + extras = {} + if stream_response.metadata: + extras["metadata"] = stream_response.metadata - return ChatbotAppBlockingResponse( - task_id=stream_response.task_id, - data=ChatbotAppBlockingResponse.Data( - id=self._message_id, - mode=self._conversation_mode, - conversation_id=self._conversation_id, - message_id=self._message_id, - answer=self._task_state.answer, - created_at=self._message_created_at, - **extras, - ), - ) - else: - continue + return ChatbotAppBlockingResponse( + task_id=stream_response.task_id, + data=ChatbotAppBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + answer=self._task_state.answer, + created_at=self._message_created_at, + **extras, + ), + ) + case _: + continue if human_input_responses: return self._build_paused_blocking_response_from_human_input(human_input_responses) @@ -425,11 +426,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): self._workflow_run_id = run_id with self._database_session() as session: - message = self._get_message(session=session) - if not message: - raise ValueError(f"Message not found: {self._message_id}") - - message.workflow_run_id = run_id + session.execute(update(Message).where(Message.id == self._message_id).values(workflow_run_id=run_id)) workflow_start_resp = self._workflow_response_converter.workflow_start_to_stream_response( task_id=self._application_generate_entity.task_id, diff --git a/api/core/app/apps/agent_chat/app_config_manager.py b/api/core/app/apps/agent_chat/app_config_manager.py index f0d81e0c59..e269b98bad 100644 --- a/api/core/app/apps/agent_chat/app_config_manager.py +++ b/api/core/app/apps/agent_chat/app_config_manager.py @@ -178,7 +178,7 @@ class AgentChatAppConfigManager(BaseAppConfigManager): if not isinstance(agent_mode, dict): raise ValueError("agent_mode must be of object type") - # FIXME(-LAN-): Cast needed due to basedpyright limitation with dict type narrowing + # FIXME(-LAN-): Cast needed because static checkers do not narrow this dict value. agent_mode = cast(dict[str, Any], agent_mode) if "enabled" not in agent_mode or not agent_mode["enabled"]: diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index 8e8ccf2b90..d7ef5165f0 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -195,22 +195,23 @@ class BaseAppGenerator: ) if variable_entity.type == VariableEntityType.NUMBER: - if isinstance(value, (int, float)): - return value - elif isinstance(value, str): - # handle empty string case - if not value.strip(): - return None - # may raise ValueError if user_input_value is not a valid number - try: - if "." in value: - return float(value) - else: - return int(value) - except ValueError: - raise ValueError(f"{variable_entity.variable} in input form must be a valid number") - else: - raise TypeError(f"expected value type int, float or str, got {type(value)}, value: {value}") + match value: + case int() | float(): + return value + case str(): + # handle empty string case + if not value.strip(): + return None + # may raise ValueError if user_input_value is not a valid number + try: + if "." in value: + return float(value) + else: + return int(value) + except ValueError: + raise ValueError(f"{variable_entity.variable} in input form must be a valid number") + case _: + raise TypeError(f"expected value type int, float or str, got {type(value)}, value: {value}") match variable_entity.type: case VariableEntityType.SELECT: @@ -241,17 +242,18 @@ class BaseAppGenerator: f"{variable_entity.variable} in input form must be less than {variable_entity.max_length} files" ) case VariableEntityType.CHECKBOX: - if isinstance(value, str): - normalized_value = value.strip().lower() - if normalized_value in {"true", "1", "yes", "on"}: - value = True - elif normalized_value in {"false", "0", "no", "off"}: - value = False - elif isinstance(value, (int, float)): - if value == 1: - value = True - elif value == 0: - value = False + match value: + case str(): + normalized_value = value.strip().lower() + if normalized_value in {"true", "1", "yes", "on"}: + value = True + elif normalized_value in {"false", "0", "no", "off"}: + value = False + case int() | float(): + if value == 1: + value = True + elif value == 0: + value = False case VariableEntityType.JSON_OBJECT: if value and not isinstance(value, dict): raise ValueError(f"{variable_entity.variable} in input form must be a dict") diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 7bab3f7bff..4a741d3154 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -842,24 +842,24 @@ class WorkflowResponseConverter: return [] files: list[Mapping[str, Any]] = [] - if isinstance(value, FileSegment): - files.append(value.value.to_dict()) - elif isinstance(value, ArrayFileSegment): - files.extend([i.to_dict() for i in value.value]) - elif isinstance(value, File): - files.append(value.to_dict()) - elif isinstance(value, list): - for item in value: - file = cls._get_file_var_from_value(item) + match value: + case FileSegment(): + files.append(value.value.to_dict()) + case ArrayFileSegment(): + files.extend([i.to_dict() for i in value.value]) + case File(): + files.append(value.to_dict()) + case list(): + for item in value: + file = cls._get_file_var_from_value(item) + if file: + files.append(file) + case dict(): + file = cls._get_file_var_from_value(value) if file: files.append(file) - elif isinstance( - value, - dict, - ): - file = cls._get_file_var_from_value(value) - if file: - files.append(file) + case _: + pass return files diff --git a/api/core/app/apps/pipeline/generate_response_converter.py b/api/core/app/apps/pipeline/generate_response_converter.py index 3913657ae8..03d7049150 100644 --- a/api/core/app/apps/pipeline/generate_response_converter.py +++ b/api/core/app/apps/pipeline/generate_response_converter.py @@ -45,20 +45,24 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter[Workflow chunk = cast(WorkflowAppStreamResponse, chunk) sub_stream_response = chunk.stream_response - if isinstance(sub_stream_response, PingStreamResponse): - yield "ping" - continue + match sub_stream_response: + case PingStreamResponse(): + yield "ping" + continue + case ErrorStreamResponse(): + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + data = cls._error_to_stream_response(sub_stream_response.err) + response_chunk.update(cast(dict, data)) + case _: + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + response_chunk.update(sub_stream_response.model_dump()) - response_chunk = { - "event": sub_stream_response.event.value, - "workflow_run_id": chunk.workflow_run_id, - } - - if isinstance(sub_stream_response, ErrorStreamResponse): - data = cls._error_to_stream_response(sub_stream_response.err) - response_chunk.update(cast(dict, data)) - else: - response_chunk.update(sub_stream_response.model_dump()) yield response_chunk @classmethod @@ -74,20 +78,28 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter[Workflow chunk = cast(WorkflowAppStreamResponse, chunk) sub_stream_response = chunk.stream_response - if isinstance(sub_stream_response, PingStreamResponse): - yield "ping" - continue + match sub_stream_response: + case PingStreamResponse(): + yield "ping" + continue + case ErrorStreamResponse(): + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + data = cls._error_to_stream_response(sub_stream_response.err) + response_chunk.update(cast(dict, data)) + case NodeStartStreamResponse() | NodeFinishStreamResponse(): + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + response_chunk.update(cast(dict, sub_stream_response.to_ignore_detail_dict())) + case _: + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + response_chunk.update(sub_stream_response.model_dump()) - response_chunk = { - "event": sub_stream_response.event.value, - "workflow_run_id": chunk.workflow_run_id, - } - - if isinstance(sub_stream_response, ErrorStreamResponse): - data = cls._error_to_stream_response(sub_stream_response.err) - response_chunk.update(cast(dict, data)) - elif isinstance(sub_stream_response, NodeStartStreamResponse | NodeFinishStreamResponse): - response_chunk.update(cast(dict, sub_stream_response.to_ignore_detail_dict())) - else: - response_chunk.update(sub_stream_response.model_dump()) yield response_chunk diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index e811c2b2e0..78255c0512 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -32,7 +32,7 @@ from core.app.entities.task_entities import ( ) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.db.session_factory import session_factory -from core.helper.trace_id_helper import extract_external_trace_id_from_args +from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_parent_trace_context_from_args from core.ops.ops_trace_manager import TraceQueueManager from core.repositories import DifyCoreRepositoryFactory from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -166,6 +166,7 @@ class WorkflowAppGenerator(BaseAppGenerator): extras = { **extract_external_trace_id_from_args(args), + **extract_parent_trace_context_from_args(args), } workflow_run_id = str(workflow_run_id or uuid.uuid4()) # FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args @@ -252,7 +253,20 @@ class WorkflowAppGenerator(BaseAppGenerator): ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]: """ Resume a paused workflow execution using the persisted runtime state. + + ``trace_manager`` is transient and excluded from generate-entity serialization, + so resumed executions rebuild it here before persistence layers receive the entity. """ + if application_generate_entity.trace_manager is None: + application_generate_entity = application_generate_entity.model_copy( + update={ + "trace_manager": TraceQueueManager( + app_id=app_model.id, + user_id=user.id if isinstance(user, Account) else user.session_id, + ) + } + ) + return self._generate( app_model=app_model, workflow=workflow, diff --git a/api/core/app/apps/workflow/generate_response_converter.py b/api/core/app/apps/workflow/generate_response_converter.py index 4037388798..c390ad30c9 100644 --- a/api/core/app/apps/workflow/generate_response_converter.py +++ b/api/core/app/apps/workflow/generate_response_converter.py @@ -52,20 +52,24 @@ class WorkflowAppGenerateResponseConverter( chunk = cast(WorkflowAppStreamResponse, chunk) sub_stream_response = chunk.stream_response - if isinstance(sub_stream_response, PingStreamResponse): - yield "ping" - continue + match sub_stream_response: + case PingStreamResponse(): + yield "ping" + continue + case ErrorStreamResponse(): + response_chunk: dict[str, object] = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + data = cls._error_to_stream_response(sub_stream_response.err) + response_chunk.update(data) + case _: + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + response_chunk.update(sub_stream_response.model_dump(mode="json")) - response_chunk: dict[str, object] = { - "event": sub_stream_response.event.value, - "workflow_run_id": chunk.workflow_run_id, - } - - if isinstance(sub_stream_response, ErrorStreamResponse): - data = cls._error_to_stream_response(sub_stream_response.err) - response_chunk.update(data) - else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) yield response_chunk @classmethod @@ -81,20 +85,28 @@ class WorkflowAppGenerateResponseConverter( chunk = cast(WorkflowAppStreamResponse, chunk) sub_stream_response = chunk.stream_response - if isinstance(sub_stream_response, PingStreamResponse): - yield "ping" - continue + match sub_stream_response: + case PingStreamResponse(): + yield "ping" + continue + case ErrorStreamResponse(): + response_chunk: dict[str, object] = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + data = cls._error_to_stream_response(sub_stream_response.err) + response_chunk.update(data) + case NodeStartStreamResponse() | NodeFinishStreamResponse(): + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + response_chunk.update(sub_stream_response.to_ignore_detail_dict()) + case _: + response_chunk = { + "event": sub_stream_response.event.value, + "workflow_run_id": chunk.workflow_run_id, + } + response_chunk.update(sub_stream_response.model_dump(mode="json")) - response_chunk: dict[str, object] = { - "event": sub_stream_response.event.value, - "workflow_run_id": chunk.workflow_run_id, - } - - if isinstance(sub_stream_response, ErrorStreamResponse): - data = cls._error_to_stream_response(sub_stream_response.err) - response_chunk.update(data) - elif isinstance(sub_stream_response, NodeStartStreamResponse | NodeFinishStreamResponse): - response_chunk.update(sub_stream_response.to_ignore_detail_dict()) - else: - response_chunk.update(sub_stream_response.model_dump(mode="json")) yield response_chunk diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 87d9b73078..80f8e3ad4a 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -145,50 +145,51 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): """ human_input_responses: list[HumanInputRequiredResponse] = [] for stream_response in generator: - if isinstance(stream_response, ErrorStreamResponse): - raise stream_response.err - elif isinstance(stream_response, HumanInputRequiredResponse): - human_input_responses.append(stream_response) - elif isinstance(stream_response, WorkflowPauseStreamResponse): - return WorkflowAppPausedBlockingResponse( - task_id=self._application_generate_entity.task_id, - workflow_run_id=stream_response.data.workflow_run_id, - data=WorkflowAppPausedBlockingResponse.Data( - id=stream_response.data.workflow_run_id, - workflow_id=self._workflow.id, - status=stream_response.data.status, - outputs=stream_response.data.outputs or {}, - error=None, - elapsed_time=stream_response.data.elapsed_time, - total_tokens=stream_response.data.total_tokens, - total_steps=stream_response.data.total_steps, - created_at=stream_response.data.created_at, - finished_at=None, - paused_nodes=stream_response.data.paused_nodes, - reasons=stream_response.data.reasons, - ), - ) - - elif isinstance(stream_response, WorkflowFinishStreamResponse): - return WorkflowAppBlockingResponse( - task_id=self._application_generate_entity.task_id, - workflow_run_id=stream_response.data.id, - data=WorkflowAppBlockingResponse.Data( - id=stream_response.data.id, - workflow_id=stream_response.data.workflow_id, - status=stream_response.data.status, - outputs=stream_response.data.outputs, - error=stream_response.data.error, - elapsed_time=stream_response.data.elapsed_time, - total_tokens=stream_response.data.total_tokens, - total_steps=stream_response.data.total_steps, - created_at=int(stream_response.data.created_at), - finished_at=int(stream_response.data.finished_at) if stream_response.data.finished_at else None, - ), - ) - - else: - continue + match stream_response: + case ErrorStreamResponse(): + raise stream_response.err + case HumanInputRequiredResponse(): + human_input_responses.append(stream_response) + case WorkflowPauseStreamResponse(): + return WorkflowAppPausedBlockingResponse( + task_id=self._application_generate_entity.task_id, + workflow_run_id=stream_response.data.workflow_run_id, + data=WorkflowAppPausedBlockingResponse.Data( + id=stream_response.data.workflow_run_id, + workflow_id=self._workflow.id, + status=stream_response.data.status, + outputs=stream_response.data.outputs or {}, + error=None, + elapsed_time=stream_response.data.elapsed_time, + total_tokens=stream_response.data.total_tokens, + total_steps=stream_response.data.total_steps, + created_at=stream_response.data.created_at, + finished_at=None, + paused_nodes=stream_response.data.paused_nodes, + reasons=stream_response.data.reasons, + ), + ) + case WorkflowFinishStreamResponse(): + return WorkflowAppBlockingResponse( + task_id=self._application_generate_entity.task_id, + workflow_run_id=stream_response.data.id, + data=WorkflowAppBlockingResponse.Data( + id=stream_response.data.id, + workflow_id=stream_response.data.workflow_id, + status=stream_response.data.status, + outputs=stream_response.data.outputs, + error=stream_response.data.error, + elapsed_time=stream_response.data.elapsed_time, + total_tokens=stream_response.data.total_tokens, + total_steps=stream_response.data.total_steps, + created_at=int(stream_response.data.created_at), + finished_at=int(stream_response.data.finished_at) + if stream_response.data.finished_at + else None, + ), + ) + case _: + continue if human_input_responses: return self._build_paused_blocking_response_from_human_input(human_input_responses) diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 047b54c86c..84e9573416 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -399,278 +399,281 @@ class WorkflowBasedAppRunner: :param workflow_entry: workflow entry :param event: event """ - if isinstance(event, GraphRunStartedEvent): - self._publish_event(QueueWorkflowStartedEvent(reason=event.reason)) - elif isinstance(event, GraphRunSucceededEvent): - self._publish_event(QueueWorkflowSucceededEvent(outputs=event.outputs)) - elif isinstance(event, GraphRunPartialSucceededEvent): - self._publish_event( - QueueWorkflowPartialSuccessEvent(outputs=event.outputs, exceptions_count=event.exceptions_count) - ) - elif isinstance(event, GraphRunFailedEvent): - self._publish_event(QueueWorkflowFailedEvent(error=event.error, exceptions_count=event.exceptions_count)) - elif isinstance(event, GraphRunAbortedEvent): - self._publish_event(QueueWorkflowFailedEvent(error=event.reason or "Unknown error", exceptions_count=0)) - elif isinstance(event, GraphRunPausedEvent): - runtime_state = workflow_entry.graph_engine.graph_runtime_state - paused_nodes = runtime_state.get_paused_nodes() - self._enqueue_human_input_notifications(event.reasons) - self._publish_event( - QueueWorkflowPausedEvent( - reasons=event.reasons, - outputs=event.outputs, - paused_nodes=paused_nodes, + match event: + case GraphRunStartedEvent(): + self._publish_event(QueueWorkflowStartedEvent(reason=event.reason)) + case GraphRunSucceededEvent(): + self._publish_event(QueueWorkflowSucceededEvent(outputs=event.outputs)) + case GraphRunPartialSucceededEvent(): + self._publish_event( + QueueWorkflowPartialSuccessEvent(outputs=event.outputs, exceptions_count=event.exceptions_count) ) - ) - elif isinstance(event, NodeRunHumanInputFormFilledEvent): - self._publish_event( - QueueHumanInputFormFilledEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - rendered_content=event.rendered_content, - action_id=event.action_id, - action_text=event.action_text, + case GraphRunFailedEvent(): + self._publish_event( + QueueWorkflowFailedEvent(error=event.error, exceptions_count=event.exceptions_count) ) - ) - elif isinstance(event, NodeRunHumanInputFormTimeoutEvent): - self._publish_event( - QueueHumanInputFormTimeoutEvent( - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - expiration_time=event.expiration_time, + case GraphRunAbortedEvent(): + self._publish_event(QueueWorkflowFailedEvent(error=event.reason or "Unknown error", exceptions_count=0)) + case GraphRunPausedEvent(): + runtime_state = workflow_entry.graph_engine.graph_runtime_state + paused_nodes = runtime_state.get_paused_nodes() + self._enqueue_human_input_notifications(event.reasons) + self._publish_event( + QueueWorkflowPausedEvent( + reasons=event.reasons, + outputs=event.outputs, + paused_nodes=paused_nodes, + ) ) - ) - elif isinstance(event, NodeRunRetryEvent): - node_run_result = event.node_run_result - inputs = node_run_result.inputs - process_data = node_run_result.process_data - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=inputs, - outputs=node_run_result.outputs, - ) - execution_metadata = node_run_result.metadata - self._publish_event( - QueueNodeRetryEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_title=event.node_title, + case NodeRunHumanInputFormFilledEvent(): + self._publish_event( + QueueHumanInputFormFilledEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + rendered_content=event.rendered_content, + action_id=event.action_id, + action_text=event.action_text, + ) + ) + case NodeRunHumanInputFormTimeoutEvent(): + self._publish_event( + QueueHumanInputFormTimeoutEvent( + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + expiration_time=event.expiration_time, + ) + ) + case NodeRunRetryEvent(): + node_run_result = event.node_run_result + inputs = node_run_result.inputs + process_data = node_run_result.process_data + outputs = project_node_outputs_for_workflow_run( node_type=event.node_type, - start_at=event.start_at, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, inputs=inputs, - process_data=process_data, - outputs=outputs, - error=event.error, - execution_metadata=execution_metadata, - retry_index=event.retry_index, - provider_type=event.provider_type, - provider_id=event.provider_id, + outputs=node_run_result.outputs, ) - ) - elif isinstance(event, NodeRunStartedEvent): - self._publish_event( - QueueNodeStartedEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_title=event.node_title, - node_type=event.node_type, - start_at=event.start_at, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, - agent_strategy=self._build_agent_strategy_info(event), - provider_type=event.provider_type, - provider_id=event.provider_id, + execution_metadata = node_run_result.metadata + self._publish_event( + QueueNodeRetryEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_title=event.node_title, + node_type=event.node_type, + start_at=event.start_at, + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + inputs=inputs, + process_data=process_data, + outputs=outputs, + error=event.error, + execution_metadata=execution_metadata, + retry_index=event.retry_index, + provider_type=event.provider_type, + provider_id=event.provider_id, + ) ) - ) - elif isinstance(event, NodeRunSucceededEvent): - node_run_result = event.node_run_result - inputs = node_run_result.inputs - process_data = node_run_result.process_data - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=inputs, - outputs=node_run_result.outputs, - ) - execution_metadata = node_run_result.metadata - self._publish_event( - QueueNodeSucceededEvent( - node_execution_id=event.id, - node_id=event.node_id, + case NodeRunStartedEvent(): + self._publish_event( + QueueNodeStartedEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_title=event.node_title, + node_type=event.node_type, + start_at=event.start_at, + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + agent_strategy=self._build_agent_strategy_info(event), + provider_type=event.provider_type, + provider_id=event.provider_id, + ) + ) + case NodeRunSucceededEvent(): + node_run_result = event.node_run_result + inputs = node_run_result.inputs + process_data = node_run_result.process_data + outputs = project_node_outputs_for_workflow_run( node_type=event.node_type, - start_at=event.start_at, - finished_at=event.finished_at, inputs=inputs, - process_data=process_data, - outputs=outputs, - execution_metadata=execution_metadata, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, + outputs=node_run_result.outputs, ) - ) - elif isinstance(event, NodeRunFailedEvent): - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=event.node_run_result.inputs, - outputs=event.node_run_result.outputs, - ) - self._publish_event( - QueueNodeFailedEvent( - node_execution_id=event.id, - node_id=event.node_id, + execution_metadata = node_run_result.metadata + self._publish_event( + QueueNodeSucceededEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + start_at=event.start_at, + finished_at=event.finished_at, + inputs=inputs, + process_data=process_data, + outputs=outputs, + execution_metadata=execution_metadata, + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + ) + ) + case NodeRunFailedEvent(): + outputs = project_node_outputs_for_workflow_run( node_type=event.node_type, - start_at=event.start_at, - finished_at=event.finished_at, inputs=event.node_run_result.inputs, - process_data=event.node_run_result.process_data, - outputs=outputs, - error=event.node_run_result.error or "Unknown error", - execution_metadata=event.node_run_result.metadata, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, + outputs=event.node_run_result.outputs, ) - ) - elif isinstance(event, NodeRunExceptionEvent): - outputs = project_node_outputs_for_workflow_run( - node_type=event.node_type, - inputs=event.node_run_result.inputs, - outputs=event.node_run_result.outputs, - ) - self._publish_event( - QueueNodeExceptionEvent( - node_execution_id=event.id, - node_id=event.node_id, + self._publish_event( + QueueNodeFailedEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + start_at=event.start_at, + finished_at=event.finished_at, + inputs=event.node_run_result.inputs, + process_data=event.node_run_result.process_data, + outputs=outputs, + error=event.node_run_result.error or "Unknown error", + execution_metadata=event.node_run_result.metadata, + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + ) + ) + case NodeRunExceptionEvent(): + outputs = project_node_outputs_for_workflow_run( node_type=event.node_type, - start_at=event.start_at, - finished_at=event.finished_at, inputs=event.node_run_result.inputs, - process_data=event.node_run_result.process_data, - outputs=outputs, - error=event.node_run_result.error or "Unknown error", - execution_metadata=event.node_run_result.metadata, - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, + outputs=event.node_run_result.outputs, ) - ) - elif isinstance(event, NodeRunStreamChunkEvent): - self._publish_event( - QueueTextChunkEvent( - text=event.chunk, - from_variable_selector=list(event.selector), - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, + self._publish_event( + QueueNodeExceptionEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + start_at=event.start_at, + finished_at=event.finished_at, + inputs=event.node_run_result.inputs, + process_data=event.node_run_result.process_data, + outputs=outputs, + error=event.node_run_result.error or "Unknown error", + execution_metadata=event.node_run_result.metadata, + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + ) ) - ) - elif isinstance(event, NodeRunRetrieverResourceEvent): - self._publish_event( - QueueRetrieverResourcesEvent( - retriever_resources=[ - RetrievalSourceMetadata.model_validate(resource) for resource in event.retriever_resources - ], - in_iteration_id=event.in_iteration_id, - in_loop_id=event.in_loop_id, + case NodeRunStreamChunkEvent(): + self._publish_event( + QueueTextChunkEvent( + text=event.chunk, + from_variable_selector=list(event.selector), + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + ) ) - ) - elif isinstance(event, NodeRunAgentLogEvent): - self._publish_event( - QueueAgentLogEvent( - id=event.message_id, - label=event.label, - node_execution_id=event.node_execution_id, - parent_id=event.parent_id, - error=event.error, - status=event.status, - data=event.data, - metadata=event.metadata, - node_id=event.node_id, + case NodeRunRetrieverResourceEvent(): + self._publish_event( + QueueRetrieverResourcesEvent( + retriever_resources=[ + RetrievalSourceMetadata.model_validate(resource) for resource in event.retriever_resources + ], + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + ) ) - ) - elif isinstance(event, NodeRunIterationStartedEvent): - self._publish_event( - QueueIterationStartEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, - inputs=event.inputs, - metadata=event.metadata, + case NodeRunAgentLogEvent(): + self._publish_event( + QueueAgentLogEvent( + id=event.message_id, + label=event.label, + node_execution_id=event.node_execution_id, + parent_id=event.parent_id, + error=event.error, + status=event.status, + data=event.data, + metadata=event.metadata, + node_id=event.node_id, + ) ) - ) - elif isinstance(event, NodeRunIterationNextEvent): - self._publish_event( - QueueIterationNextEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - index=event.index, - node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, - output=event.pre_iteration_output, + case NodeRunIterationStartedEvent(): + self._publish_event( + QueueIterationStartEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + start_at=event.start_at, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + inputs=event.inputs, + metadata=event.metadata, + ) ) - ) - elif isinstance(event, (NodeRunIterationSucceededEvent | NodeRunIterationFailedEvent)): - self._publish_event( - QueueIterationCompletedEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, - inputs=event.inputs, - outputs=event.outputs, - metadata=event.metadata, - steps=event.steps, - error=event.error if isinstance(event, NodeRunIterationFailedEvent) else None, + case NodeRunIterationNextEvent(): + self._publish_event( + QueueIterationNextEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + index=event.index, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + output=event.pre_iteration_output, + ) ) - ) - elif isinstance(event, NodeRunLoopStartedEvent): - self._publish_event( - QueueLoopStartEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, - inputs=event.inputs, - metadata=event.metadata, + case NodeRunIterationSucceededEvent() | NodeRunIterationFailedEvent(): + self._publish_event( + QueueIterationCompletedEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + start_at=event.start_at, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + inputs=event.inputs, + outputs=event.outputs, + metadata=event.metadata, + steps=event.steps, + error=event.error if isinstance(event, NodeRunIterationFailedEvent) else None, + ) ) - ) - elif isinstance(event, NodeRunLoopNextEvent): - self._publish_event( - QueueLoopNextEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - index=event.index, - node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, - output=event.pre_loop_output, + case NodeRunLoopStartedEvent(): + self._publish_event( + QueueLoopStartEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + start_at=event.start_at, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + inputs=event.inputs, + metadata=event.metadata, + ) ) - ) - elif isinstance(event, (NodeRunLoopSucceededEvent | NodeRunLoopFailedEvent)): - self._publish_event( - QueueLoopCompletedEvent( - node_execution_id=event.id, - node_id=event.node_id, - node_type=event.node_type, - node_title=event.node_title, - start_at=event.start_at, - node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, - inputs=event.inputs, - outputs=event.outputs, - metadata=event.metadata, - steps=event.steps, - error=event.error if isinstance(event, NodeRunLoopFailedEvent) else None, + case NodeRunLoopNextEvent(): + self._publish_event( + QueueLoopNextEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + index=event.index, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + output=event.pre_loop_output, + ) + ) + case NodeRunLoopSucceededEvent() | NodeRunLoopFailedEvent(): + self._publish_event( + QueueLoopCompletedEvent( + node_execution_id=event.id, + node_id=event.node_id, + node_type=event.node_type, + node_title=event.node_title, + start_at=event.start_at, + node_run_index=workflow_entry.graph_engine.graph_runtime_state.node_run_steps, + inputs=event.inputs, + outputs=event.outputs, + metadata=event.metadata, + steps=event.steps, + error=event.error if isinstance(event, NodeRunLoopFailedEvent) else None, + ) ) - ) def _enqueue_human_input_notifications(self, reasons: Sequence[object]) -> None: for reason in reasons: diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index ad05566521..3a33899bdf 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -10,7 +10,7 @@ from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage -from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig class AnnotationReplyAccount(BaseModel): @@ -284,8 +284,8 @@ class HumanInputRequiredResponse(StreamResponse): node_id: str node_title: str form_content: str - inputs: Sequence[FormInput] = Field(default_factory=list) - actions: Sequence[UserAction] = Field(default_factory=list) + inputs: Sequence[FormInputConfig] = Field(default_factory=list) + actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False form_token: str | None = None resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) @@ -307,8 +307,8 @@ class HumanInputRequiredPauseReasonPayload(BaseModel): node_id: str node_title: str form_content: str - inputs: Sequence[FormInput] = Field(default_factory=list) - actions: Sequence[UserAction] = Field(default_factory=list) + inputs: Sequence[FormInputConfig] = Field(default_factory=list) + actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False form_token: str | None = None resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) diff --git a/api/core/app/file_access/__init__.py b/api/core/app/file_access/__init__.py index a75ab9781b..e02aba102b 100644 --- a/api/core/app/file_access/__init__.py +++ b/api/core/app/file_access/__init__.py @@ -1,6 +1,13 @@ from .controller import DatabaseFileAccessController from .protocols import FileAccessControllerProtocol -from .scope import FileAccessScope, bind_file_access_scope, get_current_file_access_scope +from .scope import ( + FileAccessScope, + bind_file_access_scope, + get_current_file_access_scope, + grant_retriever_segment_access, + grant_upload_file_access, + is_retriever_segment_access_granted, +) __all__ = [ "DatabaseFileAccessController", @@ -8,4 +15,7 @@ __all__ = [ "FileAccessScope", "bind_file_access_scope", "get_current_file_access_scope", + "grant_retriever_segment_access", + "grant_upload_file_access", + "is_retriever_segment_access_granted", ] diff --git a/api/core/app/file_access/controller.py b/api/core/app/file_access/controller.py index 300c187083..a6c6e74f06 100644 --- a/api/core/app/file_access/controller.py +++ b/api/core/app/file_access/controller.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from sqlalchemy import select +from sqlalchemy import and_, or_, select from sqlalchemy.orm import Session from sqlalchemy.sql import Select @@ -18,7 +18,8 @@ class DatabaseFileAccessController(FileAccessControllerProtocol): Tenant scoping remains mandatory. When the current execution belongs to an end user, the lookup is additionally constrained to that end user's file - ownership markers. + ownership markers, plus upload files explicitly granted by the current + execution context. """ _scope_getter: Callable[[], FileAccessScope | None] @@ -47,10 +48,19 @@ class DatabaseFileAccessController(FileAccessControllerProtocol): if not resolved_scope.requires_user_ownership: return scoped_stmt - return scoped_stmt.where( + user_owned_filter = and_( UploadFile.created_by_role == CreatorUserRole.END_USER, UploadFile.created_by == resolved_scope.user_id, ) + if not resolved_scope.granted_upload_file_ids: + return scoped_stmt.where(user_owned_filter) + + return scoped_stmt.where( + or_( + user_owned_filter, + UploadFile.id.in_(resolved_scope.granted_upload_file_ids), + ) + ) def apply_tool_file_filters( self, diff --git a/api/core/app/file_access/scope.py b/api/core/app/file_access/scope.py index a583301f9b..12fe7b3840 100644 --- a/api/core/app/file_access/scope.py +++ b/api/core/app/file_access/scope.py @@ -1,9 +1,9 @@ from __future__ import annotations -from collections.abc import Generator # Changed from Iterator +from collections.abc import Generator, Iterable from contextlib import contextmanager from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, field, replace from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom @@ -15,12 +15,23 @@ _current_file_access_scope: ContextVar[FileAccessScope | None] = ContextVar( @dataclass(frozen=True, slots=True) class FileAccessScope: - """Request-scoped ownership context used by workflow-layer file lookups.""" + """Request-scoped ownership context used by workflow-layer file lookups. + + ``granted_upload_file_ids`` is execution-local: callers may add upload files + that were returned by trusted retrieval paths without changing persistent + ownership markers. + + ``granted_retriever_segment_ids`` gates lazy attachment loading by segment + ID, so user-provided context cannot make a later LLM node load arbitrary + same-tenant knowledge attachments. + """ tenant_id: str user_id: str user_from: UserFrom invoke_from: InvokeFrom + granted_upload_file_ids: frozenset[str] = field(default_factory=frozenset) + granted_retriever_segment_ids: frozenset[str] = field(default_factory=frozenset) @property def requires_user_ownership(self) -> bool: @@ -31,8 +42,49 @@ def get_current_file_access_scope() -> FileAccessScope | None: return _current_file_access_scope.get() +def grant_upload_file_access(upload_file_ids: Iterable[str]) -> None: + scope = _current_file_access_scope.get() + if scope is None: + return + + granted_upload_file_ids = frozenset(str(file_id) for file_id in upload_file_ids if file_id) + if not granted_upload_file_ids: + return + + _current_file_access_scope.set( + replace( + scope, + granted_upload_file_ids=scope.granted_upload_file_ids | granted_upload_file_ids, + ) + ) + + +def grant_retriever_segment_access(segment_ids: Iterable[str]) -> None: + scope = _current_file_access_scope.get() + if scope is None: + return + + granted_segment_ids = frozenset(str(segment_id) for segment_id in segment_ids if segment_id) + if not granted_segment_ids: + return + + _current_file_access_scope.set( + replace( + scope, + granted_retriever_segment_ids=scope.granted_retriever_segment_ids | granted_segment_ids, + ) + ) + + +def is_retriever_segment_access_granted(segment_id: str) -> bool: + scope = _current_file_access_scope.get() + if scope is None or not scope.requires_user_ownership: + return True + return str(segment_id) in scope.granted_retriever_segment_ids + + @contextmanager -def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: # Changed from Iterator[None] +def bind_file_access_scope(scope: FileAccessScope) -> Generator[None, None, None]: token = _current_file_access_scope.set(scope) try: yield diff --git a/api/core/app/llm/__init__.py b/api/core/app/llm/__init__.py index f069bede74..d20a5b2344 100644 --- a/api/core/app/llm/__init__.py +++ b/api/core/app/llm/__init__.py @@ -1,5 +1,15 @@ """LLM-related application services.""" -from .quota import deduct_llm_quota, ensure_llm_quota_available +from .quota import ( + deduct_llm_quota, + deduct_llm_quota_for_model, + ensure_llm_quota_available, + ensure_llm_quota_available_for_model, +) -__all__ = ["deduct_llm_quota", "ensure_llm_quota_available"] +__all__ = [ + "deduct_llm_quota", + "deduct_llm_quota_for_model", + "ensure_llm_quota_available", + "ensure_llm_quota_available_for_model", +] diff --git a/api/core/app/llm/quota.py b/api/core/app/llm/quota.py index b6039e1e4e..5bf3334a7b 100644 --- a/api/core/app/llm/quota.py +++ b/api/core/app/llm/quota.py @@ -1,4 +1,14 @@ -from sqlalchemy import update +"""Tenant-scoped helpers for checking and deducting LLM provider quota. + +System-hosted quota accounting is currently defined only for LLM models. Keep +the public helpers LLM-specific so callers do not carry unused model-type +plumbing, and fail loudly if the deprecated ``ModelInstance`` wrappers are used +with a non-LLM model. +""" + +import warnings + +from sqlalchemy import select from sqlalchemy.orm import sessionmaker from configs import dify_config @@ -6,44 +16,47 @@ from core.entities.model_entities import ModelStatus from core.entities.provider_entities import ProviderQuotaType, QuotaUnit from core.errors.error import QuotaExceededError from core.model_manager import ModelInstance +from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager from extensions.ext_database import db from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.model_runtime.entities.model_entities import ModelType from libs.datetime_utils import naive_utc_now from models.provider import Provider, ProviderType from models.provider_ids import ModelProviderID -def ensure_llm_quota_available(*, model_instance: ModelInstance) -> None: - provider_model_bundle = model_instance.provider_model_bundle - provider_configuration = provider_model_bundle.configuration +def _get_provider_configuration(*, tenant_id: str, provider: str): + """Resolve the tenant-bound provider configuration for quota decisions.""" + provider_manager = create_plugin_provider_manager(tenant_id=tenant_id) + provider_configuration = provider_manager.get_configurations(tenant_id).get(provider) + if provider_configuration is None: + raise ValueError(f"Provider {provider} does not exist.") + return provider_configuration + +def ensure_llm_quota_available_for_model(*, tenant_id: str, provider: str, model: str) -> None: + """Raise when a tenant-bound LLM model is already out of quota.""" + provider_configuration = _get_provider_configuration(tenant_id=tenant_id, provider=provider) if provider_configuration.using_provider_type != ProviderType.SYSTEM: return provider_model = provider_configuration.get_provider_model( - model_type=model_instance.model_type_instance.model_type, - model=model_instance.model_name, + model_type=ModelType.LLM, + model=model, ) if provider_model and provider_model.status == ModelStatus.QUOTA_EXCEEDED: - raise QuotaExceededError(f"Model provider {model_instance.provider} quota exceeded.") + raise QuotaExceededError(f"Model provider {provider} quota exceeded.") -def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None: - provider_model_bundle = model_instance.provider_model_bundle - provider_configuration = provider_model_bundle.configuration - - if provider_configuration.using_provider_type != ProviderType.SYSTEM: - return - - system_configuration = provider_configuration.system_configuration - +def _resolve_llm_used_quota(*, system_configuration, model: str, usage: LLMUsage) -> int | None: + """Compute the quota impact for an LLM invocation under the current quota mode.""" quota_unit = None for quota_configuration in system_configuration.quota_configurations: if quota_configuration.quota_type == system_configuration.current_quota_type: quota_unit = quota_configuration.quota_unit if quota_configuration.quota_limit == -1: - return + return None break @@ -52,42 +65,136 @@ def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LL if quota_unit == QuotaUnit.TOKENS: used_quota = usage.total_tokens elif quota_unit == QuotaUnit.CREDITS: - used_quota = dify_config.get_model_credits(model_instance.model_name) + used_quota = dify_config.get_model_credits(model) else: used_quota = 1 + return used_quota + + +def _deduct_free_llm_quota( + *, + tenant_id: str, + provider: str, + quota_type: ProviderQuotaType, + used_quota: int, +) -> None: + """Deduct FREE provider quota, capping at the limit before reporting exhaustion.""" + quota_exceeded = False + with sessionmaker(bind=db.engine).begin() as session: + provider_record = session.scalar( + select(Provider) + .where( + Provider.tenant_id == tenant_id, + # TODO: Use provider name with prefix after the data migration. + Provider.provider_name == ModelProviderID(provider).provider_name, + Provider.provider_type == ProviderType.SYSTEM.value, + Provider.quota_type == quota_type, + ) + .with_for_update() + ) + if ( + provider_record is None + or provider_record.quota_limit is None + or provider_record.quota_used is None + or provider_record.quota_limit <= provider_record.quota_used + ): + quota_exceeded = True + else: + available_quota = provider_record.quota_limit - provider_record.quota_used + deducted_quota = min(used_quota, available_quota) + provider_record.quota_used += deducted_quota + provider_record.last_used = naive_utc_now() + quota_exceeded = deducted_quota < used_quota + + if quota_exceeded: + raise QuotaExceededError(f"Model provider {provider} quota exceeded.") + + +def _deduct_used_llm_quota(*, tenant_id: str, provider: str, provider_configuration, used_quota: int | None) -> None: + """Apply a resolved LLM quota charge against the current provider quota bucket.""" + if provider_configuration.using_provider_type != ProviderType.SYSTEM: + return + + system_configuration = provider_configuration.system_configuration if used_quota is not None and system_configuration.current_quota_type is not None: match system_configuration.current_quota_type: case ProviderQuotaType.TRIAL: from services.credit_pool_service import CreditPoolService - CreditPoolService.check_and_deduct_credits( + CreditPoolService.deduct_credits_capped( tenant_id=tenant_id, credits_required=used_quota, ) case ProviderQuotaType.PAID: from services.credit_pool_service import CreditPoolService - CreditPoolService.check_and_deduct_credits( + CreditPoolService.deduct_credits_capped( tenant_id=tenant_id, credits_required=used_quota, pool_type="paid", ) case ProviderQuotaType.FREE: - with sessionmaker(bind=db.engine).begin() as session: - stmt = ( - update(Provider) - .where( - Provider.tenant_id == tenant_id, - # TODO: Use provider name with prefix after the data migration. - Provider.provider_name == ModelProviderID(model_instance.provider).provider_name, - Provider.provider_type == ProviderType.SYSTEM.value, - Provider.quota_type == system_configuration.current_quota_type, - Provider.quota_limit > Provider.quota_used, - ) - .values( - quota_used=Provider.quota_used + used_quota, - last_used=naive_utc_now(), - ) - ) - session.execute(stmt) + _deduct_free_llm_quota( + tenant_id=tenant_id, + provider=provider, + quota_type=system_configuration.current_quota_type, + used_quota=used_quota, + ) + case _: + return + + +def deduct_llm_quota_for_model(*, tenant_id: str, provider: str, model: str, usage: LLMUsage) -> None: + """Deduct tenant-bound quota for the resolved LLM model identity.""" + provider_configuration = _get_provider_configuration(tenant_id=tenant_id, provider=provider) + used_quota = _resolve_llm_used_quota( + system_configuration=provider_configuration.system_configuration, + model=model, + usage=usage, + ) + _deduct_used_llm_quota( + tenant_id=tenant_id, + provider=provider, + provider_configuration=provider_configuration, + used_quota=used_quota, + ) + + +def _require_llm_model_instance(model_instance: ModelInstance) -> None: + """Reject deprecated wrapper calls that pass a non-LLM model instance.""" + if model_instance.model_type_instance.model_type != ModelType.LLM: + raise ValueError("LLM quota helpers only support LLM model instances.") + + +def ensure_llm_quota_available(*, model_instance: ModelInstance) -> None: + """Deprecated compatibility wrapper for callers that still pass ModelInstance.""" + warnings.warn( + "ensure_llm_quota_available(model_instance=...) is deprecated; " + "use ensure_llm_quota_available_for_model(...) instead.", + DeprecationWarning, + stacklevel=2, + ) + _require_llm_model_instance(model_instance) + ensure_llm_quota_available_for_model( + tenant_id=model_instance.provider_model_bundle.configuration.tenant_id, + provider=model_instance.provider, + model=model_instance.model_name, + ) + + +def deduct_llm_quota(*, tenant_id: str, model_instance: ModelInstance, usage: LLMUsage) -> None: + """Deprecated compatibility wrapper for callers that still pass ModelInstance.""" + warnings.warn( + "deduct_llm_quota(tenant_id=..., model_instance=..., usage=...) is deprecated; " + "use deduct_llm_quota_for_model(...) instead.", + DeprecationWarning, + stacklevel=2, + ) + _require_llm_model_instance(model_instance) + deduct_llm_quota_for_model( + tenant_id=tenant_id, + provider=model_instance.provider, + model=model_instance.model_name, + usage=usage, + ) diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index e2e07ebaff..171d5ab342 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -140,42 +140,43 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): :return: """ for stream_response in generator: - if isinstance(stream_response, ErrorStreamResponse): - raise stream_response.err - elif isinstance(stream_response, MessageEndStreamResponse): - extras = {"usage": self._task_state.llm_result.usage.model_dump()} - if self._task_state.metadata: - extras["metadata"] = self._task_state.metadata.model_dump() - response: ChatbotAppBlockingResponse | CompletionAppBlockingResponse - if self._conversation_mode == AppMode.COMPLETION: - response = CompletionAppBlockingResponse( - task_id=self._application_generate_entity.task_id, - data=CompletionAppBlockingResponse.Data( - id=self._message_id, - mode=self._conversation_mode, - message_id=self._message_id, - answer=self._task_state.llm_result.message.get_text_content(), - created_at=self._message_created_at, - **extras, - ), - ) - else: - response = ChatbotAppBlockingResponse( - task_id=self._application_generate_entity.task_id, - data=ChatbotAppBlockingResponse.Data( - id=self._message_id, - mode=self._conversation_mode, - conversation_id=self._conversation_id, - message_id=self._message_id, - answer=self._task_state.llm_result.message.get_text_content(), - created_at=self._message_created_at, - **extras, - ), - ) + match stream_response: + case ErrorStreamResponse(): + raise stream_response.err + case MessageEndStreamResponse(): + extras = {"usage": self._task_state.llm_result.usage.model_dump()} + if self._task_state.metadata: + extras["metadata"] = self._task_state.metadata.model_dump() + response: ChatbotAppBlockingResponse | CompletionAppBlockingResponse + if self._conversation_mode == AppMode.COMPLETION: + response = CompletionAppBlockingResponse( + task_id=self._application_generate_entity.task_id, + data=CompletionAppBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + message_id=self._message_id, + answer=self._task_state.llm_result.message.get_text_content(), + created_at=self._message_created_at, + **extras, + ), + ) + else: + response = ChatbotAppBlockingResponse( + task_id=self._application_generate_entity.task_id, + data=ChatbotAppBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + answer=self._task_state.llm_result.message.get_text_content(), + created_at=self._message_created_at, + **extras, + ), + ) - return response - else: - continue + return response + case _: + continue raise RuntimeError("queue listening stopped unexpectedly.") @@ -265,104 +266,107 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): publisher.publish(message) event = message.event - if isinstance(event, QueueErrorEvent): - with sessionmaker(bind=db.engine).begin() as session: - err = self.handle_error(event=event, session=session, message_id=self._message_id) - yield self.error_to_stream_response(err) - break - elif isinstance(event, QueueStopEvent | QueueMessageEndEvent): - if isinstance(event, QueueMessageEndEvent): - if event.llm_result: - self._task_state.llm_result = event.llm_result - else: - self._handle_stop(event) + match event: + case QueueErrorEvent(): + with sessionmaker(bind=db.engine).begin() as session: + err = self.handle_error(event=event, session=session, message_id=self._message_id) + yield self.error_to_stream_response(err) + break + case QueueStopEvent() | QueueMessageEndEvent(): + if isinstance(event, QueueMessageEndEvent): + if event.llm_result: + self._task_state.llm_result = event.llm_result + else: + self._handle_stop(event) - # handle output moderation - output_moderation_answer = self.handle_output_moderation_when_task_finished( - self._task_state.llm_result.message.get_text_content() - ) - if output_moderation_answer: - self._task_state.llm_result.message.content = output_moderation_answer - yield self._message_cycle_manager.message_replace_to_stream_response( - answer=output_moderation_answer + # handle output moderation + output_moderation_answer = self.handle_output_moderation_when_task_finished( + self._task_state.llm_result.message.get_text_content() ) - - with sessionmaker(bind=db.engine).begin() as session: - # Save message - self._save_message(session=session, trace_manager=trace_manager) - message_end_resp = self._message_end_to_stream_response() - yield message_end_resp - elif isinstance(event, QueueRetrieverResourcesEvent): - self._message_cycle_manager.handle_retriever_resources(event) - elif isinstance(event, QueueAnnotationReplyEvent): - annotation = self._message_cycle_manager.handle_annotation_reply(event) - if annotation: - self._task_state.llm_result.message.content = annotation.content - elif isinstance(event, QueueAgentThoughtEvent): - agent_thought_response = self._agent_thought_to_stream_response(event) - if agent_thought_response is not None: - yield agent_thought_response - elif isinstance(event, QueueMessageFileEvent): - response = self._message_cycle_manager.message_file_to_stream_response(event) - if response: - yield response - elif isinstance(event, QueueLLMChunkEvent | QueueAgentMessageEvent): - chunk = event.chunk - delta_text = chunk.delta.message.content - if delta_text is None: - continue - if isinstance(chunk.delta.message.content, list): - delta_text = "" - for content in chunk.delta.message.content: - logger.debug( - "The content type %s in LLM chunk delta message content.: %r", type(content), content + if output_moderation_answer: + self._task_state.llm_result.message.content = output_moderation_answer + yield self._message_cycle_manager.message_replace_to_stream_response( + answer=output_moderation_answer ) - if isinstance(content, TextPromptMessageContent): - delta_text += content.data - elif isinstance(content, str): - delta_text += content # failback to str - else: - logger.warning( - "Unsupported content type %s in LLM chunk delta message content.: %r", - type(content), - content, + + with sessionmaker(bind=db.engine).begin() as session: + # Save message + self._save_message(session=session, trace_manager=trace_manager) + message_end_resp = self._message_end_to_stream_response() + yield message_end_resp + case QueueRetrieverResourcesEvent(): + self._message_cycle_manager.handle_retriever_resources(event) + case QueueAnnotationReplyEvent(): + annotation = self._message_cycle_manager.handle_annotation_reply(event) + if annotation: + self._task_state.llm_result.message.content = annotation.content + case QueueAgentThoughtEvent(): + agent_thought_response = self._agent_thought_to_stream_response(event) + if agent_thought_response is not None: + yield agent_thought_response + case QueueMessageFileEvent(): + response = self._message_cycle_manager.message_file_to_stream_response(event) + if response: + yield response + case QueueLLMChunkEvent() | QueueAgentMessageEvent(): + chunk = event.chunk + delta_text = chunk.delta.message.content + if delta_text is None: + continue + if isinstance(chunk.delta.message.content, list): + delta_text = "" + for content in chunk.delta.message.content: + logger.debug( + "The content type %s in LLM chunk delta message content.: %r", type(content), content ) - continue + match content: + case TextPromptMessageContent(): + delta_text += content.data + case str(): + delta_text += content # failback to str + case _: + logger.warning( + "Unsupported content type %s in LLM chunk delta message content.: %r", + type(content), + content, + ) + continue - if not self._task_state.llm_result.prompt_messages: - self._task_state.llm_result.prompt_messages = chunk.prompt_messages + if not self._task_state.llm_result.prompt_messages: + self._task_state.llm_result.prompt_messages = chunk.prompt_messages - # handle output moderation chunk - should_direct_answer = self._handle_output_moderation_chunk(cast(str, delta_text)) - if should_direct_answer: + # handle output moderation chunk + should_direct_answer = self._handle_output_moderation_chunk(cast(str, delta_text)) + if should_direct_answer: + continue + + current_content = cast(str, self._task_state.llm_result.message.content) + current_content += cast(str, delta_text) + self._task_state.llm_result.message.content = current_content + + match event: + case QueueLLMChunkEvent(): + # Determine the event type once, on first LLM chunk, and reuse for subsequent chunks + if not hasattr(self, "_precomputed_event_type") or self._precomputed_event_type is None: + self._precomputed_event_type = self._message_cycle_manager.get_message_event_type( + message_id=self._message_id + ) + yield self._message_cycle_manager.message_to_stream_response( + answer=cast(str, delta_text), + message_id=self._message_id, + event_type=self._precomputed_event_type, + ) + case _: + yield self._agent_message_to_stream_response( + answer=cast(str, delta_text), + message_id=self._message_id, + ) + case QueueMessageReplaceEvent(): + yield self._message_cycle_manager.message_replace_to_stream_response(answer=event.text) + case QueuePingEvent(): + yield self.ping_stream_response() + case _: continue - - current_content = cast(str, self._task_state.llm_result.message.content) - current_content += cast(str, delta_text) - self._task_state.llm_result.message.content = current_content - - if isinstance(event, QueueLLMChunkEvent): - # Determine the event type once, on first LLM chunk, and reuse for subsequent chunks - if not hasattr(self, "_precomputed_event_type") or self._precomputed_event_type is None: - self._precomputed_event_type = self._message_cycle_manager.get_message_event_type( - message_id=self._message_id - ) - yield self._message_cycle_manager.message_to_stream_response( - answer=cast(str, delta_text), - message_id=self._message_id, - event_type=self._precomputed_event_type, - ) - else: - yield self._agent_message_to_stream_response( - answer=cast(str, delta_text), - message_id=self._message_id, - ) - elif isinstance(event, QueueMessageReplaceEvent): - yield self._message_cycle_manager.message_replace_to_stream_response(answer=event.text) - elif isinstance(event, QueuePingEvent): - yield self.ping_stream_response() - else: - continue if publisher: publisher.publish(None) if self._conversation_name_generate_thread: diff --git a/api/core/app/workflow/file_runtime.py b/api/core/app/workflow/file_runtime.py index 3a6f9d575a..587f700286 100644 --- a/api/core/app/workflow/file_runtime.py +++ b/api/core/app/workflow/file_runtime.py @@ -128,7 +128,7 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol): @staticmethod def _secret_key() -> bytes: - return dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + return dify_config.SECRET_KEY.encode() def _sign_query(self, *, payload: str) -> dict[str, str]: timestamp = str(int(time.time())) diff --git a/api/core/app/workflow/layers/llm_quota.py b/api/core/app/workflow/layers/llm_quota.py index 4a7918032e..2422eed5a7 100644 --- a/api/core/app/workflow/layers/llm_quota.py +++ b/api/core/app/workflow/layers/llm_quota.py @@ -1,36 +1,48 @@ """ LLM quota deduction layer for GraphEngine. -This layer centralizes model-quota deduction outside node implementations. +This layer centralizes model-quota handling outside node implementations. + +Graphon LLM-backed nodes expose provider/model identity through public node +configuration and, after execution, through ``node_run_result.inputs``. Resolve +quota billing from that public identity instead of depending on +``ModelInstance`` reconstruction inside the workflow layer. Missing identity on +quota-tracked nodes is treated as a workflow bug and aborts execution so quota +handling is never silently skipped. """ import logging -from typing import TYPE_CHECKING, cast, final, override +from typing import final, override -from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.app.llm import deduct_llm_quota, ensure_llm_quota_available +from core.app.llm import deduct_llm_quota_for_model, ensure_llm_quota_available_for_model from core.errors.error import QuotaExceededError -from core.model_manager import ModelInstance -from graphon.enums import BuiltinNodeTypes +from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.graph_engine.entities.commands import AbortCommand, CommandType from graphon.graph_engine.layers import GraphEngineLayer from graphon.graph_events import GraphEngineEvent, GraphNodeEventBase, NodeRunSucceededEvent +from graphon.node_events import NodeRunResult from graphon.nodes.base.node import Node -if TYPE_CHECKING: - from graphon.nodes.llm.node import LLMNode - from graphon.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode - from graphon.nodes.question_classifier.question_classifier_node import QuestionClassifierNode - logger = logging.getLogger(__name__) +_QUOTA_NODE_TYPES = frozenset( + [ + BuiltinNodeTypes.LLM, + BuiltinNodeTypes.PARAMETER_EXTRACTOR, + BuiltinNodeTypes.QUESTION_CLASSIFIER, + ] +) @final class LLMQuotaLayer(GraphEngineLayer): - """Graph layer that applies LLM quota deduction after node execution.""" + """Graph layer that applies tenant-scoped quota checks to LLM-backed nodes.""" - def __init__(self) -> None: + tenant_id: str + _abort_sent: bool + + def __init__(self, tenant_id: str) -> None: super().__init__() + self.tenant_id = tenant_id self._abort_sent = False @override @@ -50,33 +62,49 @@ class LLMQuotaLayer(GraphEngineLayer): if self._abort_sent: return - model_instance = self._extract_model_instance(node) - if model_instance is None: + if not self._supports_quota(node): return + model_identity = self._extract_model_identity_from_node(node) + if model_identity is None: + reason = "LLM quota check requires public node model identity before execution." + self._abort_before_node_run(node=node, reason=reason, error_type="LLMQuotaIdentityError") + logger.error("LLM quota handling aborted, node_id=%s, reason=%s", node.id, reason) + return + + provider, model_name = model_identity try: - ensure_llm_quota_available(model_instance=model_instance) + ensure_llm_quota_available_for_model( + tenant_id=self.tenant_id, + provider=provider, + model=model_name, + ) except QuotaExceededError as exc: - self._set_stop_event(node) - self._send_abort_command(reason=str(exc)) + self._abort_before_node_run(node=node, reason=str(exc), error_type=QuotaExceededError.__name__) logger.warning("LLM quota check failed, node_id=%s, error=%s", node.id, exc) @override def on_node_run_end( self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None ) -> None: - if error is not None or not isinstance(result_event, NodeRunSucceededEvent): + if error is not None or not isinstance(result_event, NodeRunSucceededEvent) or not self._supports_quota(node): return - model_instance = self._extract_model_instance(node) - if model_instance is None: + model_identity = self._extract_model_identity_from_result_event(result_event) + if model_identity is None: + self._abort_for_missing_model_identity( + node=node, + reason="LLM quota deduction requires model identity in the node result event.", + ) return + provider, model_name = model_identity + try: - dify_ctx = DifyRunContext.model_validate(node.require_run_context_value(DIFY_RUN_CONTEXT_KEY)) - deduct_llm_quota( - tenant_id=dify_ctx.tenant_id, - model_instance=model_instance, + deduct_llm_quota_for_model( + tenant_id=self.tenant_id, + provider=provider, + model=model_name, usage=result_event.node_run_result.llm_usage, ) except QuotaExceededError as exc: @@ -92,6 +120,27 @@ class LLMQuotaLayer(GraphEngineLayer): if stop_event is not None: stop_event.set() + def _abort_before_node_run(self, *, node: Node, reason: str, error_type: str) -> None: + self._set_stop_event(node) + node.node_data.error_strategy = None + node.node_data.retry_config.retry_enabled = False + + def quota_aborted_run() -> NodeRunResult: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=reason, + error_type=error_type, + ) + + # TODO: Push Graphon to expose a public pre-run failure/skip hook, then replace this private _run override. + node._run = quota_aborted_run # type: ignore[method-assign] + self._send_abort_command(reason=reason) + + def _abort_for_missing_model_identity(self, *, node: Node, reason: str) -> None: + self._set_stop_event(node) + self._send_abort_command(reason=reason) + logger.error("LLM quota handling aborted, node_id=%s, reason=%s", node.id, reason) + def _send_abort_command(self, *, reason: str) -> None: if not self.command_channel or self._abort_sent: return @@ -108,29 +157,38 @@ class LLMQuotaLayer(GraphEngineLayer): logger.exception("Failed to send quota abort command") @staticmethod - def _extract_model_instance(node: Node) -> ModelInstance | None: - try: - match node.node_type: - case BuiltinNodeTypes.LLM: - model_instance = cast("LLMNode", node).model_instance - case BuiltinNodeTypes.PARAMETER_EXTRACTOR: - model_instance = cast("ParameterExtractorNode", node).model_instance - case BuiltinNodeTypes.QUESTION_CLASSIFIER: - model_instance = cast("QuestionClassifierNode", node).model_instance - case _: - return None - except AttributeError: + def _supports_quota(node: Node) -> bool: + return node.node_type in _QUOTA_NODE_TYPES + + @staticmethod + def _extract_model_identity_from_result_event(result_event: NodeRunSucceededEvent) -> tuple[str, str] | None: + provider = result_event.node_run_result.inputs.get("model_provider") + model_name = result_event.node_run_result.inputs.get("model_name") + if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name: + return provider, model_name + return None + + @staticmethod + def _extract_model_identity_from_node(node: Node) -> tuple[str, str] | None: + node_data = getattr(node, "node_data", None) + if node_data is None: + node_data = getattr(node, "data", None) + + model_config = getattr(node_data, "model", None) + if model_config is None: logger.warning( - "LLMQuotaLayer skipped quota deduction because node does not expose a model instance, node_id=%s", + "LLMQuotaLayer skipped quota handling because node model config is missing, node_id=%s", node.id, ) return None - if isinstance(model_instance, ModelInstance): - return model_instance - - raw_model_instance = getattr(model_instance, "_model_instance", None) - if isinstance(raw_model_instance, ModelInstance): - return raw_model_instance + provider = getattr(model_config, "provider", None) + model_name = getattr(model_config, "name", None) + if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name: + return provider, model_name + logger.warning( + "LLMQuotaLayer skipped quota handling because node model identity is invalid, node_id=%s", + node.id, + ) return None diff --git a/api/core/app/workflow/layers/persistence.py b/api/core/app/workflow/layers/persistence.py index d521304615..c5dba65232 100644 --- a/api/core/app/workflow/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -15,6 +15,7 @@ from datetime import datetime from typing import Any, Union from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity +from core.helper.trace_id_helper import ParentTraceContext from core.ops.entities.trace_entity import TraceTaskName from core.ops.ops_trace_manager import TraceQueueManager, TraceTask from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -104,52 +105,31 @@ class WorkflowPersistenceLayer(GraphEngineLayer): self._node_sequence = 0 def on_event(self, event: GraphEngineEvent) -> None: - if isinstance(event, GraphRunStartedEvent): - self._handle_graph_run_started() - return - - if isinstance(event, GraphRunSucceededEvent): - self._handle_graph_run_succeeded(event) - return - - if isinstance(event, GraphRunPartialSucceededEvent): - self._handle_graph_run_partial_succeeded(event) - return - - if isinstance(event, GraphRunFailedEvent): - self._handle_graph_run_failed(event) - return - - if isinstance(event, GraphRunAbortedEvent): - self._handle_graph_run_aborted(event) - return - - if isinstance(event, GraphRunPausedEvent): - self._handle_graph_run_paused(event) - return - - if isinstance(event, NodeRunRetryEvent): - self._handle_node_retry(event) - return - - if isinstance(event, NodeRunStartedEvent): - self._handle_node_started(event) - return - - if isinstance(event, NodeRunSucceededEvent): - self._handle_node_succeeded(event) - return - - if isinstance(event, NodeRunFailedEvent): - self._handle_node_failed(event) - return - - if isinstance(event, NodeRunExceptionEvent): - self._handle_node_exception(event) - return - - if isinstance(event, NodeRunPauseRequestedEvent): - self._handle_node_pause_requested(event) + match event: + case GraphRunStartedEvent(): + self._handle_graph_run_started() + case GraphRunSucceededEvent(): + self._handle_graph_run_succeeded(event) + case GraphRunPartialSucceededEvent(): + self._handle_graph_run_partial_succeeded(event) + case GraphRunFailedEvent(): + self._handle_graph_run_failed(event) + case GraphRunAbortedEvent(): + self._handle_graph_run_aborted(event) + case GraphRunPausedEvent(): + self._handle_graph_run_paused(event) + case NodeRunRetryEvent(): + self._handle_node_retry(event) + case NodeRunStartedEvent(): + self._handle_node_started(event) + case NodeRunSucceededEvent(): + self._handle_node_succeeded(event) + case NodeRunFailedEvent(): + self._handle_node_failed(event) + case NodeRunExceptionEvent(): + self._handle_node_exception(event) + case NodeRunPauseRequestedEvent(): + self._handle_node_pause_requested(event) def on_graph_end(self, error: Exception | None) -> None: return @@ -403,8 +383,13 @@ class WorkflowPersistenceLayer(GraphEngineLayer): conversation_id = self._system_variables().get(SystemVariableKey.CONVERSATION_ID.value) external_trace_id = None + parent_trace_context = None if isinstance(self._application_generate_entity, (WorkflowAppGenerateEntity, AdvancedChatAppGenerateEntity)): - external_trace_id = self._application_generate_entity.extras.get("external_trace_id") + extras = self._application_generate_entity.extras + external_trace_id = extras.get("external_trace_id") + parent_trace_context = extras.get("parent_trace_context") + if isinstance(parent_trace_context, ParentTraceContext): + parent_trace_context = parent_trace_context.model_dump(exclude_none=True) trace_task = TraceTask( TraceTaskName.WORKFLOW_TRACE, @@ -412,6 +397,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): conversation_id=conversation_id, user_id=self._trace_manager.user_id, external_trace_id=external_trace_id, + parent_trace_context=parent_trace_context, ) self._trace_manager.add_trace_task(trace_task) diff --git a/api/core/base/tts/app_generator_tts_publisher.py b/api/core/base/tts/app_generator_tts_publisher.py index 9e3c187210..7002b1a470 100644 --- a/api/core/base/tts/app_generator_tts_publisher.py +++ b/api/core/base/tts/app_generator_tts_publisher.py @@ -95,13 +95,14 @@ class AppGeneratorTTSPublisher: message_content = message.event.chunk.delta.message.content if not message_content: continue - if isinstance(message_content, str): - self.msg_text += message_content - elif isinstance(message_content, list): - for content in message_content: - if not isinstance(content, TextPromptMessageContent): - continue - self.msg_text += content.data + match message_content: + case str(): + self.msg_text += message_content + case list(): + for content in message_content: + if not isinstance(content, TextPromptMessageContent): + continue + self.msg_text += content.data elif isinstance(message.event, QueueTextChunkEvent): self.msg_text += message.event.text elif isinstance(message.event, QueueNodeSucceededEvent): diff --git a/api/core/datasource/datasource_file_manager.py b/api/core/datasource/datasource_file_manager.py index 492b507aa9..79b84a28be 100644 --- a/api/core/datasource/datasource_file_manager.py +++ b/api/core/datasource/datasource_file_manager.py @@ -35,8 +35,11 @@ class DatasourceFileManager: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -47,8 +50,11 @@ class DatasourceFileManager: verify signature """ data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py index 04ae193396..f11c670069 100644 --- a/api/core/entities/execution_extra_content.py +++ b/api/core/entities/execution_extra_content.py @@ -5,7 +5,7 @@ from typing import Any, TypeAlias from pydantic import BaseModel, ConfigDict, Field -from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.entities import FormInputConfig, UserActionConfig from models.execution_extra_content import ExecutionContentType @@ -16,8 +16,8 @@ class HumanInputFormDefinition(BaseModel): node_id: str node_title: str form_content: str - inputs: Sequence[FormInput] = Field(default_factory=list) - actions: Sequence[UserAction] = Field(default_factory=list) + inputs: Sequence[FormInputConfig] = Field(default_factory=list) + actions: Sequence[UserActionConfig] = Field(default_factory=list) display_in_ui: bool = False form_token: str | None = None resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index 38b87e2cd1..453dd41957 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -23,7 +23,7 @@ from core.entities.provider_entities import ( ) from core.helper import encrypter from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType -from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory +from core.plugin.impl.model_runtime_factory import create_model_type_instance, create_plugin_model_assembly from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ( ConfigurateMethod, @@ -33,7 +33,7 @@ from graphon.model_runtime.entities.provider_entities import ( ) from graphon.model_runtime.model_providers.base.ai_model import AIModel from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory -from graphon.model_runtime.runtime import ModelRuntime +from graphon.model_runtime.protocols.runtime import ModelRuntime from libs.datetime_utils import naive_utc_now from models.engine import db from models.enums import CredentialSourceType @@ -106,11 +106,18 @@ class ProviderConfiguration(BaseModel): """Attach the already-composed runtime for request-bound call chains.""" self._bound_model_runtime = model_runtime + def _get_runtime_and_provider_factory(self) -> tuple[ModelRuntime, ModelProviderFactory]: + """Resolve a provider factory that stays aligned with the runtime used by the caller.""" + if self._bound_model_runtime is not None: + return self._bound_model_runtime, ModelProviderFactory(runtime=self._bound_model_runtime) + + model_assembly = create_plugin_model_assembly(tenant_id=self.tenant_id) + return model_assembly.model_runtime, model_assembly.model_provider_factory + def get_model_provider_factory(self) -> ModelProviderFactory: """Return a provider factory that preserves any request-bound runtime.""" - if self._bound_model_runtime is not None: - return ModelProviderFactory(model_runtime=self._bound_model_runtime) - return create_plugin_model_provider_factory(tenant_id=self.tenant_id) + _, model_provider_factory = self._get_runtime_and_provider_factory() + return model_provider_factory def get_current_credentials(self, model_type: ModelType, model: str) -> dict[str, Any] | None: """ @@ -164,9 +171,9 @@ class ProviderConfiguration(BaseModel): current_credential_id = self.custom_configuration.provider.current_credential_id if current_credential_id: - from core.helper.credential_utils import check_credential_policy_compliance + from core.helper.credential_utils import runtime_check_credential_policy_compliance - check_credential_policy_compliance( + runtime_check_credential_policy_compliance( credential_id=current_credential_id, provider=self.provider.provider, credential_type=PluginCredentialType.MODEL, @@ -175,9 +182,9 @@ class ProviderConfiguration(BaseModel): # no current credential id, check all available credentials if self.custom_configuration.provider: for credential_configuration in self.custom_configuration.provider.available_credentials: - from core.helper.credential_utils import check_credential_policy_compliance + from core.helper.credential_utils import runtime_check_credential_policy_compliance - check_credential_policy_compliance( + runtime_check_credential_policy_compliance( credential_id=credential_configuration.credential_id, provider=self.provider.provider, credential_type=PluginCredentialType.MODEL, @@ -1392,10 +1399,13 @@ class ProviderConfiguration(BaseModel): :param model_type: model type :return: """ - model_provider_factory = self.get_model_provider_factory() - - # Get model instance of LLM - return model_provider_factory.get_model_type_instance(provider=self.provider.provider, model_type=model_type) + model_runtime, model_provider_factory = self._get_runtime_and_provider_factory() + provider_schema = model_provider_factory.get_provider_schema(provider=self.provider.provider) + return create_model_type_instance( + runtime=model_runtime, + provider_schema=provider_schema, + model_type=model_type, + ) def get_model_schema( self, model_type: ModelType, model: str, credentials: dict[str, Any] | None diff --git a/api/core/errors/error.py b/api/core/errors/error.py index 8c1ba98ae1..826a5ebae5 100644 --- a/api/core/errors/error.py +++ b/api/core/errors/error.py @@ -1,9 +1,9 @@ class LLMError(ValueError): """Base class for all LLM exceptions.""" - description: str | None = None + description: str = "" - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): self.description = description diff --git a/api/core/helper/credential_utils.py b/api/core/helper/credential_utils.py index 882639a16a..b5f68039d7 100644 --- a/api/core/helper/credential_utils.py +++ b/api/core/helper/credential_utils.py @@ -2,6 +2,7 @@ Credential utility functions for checking credential existence and policy compliance. """ +from configs import dify_config from core.entities import PluginCredentialType @@ -39,6 +40,16 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT return False +def runtime_check_credential_policy_compliance( + credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True +): + if dify_config.ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK: + return + check_credential_policy_compliance( + credential_id=credential_id, provider=provider, credential_type=credential_type, check_existence=check_existence + ) + + def check_credential_policy_compliance( credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True ) -> None: diff --git a/api/core/helper/moderation.py b/api/core/helper/moderation.py index f169f247cf..18b9b72e9d 100644 --- a/api/core/helper/moderation.py +++ b/api/core/helper/moderation.py @@ -4,7 +4,7 @@ from typing import cast from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.entities import DEFAULT_PLUGIN_ID -from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory +from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from extensions.ext_hosting_provider import hosting_configuration from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.invoke import InvokeBadRequestError @@ -41,10 +41,8 @@ def check_moderation(tenant_id: str, model_config: ModelConfigWithCredentialsEnt text_chunk = secrets.choice(text_chunks) try: - model_provider_factory = create_plugin_model_provider_factory(tenant_id=tenant_id) - - # Get model instance of LLM - model_type_instance = model_provider_factory.get_model_type_instance( + model_assembly = create_plugin_model_assembly(tenant_id=tenant_id) + model_type_instance = model_assembly.create_model_type_instance( provider=openai_provider_name, model_type=ModelType.MODERATION ) model_type_instance = cast(ModerationModel, model_type_instance) diff --git a/api/core/helper/trace_id_helper.py b/api/core/helper/trace_id_helper.py index e827859109..e4890c8d4d 100644 --- a/api/core/helper/trace_id_helper.py +++ b/api/core/helper/trace_id_helper.py @@ -3,6 +3,17 @@ import re from collections.abc import Mapping from typing import Any +from pydantic import BaseModel, ConfigDict, StrictStr, ValidationError + + +class ParentTraceContext(BaseModel): + """Typed parent trace context propagated from an outer workflow tool node.""" + + parent_workflow_run_id: StrictStr + parent_node_execution_id: StrictStr | None = None + + model_config = ConfigDict(extra="forbid") + def is_valid_trace_id(trace_id: str) -> bool: """ @@ -61,6 +72,30 @@ def extract_external_trace_id_from_args(args: Mapping[str, Any]): return {} +def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str, ParentTraceContext]: + """ + Extract 'parent_trace_context' from args. + + Returns a dict suitable for use in extras when both parent identifiers exist. + Returns an empty dict if the context is missing or incomplete. + """ + parent_trace_context = args.get("parent_trace_context") + if isinstance(parent_trace_context, ParentTraceContext): + context = parent_trace_context + elif isinstance(parent_trace_context, Mapping): + try: + context = ParentTraceContext.model_validate(parent_trace_context) + except ValidationError: + return {} + else: + return {} + + if context.parent_node_execution_id is None: + return {} + + return {"parent_trace_context": context} + + def get_trace_id_from_otel_context() -> str | None: """ Retrieve the current trace ID from the active OpenTelemetry trace context. diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index b6e33396d1..537b14388e 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -324,9 +324,10 @@ class IndexingRunner: # one extract_setting is one source document for extract_setting in extract_settings: # extract - processing_rule = DatasetProcessRule( - mode=tmp_processing_rule["mode"], rules=json.dumps(tmp_processing_rule["rules"]) - ) + processing_rule = { + "mode": tmp_processing_rule["mode"], + "rules": tmp_processing_rule.get("rules"), + } # Extract document content text_docs = index_processor.extract(extract_setting, process_rule_mode=tmp_processing_rule["mode"]) # Cleaning and segmentation @@ -334,7 +335,7 @@ class IndexingRunner: text_docs, current_user=None, embedding_model_instance=embedding_model_instance, - process_rule=processing_rule.to_dict(), + process_rule=processing_rule, tenant_id=tenant_id, doc_language=doc_language, preview=True, diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index af2611bb0b..30b9523146 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -435,7 +435,7 @@ class LLMGenerator: stream=False, ) - # Runtime type check since pyright has issues with the overload + # Runtime type check for overload narrowing. if not isinstance(result, LLMResult): raise TypeError("Expected LLMResult when stream=False") response = result diff --git a/api/core/llm_generator/output_parser/structured_output.py b/api/core/llm_generator/output_parser/structured_output.py index d2e375626f..6cba4fbdf6 100644 --- a/api/core/llm_generator/output_parser/structured_output.py +++ b/api/core/llm_generator/output_parser/structured_output.py @@ -288,11 +288,13 @@ def _parse_structured_output(result_text: str) -> Mapping[str, Any]: except ValidationError: # if the result_text is not a valid json, try to repair it temp_parsed = json_repair.loads(result_text) - if not isinstance(temp_parsed, dict): - # handle reasoning model like deepseek-r1 got '\n\n\n' prefix - if isinstance(temp_parsed, list): + match temp_parsed: + case dict(): + pass + case list(): + # handle reasoning model like deepseek-r1 got '\n\n\n' prefix temp_parsed = next((item for item in temp_parsed if isinstance(item, dict)), {}) - else: + case _: raise OutputParserError(f"Failed to parse structured output: {result_text}") structured_output = cast(dict, temp_parsed) return structured_output @@ -341,12 +343,13 @@ def remove_additional_properties(schema: dict[str, Any]) -> None: # Process nested structures recursively for value in schema.values(): - if isinstance(value, dict): - remove_additional_properties(value) - elif isinstance(value, list): - for item in value: - if isinstance(item, dict): - remove_additional_properties(item) + match value: + case dict(): + remove_additional_properties(value) + case list(): + for item in value: + if isinstance(item, dict): + remove_additional_properties(item) def convert_boolean_to_string(schema: dict[str, Any]) -> None: @@ -364,9 +367,10 @@ def convert_boolean_to_string(schema: dict[str, Any]) -> None: # Process nested dictionaries and lists recursively for value in schema.values(): - if isinstance(value, dict): - convert_boolean_to_string(value) - elif isinstance(value, list): - for item in value: - if isinstance(item, dict): - convert_boolean_to_string(item) + match value: + case dict(): + convert_boolean_to_string(value) + case list(): + for item in value: + if isinstance(item, dict): + convert_boolean_to_string(item) diff --git a/api/core/mcp/types.py b/api/core/mcp/types.py index 10e3082aa3..9470d39f41 100644 --- a/api/core/mcp/types.py +++ b/api/core/mcp/types.py @@ -2,7 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel +from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel, model_validator from pydantic.networks import AnyUrl, UrlConstraints """ @@ -173,7 +173,21 @@ class JSONRPCError(BaseModel): class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]): - pass + @model_validator(mode="before") + @classmethod + def _select_message_type( + cls, value: Any + ) -> JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError | Any: + if isinstance(value, dict): + if "result" in value: + return JSONRPCResponse.model_validate(value) + if "error" in value: + return JSONRPCError.model_validate(value) + if "method" in value: + if "id" in value: + return JSONRPCRequest.model_validate(value) + return JSONRPCNotification.model_validate(value) + return value class EmptyResult(Result): diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index d840ee213c..c41c175cca 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -86,12 +86,10 @@ class TokenBufferMemory: detail = ImagePromptMessageContent.DETAIL.HIGH if file_extra_config and app_record: - # Build files directly without filtering by belongs_to file_objs = [ file_factory.build_from_message_file( message_file=message_file, tenant_id=app_record.tenant_id, - config=file_extra_config, access_controller=_file_access_controller, ) for message_file in message_files diff --git a/api/core/model_manager.py b/api/core/model_manager.py index 457c888e33..56a8f3bd98 100644 --- a/api/core/model_manager.py +++ b/api/core/model_manager.py @@ -391,10 +391,10 @@ class ModelInstance: # Additional policy compliance check as fallback (in case fetch_next didn't catch it) try: - from core.helper.credential_utils import check_credential_policy_compliance + from core.helper.credential_utils import runtime_check_credential_policy_compliance if lb_config.credential_id: - check_credential_policy_compliance( + runtime_check_credential_policy_compliance( credential_id=lb_config.credential_id, provider=self.provider, credential_type=PluginCredentialType.MODEL, @@ -630,10 +630,10 @@ class LBModelManager: # Check policy compliance for the selected configuration try: - from core.helper.credential_utils import check_credential_policy_compliance + from core.helper.credential_utils import runtime_check_credential_policy_compliance if config.credential_id: - check_credential_policy_compliance( + runtime_check_credential_policy_compliance( credential_id=config.credential_id, provider=self._provider, credential_type=PluginCredentialType.MODEL, diff --git a/api/core/ops/entities/trace_entity.py b/api/core/ops/entities/trace_entity.py index 45b2f635ba..98e87a0ceb 100644 --- a/api/core/ops/entities/trace_entity.py +++ b/api/core/ops/entities/trace_entity.py @@ -5,6 +5,8 @@ from typing import Any, Union from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from core.helper.trace_id_helper import ParentTraceContext + class BaseTraceInfo(BaseModel): message_id: str | None = None @@ -51,8 +53,8 @@ class BaseTraceInfo(BaseModel): def resolved_parent_context(self) -> tuple[str | None, str | None]: """Resolve cross-workflow parent linking from metadata. - Extracts typed parent IDs from the untyped ``parent_trace_context`` - metadata dict (set by tool_node when invoking nested workflows). + Extracts typed parent IDs from the ``parent_trace_context`` metadata + payload (set by tool_node when invoking nested workflows). Returns: (trace_correlation_override, parent_span_id_source) where @@ -60,13 +62,18 @@ class BaseTraceInfo(BaseModel): parent_span_id_source is the outer node_execution_id. """ parent_ctx = self.metadata.get("parent_trace_context") - if not isinstance(parent_ctx, dict): + if isinstance(parent_ctx, ParentTraceContext): + context = parent_ctx + elif isinstance(parent_ctx, Mapping): + try: + context = ParentTraceContext.model_validate(parent_ctx) + except ValueError: + return None, None + else: return None, None - trace_override = parent_ctx.get("parent_workflow_run_id") - parent_span = parent_ctx.get("parent_node_execution_id") return ( - trace_override if isinstance(trace_override, str) else None, - parent_span if isinstance(parent_span, str) else None, + context.parent_workflow_run_id, + context.parent_node_execution_id, ) @field_serializer("start_time", "end_time") diff --git a/api/core/ops/exceptions.py b/api/core/ops/exceptions.py new file mode 100644 index 0000000000..4551704687 --- /dev/null +++ b/api/core/ops/exceptions.py @@ -0,0 +1,22 @@ +"""Core exceptions shared by ops trace dispatchers and trace providers. + +Provider packages may raise these types to request generic task behavior, but +generic Celery tasks should not import provider-specific exception classes. +""" + + +class RetryableTraceDispatchError(RuntimeError): + """Base class for transient trace dispatch failures that Celery may retry.""" + + +class PendingTraceParentContextError(RetryableTraceDispatchError): + """Raised when a nested trace arrives before its parent span context is available.""" + + parent_node_execution_id: str + + def __init__(self, parent_node_execution_id: str) -> None: + self.parent_node_execution_id = parent_node_execution_id + super().__init__( + "Pending trace parent context for parent_node_execution_id=" + f"{parent_node_execution_id}. Retry after the parent span context is published." + ) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index e7ba6e502b..61fd0e5c1f 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -16,6 +16,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token +from core.helper.trace_id_helper import ParentTraceContext from core.ops.entities.config_entity import ( OPS_FILE_PATH, BaseTracingConfig, @@ -52,6 +53,17 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _dump_parent_trace_context(parent_trace_context: Any) -> dict[str, str] | None: + if isinstance(parent_trace_context, ParentTraceContext): + return parent_trace_context.model_dump(exclude_none=True) + if isinstance(parent_trace_context, dict): + try: + return ParentTraceContext.model_validate(parent_trace_context).model_dump(exclude_none=True) + except ValueError: + return None + return None + + class _AppTracingConfig(TypedDict, total=False): enabled: bool tracing_provider: str | None @@ -569,13 +581,13 @@ class OpsTraceManager: db.session.commit() @classmethod - def get_app_tracing_config(cls, app_id: str): + def get_app_tracing_config(cls, app_id: str, session: Session): """ Get app tracing config :param app_id: app id :return: """ - app: App | None = db.session.get(App, app_id) + app: App | None = session.get(App, app_id) if not app: raise ValueError("App not found") if not app.tracing: @@ -857,8 +869,9 @@ class TraceTask: } parent_trace_context = self.kwargs.get("parent_trace_context") - if parent_trace_context: - metadata["parent_trace_context"] = parent_trace_context + dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context) + if dumped_parent_trace_context: + metadata["parent_trace_context"] = dumped_parent_trace_context workflow_trace_info = WorkflowTraceInfo( trace_id=self.trace_id, @@ -1371,13 +1384,14 @@ class TraceTask: } parent_trace_context = node_data.get("parent_trace_context") - if parent_trace_context: - metadata["parent_trace_context"] = parent_trace_context + dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context) + if dumped_parent_trace_context: + metadata["parent_trace_context"] = dumped_parent_trace_context message_id: str | None = None conversation_id = node_data.get("conversation_id") workflow_execution_id = node_data.get("workflow_execution_id") - if conversation_id and workflow_execution_id and not parent_trace_context: + if conversation_id and workflow_execution_id and not dumped_parent_trace_context: with Session(db.engine) as session: msg_id = session.scalar( select(Message.id).where( diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index 4e66d58b5e..62573ba2f5 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -4,23 +4,32 @@ import hashlib import logging from collections.abc import Generator, Iterable, Sequence from threading import Lock -from typing import IO, Any, Union +from typing import IO, Any, Literal, cast, overload from pydantic import ValidationError from redis import RedisError from configs import dify_config +from core.llm_generator.output_parser.structured_output import ( + invoke_llm_with_structured_output as invoke_llm_with_structured_output_helper, +) from core.plugin.entities.plugin_daemon import PluginModelProviderEntity from core.plugin.impl.asset import PluginAssetManager from core.plugin.impl.model import PluginModelClient from extensions.ext_redis import redis_client -from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk +from graphon.model_runtime.entities.llm_entities import ( + LLMResult, + LLMResultChunk, + LLMResultChunkWithStructuredOutput, + LLMResultWithStructuredOutput, +) from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType from graphon.model_runtime.entities.provider_entities import ProviderEntity from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingInputType, EmbeddingResult -from graphon.model_runtime.runtime import ModelRuntime +from graphon.model_runtime.model_providers.base.large_language_model import normalize_non_stream_runtime_result +from graphon.model_runtime.protocols.runtime import ModelRuntime from models.provider_ids import ModelProviderID logger = logging.getLogger(__name__) @@ -29,6 +38,68 @@ logger = logging.getLogger(__name__) TENANT_SCOPE_SCHEMA_CACHE_USER_ID = "__DIFY_TS__" +# TODO(-LAN-): Move native structured-output invocation into Graphon's LLM node. +# TODO(-LAN-): Remove this Dify-side adapter once Graphon owns structured output end-to-end. +class _PluginStructuredOutputModelInstance: + """Bind plugin model identity to the shared structured-output helper. + + The structured-output parser is shared with legacy ``ModelInstance`` flows + and only needs an object exposing ``invoke_llm(...)``. ``PluginModelRuntime`` + intentionally exposes a lower-level API where provider, model, and + credentials are passed per call. This adapter supplies the small bound + ``invoke_llm`` surface the helper needs without constructing a full + ``ModelInstance`` or reintroducing model-manager dependencies into the + plugin runtime path. + """ + + def __init__( + self, + *, + runtime: PluginModelRuntime, + provider: str, + model: str, + credentials: dict[str, Any], + ) -> None: + self._runtime = runtime + self._provider = provider + self._model = model + self._credentials = credentials + + def invoke_llm( + self, + *, + prompt_messages: Sequence[PromptMessage], + model_parameters: dict[str, Any] | None = None, + tools: Sequence[PromptMessageTool] | None = None, + stop: Sequence[str] | None = None, + stream: bool = True, + callbacks: object | None = None, + ) -> LLMResult | Generator[LLMResultChunk, None, None]: + del callbacks + if stream: + return self._runtime.invoke_llm( + provider=self._provider, + model=self._model, + credentials=self._credentials, + model_parameters=model_parameters or {}, + prompt_messages=prompt_messages, + tools=list(tools) if tools else None, + stop=stop, + stream=True, + ) + + return self._runtime.invoke_llm( + provider=self._provider, + model=self._model, + credentials=self._credentials, + model_parameters=model_parameters or {}, + prompt_messages=prompt_messages, + tools=list(tools) if tools else None, + stop=stop, + stream=False, + ) + + class PluginModelRuntime(ModelRuntime): """Plugin-backed runtime adapter bound to tenant context and optional caller scope.""" @@ -195,6 +266,34 @@ class PluginModelRuntime(ModelRuntime): return schema + @overload + def invoke_llm( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + tools: list[PromptMessageTool] | None, + stop: Sequence[str] | None, + stream: Literal[False], + ) -> LLMResult: ... + + @overload + def invoke_llm( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + tools: list[PromptMessageTool] | None, + stop: Sequence[str] | None, + stream: Literal[True], + ) -> Generator[LLMResultChunk, None, None]: ... + def invoke_llm( self, *, @@ -206,9 +305,9 @@ class PluginModelRuntime(ModelRuntime): tools: list[PromptMessageTool] | None, stop: Sequence[str] | None, stream: bool, - ) -> Union[LLMResult, Generator[LLMResultChunk, None, None]]: + ) -> LLMResult | Generator[LLMResultChunk, None, None]: plugin_id, provider_name = self._split_provider(provider) - return self.client.invoke_llm( + result = self.client.invoke_llm( tenant_id=self.tenant_id, user_id=self.user_id, plugin_id=plugin_id, @@ -221,6 +320,81 @@ class PluginModelRuntime(ModelRuntime): stop=list(stop) if stop else None, stream=stream, ) + if stream: + return result + + return normalize_non_stream_runtime_result( + model=model, + prompt_messages=prompt_messages, + result=result, + ) + + @overload + def invoke_llm_with_structured_output( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + json_schema: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + stop: Sequence[str] | None, + stream: Literal[False], + ) -> LLMResultWithStructuredOutput: ... + + @overload + def invoke_llm_with_structured_output( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + json_schema: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + stop: Sequence[str] | None, + stream: Literal[True], + ) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ... + + def invoke_llm_with_structured_output( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + json_schema: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + stop: Sequence[str] | None, + stream: bool, + ) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]: + model_schema = self.get_model_schema( + provider=provider, + model_type=ModelType.LLM, + model=model, + credentials=credentials, + ) + if model_schema is None: + raise ValueError(f"Model schema not found for {model}") + + adapter = _PluginStructuredOutputModelInstance( + runtime=self, + provider=provider, + model=model, + credentials=credentials, + ) + return invoke_llm_with_structured_output_helper( + provider=provider, + model_schema=model_schema, + model_instance=cast(Any, adapter), + prompt_messages=prompt_messages, + json_schema=json_schema, + model_parameters=model_parameters, + tools=None, + stop=list(stop) if stop else None, + stream=stream, + ) def get_llm_num_tokens( self, diff --git a/api/core/plugin/impl/model_runtime_factory.py b/api/core/plugin/impl/model_runtime_factory.py index 35abd2ae8c..fbe307ea60 100644 --- a/api/core/plugin/impl/model_runtime_factory.py +++ b/api/core/plugin/impl/model_runtime_factory.py @@ -3,13 +3,46 @@ from __future__ import annotations from typing import TYPE_CHECKING from core.plugin.impl.model import PluginModelClient +from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.provider_entities import ProviderEntity +from graphon.model_runtime.model_providers.base.ai_model import AIModel +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel +from graphon.model_runtime.model_providers.base.moderation_model import ModerationModel +from graphon.model_runtime.model_providers.base.rerank_model import RerankModel +from graphon.model_runtime.model_providers.base.speech2text_model import Speech2TextModel +from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel +from graphon.model_runtime.model_providers.base.tts_model import TTSModel from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory +from graphon.model_runtime.protocols.runtime import ModelRuntime if TYPE_CHECKING: from core.model_manager import ModelManager from core.plugin.impl.model_runtime import PluginModelRuntime from core.provider_manager import ProviderManager +_MODEL_CLASS_BY_TYPE: dict[ModelType, type[AIModel]] = { + ModelType.LLM: LargeLanguageModel, + ModelType.TEXT_EMBEDDING: TextEmbeddingModel, + ModelType.RERANK: RerankModel, + ModelType.SPEECH2TEXT: Speech2TextModel, + ModelType.MODERATION: ModerationModel, + ModelType.TTS: TTSModel, +} + + +def create_model_type_instance( + *, + runtime: ModelRuntime, + provider_schema: ProviderEntity, + model_type: ModelType, +) -> AIModel: + """Build the graphon model wrapper explicitly against the request runtime.""" + model_class = _MODEL_CLASS_BY_TYPE.get(model_type) + if model_class is None: + raise ValueError(f"Unsupported model type: {model_type}") + + return model_class(provider_schema=provider_schema, model_runtime=runtime) + class PluginModelAssembly: """Compose request-scoped model views on top of a single plugin runtime.""" @@ -38,9 +71,22 @@ class PluginModelAssembly: @property def model_provider_factory(self) -> ModelProviderFactory: if self._model_provider_factory is None: - self._model_provider_factory = ModelProviderFactory(model_runtime=self.model_runtime) + self._model_provider_factory = ModelProviderFactory(runtime=self.model_runtime) return self._model_provider_factory + def create_model_type_instance( + self, + *, + provider: str, + model_type: ModelType, + ) -> AIModel: + provider_schema = self.model_provider_factory.get_provider_schema(provider=provider) + return create_model_type_instance( + runtime=self.model_runtime, + provider_schema=provider_schema, + model_type=model_type, + ) + @property def provider_manager(self) -> ProviderManager: if self._provider_manager is None: diff --git a/api/core/prompt/advanced_prompt_transform.py b/api/core/prompt/advanced_prompt_transform.py index 24e05ef865..5a9914e6e4 100644 --- a/api/core/prompt/advanced_prompt_transform.py +++ b/api/core/prompt/advanced_prompt_transform.py @@ -161,35 +161,39 @@ class AdvancedPromptTransform(PromptTransform): prompt_messages: list[PromptMessage] = [] for prompt_item in prompt_template: raw_prompt = prompt_item.text - - if prompt_item.edition_type == "basic" or not prompt_item.edition_type: - if self.with_variable_tmpl: - vp = VariablePool.empty() - for k, v in inputs.items(): - if k.startswith("#"): - vp.add(k[1:-1].split("."), v) - raw_prompt = raw_prompt.replace("{{#context#}}", context or "") - prompt = vp.convert_template(raw_prompt).text - else: - parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl) - prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs} - prompt_inputs = self._set_context_variable( - context=context, parser=parser, prompt_inputs=prompt_inputs - ) - prompt = parser.format(prompt_inputs) - elif prompt_item.edition_type == "jinja2": - prompt = raw_prompt - prompt_inputs = inputs - prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs) - else: - raise ValueError(f"Invalid edition type: {prompt_item.edition_type}") - - if prompt_item.role == PromptMessageRole.USER: - prompt_messages.append(UserPromptMessage(content=prompt)) - elif prompt_item.role == PromptMessageRole.SYSTEM and prompt: - prompt_messages.append(SystemPromptMessage(content=prompt)) - elif prompt_item.role == PromptMessageRole.ASSISTANT: - prompt_messages.append(AssistantPromptMessage(content=prompt)) + edition_type = prompt_item.edition_type or "basic" + match edition_type: + case "basic": + if self.with_variable_tmpl: + vp = VariablePool.empty() + for k, v in inputs.items(): + if k.startswith("#"): + vp.add(k[1:-1].split("."), v) + raw_prompt = raw_prompt.replace("{{#context#}}", context or "") + prompt = vp.convert_template(raw_prompt).text + else: + parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl) + prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs} + prompt_inputs = self._set_context_variable( + context=context, parser=parser, prompt_inputs=prompt_inputs + ) + prompt = parser.format(prompt_inputs) + case "jinja2": + prompt = raw_prompt + prompt_inputs = inputs + prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs) + case _: + raise ValueError(f"Invalid edition type: {prompt_item.edition_type}") + match prompt_item.role: + case PromptMessageRole.USER: + prompt_messages.append(UserPromptMessage(content=prompt)) + case PromptMessageRole.SYSTEM: + if prompt: + prompt_messages.append(SystemPromptMessage(content=prompt)) + case PromptMessageRole.ASSISTANT: + prompt_messages.append(AssistantPromptMessage(content=prompt)) + case PromptMessageRole.TOOL: + pass if query and memory_config and memory_config.query_prompt_template: parser = PromptTemplateParser( diff --git a/api/core/prompt/simple_prompt_transform.py b/api/core/prompt/simple_prompt_transform.py index 1665bdeb52..e836554ca0 100644 --- a/api/core/prompt/simple_prompt_transform.py +++ b/api/core/prompt/simple_prompt_transform.py @@ -123,12 +123,15 @@ class SimplePromptTransform(PromptTransform): for v in special_variable_keys: # support #context#, #query# and #histories# - if v == "#context#": - variables["#context#"] = context or "" - elif v == "#query#": - variables["#query#"] = query or "" - elif v == "#histories#": - variables["#histories#"] = histories or "" + match v: + case "#context#": + variables["#context#"] = context or "" + case "#query#": + variables["#query#"] = query or "" + case "#histories#": + variables["#histories#"] = histories or "" + case _: + pass prompt_template = prompt_template_config["prompt_template"] if not isinstance(prompt_template, PromptTemplateParser): diff --git a/api/core/prompt/utils/prompt_message_util.py b/api/core/prompt/utils/prompt_message_util.py index ba76eb0c4e..11414832e3 100644 --- a/api/core/prompt/utils/prompt_message_util.py +++ b/api/core/prompt/utils/prompt_message_util.py @@ -53,24 +53,27 @@ class PromptMessageUtil: files = [] if isinstance(prompt_message.content, list): for content in prompt_message.content: - if isinstance(content, TextPromptMessageContent): - text += content.data - elif isinstance(content, ImagePromptMessageContent): - files.append( - { - "type": "image", - "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], - "detail": content.detail.value, - } - ) - elif isinstance(content, AudioPromptMessageContent): - files.append( - { - "type": "audio", - "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], - "format": content.format, - } - ) + match content: + case TextPromptMessageContent(): + text += content.data + case ImagePromptMessageContent(): + files.append( + { + "type": "image", + "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], + "detail": content.detail.value, + } + ) + case AudioPromptMessageContent(): + files.append( + { + "type": "audio", + "data": content.data[:10] + "...[TRUNCATED]..." + content.data[-10:], + "format": content.format, + } + ) + case _: + continue else: text = cast(str, prompt_message.content) diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 8969825be4..9faa70a0b8 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any from pydantic import TypeAdapter from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session from configs import dify_config +from core.db.session_factory import session_factory from core.entities.model_entities import DefaultModelEntity, DefaultModelProviderEntity from core.entities.provider_configuration import ProviderConfiguration, ProviderConfigurations, ProviderModelBundle from core.entities.provider_entities import ( @@ -56,7 +56,7 @@ from models.provider_ids import ModelProviderID from services.feature_service import FeatureService if TYPE_CHECKING: - from graphon.model_runtime.runtime import ModelRuntime + from graphon.model_runtime.protocols.runtime import ModelRuntime _credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) @@ -165,7 +165,7 @@ class ProviderManager: ) # Get all provider entities - model_provider_factory = ModelProviderFactory(model_runtime=self._model_runtime) + model_provider_factory = ModelProviderFactory(runtime=self._model_runtime) provider_entities = model_provider_factory.get_providers() # Get All preferred provider types of the workspace @@ -362,7 +362,7 @@ class ProviderManager: if not default_model: return None - model_provider_factory = ModelProviderFactory(model_runtime=self._model_runtime) + model_provider_factory = ModelProviderFactory(runtime=self._model_runtime) provider_schema = model_provider_factory.get_provider_schema(provider=default_model.provider_name) return DefaultModelEntity( @@ -445,7 +445,7 @@ class ProviderManager: @staticmethod def _get_all_providers(tenant_id: str) -> dict[str, list[Provider]]: provider_name_to_provider_records_dict = defaultdict(list) - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True) providers = session.scalars(stmt) for provider in providers: @@ -462,7 +462,7 @@ class ProviderManager: :return: """ provider_name_to_provider_model_records_dict = defaultdict(list) - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True) provider_models = session.scalars(stmt) for provider_model in provider_models: @@ -478,7 +478,7 @@ class ProviderManager: :return: """ provider_name_to_preferred_provider_type_records_dict = {} - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id) preferred_provider_types = session.scalars(stmt) provider_name_to_preferred_provider_type_records_dict = { @@ -496,7 +496,7 @@ class ProviderManager: :return: """ provider_name_to_provider_model_settings_dict = defaultdict(list) - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id) provider_model_settings = session.scalars(stmt) for provider_model_setting in provider_model_settings: @@ -514,7 +514,7 @@ class ProviderManager: :return: """ provider_name_to_provider_model_credentials_dict = defaultdict(list) - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id) provider_model_credentials = session.scalars(stmt) for provider_model_credential in provider_model_credentials: @@ -544,7 +544,7 @@ class ProviderManager: return {} provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list) - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id) provider_load_balancing_configs = session.scalars(stmt) for provider_load_balancing_config in provider_load_balancing_configs: @@ -578,7 +578,7 @@ class ProviderManager: :param provider_name: provider name :return: """ - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = ( select(ProviderCredential) .where( @@ -608,7 +608,7 @@ class ProviderManager: :param model_type: model type :return: """ - with Session(db.engine, expire_on_commit=False) as session: + with session_factory.create_session() as session: stmt = ( select(ProviderModelCredential) .where( diff --git a/api/core/rag/datasource/keyword/jieba/jieba.py b/api/core/rag/datasource/keyword/jieba/jieba.py index 392af351b6..b3f174bf78 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba.py +++ b/api/core/rag/datasource/keyword/jieba/jieba.py @@ -245,6 +245,7 @@ class Jieba(BaseKeyword): segment = pre_segment_data["segment"] if pre_segment_data["keywords"]: segment.keywords = pre_segment_data["keywords"] + assert segment.index_node_id keyword_table = self._add_text_to_keyword_table( keyword_table or {}, segment.index_node_id, pre_segment_data["keywords"] ) @@ -253,6 +254,7 @@ class Jieba(BaseKeyword): keywords = keyword_table_handler.extract_keywords(segment.content, keyword_number) segment.keywords = list(keywords) + assert segment.index_node_id keyword_table = self._add_text_to_keyword_table( keyword_table or {}, segment.index_node_id, list(keywords) ) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index b985ebbe1d..904d5c843f 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -1,5 +1,6 @@ import concurrent.futures import logging +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from typing import Any, NotRequired, TypedDict @@ -8,6 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, load_only from configs import dify_config +from core.app.file_access import grant_upload_file_access from core.db.session_factory import session_factory from core.model_manager import ModelManager from core.rag.data_post_processor.data_post_processor import DataPostProcessor, RerankingModelDict, WeightsDict @@ -21,7 +23,7 @@ from core.rag.index_processor.constant.query_type import QueryType from core.rag.models.document import Document from core.rag.rerank.rerank_type import RerankMode from core.rag.retrieval.retrieval_methods import RetrievalMethod -from core.tools.signature import sign_upload_file +from core.tools.signature import sign_upload_file_preview_url from extensions.ext_database import db from graphon.model_runtime.entities.model_entities import ModelType from models.dataset import ( @@ -526,7 +528,7 @@ class RetrievalService: index_node_ids = [i for i in index_node_ids if i] segment_ids: list[str] = [] - index_node_segments: list[DocumentSegment] = [] + index_node_segments: Sequence[DocumentSegment] = [] segments: list[DocumentSegment] = [] attachment_map: dict[str, list[AttachmentInfoDict]] = {} child_chunk_map: dict[str, list[ChildChunk]] = {} @@ -568,8 +570,9 @@ class RetrievalService: DocumentSegment.status == "completed", DocumentSegment.index_node_id.in_(index_node_ids), ) - index_node_segments = session.execute(document_segment_stmt).scalars().all() # type: ignore + index_node_segments = session.execute(document_segment_stmt).scalars().all() for index_node_segment in index_node_segments: + assert index_node_segment.index_node_id doc_segment_map[index_node_segment.id] = [index_node_segment.index_node_id] if segment_ids: @@ -888,12 +891,13 @@ class RetrievalService: .limit(1) ) if attachment_binding: + grant_upload_file_access([str(upload_file.id)]) attachment_info: AttachmentInfoDict = { "id": upload_file.id, "name": upload_file.name, "extension": "." + upload_file.extension, "mime_type": upload_file.mime_type, - "source_url": sign_upload_file(upload_file.id, upload_file.extension), + "source_url": sign_upload_file_preview_url(upload_file.id, upload_file.extension), "size": upload_file.size, } return {"attachment_info": attachment_info, "segment_id": attachment_binding.segment_id} @@ -904,6 +908,7 @@ class RetrievalService: cls, attachment_ids: list[str], session: Session ) -> list[SegmentAttachmentInfoResult]: attachment_infos: list[SegmentAttachmentInfoResult] = [] + granted_upload_file_ids: list[str] = [] upload_files = session.scalars(select(UploadFile).where(UploadFile.id.in_(attachment_ids))).all() if upload_files: upload_file_ids = [upload_file.id for upload_file in upload_files] @@ -920,10 +925,11 @@ class RetrievalService: "name": upload_file.name, "extension": "." + upload_file.extension, "mime_type": upload_file.mime_type, - "source_url": sign_upload_file(upload_file.id, upload_file.extension), + "source_url": sign_upload_file_preview_url(upload_file.id, upload_file.extension), "size": upload_file.size, } if attachment_binding: + granted_upload_file_ids.append(str(upload_file.id)) attachment_infos.append( { "attachment_id": attachment_binding.attachment_id, @@ -931,4 +937,5 @@ class RetrievalService: "segment_id": attachment_binding.segment_id, } ) + grant_upload_file_access(granted_upload_file_ids) return attachment_infos diff --git a/api/core/rag/docstore/dataset_docstore.py b/api/core/rag/docstore/dataset_docstore.py index 78305a6ac0..c7d52d74cb 100644 --- a/api/core/rag/docstore/dataset_docstore.py +++ b/api/core/rag/docstore/dataset_docstore.py @@ -50,6 +50,7 @@ class DatasetDocumentStore: output = {} for document_segment in document_segments: + assert document_segment.index_node_id doc_id = document_segment.index_node_id output[doc_id] = Document( page_content=document_segment.content, @@ -103,7 +104,7 @@ class DatasetDocumentStore: if not segment_document: max_position += 1 - + assert self._document_id segment_document = DocumentSegment( tenant_id=self._dataset.tenant_id, dataset_id=self._dataset.id, diff --git a/api/core/rag/extractor/extract_processor.py b/api/core/rag/extractor/extract_processor.py index b679edab36..e49e814149 100644 --- a/api/core/rag/extractor/extract_processor.py +++ b/api/core/rag/extractor/extract_processor.py @@ -183,34 +183,35 @@ class ExtractProcessor: return extractor.extract() elif extract_setting.datasource_type == DatasourceType.WEBSITE: assert extract_setting.website_info is not None, "website_info is required" - if extract_setting.website_info.provider == "firecrawl": - extractor = FirecrawlWebExtractor( - url=extract_setting.website_info.url, - job_id=extract_setting.website_info.job_id, - tenant_id=extract_setting.website_info.tenant_id, - mode=extract_setting.website_info.mode, - only_main_content=extract_setting.website_info.only_main_content, - ) - return extractor.extract() - elif extract_setting.website_info.provider == "watercrawl": - extractor = WaterCrawlWebExtractor( - url=extract_setting.website_info.url, - job_id=extract_setting.website_info.job_id, - tenant_id=extract_setting.website_info.tenant_id, - mode=extract_setting.website_info.mode, - only_main_content=extract_setting.website_info.only_main_content, - ) - return extractor.extract() - elif extract_setting.website_info.provider == "jinareader": - extractor = JinaReaderWebExtractor( - url=extract_setting.website_info.url, - job_id=extract_setting.website_info.job_id, - tenant_id=extract_setting.website_info.tenant_id, - mode=extract_setting.website_info.mode, - only_main_content=extract_setting.website_info.only_main_content, - ) - return extractor.extract() - else: - raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}") + match extract_setting.website_info.provider: + case "firecrawl": + extractor = FirecrawlWebExtractor( + url=extract_setting.website_info.url, + job_id=extract_setting.website_info.job_id, + tenant_id=extract_setting.website_info.tenant_id, + mode=extract_setting.website_info.mode, + only_main_content=extract_setting.website_info.only_main_content, + ) + return extractor.extract() + case "watercrawl": + extractor = WaterCrawlWebExtractor( + url=extract_setting.website_info.url, + job_id=extract_setting.website_info.job_id, + tenant_id=extract_setting.website_info.tenant_id, + mode=extract_setting.website_info.mode, + only_main_content=extract_setting.website_info.only_main_content, + ) + return extractor.extract() + case "jinareader": + extractor = JinaReaderWebExtractor( + url=extract_setting.website_info.url, + job_id=extract_setting.website_info.job_id, + tenant_id=extract_setting.website_info.tenant_id, + mode=extract_setting.website_info.mode, + only_main_content=extract_setting.website_info.only_main_content, + ) + return extractor.extract() + case _: + raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}") else: raise ValueError(f"Unsupported datasource type: {extract_setting.datasource_type}") diff --git a/api/core/rag/extractor/pdf_extractor.py b/api/core/rag/extractor/pdf_extractor.py index 02f0efc908..25f6fe3e2a 100644 --- a/api/core/rag/extractor/pdf_extractor.py +++ b/api/core/rag/extractor/pdf_extractor.py @@ -115,7 +115,7 @@ class PdfExtractor(BaseExtractor): """ image_content = [] upload_files = [] - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + base_url = dify_config.FILES_URL try: image_objects = page.get_objects(filter=(pdfium_c.FPDF_PAGEOBJ_IMAGE,)) diff --git a/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py b/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py index f9fbfbc409..da370f70e7 100644 --- a/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py +++ b/api/core/rag/extractor/unstructured/unstructured_doc_extractor.py @@ -19,7 +19,7 @@ class UnstructuredWordExtractor(BaseExtractor): def extract(self) -> list[Document]: from unstructured.__version__ import __version__ as __unstructured_version__ - from unstructured.file_utils.filetype import ( # pyright: ignore[reportPrivateImportUsage] + from unstructured.file_utils.filetype import ( FileType, detect_filetype, ) @@ -27,7 +27,7 @@ class UnstructuredWordExtractor(BaseExtractor): unstructured_version = tuple(int(x) for x in __unstructured_version__.split(".")) # check the file extension try: - import magic # noqa: F401 # pyright: ignore[reportUnusedImport] + import magic # noqa: F401 is_doc = detect_filetype(self._file_path) == FileType.DOC except ImportError: diff --git a/api/core/rag/extractor/word_extractor.py b/api/core/rag/extractor/word_extractor.py index 0330a43b28..60f8906181 100644 --- a/api/core/rag/extractor/word_extractor.py +++ b/api/core/rag/extractor/word_extractor.py @@ -110,7 +110,7 @@ class WordExtractor(BaseExtractor): def _extract_images_from_docx(self, doc): image_count = 0 image_map = {} - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + base_url = dify_config.FILES_URL for r_id, rel in doc.part.rels.items(): if "image" in rel.target_ref: diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index aded5315bd..757134e734 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -84,7 +84,7 @@ class IndexProcessor: select(DocumentSegment).where(DocumentSegment.document_id == original_document_id) ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] indexing_start_at = time.perf_counter() # delete from vector index diff --git a/api/core/rag/index_processor/processor/parent_child_index_processor.py b/api/core/rag/index_processor/processor/parent_child_index_processor.py index ba277d5018..a26a900512 100644 --- a/api/core/rag/index_processor/processor/parent_child_index_processor.py +++ b/api/core/rag/index_processor/processor/parent_child_index_processor.py @@ -29,6 +29,7 @@ from libs import helper from models import Account from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment from models.dataset import Document as DatasetDocument +from models.enums import ProcessRuleMode from services.account_service import AccountService from services.summary_index_service import SummaryIndexService @@ -325,7 +326,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor): # update document parent mode dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode="hierarchical", + mode=ProcessRuleMode.HIERARCHICAL, rules=json.dumps( { "parent_mode": parent_childs.parent_mode, diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 5631b3a921..039a266f44 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -19,6 +19,7 @@ from core.app.app_config.entities import ( ModelConfig, ) from core.app.entities.app_invoke_entities import InvokeFrom, ModelConfigWithCredentialsEntity +from core.app.file_access import grant_retriever_segment_access, grant_upload_file_access from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler from core.db.session_factory import session_factory from core.entities.agent_entities import PlanningStrategy @@ -52,7 +53,7 @@ from core.rag.retrieval.template_prompts import ( METADATA_FILTER_USER_PROMPT_2, METADATA_FILTER_USER_PROMPT_3, ) -from core.tools.signature import sign_upload_file +from core.tools.signature import sign_upload_file_preview_url from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import DatasetRetrieverBaseTool from core.workflow.file_reference import build_file_reference from core.workflow.nodes.knowledge_retrieval import exc @@ -326,6 +327,7 @@ class DatasetRetrieval: if record.summary: source.summary = record.summary + grant_retriever_segment_access([str(segment.id)]) retrieval_resource_list.append(source) if retrieval_resource_list: @@ -515,6 +517,9 @@ class DatasetRetrieval: ) ).all() if attachments_with_bindings: + grant_upload_file_access( + str(upload_file.id) for _, upload_file in attachments_with_bindings + ) for _, upload_file in attachments_with_bindings: attachment_info = File( file_id=upload_file.id, @@ -529,7 +534,7 @@ class DatasetRetrieval: ), size=upload_file.size, storage_key=upload_file.key, - url=sign_upload_file(upload_file.id, upload_file.extension), + url=sign_upload_file_preview_url(upload_file.id, upload_file.extension), ) context_files.append(attachment_info) if show_retrieve_source: diff --git a/api/core/rag/retrieval/router/multi_dataset_function_call_router.py b/api/core/rag/retrieval/router/multi_dataset_function_call_router.py index dd17545c86..ef56567a5a 100644 --- a/api/core/rag/retrieval/router/multi_dataset_function_call_router.py +++ b/api/core/rag/retrieval/router/multi_dataset_function_call_router.py @@ -28,10 +28,10 @@ class FunctionCallMultiDatasetRouter: SystemPromptMessage(content="You are a helpful AI assistant."), UserPromptMessage(content=query), ] - result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType] + result: LLMResult = model_instance.invoke_llm( # pyrefly: ignore[no-matching-overload] prompt_messages=prompt_messages, tools=dataset_tools, - stream=False, # pyright: ignore[reportArgumentType] + stream=False, model_parameters={"temperature": 0.2, "top_p": 0.3, "max_tokens": 1500}, ) usage = result.usage or LLMUsage.empty_usage() diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py index 00fc8a8282..b54adea98e 100644 --- a/api/core/tools/mcp_tool/tool.py +++ b/api/core/tools/mcp_tool/tool.py @@ -67,23 +67,27 @@ class MCPTool(Tool): # handle dify tool output for content in result.content: - if isinstance(content, TextContent): - yield from self._process_text_content(content) - elif isinstance(content, ImageContent | AudioContent): - yield self.create_blob_message( - blob=base64.b64decode(content.data), meta={"mime_type": content.mimeType} - ) - elif isinstance(content, EmbeddedResource): - resource = content.resource - if isinstance(resource, TextResourceContents): - yield self.create_text_message(resource.text) - elif isinstance(resource, BlobResourceContents): - mime_type = resource.mimeType or "application/octet-stream" - yield self.create_blob_message(blob=base64.b64decode(resource.blob), meta={"mime_type": mime_type}) - else: - raise ToolInvokeError(f"Unsupported embedded resource type: {type(resource)}") - else: - logger.warning("Unsupported content type=%s", type(content)) + match content: + case TextContent(): + yield from self._process_text_content(content) + case ImageContent() | AudioContent(): + yield self.create_blob_message( + blob=base64.b64decode(content.data), meta={"mime_type": content.mimeType} + ) + case EmbeddedResource(): + resource = content.resource + match resource: + case TextResourceContents(): + yield self.create_text_message(resource.text) + case BlobResourceContents(): + mime_type = resource.mimeType or "application/octet-stream" + yield self.create_blob_message( + blob=base64.b64decode(resource.blob), meta={"mime_type": mime_type} + ) + case _: + raise ToolInvokeError(f"Unsupported embedded resource type: {type(resource)}") + case _: + logger.warning("Unsupported content type=%s", type(content)) # handle MCP structured output if self.entity.output_schema and result.structuredContent: diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index 1807226924..ca4756f2a4 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -8,6 +8,10 @@ import urllib.parse from configs import dify_config +def _secret_key() -> bytes: + return dify_config.SECRET_KEY.encode() + + def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str: """ sign file to get a temporary url for plugin access @@ -19,26 +23,26 @@ def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" -def sign_upload_file(upload_file_id: str, extension: str) -> str: +def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str: """ - sign file to get a temporary url for plugin access + Sign an upload file to get a temporary image preview URL. + + The URL generated by this function is only for external preview and download, + not for internal communication. """ - # Use internal URL for plugin/tool file access in Docker environments - base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL + base_url = dify_config.FILES_URL file_preview_url = f"{base_url}/files/{upload_file_id}/image-preview" timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -49,8 +53,7 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s verify signature """ data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature @@ -69,8 +72,7 @@ def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() query = urllib.parse.urlencode( { @@ -90,8 +92,7 @@ def verify_plugin_file_signature( """Verify the signature used by the plugin-facing file upload endpoint.""" data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() if sign != recalculated_encoded_sign: diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index c87e8a3ae0..f2552e7cbd 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -51,8 +51,11 @@ class ToolFileManager: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -63,8 +66,11 @@ class ToolFileManager: verify signature """ data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 87cf6d7085..53e82ce994 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -264,9 +264,9 @@ class ToolManager: if builtin_provider is None: raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found") - from core.helper.credential_utils import check_credential_policy_compliance + from core.helper.credential_utils import runtime_check_credential_policy_compliance - check_credential_policy_compliance( + runtime_check_credential_policy_compliance( credential_id=builtin_provider.id, provider=provider_id, credential_type=PluginCredentialType.TOOL, @@ -1078,6 +1078,13 @@ class ToolManager: if parameter.form == ToolParameter.ToolParameterForm.FORM: if variable_pool: config = tool_configurations.get(parameter.name, {}) + + selector_value = cls._extract_runtime_selector_value(parameter, config) + if selector_value is not None: + # Selector parameters carry structured dictionaries, not scalar ToolInput values. + runtime_parameters[parameter.name] = selector_value + continue + if not (config and isinstance(config, dict) and config.get("value") is not None): continue tool_input = ToolNodeData.ToolInput.model_validate(tool_configurations.get(parameter.name, {})) @@ -1105,5 +1112,39 @@ class ToolManager: runtime_parameters[parameter.name] = value return runtime_parameters + @classmethod + def _extract_runtime_selector_value(cls, parameter: ToolParameter, config: Any) -> dict[str, Any] | None: + if parameter.type not in { + ToolParameter.ToolParameterType.MODEL_SELECTOR, + ToolParameter.ToolParameterType.APP_SELECTOR, + }: + return None + if not isinstance(config, dict): + return None + + input_value = config.get("value") + if isinstance(input_value, dict) and cls._is_selector_value(parameter, input_value): + return cast("dict[str, Any]", parameter.init_frontend_parameter(input_value)) + + if cls._is_selector_value(parameter, config): + selector_value = dict(config) + selector_value.pop("type", None) + selector_value.pop("value", None) + return cast("dict[str, Any]", parameter.init_frontend_parameter(selector_value)) + + return None + + @classmethod + def _is_selector_value(cls, parameter: ToolParameter, value: Mapping[str, Any]) -> bool: + if parameter.type == ToolParameter.ToolParameterType.MODEL_SELECTOR: + return ( + isinstance(value.get("provider"), str) + and isinstance(value.get("model"), str) + and isinstance(value.get("model_type"), str) + ) + if parameter.type == ToolParameter.ToolParameterType.APP_SELECTOR: + return isinstance(value.get("app_id"), str) + return False + ToolManager.load_hardcoded_providers_cache() diff --git a/api/core/tools/utils/message_transformer.py b/api/core/tools/utils/message_transformer.py index 5679466cbc..4c6e647335 100644 --- a/api/core/tools/utils/message_transformer.py +++ b/api/core/tools/utils/message_transformer.py @@ -23,36 +23,37 @@ _TOOL_FILE_URL_PATTERN = re.compile(r"(?:^|/+)files/tools/(?P[^/?# def safe_json_value(v): - if isinstance(v, datetime): - tz_name = "UTC" - if isinstance(current_user, Account) and current_user.timezone is not None: - tz_name = current_user.timezone - return v.astimezone(pytz.timezone(tz_name)).isoformat() - elif isinstance(v, date): - return v.isoformat() - elif isinstance(v, UUID): - return str(v) - elif isinstance(v, Decimal): - return float(v) - elif isinstance(v, bytes): - try: - return v.decode("utf-8") - except UnicodeDecodeError: - return v.hex() - elif isinstance(v, memoryview): - return v.tobytes().hex() - elif isinstance(v, np.integer): - return int(v) - elif isinstance(v, np.floating): - return float(v) - elif isinstance(v, np.ndarray): - return v.tolist() - elif isinstance(v, dict): - return safe_json_dict(v) - elif isinstance(v, list | tuple | set): - return [safe_json_value(i) for i in v] - else: - return v + match v: + case datetime(): + tz_name = "UTC" + if isinstance(current_user, Account) and current_user.timezone is not None: + tz_name = current_user.timezone + return v.astimezone(pytz.timezone(tz_name)).isoformat() + case date(): + return v.isoformat() + case UUID(): + return str(v) + case Decimal(): + return float(v) + case bytes(): + try: + return v.decode("utf-8") + except UnicodeDecodeError: + return v.hex() + case memoryview(): + return v.tobytes().hex() + case np.integer(): + return int(v) + case np.floating(): + return float(v) + case np.ndarray(): + return v.tolist() + case dict(): + return safe_json_dict(v) + case list() | tuple() | set(): + return [safe_json_value(i) for i in v] + case _: + return v def safe_json_dict(d: dict[str, Any]): diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index cd8c6352b5..3fbd456fe5 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -9,6 +9,7 @@ from sqlalchemy import select from core.app.file_access import DatabaseFileAccessController from core.db.session_factory import session_factory +from core.helper.trace_id_helper import ParentTraceContext, extract_parent_trace_context_from_args from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.tool_entities import ( @@ -36,6 +37,8 @@ class WorkflowTool(Tool): Workflow tool. """ + _parent_trace_context: ParentTraceContext | None + def __init__( self, workflow_app_id: str, @@ -54,6 +57,7 @@ class WorkflowTool(Tool): self.workflow_call_depth = workflow_call_depth self.label = label self._latest_usage = LLMUsage.empty_usage() + self._parent_trace_context = None super().__init__(entity=entity, runtime=runtime) @@ -94,11 +98,17 @@ class WorkflowTool(Tool): self._latest_usage = LLMUsage.empty_usage() + generator_args: dict[str, Any] = {"inputs": tool_parameters, "files": files} + if self._parent_trace_context: + generator_args.update( + extract_parent_trace_context_from_args({"parent_trace_context": self._parent_trace_context}) + ) + result = generator.generate( app_model=app, workflow=workflow, user=user, - args={"inputs": tool_parameters, "files": files}, + args=generator_args, invoke_from=self.runtime.invoke_from, streaming=False, call_depth=self.workflow_call_depth + 1, @@ -194,7 +204,7 @@ class WorkflowTool(Tool): :return: the new tool """ - return self.__class__( + forked = self.__class__( entity=self.entity.model_copy(), runtime=runtime, workflow_app_id=self.workflow_app_id, @@ -204,6 +214,24 @@ class WorkflowTool(Tool): version=self.version, label=self.label, ) + forked._parent_trace_context = self._parent_trace_context.model_copy() if self._parent_trace_context else None + return forked + + def set_parent_trace_context( + self, + *, + parent_workflow_run_id: str, + parent_node_execution_id: str, + ) -> None: + """Attach outer workflow trace context without exposing it as tool input.""" + self._parent_trace_context = ParentTraceContext( + parent_workflow_run_id=parent_workflow_run_id, + parent_node_execution_id=parent_node_execution_id, + ) + + def clear_parent_trace_context(self) -> None: + """Remove parent trace context before invoking this tool outside a nested workflow.""" + self._parent_trace_context = None def _resolve_user(self, user_id: str) -> Account | EndUser | None: """ diff --git a/api/core/workflow/human_input_adapter.py b/api/core/workflow/human_input_adapter.py index 4b765e6aea..731ae2b858 100644 --- a/api/core/workflow/human_input_adapter.py +++ b/api/core/workflow/human_input_adapter.py @@ -272,6 +272,14 @@ def _adapt_tool_node_data_for_graph(node_data: Mapping[str, Any]) -> dict[str, A normalized_tool_configurations[name] = value continue + selector_value = _extract_selector_configuration(value) + if selector_value is not None: + # Model/app selectors are dictionaries even when they come through the legacy tool configuration path. + # Move them to tool_parameters so graph validation does not flatten them as primitive constants. + found_legacy_tool_inputs = True + normalized_tool_parameters.setdefault(name, {"type": "constant", "value": selector_value}) + continue + input_type = value.get("type") input_value = value.get("value") if input_type not in {"mixed", "variable", "constant"}: @@ -310,6 +318,28 @@ def _flatten_legacy_tool_configuration_value(*, input_type: Any, input_value: An return None +def _extract_selector_configuration(value: Mapping[str, Any]) -> dict[str, Any] | None: + input_value = value.get("value") + if isinstance(input_value, Mapping) and _is_selector_configuration(input_value): + return dict(input_value) + + if _is_selector_configuration(value): + selector_value = dict(value) + selector_value.pop("type", None) + selector_value.pop("value", None) + return selector_value + + return None + + +def _is_selector_configuration(value: Mapping[str, Any]) -> bool: + return ( + isinstance(value.get("provider"), str) + and isinstance(value.get("model"), str) + and isinstance(value.get("model_type"), str) + ) or isinstance(value.get("app_id"), str) + + def _normalize_email_recipients(recipients: Mapping[str, Any]) -> dict[str, Any]: normalized = dict(recipients) diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 895953a3c1..5d572bbd5e 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -1,16 +1,16 @@ import importlib import pkgutil -from collections.abc import Callable, Iterator, Mapping, MutableMapping +from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence from dataclasses import dataclass from functools import lru_cache from typing import TYPE_CHECKING, Any, cast, final, override from sqlalchemy import select -from sqlalchemy.orm import Session from configs import dify_config from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.app.llm.model_access import build_dify_model_access, fetch_model_config +from core.db.session_factory import session_factory from core.helper.code_executor.code_executor import ( CodeExecutionError, CodeExecutor, @@ -39,7 +39,6 @@ from core.workflow.nodes.agent.plugin_strategy_adapter import ( from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport from core.workflow.system_variables import SystemVariableKey, get_system_text, system_variable_selector from core.workflow.template_rendering import CodeExecutorJinja2TemplateRenderer -from extensions.ext_database import db from graphon.entities.base_node_data import BaseNodeData from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from graphon.enums import BuiltinNodeTypes, NodeType @@ -48,7 +47,7 @@ from graphon.graph.graph import NodeFactory from graphon.model_runtime.memory import PromptMessageMemory from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.nodes.base.node import Node -from graphon.nodes.code.code_node import WorkflowCodeExecutor +from graphon.nodes.code.code_node import CodeExecutorProtocol from graphon.nodes.code.entities import CodeLanguage from graphon.nodes.code.limits import CodeNodeLimits from graphon.nodes.document_extractor import UnstructuredApiConfig @@ -56,6 +55,7 @@ from graphon.nodes.http_request import build_http_request_config from graphon.nodes.llm.entities import LLMNodeData from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData +from graphon.variables.segments import ArrayObjectSegment from models.model import Conversation if TYPE_CHECKING: @@ -228,10 +228,14 @@ def fetch_memory( node_data_memory: MemoryConfig | None, model_instance: ModelInstance, ) -> TokenBufferMemory | None: + """Build prompt memory for node construction without requiring Flask-local state.""" if not node_data_memory or not conversation_id: return None - with Session(db.engine, expire_on_commit=False) as session: + # Node construction can happen in graph initialization paths where Flask's + # app context is not active. Use the app-configured session factory instead + # of resolving db.engine through Flask-SQLAlchemy's current_app proxy. + with session_factory.create_session() as session: stmt = select(Conversation).where(Conversation.app_id == app_id, Conversation.id == conversation_id) conversation = session.scalar(stmt) if not conversation: @@ -285,7 +289,7 @@ class DifyNodeFactory(NodeFactory): self.graph_init_params = graph_init_params self.graph_runtime_state = graph_runtime_state self._dify_context = self._resolve_dify_context(graph_init_params.run_context) - self._code_executor: WorkflowCodeExecutor = DefaultWorkflowCodeExecutor() + self._code_executor: CodeExecutorProtocol = DefaultWorkflowCodeExecutor() self._code_limits = CodeNodeLimits( max_string_length=dify_config.CODE_MAX_STRING_LENGTH, max_number=dify_config.CODE_MAX_NUMBER, @@ -374,11 +378,6 @@ class DifyNodeFactory(NodeFactory): # Re-validate using the resolved node class so workflow-local node schemas # stay explicit and constructors receive the concrete typed payload. resolved_node_data = self._validate_resolved_node_data(node_class, node_data) - config_for_node_init: BaseNodeData | dict[str, Any] - if isinstance(resolved_node_data, BaseNodeData): - config_for_node_init = resolved_node_data.model_dump(mode="python", by_alias=True) - else: - config_for_node_init = resolved_node_data node_type = node_data.type node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = { BuiltinNodeTypes.CODE: lambda: { @@ -398,6 +397,7 @@ class DifyNodeFactory(NodeFactory): }, BuiltinNodeTypes.HUMAN_INPUT: lambda: { "runtime": self._human_input_runtime, + "file_reference_factory": self._file_reference_factory, "form_repository": self._human_input_runtime.build_form_repository(), }, BuiltinNodeTypes.LLM: lambda: self._build_llm_compatible_node_init_kwargs( @@ -435,7 +435,7 @@ class DifyNodeFactory(NodeFactory): include_jinja2_template_renderer=False, ), BuiltinNodeTypes.TOOL: lambda: { - "tool_file_manager_factory": self._bound_tool_file_manager_factory(), + "tool_file_manager": self._bound_tool_file_manager_factory(), "runtime": self._tool_runtime, }, BuiltinNodeTypes.AGENT: lambda: { @@ -446,9 +446,10 @@ class DifyNodeFactory(NodeFactory): }, } node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})() + constructor_node_data = resolved_node_data.model_dump(mode="python", by_alias=True) return node_class( node_id=node_id, - config=config_for_node_init, + data=constructor_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, **node_init_kwargs, @@ -500,13 +501,47 @@ class DifyNodeFactory(NodeFactory): if include_prompt_message_serializer: node_init_kwargs["prompt_message_serializer"] = self._prompt_message_serializer if include_retriever_attachment_loader: - node_init_kwargs["retriever_attachment_loader"] = self._retriever_attachment_loader + node_init_kwargs["retriever_attachment_loader"] = self._build_retriever_attachment_loader( + cast(LLMNodeData, validated_node_data) + ) if include_jinja2_template_renderer: node_init_kwargs["jinja2_template_renderer"] = self._jinja2_template_renderer if validated_node_data.type == BuiltinNodeTypes.LLM: node_init_kwargs["default_query_selector"] = system_variable_selector(SystemVariableKey.QUERY) return node_init_kwargs + def _build_retriever_attachment_loader(self, node_data: LLMNodeData) -> DifyRetrieverAttachmentLoader: + return DifyRetrieverAttachmentLoader( + file_reference_factory=self._file_reference_factory, + segment_access_checker=self._build_retriever_segment_access_checker( + node_data.context.variable_selector if node_data.context.enabled else None + ), + ) + + def _build_retriever_segment_access_checker( + self, + context_variable_selector: Sequence[str] | None, + ) -> Callable[[str], bool]: + def checker(segment_id: str) -> bool: + if not context_variable_selector: + return False + + context_value = self.graph_runtime_state.variable_pool.get(context_variable_selector) + if not isinstance(context_value, ArrayObjectSegment): + return False + + for item in context_value.value: + if not isinstance(item, Mapping): + continue + metadata = item.get("metadata") + if not isinstance(metadata, Mapping): + continue + if metadata.get("_source") == "knowledge" and str(metadata.get("segment_id")) == str(segment_id): + return True + return False + + return checker + def _build_model_instance_for_llm_node(self, node_data: LLMCompatibleNodeData) -> ModelInstance: node_data_model = node_data.model model_instance, _ = fetch_model_config( diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index b8725853c4..7ea03a4a33 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -8,8 +8,13 @@ from sqlalchemy import select from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext -from core.app.file_access import DatabaseFileAccessController +from core.app.file_access import ( + DatabaseFileAccessController, + grant_upload_file_access, + is_retriever_segment_access_granted, +) from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler +from core.helper.trace_id_helper import ParentTraceContext from core.llm_generator.output_parser.errors import OutputParserError from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output from core.model_manager import ModelInstance @@ -44,7 +49,7 @@ from graphon.model_runtime.entities.model_entities import AIModelEntity from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.nodes.human_input.entities import HumanInputNodeData from graphon.nodes.llm.runtime_protocols import ( - PreparedLLMProtocol, + LLMProtocol, PromptMessageSerializerProtocol, RetrieverAttachmentLoaderProtocol, ) @@ -135,7 +140,7 @@ class DifyFileReferenceFactory(FileReferenceFactoryProtocol): ) -class DifyPreparedLLM(PreparedLLMProtocol): +class DifyPreparedLLM(LLMProtocol): """Workflow-layer adapter that hides the full `ModelInstance` API from `graphon` nodes.""" def __init__(self, model_instance: ModelInstance) -> None: @@ -274,10 +279,23 @@ class DifyPromptMessageSerializer(PromptMessageSerializerProtocol): class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol): """Resolve retriever attachments through Dify persistence and return graph file references.""" - def __init__(self, *, file_reference_factory: FileReferenceFactoryProtocol) -> None: + _segment_access_checker: Callable[[str], bool] | None + + def __init__( + self, + *, + file_reference_factory: FileReferenceFactoryProtocol, + segment_access_checker: Callable[[str], bool] | None = None, + ) -> None: self._file_reference_factory = file_reference_factory + self._segment_access_checker = segment_access_checker def load(self, *, segment_id: str) -> Sequence[File]: + if not is_retriever_segment_access_granted(segment_id): + return [] + if self._segment_access_checker is not None and not self._segment_access_checker(segment_id): + return [] + with Session(db.engine, expire_on_commit=False) as session: attachments_with_bindings = session.execute( select(SegmentAttachmentBinding, UploadFile) @@ -285,6 +303,7 @@ class DifyRetrieverAttachmentLoader(RetrieverAttachmentLoaderProtocol): .where(SegmentAttachmentBinding.segment_id == segment_id) ).all() + grant_upload_file_access(str(upload_file.id) for _, upload_file in attachments_with_bindings) return [ self._file_reference_factory.build_from_mapping( mapping={ @@ -358,6 +377,7 @@ class _WorkflowToolRuntimeBinding: tool: Tool conversation_id: str | None = None + parent_trace_context: ParentTraceContext | None = None class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): @@ -378,6 +398,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): node_id: str, node_data: ToolNodeData, variable_pool, + node_execution_id: str | None = None, ) -> ToolRuntimeHandle: try: tool_runtime = ToolManager.get_workflow_tool_runtime( @@ -397,7 +418,25 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): conversation_id = ( None if variable_pool is None else get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID) ) - return ToolRuntimeHandle(raw=_WorkflowToolRuntimeBinding(tool=tool_runtime, conversation_id=conversation_id)) + parent_trace_context: ParentTraceContext | None = None + if self._is_workflow_tool_provider(node_data): + outer_workflow_run_id = ( + None + if variable_pool is None + else get_system_text(variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID) + ) + if isinstance(outer_workflow_run_id, str) and isinstance(node_execution_id, str): + parent_trace_context = ParentTraceContext( + parent_workflow_run_id=outer_workflow_run_id, + parent_node_execution_id=node_execution_id, + ) + return ToolRuntimeHandle( + raw=_WorkflowToolRuntimeBinding( + tool=tool_runtime, + conversation_id=conversation_id, + parent_trace_context=parent_trace_context, + ) + ) def get_runtime_parameters( self, @@ -421,6 +460,13 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): runtime_binding = self._binding_from_handle(tool_runtime) tool = runtime_binding.tool callback = DifyWorkflowCallbackHandler() + if runtime_binding.parent_trace_context and hasattr(tool, "set_parent_trace_context"): + tool.set_parent_trace_context( + parent_workflow_run_id=runtime_binding.parent_trace_context.parent_workflow_run_id, + parent_node_execution_id=runtime_binding.parent_trace_context.parent_node_execution_id, + ) + elif hasattr(tool, "clear_parent_trace_context"): + tool.clear_parent_trace_context() try: messages = ToolEngine.generic_invoke( @@ -501,14 +547,22 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): @staticmethod def _build_tool_runtime_spec(node_data: ToolNodeData) -> _WorkflowToolRuntimeSpec: + tool_configurations = dict(node_data.tool_configurations) + tool_configurations.update( + {name: tool_input.model_dump(mode="python") for name, tool_input in node_data.tool_parameters.items()} + ) return _WorkflowToolRuntimeSpec( provider_type=CoreToolProviderType(node_data.provider_type.value), provider_id=node_data.provider_id, tool_name=node_data.tool_name, - tool_configurations=dict(node_data.tool_configurations), + tool_configurations=tool_configurations, credential_id=node_data.credential_id, ) + @staticmethod + def _is_workflow_tool_provider(node_data: ToolNodeData) -> bool: + return node_data.provider_type.value == CoreToolProviderType.WORKFLOW.value + def _adapt_messages( self, messages: Generator[CoreToolInvokeMessage, None, None], @@ -554,64 +608,67 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage - if isinstance(message, CoreToolInvokeMessage.TextMessage): - return ToolRuntimeMessage.TextMessage(text=message.text) - if isinstance(message, CoreToolInvokeMessage.JsonMessage): - return ToolRuntimeMessage.JsonMessage( - json_object=message.json_object, - suppress_output=message.suppress_output, - ) - if isinstance(message, CoreToolInvokeMessage.BlobMessage): - return ToolRuntimeMessage.BlobMessage(blob=message.blob) - if isinstance(message, CoreToolInvokeMessage.BlobChunkMessage): - return ToolRuntimeMessage.BlobChunkMessage( - id=message.id, - sequence=message.sequence, - total_length=message.total_length, - blob=message.blob, - end=message.end, - ) - if isinstance(message, CoreToolInvokeMessage.FileMessage): - return ToolRuntimeMessage.FileMessage(file_marker=message.file_marker) - if isinstance(message, CoreToolInvokeMessage.VariableMessage): - return ToolRuntimeMessage.VariableMessage( - variable_name=message.variable_name, - variable_value=message.variable_value, - stream=message.stream, - ) - if isinstance(message, CoreToolInvokeMessage.LogMessage): - return ToolRuntimeMessage.LogMessage( - id=message.id, - label=message.label, - parent_id=message.parent_id, - error=message.error, - status=ToolRuntimeMessage.LogMessage.LogStatus(message.status.value), - data=dict(message.data), - metadata=dict(message.metadata), - ) - if isinstance(message, CoreToolInvokeMessage.RetrieverResourceMessage): - retriever_resources = [ - resource.model_dump() if hasattr(resource, "model_dump") else dict(resource) - for resource in message.retriever_resources - ] - return ToolRuntimeMessage.RetrieverResourceMessage( - retriever_resources=retriever_resources, - context=message.context, - ) - - raise TypeError(f"unsupported tool message payload: {type(message).__name__}") + match message: + case CoreToolInvokeMessage.TextMessage(): + return ToolRuntimeMessage.TextMessage(text=message.text) + case CoreToolInvokeMessage.JsonMessage(): + return ToolRuntimeMessage.JsonMessage( + json_object=message.json_object, + suppress_output=message.suppress_output, + ) + case CoreToolInvokeMessage.BlobMessage(): + return ToolRuntimeMessage.BlobMessage(blob=message.blob) + case CoreToolInvokeMessage.BlobChunkMessage(): + return ToolRuntimeMessage.BlobChunkMessage( + id=message.id, + sequence=message.sequence, + total_length=message.total_length, + blob=message.blob, + end=message.end, + ) + case CoreToolInvokeMessage.FileMessage(): + return ToolRuntimeMessage.FileMessage(file_marker=message.file_marker) + case CoreToolInvokeMessage.VariableMessage(): + return ToolRuntimeMessage.VariableMessage( + variable_name=message.variable_name, + variable_value=message.variable_value, + stream=message.stream, + ) + case CoreToolInvokeMessage.LogMessage(): + return ToolRuntimeMessage.LogMessage( + id=message.id, + label=message.label, + parent_id=message.parent_id, + error=message.error, + status=ToolRuntimeMessage.LogMessage.LogStatus(message.status.value), + data=dict(message.data), + metadata=dict(message.metadata), + ) + case CoreToolInvokeMessage.RetrieverResourceMessage(): + retriever_resources = [ + resource.model_dump() if hasattr(resource, "model_dump") else dict(resource) + for resource in message.retriever_resources + ] + return ToolRuntimeMessage.RetrieverResourceMessage( + retriever_resources=retriever_resources, + context=message.context, + ) + case _: + raise TypeError(f"unsupported tool message payload: {type(message).__name__}") @staticmethod def _map_invocation_exception(exc: Exception, *, provider_name: str) -> ToolNodeError: - if isinstance(exc, ToolNodeError): - return exc - if isinstance(exc, PluginInvokeError): - return ToolRuntimeInvocationError(exc.to_user_friendly_error(plugin_name=provider_name)) - if isinstance(exc, PluginDaemonClientSideError): - return ToolRuntimeInvocationError(f"Failed to invoke tool, error: {exc.description}") - if isinstance(exc, ToolInvokeError): - return ToolRuntimeInvocationError(f"Failed to invoke tool {provider_name}: {exc}") - return ToolRuntimeInvocationError(str(exc)) + match exc: + case ToolNodeError(): + return exc + case PluginInvokeError(): + return ToolRuntimeInvocationError(exc.to_user_friendly_error(plugin_name=provider_name)) + case PluginDaemonClientSideError(): + return ToolRuntimeInvocationError(f"Failed to invoke tool, error: {exc.description}") + case ToolInvokeError(): + return ToolRuntimeInvocationError(f"Failed to invoke tool {provider_name}: {exc}") + case _: + return ToolRuntimeInvocationError(str(exc)) class DifyHumanInputNodeRuntime(HumanInputNodeRuntimeProtocol): diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 68a24e86b1..17d71668cb 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -35,7 +35,7 @@ class AgentNode(Node[AgentNodeData]): def __init__( self, node_id: str, - config: AgentNodeData, + data: AgentNodeData, *, graph_init_params: GraphInitParams, graph_runtime_state: GraphRuntimeState, @@ -46,7 +46,7 @@ class AgentNode(Node[AgentNodeData]): ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/nodes/datasource/datasource_node.py b/api/core/workflow/nodes/datasource/datasource_node.py index f3006c4242..a4ef3d1ea7 100644 --- a/api/core/workflow/nodes/datasource/datasource_node.py +++ b/api/core/workflow/nodes/datasource/datasource_node.py @@ -36,14 +36,14 @@ class DatasourceNode(Node[DatasourceNodeData]): def __init__( self, node_id: str, - config: DatasourceNodeData, + data: DatasourceNodeData, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index 9c1b7ab2c4..1d60f530a1 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -32,14 +32,14 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]): def __init__( self, node_id: str, - config: KnowledgeIndexNodeData, + data: KnowledgeIndexNodeData, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py index 25f73e446d..1aba2737b0 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py @@ -71,14 +71,14 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD def __init__( self, node_id: str, - config: KnowledgeRetrievalNodeData, + data: KnowledgeRetrievalNodeData, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) diff --git a/api/core/workflow/system_variables.py b/api/core/workflow/system_variables.py index 9d15a3fcea..77ef3826e9 100644 --- a/api/core/workflow/system_variables.py +++ b/api/core/workflow/system_variables.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import Any, Protocol, cast +from typing import Any, Protocol from uuid import uuid4 from graphon.enums import BuiltinNodeTypes @@ -82,13 +82,10 @@ def build_system_variables(values: Mapping[str, Any] | None = None, /, **kwargs: normalized = _normalize_system_variable_values(values, **kwargs) return [ - cast( - Variable, - segment_to_variable( - segment=build_segment(value), - selector=system_variable_selector(key), - name=key, - ), + segment_to_variable( + segment=build_segment(value), + selector=system_variable_selector(key), + name=key, ) for key, value in normalized.items() ] @@ -130,13 +127,10 @@ def build_bootstrap_variables( for node_id, value in rag_pipeline_variables_map.items(): variables.append( - cast( - Variable, - segment_to_variable( - segment=build_segment(value), - selector=(RAG_PIPELINE_VARIABLE_NODE_ID, node_id), - name=node_id, - ), + segment_to_variable( + segment=build_segment(value), + selector=(RAG_PIPELINE_VARIABLE_NODE_ID, node_id), + name=node_id, ) ) diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py index 4e2f603e5b..3019704dac 100644 --- a/api/core/workflow/workflow_entry.py +++ b/api/core/workflow/workflow_entry.py @@ -46,6 +46,11 @@ _file_access_controller = DatabaseFileAccessController() class _WorkflowChildEngineBuilder: + tenant_id: str + + def __init__(self, *, tenant_id: str) -> None: + self.tenant_id = tenant_id + @staticmethod def _has_node_id(graph_config: Mapping[str, Any], node_id: str) -> bool | None: """ @@ -107,7 +112,7 @@ class _WorkflowChildEngineBuilder: config=config, child_engine_builder=self, ) - child_engine.layer(LLMQuotaLayer()) + child_engine.layer(LLMQuotaLayer(tenant_id=self.tenant_id)) return child_engine @@ -176,7 +181,7 @@ class WorkflowEntry: self.command_channel = command_channel execution_context = capture_current_context() graph_runtime_state.execution_context = execution_context - self._child_engine_builder = _WorkflowChildEngineBuilder() + self._child_engine_builder = _WorkflowChildEngineBuilder(tenant_id=tenant_id) self.graph_engine = GraphEngine( workflow_id=workflow_id, graph=graph, @@ -208,7 +213,7 @@ class WorkflowEntry: max_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME ) self.graph_engine.layer(limits_layer) - self.graph_engine.layer(LLMQuotaLayer()) + self.graph_engine.layer(LLMQuotaLayer(tenant_id=tenant_id)) # Add observability layer when OTel is enabled if dify_config.ENABLE_OTEL or is_instrument_flag_enabled(): diff --git a/api/dev/generate_fastopenapi_specs.py b/api/dev/generate_fastopenapi_specs.py new file mode 100644 index 0000000000..5a94d32b93 --- /dev/null +++ b/api/dev/generate_fastopenapi_specs.py @@ -0,0 +1,95 @@ +"""Generate FastOpenAPI OpenAPI 3.0 specs without booting the full backend.""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from dataclasses import dataclass +from pathlib import Path + +API_ROOT = Path(__file__).resolve().parents[1] +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + +from dev.generate_swagger_specs import apply_runtime_defaults, drop_null_values, sort_openapi_arrays + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class FastOpenApiSpecTarget: + route: str + filename: str + + +FASTOPENAPI_SPEC_TARGETS: tuple[FastOpenApiSpecTarget, ...] = ( + FastOpenApiSpecTarget(route="/fastopenapi/openapi.json", filename="fastopenapi-console-openapi.json"), +) + + +def create_fastopenapi_spec_app(): + """Build a minimal Flask app that only mounts FastOpenAPI docs routes.""" + + apply_runtime_defaults() + + from app_factory import create_flask_app_with_configs + from extensions import ext_fastopenapi + + app = create_flask_app_with_configs() + ext_fastopenapi.init_app(app) + return app + + +def generate_fastopenapi_specs(output_dir: Path) -> list[Path]: + """Write FastOpenAPI specs to `output_dir` and return the written paths.""" + + output_dir.mkdir(parents=True, exist_ok=True) + + app = create_fastopenapi_spec_app() + client = app.test_client() + + written_paths: list[Path] = [] + for target in FASTOPENAPI_SPEC_TARGETS: + response = client.get(target.route) + if response.status_code != 200: + raise RuntimeError(f"failed to fetch {target.route}: {response.status_code}") + + payload = response.get_json() + if not isinstance(payload, dict): + raise RuntimeError(f"unexpected response payload for {target.route}") + payload = drop_null_values(payload) + payload = sort_openapi_arrays(payload) + + output_path = output_dir / target.filename + output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + written_paths.append(output_path) + + return written_paths + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-o", + "--output-dir", + type=Path, + default=Path("openapi"), + help="Directory where the OpenAPI JSON files will be written.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + written_paths = generate_fastopenapi_specs(args.output_dir) + + for path in written_paths: + logger.debug(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py new file mode 100644 index 0000000000..c87b25eeb0 --- /dev/null +++ b/api/dev/generate_swagger_markdown_docs.py @@ -0,0 +1,305 @@ +"""Generate OpenAPI JSON specs and split Markdown API docs. + +The Markdown step uses `swagger-markdown`, the same converter family as the +Swagger Markdown UI, so CI and local regeneration catch converter-incompatible +OpenAPI output early. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import subprocess +import sys +import tempfile +from pathlib import Path + +API_ROOT = Path(__file__).resolve().parents[1] +if str(API_ROOT) not in sys.path: + sys.path.insert(0, str(API_ROOT)) + +from dev.generate_fastopenapi_specs import FASTOPENAPI_SPEC_TARGETS, generate_fastopenapi_specs +from dev.generate_swagger_specs import SPEC_TARGETS, generate_specs + +logger = logging.getLogger(__name__) + +SWAGGER_MARKDOWN_PACKAGE = "swagger-markdown@3.0.0" +CONSOLE_SWAGGER_FILENAME = "console-swagger.json" +STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md" + + +def _definition_ref_name(schema: object) -> str | None: + if not isinstance(schema, dict): + return None + + ref = schema.get("$ref") + if not isinstance(ref, str) or not ref.startswith("#/definitions/"): + return None + + return ref.removeprefix("#/definitions/") + + +def _markdown_anchor(name: str) -> str: + return name.lower() + + +def _schema_markdown_type(schema: object) -> str: + if not isinstance(schema, dict): + return "" + + ref_name = _definition_ref_name(schema) + if ref_name is not None: + return f"[{ref_name}](#{_markdown_anchor(ref_name)})" + + for union_key in ("oneOf", "anyOf"): + variants = schema.get(union_key) + if not isinstance(variants, list): + continue + + variant_types = [ + variant_type + for variant in variants + if not (isinstance(variant, dict) and variant.get("type") == "null") + for variant_type in [_schema_markdown_type(variant)] + if variant_type + ] + if len(variant_types) == 1: + return variant_types[0] + if variant_types: + return "
".join(variant_types) + + schema_type = schema.get("type") + if schema_type == "array": + item_type = _schema_markdown_type(schema.get("items")) + return f"[ {item_type or 'object'} ]" + if isinstance(schema_type, str): + return schema_type + + return "" + + +def _replace_schema_table_type(markdown: str, definition_name: str, row_name: str, type_markdown: str) -> str: + if not type_markdown: + return markdown + + lines = markdown.splitlines() + section_header = f"#### {definition_name}" + in_section = False + + for index, line in enumerate(lines): + if line == section_header: + in_section = True + continue + if in_section and line.startswith("#### "): + break + if not in_section or not line.startswith(f"| {row_name} |"): + continue + + cells = line.split("|") + if len(cells) < 5: + continue + cells[2] = f" {type_markdown} " + lines[index] = "|".join(cells) + break + + return "\n".join(lines) + + +def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str: + """Fill Swagger Markdown table cells that `swagger-markdown` leaves blank for union schemas.""" + + spec = json.loads(spec_path.read_text(encoding="utf-8")) + definitions = spec.get("definitions") + if not isinstance(definitions, dict): + return markdown + + for definition_name, schema in definitions.items(): + if not isinstance(definition_name, str) or not isinstance(schema, dict): + continue + one_of = schema.get("oneOf") + if not isinstance(one_of, list): + continue + + markdown = _replace_schema_table_type( + markdown, + definition_name, + definition_name, + _schema_markdown_type(schema), + ) + + for variant in one_of: + variant_name = _definition_ref_name(variant) + variant_schema = definitions.get(variant_name) if variant_name is not None else None + if not isinstance(variant_name, str) or not isinstance(variant_schema, dict): + continue + properties = variant_schema.get("properties") + if not isinstance(properties, dict): + continue + for property_name, property_schema in properties.items(): + if isinstance(property_name, str): + markdown = _replace_schema_table_type( + markdown, + variant_name, + property_name, + _schema_markdown_type(property_schema), + ) + + return markdown + + +def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: + markdown_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"{markdown_path.stem}-", dir=markdown_path.parent) as temp_dir: + temp_markdown_path = Path(temp_dir) / markdown_path.name + result = subprocess.run( + [ + "npx", + "--yes", + SWAGGER_MARKDOWN_PACKAGE, + "-i", + str(spec_path), + "-o", + str(temp_markdown_path), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.args, + output=result.stdout, + stderr=result.stderr, + ) + if not temp_markdown_path.exists(): + converter_output = "\n".join(item for item in (result.stdout, result.stderr) if item).strip() + raise RuntimeError(f"swagger-markdown did not write {markdown_path}: {converter_output}") + + converted_markdown = _patch_union_schema_markdown( + temp_markdown_path.read_text(encoding="utf-8"), + spec_path, + ) + if not converted_markdown.strip(): + raise RuntimeError(f"swagger-markdown wrote an empty document for {markdown_path}") + + markdown_path.write_text(converted_markdown, encoding="utf-8") + + +def _demote_markdown_headings(markdown: str, *, levels: int = 1) -> str: + """Nest generated Markdown under another Markdown section.""" + + heading_prefix = "#" * levels + lines = [] + for line in markdown.splitlines(): + if line.startswith("#"): + lines.append(f"{heading_prefix}{line}") + else: + lines.append(line) + return "\n".join(lines).strip() + + +def _append_fastopenapi_markdown(console_markdown_path: Path, fastopenapi_markdown_path: Path) -> None: + """Append FastOpenAPI console docs to the existing console API Markdown.""" + + console_markdown = console_markdown_path.read_text(encoding="utf-8").rstrip() + fastopenapi_markdown = _demote_markdown_headings( + fastopenapi_markdown_path.read_text(encoding="utf-8"), + levels=2, + ) + console_markdown_path.write_text( + "\n\n".join( + [ + console_markdown, + "## FastOpenAPI Preview (OpenAPI 3.0)", + fastopenapi_markdown, + ] + ) + + "\n", + encoding="utf-8", + ) + + +def generate_markdown_docs( + swagger_dir: Path, + markdown_dir: Path, + *, + keep_swagger_json: bool = False, +) -> list[Path]: + """Generate intermediate specs, convert them to split Markdown API docs, and return Markdown paths.""" + + swagger_paths = generate_specs(swagger_dir) + fastopenapi_paths = generate_fastopenapi_specs(swagger_dir) + spec_paths = [*swagger_paths, *fastopenapi_paths] + swagger_paths_by_name = {path.name: path for path in swagger_paths} + fastopenapi_paths_by_name = {path.name: path for path in fastopenapi_paths} + + markdown_dir.mkdir(parents=True, exist_ok=True) + + written_paths: list[Path] = [] + try: + with tempfile.TemporaryDirectory(prefix="dify-api-docs-") as temp_dir: + temp_markdown_dir = Path(temp_dir) + + for target in SPEC_TARGETS: + swagger_path = swagger_paths_by_name[target.filename] + markdown_path = markdown_dir / f"{swagger_path.stem}.md" + _convert_spec_to_markdown(swagger_path, markdown_path) + written_paths.append(markdown_path) + + for target in FASTOPENAPI_SPEC_TARGETS: # type: ignore + fastopenapi_path = fastopenapi_paths_by_name[target.filename] + markdown_path = temp_markdown_dir / f"{fastopenapi_path.stem}.md" + _convert_spec_to_markdown(fastopenapi_path, markdown_path) + + console_markdown_path = markdown_dir / f"{Path(CONSOLE_SWAGGER_FILENAME).stem}.md" + _append_fastopenapi_markdown(console_markdown_path, markdown_path) + + (markdown_dir / STALE_COMBINED_MARKDOWN_FILENAME).unlink(missing_ok=True) + finally: + if not keep_swagger_json: + for path in spec_paths: + path.unlink(missing_ok=True) + + return written_paths + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--swagger-dir", + type=Path, + default=Path("openapi"), + help="Directory where intermediate JSON spec files will be written.", + ) + parser.add_argument( + "--markdown-dir", + type=Path, + default=Path("openapi/markdown"), + help="Directory where split Markdown API docs will be written.", + ) + parser.add_argument( + "--keep-swagger-json", + action="store_true", + help="Keep intermediate JSON spec files after Markdown generation.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + written_paths = generate_markdown_docs( + args.swagger_dir, + args.markdown_dir, + keep_swagger_json=args.keep_swagger_json, + ) + + for path in written_paths: + logger.debug(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index 7e9688bfb4..254310cd2a 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -9,15 +9,17 @@ which is unnecessary when the goal is only to serialize the Flask-RESTX from __future__ import annotations import argparse +import hashlib import json import logging import os import sys +from collections.abc import MutableMapping from dataclasses import dataclass from pathlib import Path +from typing import Protocol, TypeGuard from flask import Flask -from flask_restx.swagger import Swagger logger = logging.getLogger(__name__) @@ -30,19 +32,107 @@ if str(API_ROOT) not in sys.path: class SpecTarget: route: str filename: str + namespace: str + + +class RestxApi(Protocol): + models: MutableMapping[str, object] + + def model(self, name: str, model: dict[object, object]) -> object: ... SPEC_TARGETS: tuple[SpecTarget, ...] = ( - SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json"), - SpecTarget(route="/api/swagger.json", filename="web-swagger.json"), - SpecTarget(route="/v1/swagger.json", filename="service-swagger.json"), + SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json", namespace="console"), + SpecTarget(route="/api/swagger.json", filename="web-swagger.json", namespace="web"), + SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"), ) -_ORIGINAL_REGISTER_MODEL = Swagger.register_model -_ORIGINAL_REGISTER_FIELD = Swagger.register_field + +def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: + """Return whether a nested field map is an anonymous inline mapping.""" + + from flask_restx.model import Model, OrderedModel + + return isinstance(value, dict) and not isinstance(value, (Model, OrderedModel)) -def _apply_runtime_defaults() -> None: +def _jsonable_schema_value(value: object) -> object: + """Return a deterministic JSON-serializable representation for schema fingerprints.""" + + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, list | tuple): + return [_jsonable_schema_value(item) for item in value] + if isinstance(value, dict): + return {str(key): _jsonable_schema_value(item) for key, item in value.items()} + value_type = type(value) + return f"<{value_type.__module__}.{value_type.__qualname__}>" + + +def _field_signature(field: object) -> object: + """Build a stable signature for a Flask-RESTX field object.""" + + from flask_restx import fields + from flask_restx.model import instance + + field_instance = instance(field) + signature: dict[str, object] = { + "class": f"{field_instance.__class__.__module__}.{field_instance.__class__.__qualname__}" + } + + if isinstance(field_instance, fields.Nested): + nested = getattr(field_instance, "nested", None) + if _is_inline_field_map(nested): + signature["nested"] = _inline_model_signature(nested) + else: + signature["nested"] = getattr( + nested, + "name", + f"<{type(nested).__module__}.{type(nested).__qualname__}>", + ) + elif hasattr(field_instance, "container"): + signature["container"] = _field_signature(field_instance.container) + else: + schema = getattr(field_instance, "__schema__", None) + if isinstance(schema, dict): + signature["schema"] = _jsonable_schema_value(schema) + + for attr_name in ( + "attribute", + "default", + "description", + "example", + "max", + "min", + "nullable", + "readonly", + "required", + "title", + ): + if hasattr(field_instance, attr_name): + signature[attr_name] = _jsonable_schema_value(getattr(field_instance, attr_name)) + + return signature + + +def _inline_model_signature(nested_fields: dict[object, object]) -> object: + """Build a stable signature for an anonymous inline model.""" + + return [ + (str(field_name), _field_signature(field)) + for field_name, field in sorted(nested_fields.items(), key=lambda item: str(item[0])) + ] + + +def _inline_model_name(nested_fields: dict[object, object]) -> str: + """Return a stable Swagger model name for an anonymous inline field map.""" + + signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":")) + digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12] + return f"_AnonymousInlineModel_{digest}" + + +def apply_runtime_defaults() -> None: """Force the small config surface required for Swagger generation.""" os.environ.setdefault("SECRET_KEY", "spec-export") @@ -58,69 +148,175 @@ def _apply_runtime_defaults() -> None: dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true" -def _patch_swagger_for_inline_nested_dicts() -> None: - """Teach Flask-RESTX Swagger generation to tolerate inline nested field maps. - - Some existing controllers use `fields.Nested({...})` with a raw field mapping - instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous - dicts during schema registration, so this helper upgrades them into temporary - named models at export time. - """ - - if getattr(Swagger, "_dify_inline_nested_dict_patch", False): - return - - def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: - anonymous_models = getattr(self, "_anonymous_inline_models", None) - if anonymous_models is None: - anonymous_models = {} - self._anonymous_inline_models = anonymous_models - - anonymous_name = anonymous_models.get(id(nested_fields)) - if anonymous_name is None: - anonymous_name = f"_AnonymousInlineModel{len(anonymous_models) + 1}" - anonymous_models[id(nested_fields)] = anonymous_name - self.api.model(anonymous_name, nested_fields) - - return self.api.models[anonymous_name] - - def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: - if isinstance(model, dict): - model = get_or_create_inline_model(self, model) - - return _ORIGINAL_REGISTER_MODEL(self, model) - - def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: - nested = getattr(field, "nested", None) - if isinstance(nested, dict): - field.model = get_or_create_inline_model(self, nested) # type: ignore - - _ORIGINAL_REGISTER_FIELD(self, field) - - Swagger.register_model = register_model_with_inline_dict_support - Swagger.register_field = register_field_with_inline_dict_support - Swagger._dify_inline_nested_dict_patch = True - - def create_spec_app() -> Flask: """Build a minimal Flask app that only mounts the Swagger-producing blueprints.""" - _apply_runtime_defaults() - _patch_swagger_for_inline_nested_dicts() + apply_runtime_defaults() + + from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts + + patch_swagger_for_inline_nested_dicts() app = Flask(__name__) from controllers.console import bp as console_bp + from controllers.console import console_ns from controllers.service_api import bp as service_api_bp + from controllers.service_api import service_api_ns from controllers.web import bp as web_bp + from controllers.web import web_ns app.register_blueprint(console_bp) app.register_blueprint(web_bp) app.register_blueprint(service_api_bp) + for namespace in (console_ns, web_ns, service_api_ns): + for api in namespace.apis: + _materialize_inline_model_definitions(api) + return app +def _registered_models(namespace: str) -> dict[str, object]: + """Return the Flask-RESTX models registered for a Swagger namespace.""" + + if namespace == "console": + from controllers.console import console_ns + + models = dict(console_ns.models) + for api in console_ns.apis: + models.update(api.models) + return models + if namespace == "web": + from controllers.web import web_ns + + models = dict(web_ns.models) + for api in web_ns.apis: + models.update(api.models) + return models + if namespace == "service": + from controllers.service_api import service_api_ns + + models = dict(service_api_ns.models) + for api in service_api_ns.apis: + models.update(api.models) + return models + + raise ValueError(f"unknown Swagger namespace: {namespace}") + + +def _materialize_inline_model_definitions(api: RestxApi) -> None: + """Convert inline `fields.Nested({...})` maps into named API models.""" + + from flask_restx import fields + from flask_restx.model import Model, OrderedModel, instance + + inline_models: dict[int, dict[object, object]] = {} + inline_model_names: dict[int, str] = {} + + def collect_field(field: object) -> None: + field_instance = instance(field) + if isinstance(field_instance, fields.Nested): + nested = getattr(field_instance, "nested", None) + if _is_inline_field_map(nested) and id(nested) not in inline_models: + inline_models[id(nested)] = nested + for nested_field in nested.values(): + collect_field(nested_field) + + container = getattr(field_instance, "container", None) + if container is not None: + collect_field(container) + + for model in list(api.models.values()): + if isinstance(model, (Model, OrderedModel)): + for field in model.values(): + collect_field(field) + + for nested_fields in sorted(inline_models.values(), key=_inline_model_name): + anonymous_name = _inline_model_name(nested_fields) + inline_model_names[id(nested_fields)] = anonymous_name + if anonymous_name not in api.models: + api.model(anonymous_name, nested_fields) + + def model_name_for(nested_fields: dict[object, object]) -> str: + anonymous_name = inline_model_names.get(id(nested_fields)) + if anonymous_name is None: + anonymous_name = _inline_model_name(nested_fields) + inline_model_names[id(nested_fields)] = anonymous_name + if anonymous_name not in api.models: + api.model(anonymous_name, nested_fields) + return anonymous_name + + def materialize_field(field: object) -> None: + field_instance = instance(field) + if isinstance(field_instance, fields.Nested): + nested = getattr(field_instance, "nested", None) + if _is_inline_field_map(nested): + field_instance.model = api.models[model_name_for(nested)] # type: ignore[attr-defined] + + container = getattr(field_instance, "container", None) + if container is not None: + materialize_field(container) + + index = 0 + while index < len(api.models): + model = list(api.models.values())[index] + index += 1 + if isinstance(model, (Model, OrderedModel)): + for field in model.values(): + materialize_field(field) + + +def drop_null_values(value: object) -> object: + """Remove JSON null values that make the Markdown converter crash.""" + + if isinstance(value, dict): + return {key: drop_null_values(item) for key, item in value.items() if item is not None} + if isinstance(value, list): + return [drop_null_values(item) for item in value] + return value + + +def sort_openapi_arrays(value: object, *, parent_key: str | None = None) -> object: + """Sort order-insensitive Swagger arrays so generated Markdown is stable.""" + + if isinstance(value, dict): + return {key: sort_openapi_arrays(item, parent_key=key) for key, item in value.items()} + if not isinstance(value, list): + return value + + sorted_items = [sort_openapi_arrays(item, parent_key=parent_key) for item in value] + if parent_key == "parameters": + return sorted( + sorted_items, + key=lambda item: ( + item.get("in", "") if isinstance(item, dict) else "", + item.get("name", "") if isinstance(item, dict) else "", + json.dumps(item, sort_keys=True, default=str), + ), + ) + if parent_key in {"enum", "required", "schemes", "tags"}: + string_items = [item for item in sorted_items if isinstance(item, str)] + if len(string_items) == len(sorted_items): + return sorted(string_items) + return sorted_items + + +def _merge_registered_definitions(payload: dict[str, object], namespace: str) -> dict[str, object]: + """Include registered but route-indirect models in the exported Swagger definitions.""" + + definitions = payload.setdefault("definitions", {}) + if not isinstance(definitions, dict): + raise RuntimeError("unexpected Swagger definitions payload") + + for name, model in _registered_models(namespace).items(): + schema = getattr(model, "__schema__", None) + if isinstance(schema, dict): + definitions.setdefault(name, schema) + + return payload + + def generate_specs(output_dir: Path) -> list[Path]: """Write all Swagger specs to `output_dir` and return the written paths.""" @@ -138,6 +334,9 @@ def generate_specs(output_dir: Path) -> list[Path]: payload = response.get_json() if not isinstance(payload, dict): raise RuntimeError(f"unexpected response payload for {target.route}") + payload = _merge_registered_definitions(payload, target.namespace) + payload = drop_null_values(payload) + payload = sort_openapi_arrays(payload) output_path = output_dir / target.filename output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/api/enterprise/telemetry/enterprise_trace.py b/api/enterprise/telemetry/enterprise_trace.py index fc17d9d93e..25c1a96dc1 100644 --- a/api/enterprise/telemetry/enterprise_trace.py +++ b/api/enterprise/telemetry/enterprise_trace.py @@ -77,28 +77,29 @@ class EnterpriseOtelTrace: self._exporter = exporter def trace(self, trace_info: BaseTraceInfo) -> None: - if isinstance(trace_info, WorkflowTraceInfo): - self._workflow_trace(trace_info) - elif isinstance(trace_info, MessageTraceInfo): - self._message_trace(trace_info) - elif isinstance(trace_info, ToolTraceInfo): - self._tool_trace(trace_info) - elif isinstance(trace_info, DraftNodeExecutionTrace): - self._draft_node_execution_trace(trace_info) - elif isinstance(trace_info, WorkflowNodeTraceInfo): - self._node_execution_trace(trace_info) - elif isinstance(trace_info, ModerationTraceInfo): - self._moderation_trace(trace_info) - elif isinstance(trace_info, SuggestedQuestionTraceInfo): - self._suggested_question_trace(trace_info) - elif isinstance(trace_info, DatasetRetrievalTraceInfo): - self._dataset_retrieval_trace(trace_info) - elif isinstance(trace_info, GenerateNameTraceInfo): - self._generate_name_trace(trace_info) - elif isinstance(trace_info, PromptGenerationTraceInfo): - self._prompt_generation_trace(trace_info) - else: - raise AssertionError("this statment should be unreachable") + match trace_info: + case WorkflowTraceInfo(): + self._workflow_trace(trace_info) + case MessageTraceInfo(): + self._message_trace(trace_info) + case ToolTraceInfo(): + self._tool_trace(trace_info) + case DraftNodeExecutionTrace(): + self._draft_node_execution_trace(trace_info) + case WorkflowNodeTraceInfo(): + self._node_execution_trace(trace_info) + case ModerationTraceInfo(): + self._moderation_trace(trace_info) + case SuggestedQuestionTraceInfo(): + self._suggested_question_trace(trace_info) + case DatasetRetrievalTraceInfo(): + self._dataset_retrieval_trace(trace_info) + case GenerateNameTraceInfo(): + self._generate_name_trace(trace_info) + case PromptGenerationTraceInfo(): + self._prompt_generation_trace(trace_info) + case _: + raise AssertionError("this statment should be unreachable") def _common_attrs(self, trace_info: BaseTraceInfo) -> dict[str, Any]: metadata = self._metadata(trace_info) diff --git a/api/events/event_handlers/update_provider_when_message_created.py b/api/events/event_handlers/update_provider_when_message_created.py index 1d615f0f87..8dec5876a9 100644 --- a/api/events/event_handlers/update_provider_when_message_created.py +++ b/api/events/event_handlers/update_provider_when_message_created.py @@ -137,17 +137,13 @@ def handle(sender: Message, **kwargs): if used_quota is not None: match provider_configuration.system_configuration.current_quota_type: case ProviderQuotaType.TRIAL: - from services.credit_pool_service import CreditPoolService - - CreditPoolService.check_and_deduct_credits( + _deduct_credit_pool_quota_capped( tenant_id=tenant_id, credits_required=used_quota, pool_type="trial", ) case ProviderQuotaType.PAID: - from services.credit_pool_service import CreditPoolService - - CreditPoolService.check_and_deduct_credits( + _deduct_credit_pool_quota_capped( tenant_id=tenant_id, credits_required=used_quota, pool_type="paid", @@ -200,6 +196,26 @@ def handle(sender: Message, **kwargs): raise +def _deduct_credit_pool_quota_capped(*, tenant_id: str, credits_required: int, pool_type: str) -> None: + """Apply post-generation credit accounting without failing message persistence on quota exhaustion.""" + from services.credit_pool_service import CreditPoolService + + deducted_credits = CreditPoolService.deduct_credits_capped( + tenant_id=tenant_id, + credits_required=credits_required, + pool_type=pool_type, + ) + if deducted_credits < credits_required: + logger.warning( + "Credit pool exhausted during message-created accounting, " + "tenant_id=%s, pool_type=%s, credits_required=%s, credits_deducted=%s", + tenant_id, + pool_type, + credits_required, + deducted_credits, + ) + + def _calculate_quota_usage( *, message: Message, system_configuration: SystemConfiguration, model_name: str ) -> int | None: diff --git a/api/extensions/ext_app_metrics.py b/api/extensions/ext_app_metrics.py index 4a6490b9f0..53490c06a9 100644 --- a/api/extensions/ext_app_metrics.py +++ b/api/extensions/ext_app_metrics.py @@ -5,19 +5,20 @@ import threading from flask import Response from configs import dify_config +from controllers.console.admin import admin_required from dify_app import DifyApp def init_app(app: DifyApp): @app.after_request - def after_request(response): # pyright: ignore[reportUnusedFunction] + def after_request(response): """Add Version headers to the response.""" response.headers.add("X-Version", dify_config.project.version) response.headers.add("X-Env", dify_config.DEPLOY_ENV) return response @app.route("/health") - def health(): # pyright: ignore[reportUnusedFunction] + def health(): return Response( json.dumps({"pid": os.getpid(), "status": "ok", "version": dify_config.project.version}), status=200, @@ -25,7 +26,8 @@ def init_app(app: DifyApp): ) @app.route("/threads") - def threads(): # pyright: ignore[reportUnusedFunction] + @admin_required + def threads(): num_threads = threading.active_count() threads = threading.enumerate() @@ -50,7 +52,8 @@ def init_app(app: DifyApp): } @app.route("/db-pool-stat") - def pool_stat(): # pyright: ignore[reportUnusedFunction] + @admin_required + def pool_stat(): from extensions.ext_database import db engine = db.engine diff --git a/api/extensions/ext_database.py b/api/extensions/ext_database.py index 2e0d4c889a..466af99b95 100644 --- a/api/extensions/ext_database.py +++ b/api/extensions/ext_database.py @@ -33,7 +33,7 @@ def _setup_gevent_compatibility(): return @event.listens_for(Pool, "reset") - def _safe_reset(dbapi_connection, connection_record, reset_state): # pyright: ignore[reportUnusedFunction] + def _safe_reset(dbapi_connection, connection_record, reset_state): if reset_state.terminate_only: return diff --git a/api/extensions/ext_import_modules.py b/api/extensions/ext_import_modules.py index 4eb363ff93..9566f430b6 100644 --- a/api/extensions/ext_import_modules.py +++ b/api/extensions/ext_import_modules.py @@ -2,4 +2,4 @@ from dify_app import DifyApp def init_app(app: DifyApp): - from events import event_handlers # noqa: F401 # pyright: ignore[reportUnusedImport] + from events import event_handlers # noqa: F401 diff --git a/api/extensions/ext_mail.py b/api/extensions/ext_mail.py index 042bf8cc47..ed30e94678 100644 --- a/api/extensions/ext_mail.py +++ b/api/extensions/ext_mail.py @@ -67,7 +67,7 @@ class Mail: case _: raise ValueError(f"Unsupported mail type {mail_type}") - def send(self, to: str, subject: str, html: str, from_: str | None = None): + def send(self, to: str, subject: str, html: str, from_: str = ""): if not self._client: raise ValueError("Mail client is not initialized") diff --git a/api/extensions/ext_session_factory.py b/api/extensions/ext_session_factory.py index 0eb43d66f4..e19ccd11e5 100644 --- a/api/extensions/ext_session_factory.py +++ b/api/extensions/ext_session_factory.py @@ -1,7 +1,9 @@ +from flask import Flask + from core.db.session_factory import configure_session_factory from extensions.ext_database import db -def init_app(app): +def init_app(app: Flask): with app.app_context(): configure_session_factory(db.engine) diff --git a/api/extensions/ext_set_secretkey.py b/api/extensions/ext_set_secretkey.py index dfb87c0167..ca59a2de4d 100644 --- a/api/extensions/ext_set_secretkey.py +++ b/api/extensions/ext_set_secretkey.py @@ -1,6 +1,13 @@ from configs import dify_config +from configs.secret_key import resolve_secret_key from dify_app import DifyApp -def init_app(app: DifyApp): - app.secret_key = dify_config.SECRET_KEY +def init_app(app: DifyApp) -> None: + """Resolve SECRET_KEY after config loading and before session/login setup.""" + secret_key = dify_config.SECRET_KEY + if not secret_key: + secret_key = resolve_secret_key(secret_key) + dify_config.SECRET_KEY = secret_key + app.config["SECRET_KEY"] = secret_key + app.secret_key = secret_key diff --git a/api/extensions/ext_socketio.py b/api/extensions/ext_socketio.py index 5ed82bac8d..2fe2369e9f 100644 --- a/api/extensions/ext_socketio.py +++ b/api/extensions/ext_socketio.py @@ -1,5 +1,9 @@ +from typing import Any, cast + import socketio # type: ignore[reportMissingTypeStubs] from configs import dify_config -sio = socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS) +# TODO: FIXME(chariri) - Casting to any because app_factory attaches the +# current app as the `app` attribute on this - Bad. +sio = cast(Any, socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS)) diff --git a/api/extensions/logstore/aliyun_logstore.py b/api/extensions/logstore/aliyun_logstore.py index f6a4765f14..9869e1eaf7 100644 --- a/api/extensions/logstore/aliyun_logstore.py +++ b/api/extensions/logstore/aliyun_logstore.py @@ -198,7 +198,7 @@ class AliyunLogStore: ) # Append Dify identification to the existing user agent - original_user_agent = self.client._user_agent # pyright: ignore[reportPrivateUsage] + original_user_agent = self.client._user_agent dify_version = dify_config.project.version enhanced_user_agent = f"Dify,Dify-{dify_version},{original_user_agent}" self.client.set_user_agent(enhanced_user_agent) diff --git a/api/factories/file_factory/builders.py b/api/factories/file_factory/builders.py index 1d2ad4d445..4fb976f0e7 100644 --- a/api/factories/file_factory/builders.py +++ b/api/factories/file_factory/builders.py @@ -298,7 +298,7 @@ def _build_from_datasource_file( raise ValueError(f"DatasourceFile {mapping.get('datasource_file_id')} not found") extension = "." + datasource_file.key.split(".")[-1] if "." in datasource_file.key else ".bin" - detected_file_type = standardize_file_type(extension="." + extension, mime_type=datasource_file.mime_type) + detected_file_type = standardize_file_type(extension=extension, mime_type=datasource_file.mime_type) file_type = _resolve_file_type( detected_file_type=detected_file_type, specified_type=mapping.get("type"), diff --git a/api/factories/file_factory/message_files.py b/api/factories/file_factory/message_files.py index 4b3d514238..27441bdcc1 100644 --- a/api/factories/file_factory/message_files.py +++ b/api/factories/file_factory/message_files.py @@ -1,11 +1,18 @@ -"""Adapters from persisted message files to graph-layer file values.""" +"""Adapters from persisted message files to graph-layer file values. + +Replay paths only: files in conversation history were validated at upload time, +so these helpers deliberately do not accept (or forward) a ``FileUploadConfig`` — +re-validation here would break replays whenever workflow ``file_upload`` config +drifts between rounds. Mirrors ``build_file_from_stored_mapping`` in +``models/utils/file_input_compat.py``. +""" from __future__ import annotations from collections.abc import Sequence from core.app.file_access import FileAccessControllerProtocol -from graphon.file import File, FileBelongsTo, FileTransferMethod, FileUploadConfig +from graphon.file import File, FileBelongsTo, FileTransferMethod from models import MessageFile from .builders import build_from_mapping @@ -15,14 +22,12 @@ def build_from_message_files( *, message_files: Sequence[MessageFile], tenant_id: str, - config: FileUploadConfig | None = None, access_controller: FileAccessControllerProtocol, ) -> Sequence[File]: return [ build_from_message_file( message_file=message_file, tenant_id=tenant_id, - config=config, access_controller=access_controller, ) for message_file in message_files @@ -34,7 +39,6 @@ def build_from_message_file( *, message_file: MessageFile, tenant_id: str, - config: FileUploadConfig | None, access_controller: FileAccessControllerProtocol, ) -> File: mapping = { @@ -54,6 +58,5 @@ def build_from_message_file( return build_from_mapping( mapping=mapping, tenant_id=tenant_id, - config=config, access_controller=access_controller, ) diff --git a/api/factories/file_factory/validation.py b/api/factories/file_factory/validation.py index 4c4f6150e4..8c4e7ef1d4 100644 --- a/api/factories/file_factory/validation.py +++ b/api/factories/file_factory/validation.py @@ -2,9 +2,25 @@ from __future__ import annotations +from collections.abc import Iterable + from graphon.file import FileTransferMethod, FileType, FileUploadConfig +def _normalize_extension(extension: str) -> str: + s = extension.strip().lower() + if not s: + return "" + return s if s.startswith(".") else "." + s + + +def _extension_matches(extension: str, whitelist: Iterable[str]) -> bool: + normalized = _normalize_extension(extension) + if not normalized: + return False + return normalized in {_normalize_extension(e) for e in whitelist} + + def is_file_valid_with_config( *, input_file_type: str, @@ -12,22 +28,31 @@ def is_file_valid_with_config( file_transfer_method: FileTransferMethod, config: FileUploadConfig, ) -> bool: - # FIXME(QIN2DIM): Always allow tool files (files generated by the assistant/model) - # These are internally generated and should bypass user upload restrictions + """Return whether the file is allowed by the upload config. + + ``allowed_file_types`` lists the buckets a file may fall into; ``CUSTOM`` is + a fallback bucket gated by ``allowed_file_extensions`` (case- and + dot-insensitive). Tool-generated files bypass user-facing config. + """ if file_transfer_method == FileTransferMethod.TOOL_FILE: return True - if ( - config.allowed_file_types - and input_file_type not in config.allowed_file_types - and input_file_type != FileType.CUSTOM - ): + allowed_types = config.allowed_file_types or [] + custom_allowed = FileType.CUSTOM in allowed_types + type_allowed = not allowed_types or input_file_type in allowed_types + + if not type_allowed and not custom_allowed: return False + # When the file is in the CUSTOM bucket, the extension whitelist is authoritative. + # An explicitly set whitelist (including the empty list) is enforced; empty == deny — + # the UI never submits an empty list, so this guards against DSL/API paths that + # bypass the UI from accidentally widening the allowlist. + in_custom_bucket = input_file_type == FileType.CUSTOM or not type_allowed if ( - input_file_type == FileType.CUSTOM + in_custom_bucket and config.allowed_file_extensions is not None - and file_extension not in config.allowed_file_extensions + and not _extension_matches(file_extension, config.allowed_file_extensions) ): return False diff --git a/api/fields/annotation_fields.py b/api/fields/annotation_fields.py index b2a0e92c47..4546a051cc 100644 --- a/api/fields/annotation_fields.py +++ b/api/fields/annotation_fields.py @@ -5,12 +5,7 @@ from datetime import datetime from pydantic import Field, field_validator from fields.base import ResponseModel - - -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value +from libs.helper import to_timestamp class Annotation(ResponseModel): @@ -23,7 +18,7 @@ class Annotation(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AnnotationList(ResponseModel): @@ -50,7 +45,7 @@ class AnnotationHitHistory(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AnnotationHitHistoryList(ResponseModel): diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index bf5c9ffcb1..eb49577d59 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -7,6 +7,7 @@ from pydantic import Field, field_validator, model_validator from fields.base import ResponseModel from graphon.file import File +from libs.helper import to_timestamp type JSONValue = Any @@ -47,9 +48,7 @@ class SimpleConversation(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class ConversationInfiniteScrollPagination(ResponseModel): @@ -90,9 +89,7 @@ class ConversationAnnotation(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class ConversationAnnotationHitHistory(ResponseModel): @@ -103,9 +100,7 @@ class ConversationAnnotationHitHistory(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class AgentThought(ResponseModel): @@ -125,9 +120,7 @@ class AgentThought(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) @model_validator(mode="after") def _fallback_chain_id(self): @@ -169,9 +162,7 @@ class MessageDetail(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class FeedbackStat(ResponseModel): @@ -237,9 +228,7 @@ class Conversation(ResponseModel): @field_validator("read_at", "created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class ConversationPagination(ResponseModel): @@ -263,9 +252,7 @@ class ConversationMessageDetail(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class ConversationWithSummary(ResponseModel): @@ -291,9 +278,7 @@ class ConversationWithSummary(ResponseModel): @field_validator("read_at", "created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class ConversationWithSummaryPagination(ResponseModel): @@ -322,15 +307,7 @@ class ConversationDetail(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value - - -def to_timestamp(value: datetime | None) -> int | None: - if value is None: - return None - return int(value.timestamp()) + return to_timestamp(value) def format_files_contained(value: JSONValue) -> JSONValue: diff --git a/api/fields/conversation_variable_fields.py b/api/fields/conversation_variable_fields.py index e4219ba1ee..05a519f3b1 100644 --- a/api/fields/conversation_variable_fields.py +++ b/api/fields/conversation_variable_fields.py @@ -8,7 +8,7 @@ from pydantic import field_validator from fields.base import ResponseModel from graphon.variables.types import SegmentType -from libs.helper import TimestampField +from libs.helper import TimestampField, to_timestamp from ._value_type_serializer import serialize_value_type @@ -37,12 +37,6 @@ conversation_variable_infinite_scroll_pagination_fields = { } -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class ConversationVariableResponse(ResponseModel): id: str name: str @@ -88,7 +82,7 @@ class ConversationVariableResponse(ResponseModel): @field_validator("created_at", "updated_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class PaginatedConversationVariableResponse(ResponseModel): diff --git a/api/fields/file_fields.py b/api/fields/file_fields.py index ad8b95e4dc..a3987a7e40 100644 --- a/api/fields/file_fields.py +++ b/api/fields/file_fields.py @@ -5,12 +5,7 @@ from datetime import datetime from pydantic import field_validator from fields.base import ResponseModel - - -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value +from libs.helper import to_timestamp class UploadConfig(ResponseModel): @@ -45,7 +40,7 @@ class FileResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class RemoteFileInfo(ResponseModel): @@ -66,7 +61,7 @@ class FileWithSignedUrl(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) __all__ = [ diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 67b320beaa..7ae5e3b652 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -6,7 +6,7 @@ from flask_restx import fields from pydantic import computed_field, field_validator from fields.base import ResponseModel -from graphon.file import helpers as file_helpers +from libs.helper import build_avatar_url, to_timestamp simple_account_fields = { "id": fields.String, @@ -15,20 +15,6 @@ simple_account_fields = { } -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - -def _build_avatar_url(avatar: str | None) -> str | None: - if avatar is None: - return None - if avatar.startswith(("http://", "https://")): - return avatar - return file_helpers.get_signed_file_url(avatar) - - class SimpleAccount(ResponseModel): id: str name: str @@ -41,7 +27,7 @@ class _AccountAvatar(ResponseModel): @computed_field(return_type=str | None) # type: ignore[prop-decorator] @property def avatar_url(self) -> str | None: - return _build_avatar_url(self.avatar) + return build_avatar_url(self.avatar) class Account(_AccountAvatar): @@ -59,7 +45,7 @@ class Account(_AccountAvatar): @field_validator("last_login_at", "created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AccountWithRole(_AccountAvatar): @@ -75,7 +61,7 @@ class AccountWithRole(_AccountAvatar): @field_validator("last_login_at", "last_active_at", "created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AccountWithRoleList(ResponseModel): diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index ca18f1c203..e0d37dd701 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -9,6 +9,7 @@ from core.entities.execution_extra_content import ExecutionExtraContentDomainMod from fields.base import ResponseModel from fields.conversation_fields import AgentThought, JSONValue, MessageFile from graphon.file import File +from libs.helper import to_timestamp type JSONValueType = JSONValue @@ -39,9 +40,7 @@ class RetrieverResource(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class MessageListItem(ResponseModel): @@ -68,9 +67,7 @@ class MessageListItem(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class WebMessageListItem(MessageListItem): @@ -106,9 +103,7 @@ class SavedMessageItem(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_created_at(cls, value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return to_timestamp(value) - return value + return to_timestamp(value) class SavedMessageInfiniteScrollPagination(ResponseModel): @@ -121,12 +116,6 @@ class SuggestedQuestionsResponse(ResponseModel): data: list[str] -def to_timestamp(value: datetime | None) -> int | None: - if value is None: - return None - return int(value.timestamp()) - - def format_files_contained(value: JSONValueType) -> JSONValueType: if isinstance(value, File): # Response payloads must preserve legacy file keys like `related_id`/`url` diff --git a/api/fields/online_user_fields.py b/api/fields/online_user_fields.py deleted file mode 100644 index bdbe19679c..0000000000 --- a/api/fields/online_user_fields.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask_restx import fields - -online_user_partial_fields = { - "user_id": fields.String, - "username": fields.String, - "avatar": fields.String, -} - -workflow_online_users_fields = { - "app_id": fields.String, - "users": fields.List(fields.Nested(online_user_partial_fields)), -} - -online_user_list_fields = { - "data": fields.List(fields.Nested(workflow_online_users_fields)), -} diff --git a/api/fields/workflow_app_log_fields.py b/api/fields/workflow_app_log_fields.py index 1b2c71255d..a70f051807 100644 --- a/api/fields/workflow_app_log_fields.py +++ b/api/fields/workflow_app_log_fields.py @@ -17,7 +17,7 @@ from fields.workflow_run_fields import ( workflow_run_for_archived_log_fields, workflow_run_for_log_fields, ) -from libs.helper import TimestampField +from libs.helper import TimestampField, to_timestamp workflow_app_log_partial_fields = { "id": fields.String, @@ -96,12 +96,6 @@ def build_workflow_archived_log_pagination_model(api_or_ns: Namespace): return api_or_ns.model("WorkflowArchivedLogPagination", copied_fields) -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class WorkflowAppLogPartialResponse(ResponseModel): id: str workflow_run: WorkflowRunForLogResponse | None = None @@ -115,7 +109,7 @@ class WorkflowAppLogPartialResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowArchivedLogPartialResponse(ResponseModel): @@ -129,7 +123,7 @@ class WorkflowArchivedLogPartialResponse(ResponseModel): @field_validator("created_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowAppLogPaginationResponse(ResponseModel): diff --git a/api/fields/workflow_comment_fields.py b/api/fields/workflow_comment_fields.py deleted file mode 100644 index c708dd3460..0000000000 --- a/api/fields/workflow_comment_fields.py +++ /dev/null @@ -1,96 +0,0 @@ -from flask_restx import fields - -from libs.helper import AvatarUrlField, TimestampField - -# basic account fields for comments -account_fields = { - "id": fields.String, - "name": fields.String, - "email": fields.String, - "avatar_url": AvatarUrlField, -} - -# Comment mention fields -workflow_comment_mention_fields = { - "mentioned_user_id": fields.String, - "mentioned_user_account": fields.Nested(account_fields, allow_null=True), - "reply_id": fields.String, -} - -# Comment reply fields -workflow_comment_reply_fields = { - "id": fields.String, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(account_fields, allow_null=True), - "created_at": TimestampField, -} - -# Basic comment fields (for list views) -workflow_comment_basic_fields = { - "id": fields.String, - "position_x": fields.Float, - "position_y": fields.Float, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(account_fields, allow_null=True), - "created_at": TimestampField, - "updated_at": TimestampField, - "resolved": fields.Boolean, - "resolved_at": TimestampField, - "resolved_by": fields.String, - "resolved_by_account": fields.Nested(account_fields, allow_null=True), - "reply_count": fields.Integer, - "mention_count": fields.Integer, - "participants": fields.List(fields.Nested(account_fields)), -} - -# Detailed comment fields (for single comment view) -workflow_comment_detail_fields = { - "id": fields.String, - "position_x": fields.Float, - "position_y": fields.Float, - "content": fields.String, - "created_by": fields.String, - "created_by_account": fields.Nested(account_fields, allow_null=True), - "created_at": TimestampField, - "updated_at": TimestampField, - "resolved": fields.Boolean, - "resolved_at": TimestampField, - "resolved_by": fields.String, - "resolved_by_account": fields.Nested(account_fields, allow_null=True), - "replies": fields.List(fields.Nested(workflow_comment_reply_fields)), - "mentions": fields.List(fields.Nested(workflow_comment_mention_fields)), -} - -# Comment creation response fields (simplified) -workflow_comment_create_fields = { - "id": fields.String, - "created_at": TimestampField, -} - -# Comment update response fields (simplified) -workflow_comment_update_fields = { - "id": fields.String, - "updated_at": TimestampField, -} - -# Comment resolve response fields -workflow_comment_resolve_fields = { - "id": fields.String, - "resolved": fields.Boolean, - "resolved_at": TimestampField, - "resolved_by": fields.String, -} - -# Reply creation response fields (simplified) -workflow_comment_reply_create_fields = { - "id": fields.String, - "created_at": TimestampField, -} - -# Reply update response fields -workflow_comment_reply_update_fields = { - "id": fields.String, - "updated_at": TimestampField, -} diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py index 8c659086ed..53cdfa234f 100644 --- a/api/fields/workflow_run_fields.py +++ b/api/fields/workflow_run_fields.py @@ -1,15 +1,22 @@ +"""Workflow run response schemas for console APIs. + +Most workflow-run endpoints should document and serialize responses with the +Pydantic models in this module. The remaining Flask-RESTX field dictionaries are +kept only for workflow app-log endpoints that still build legacy log models. +""" + from __future__ import annotations from datetime import datetime from typing import Any from flask_restx import Namespace, fields -from pydantic import Field, field_validator +from pydantic import AliasChoices, Field, field_validator from fields.base import ResponseModel -from fields.end_user_fields import SimpleEndUser, simple_end_user_fields -from fields.member_fields import SimpleAccount, simple_account_fields -from libs.helper import TimestampField +from fields.end_user_fields import SimpleEndUser +from fields.member_fields import SimpleAccount +from libs.helper import TimestampField, to_timestamp workflow_run_for_log_fields = { "id": fields.String, @@ -43,125 +50,6 @@ def build_workflow_run_for_archived_log_model(api_or_ns: Namespace): return api_or_ns.model("WorkflowRunForArchivedLog", workflow_run_for_archived_log_fields) -workflow_run_for_list_fields = { - "id": fields.String, - "version": fields.String, - "status": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, - "retry_index": fields.Integer, -} - -advanced_chat_workflow_run_for_list_fields = { - "id": fields.String, - "conversation_id": fields.String, - "message_id": fields.String, - "version": fields.String, - "status": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, - "retry_index": fields.Integer, -} - -advanced_chat_workflow_run_pagination_fields = { - "limit": fields.Integer(attribute="limit"), - "has_more": fields.Boolean(attribute="has_more"), - "data": fields.List(fields.Nested(advanced_chat_workflow_run_for_list_fields), attribute="data"), -} - -workflow_run_pagination_fields = { - "limit": fields.Integer(attribute="limit"), - "has_more": fields.Boolean(attribute="has_more"), - "data": fields.List(fields.Nested(workflow_run_for_list_fields), attribute="data"), -} - -workflow_run_count_fields = { - "total": fields.Integer, - "running": fields.Integer, - "succeeded": fields.Integer, - "failed": fields.Integer, - "stopped": fields.Integer, - "partial_succeeded": fields.Integer(attribute="partial-succeeded"), -} - -workflow_run_detail_fields = { - "id": fields.String, - "version": fields.String, - "graph": fields.Raw(attribute="graph_dict"), - "inputs": fields.Raw(attribute="inputs_dict"), - "status": fields.String, - "outputs": fields.Raw(attribute="outputs_dict"), - "error": fields.String, - "elapsed_time": fields.Float, - "total_tokens": fields.Integer, - "total_steps": fields.Integer, - "created_by_role": fields.String, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True), - "created_at": TimestampField, - "finished_at": TimestampField, - "exceptions_count": fields.Integer, -} - -retry_event_field = { - "elapsed_time": fields.Float, - "status": fields.String, - "inputs": fields.Raw(attribute="inputs"), - "process_data": fields.Raw(attribute="process_data"), - "outputs": fields.Raw(attribute="outputs"), - "metadata": fields.Raw(attribute="metadata"), - "llm_usage": fields.Raw(attribute="llm_usage"), - "error": fields.String, - "retry_index": fields.Integer, -} - - -workflow_run_node_execution_fields = { - "id": fields.String, - "index": fields.Integer, - "predecessor_node_id": fields.String, - "node_id": fields.String, - "node_type": fields.String, - "title": fields.String, - "inputs": fields.Raw(attribute="inputs_dict"), - "process_data": fields.Raw(attribute="process_data_dict"), - "outputs": fields.Raw(attribute="outputs_dict"), - "status": fields.String, - "error": fields.String, - "elapsed_time": fields.Float, - "execution_metadata": fields.Raw(attribute="execution_metadata_dict"), - "extras": fields.Raw, - "created_at": TimestampField, - "created_by_role": fields.String, - "created_by_account": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), - "created_by_end_user": fields.Nested(simple_end_user_fields, attribute="created_by_end_user", allow_null=True), - "finished_at": TimestampField, - "inputs_truncated": fields.Boolean, - "outputs_truncated": fields.Boolean, - "process_data_truncated": fields.Boolean, -} - -workflow_run_node_execution_list_fields = { - "data": fields.List(fields.Nested(workflow_run_node_execution_fields)), -} - - -def _to_timestamp(value: datetime | int | None) -> int | None: - if isinstance(value, datetime): - return int(value.timestamp()) - return value - - class WorkflowRunForLogResponse(ResponseModel): id: str version: str | None = None @@ -185,7 +73,7 @@ class WorkflowRunForLogResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowRunForArchivedLogResponse(ResponseModel): @@ -226,7 +114,7 @@ class WorkflowRunForListResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class AdvancedChatWorkflowRunForListResponse(WorkflowRunForListResponse): @@ -252,7 +140,10 @@ class WorkflowRunCountResponse(ResponseModel): succeeded: int failed: int stopped: int - partial_succeeded: int = Field(validation_alias="partial-succeeded") + partial_succeeded: int = Field( + alias="partial_succeeded", + validation_alias=AliasChoices("partial_succeeded", "partial-succeeded"), + ) class WorkflowRunDetailResponse(ResponseModel): @@ -283,7 +174,7 @@ class WorkflowRunDetailResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowRunNodeExecutionResponse(ResponseModel): @@ -320,7 +211,7 @@ class WorkflowRunNodeExecutionResponse(ResponseModel): @field_validator("created_at", "finished_at", mode="before") @classmethod def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: - return _to_timestamp(value) + return to_timestamp(value) class WorkflowRunNodeExecutionListResponse(ResponseModel): diff --git a/api/libs/external_api.py b/api/libs/external_api.py index f907d17750..64eb99a42b 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,6 +9,7 @@ from werkzeug.http import HTTP_STATUS_CODES from configs import dify_config from core.errors.error import AppInvokeQuotaExceededError +from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts from libs.token import build_force_logout_cookie_headers @@ -120,6 +121,7 @@ class ExternalApi(Api): } def __init__(self, app: Blueprint | Flask, *args, **kwargs): + patch_swagger_for_inline_nested_dicts() kwargs.setdefault("authorizations", self._authorizations) kwargs.setdefault("security", "Bearer") kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED diff --git a/api/libs/flask_restx_compat.py b/api/libs/flask_restx_compat.py new file mode 100644 index 0000000000..34e0d586a0 --- /dev/null +++ b/api/libs/flask_restx_compat.py @@ -0,0 +1,149 @@ +"""Compatibility helpers for Dify's Flask-RESTX Swagger integration. + +These helpers are temporary bridges for legacy Flask-RESTX field contracts +while controllers migrate their request and response documentation to Pydantic +models. Keep the behavior centralized so live Swagger endpoints and offline +spec export fail or succeed in the same way. +""" + +import hashlib +import json +from typing import TypeGuard + +from flask import current_app +from flask_restx import fields +from flask_restx.model import Model, OrderedModel, instance +from flask_restx.swagger import Swagger + + +def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: + """Return whether a nested field map is an anonymous inline mapping.""" + + return isinstance(value, dict) and not isinstance(value, (Model, OrderedModel)) + + +def _jsonable_schema_value(value: object) -> object: + """Return a deterministic JSON-serializable representation for schema fingerprints.""" + + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, list | tuple): + return [_jsonable_schema_value(item) for item in value] + if isinstance(value, dict): + return {str(key): _jsonable_schema_value(item) for key, item in value.items()} + value_type = type(value) + return f"<{value_type.__module__}.{value_type.__qualname__}>" + + +def _field_signature(field: object) -> object: + """Build a stable signature for a Flask-RESTX field object.""" + + field_instance = instance(field) + signature: dict[str, object] = { + "class": f"{field_instance.__class__.__module__}.{field_instance.__class__.__qualname__}" + } + + if isinstance(field_instance, fields.Nested): + nested = getattr(field_instance, "nested", None) + if _is_inline_field_map(nested): + signature["nested"] = _inline_model_signature(nested) + else: + signature["nested"] = getattr( + nested, + "name", + f"<{type(nested).__module__}.{type(nested).__qualname__}>", + ) + elif hasattr(field_instance, "container"): + signature["container"] = _field_signature(field_instance.container) + else: + schema = getattr(field_instance, "__schema__", None) + if isinstance(schema, dict): + signature["schema"] = _jsonable_schema_value(schema) + + for attr_name in ( + "attribute", + "default", + "description", + "example", + "max", + "max_items", + "min", + "min_items", + "nullable", + "readonly", + "required", + "title", + "unique", + ): + if hasattr(field_instance, attr_name): + signature[attr_name] = _jsonable_schema_value(getattr(field_instance, attr_name)) + + return signature + + +def _inline_model_signature(nested_fields: dict[object, object]) -> object: + """Build a stable signature for an anonymous inline model.""" + + return [ + (str(field_name), _field_signature(field)) + for field_name, field in sorted(nested_fields.items(), key=lambda item: str(item[0])) + ] + + +def _inline_model_name(nested_fields: dict[object, object]) -> str: + """Return a stable Swagger model name for an anonymous inline field map.""" + + signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":")) + digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12] + return f"_AnonymousInlineModel_{digest}" + + +def patch_swagger_for_inline_nested_dicts() -> None: + """Allow Swagger generation to handle legacy inline Flask-RESTX field dicts. + + Some existing controllers use raw field mappings in `fields.Nested({...})` + or directly in `@namespace.response(...)`. Runtime marshalling accepts that, + but Flask-RESTX Swagger registration expects a named model. Convert those + anonymous mappings into temporary named models during docs generation. + """ + + if getattr(Swagger, "_dify_inline_nested_dict_patch", False): + return + + original_register_model = Swagger.register_model + original_register_field = Swagger.register_field + original_as_dict = Swagger.as_dict + + def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: + anonymous_name = _inline_model_name(nested_fields) + if anonymous_name not in self.api.models: + self.api.model(anonymous_name, nested_fields) + + return self.api.models[anonymous_name] + + def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: + if _is_inline_field_map(model): + model = get_or_create_inline_model(self, model) + + return original_register_model(self, model) + + def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: + nested = getattr(field, "nested", None) + if _is_inline_field_map(nested): + field.model = get_or_create_inline_model(self, nested) # type: ignore[attr-defined] + + original_register_field(self, field) + + def as_dict_with_inline_dict_support(self: Swagger): + # Temporary set RESTX_INCLUDE_ALL_MODELS = false to prevent "length changed while iterating" error + include_all_models = current_app.config.get("RESTX_INCLUDE_ALL_MODELS", False) + current_app.config["RESTX_INCLUDE_ALL_MODELS"] = False + try: + return original_as_dict(self) + finally: + current_app.config["RESTX_INCLUDE_ALL_MODELS"] = include_all_models + + Swagger.register_model = register_model_with_inline_dict_support + Swagger.register_field = register_field_with_inline_dict_support + Swagger.as_dict = as_dict_with_inline_dict_support + Swagger._dify_inline_nested_dict_patch = True diff --git a/api/libs/helper.py b/api/libs/helper.py index ac69a11084..04900f385c 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -10,7 +10,7 @@ import uuid from collections.abc import Callable, Generator, Mapping from datetime import datetime from hashlib import sha256 -from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast +from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast, overload from uuid import UUID from zoneinfo import available_timezones @@ -39,6 +39,7 @@ class _TokenData(TypedDict, total=False): token_type: str code: str old_email: str + phase: str _token_data_adapter: TypeAdapter[_TokenData] = TypeAdapter(_TokenData) @@ -97,12 +98,13 @@ def extract_tenant_id(user: "Account | EndUser") -> str | None: from models import Account from models.model import EndUser - if isinstance(user, Account): - return user.current_tenant_id - elif isinstance(user, EndUser): - return user.tenant_id - else: - raise ValueError(f"Invalid user type: {type(user)}. Expected Account or EndUser.") + match user: + case Account(): + return user.current_tenant_id + case EndUser(): + return user.tenant_id + case _: + raise ValueError(f"Invalid user type: {type(user)}. Expected Account or EndUser.") def run(script): @@ -136,6 +138,14 @@ def build_icon_url(icon_type: Any, icon: str | None) -> str | None: return file_helpers.get_signed_file_url(icon) +def build_avatar_url(avatar: str | None) -> str | None: + if avatar is None: + return None + if avatar.startswith(("http://", "https://")): + return avatar + return file_helpers.get_signed_file_url(avatar) + + class AvatarUrlField(fields.Raw): def output(self, key, obj, **kwargs): if obj is None: @@ -144,9 +154,7 @@ class AvatarUrlField(fields.Raw): from models import Account if isinstance(obj, Account) and obj.avatar is not None: - if obj.avatar.startswith(("http://", "https://")): - return obj.avatar - return file_helpers.get_signed_file_url(obj.avatar) + return build_avatar_url(obj.avatar) return None @@ -162,6 +170,35 @@ class OptionalTimestampField(fields.Raw): return int(value.timestamp()) +@overload +def to_timestamp(value: datetime) -> int: ... + + +@overload +def to_timestamp(value: int) -> int: ... + + +@overload +def to_timestamp(value: None) -> None: ... + + +def to_timestamp(value: datetime | int | None) -> int | None: + """Normalize API response timestamp values to epoch seconds.""" + if isinstance(value, datetime): + return int(value.timestamp()) + return value + + +def dump_response(model: type[BaseModel], data: Any) -> dict[str, Any]: + """Serialize a Pydantic response model to JSON-compatible dict output.""" + return model.model_validate(data, from_attributes=True).model_dump(mode="json") + + +def current_timestamp() -> int: + """Return the current Unix timestamp in seconds.""" + return int(time.time()) + + def email(email): # Define a regex pattern for email addresses pattern = r"^[\w\.!#$%&'*+\-/=?^_`{|}~]+@([\w-]+\.)+[\w-]{2,}$" @@ -386,18 +423,19 @@ def length_prefixed_response( # | Magic Number 1byte | Reserved 1byte | Header Length 2bytes | Data Length 4bytes | Reserved 6bytes | Data return struct.pack(" str | None: + state: OAuthState = {} + if invite_token: + state["invite_token"] = invite_token + if timezone: + state["timezone"] = timezone + if language: + state["language"] = language + if not state: + return None + + raw_state = json.dumps(state, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw_state).decode("ascii").rstrip("=") + + +def decode_oauth_state(state: str | None) -> OAuthState: + if not state: + return {} + + try: + padded_state = state + "=" * (-len(state) % 4) + raw_state = base64.urlsafe_b64decode(padded_state.encode("ascii")).decode("utf-8") + return OAUTH_STATE_ADAPTER.validate_python(json.loads(raw_state)) + except (binascii.Error, ValueError, UnicodeDecodeError, json.JSONDecodeError, ValidationError): + return {} + + def _json_object(response: httpx.Response) -> JsonObject: return JSON_OBJECT_ADAPTER.validate_python(response.json()) @@ -76,7 +117,12 @@ class OAuth: self.client_secret = client_secret self.redirect_uri = redirect_uri - def get_authorization_url(self, invite_token: str | None = None) -> str: + def get_authorization_url( + self, + invite_token: str | None = None, + timezone: str | None = None, + language: str | None = None, + ) -> str: raise NotImplementedError() def get_access_token(self, code: str) -> str: @@ -99,14 +145,20 @@ class GitHubOAuth(OAuth): _USER_INFO_URL = "https://api.github.com/user" _EMAIL_INFO_URL = "https://api.github.com/user/emails" - def get_authorization_url(self, invite_token: str | None = None) -> str: + def get_authorization_url( + self, + invite_token: str | None = None, + timezone: str | None = None, + language: str | None = None, + ) -> str: params = { "client_id": self.client_id, "redirect_uri": self.redirect_uri, "scope": "user:email", # Request only basic user information } - if invite_token: - params["state"] = invite_token + state = encode_oauth_state(invite_token=invite_token, timezone=timezone, language=language) + if state: + params["state"] = state return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" def get_access_token(self, code: str) -> str: @@ -186,15 +238,21 @@ class GoogleOAuth(OAuth): _TOKEN_URL = "https://oauth2.googleapis.com/token" _USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" - def get_authorization_url(self, invite_token: str | None = None) -> str: + def get_authorization_url( + self, + invite_token: str | None = None, + timezone: str | None = None, + language: str | None = None, + ) -> str: params = { "client_id": self.client_id, "response_type": "code", "redirect_uri": self.redirect_uri, "scope": "openid email", } - if invite_token: - params["state"] = invite_token + state = encode_oauth_state(invite_token=invite_token, timezone=timezone, language=language) + if state: + params["state"] = state return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" def get_access_token(self, code: str) -> str: diff --git a/api/libs/typing.py b/api/libs/typing.py deleted file mode 100644 index f84e9911e0..0000000000 --- a/api/libs/typing.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import TypeGuard - - -def is_str_dict(v: object) -> TypeGuard[dict[str, object]]: - return isinstance(v, dict) - - -def is_str(v: object) -> TypeGuard[str]: - return isinstance(v, str) diff --git a/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py new file mode 100644 index 0000000000..eee58b6310 --- /dev/null +++ b/api/migrations/versions/2026_04_29_1200-a4f2d8c9b731_add_recommended_app_categories.py @@ -0,0 +1,26 @@ +"""add recommended app categories + +Revision ID: a4f2d8c9b731 +Revises: 227822d22895 +Create Date: 2026-04-29 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a4f2d8c9b731" +down_revision = "227822d22895" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("categories", sa.JSON(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("recommended_apps", schema=None) as batch_op: + batch_op.drop_column("categories") diff --git a/api/models/comment.py b/api/models/comment.py index 5d4a08e783..6d151fe13d 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -1,19 +1,22 @@ """Workflow comment models.""" +from __future__ import annotations + from datetime import datetime -from typing import Optional import sqlalchemy as sa from sqlalchemy import Index, func from sqlalchemy.orm import Mapped, mapped_column, relationship +from models.base import TypeBase + from .account import Account -from .base import Base, gen_uuidv7_string +from .base import gen_uuidv7_string from .engine import db from .types import StringUUID -class WorkflowComment(Base): +class WorkflowComment(TypeBase): """Workflow comment model for canvas commenting functionality. Comments are associated with apps rather than specific workflow versions, @@ -42,27 +45,33 @@ class WorkflowComment(Base): Index("workflow_comments_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position_x: Mapped[float] = mapped_column(sa.Float) position_y: Mapped[float] = mapped_column(sa.Float) content: Mapped[str] = mapped_column(sa.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) - resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) - resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime) - resolved_by: Mapped[str | None] = mapped_column(StringUUID) + resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None) + resolved_by: Mapped[str | None] = mapped_column(StringUUID, default=None) + resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False) # Relationships - replies: Mapped[list["WorkflowCommentReply"]] = relationship( - "WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan" + replies: Mapped[list[WorkflowCommentReply]] = relationship( + lambda: WorkflowCommentReply, back_populates="comment", cascade="all, delete-orphan", init=False ) - mentions: Mapped[list["WorkflowCommentMention"]] = relationship( - "WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan" + mentions: Mapped[list[WorkflowCommentMention]] = relationship( + lambda: WorkflowCommentMention, back_populates="comment", cascade="all, delete-orphan", init=False ) @property @@ -131,7 +140,7 @@ class WorkflowComment(Base): return participants -class WorkflowCommentReply(Base): +class WorkflowCommentReply(TypeBase): """Workflow comment reply model. Attributes: @@ -149,18 +158,24 @@ class WorkflowCommentReply(Base): Index("comment_replies_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) content: Mapped[str] = mapped_column(sa.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) # Relationships - comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies") + comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="replies", init=False) @property def created_by_account(self): @@ -174,7 +189,7 @@ class WorkflowCommentReply(Base): self._created_by_account_cache = account -class WorkflowCommentMention(Base): +class WorkflowCommentMention(TypeBase): """Workflow comment mention model. Mentions are only for internal accounts since end users @@ -194,18 +209,18 @@ class WorkflowCommentMention(Base): Index("comment_mentions_user_idx", "mentioned_user_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) - reply_id: Mapped[str | None] = mapped_column( - StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True - ) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + reply_id: Mapped[str | None] = mapped_column( + StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True, default=None + ) # Relationships - comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions") - reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply") + comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="mentions", init=False) + reply: Mapped[WorkflowCommentReply | None] = relationship(lambda: WorkflowCommentReply, init=False) @property def mentioned_user_account(self): diff --git a/api/models/dataset.py b/api/models/dataset.py index a00e9f7640..8137ed4ff3 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -8,10 +8,9 @@ import os import pickle import re import time -from collections.abc import Sequence from datetime import datetime from json import JSONDecodeError -from typing import Any, TypedDict, cast +from typing import Any, ClassVar, TypedDict, cast from uuid import uuid4 import sqlalchemy as sa @@ -24,7 +23,7 @@ from core.rag.index_processor.constant.built_in_field import BuiltInField, Metad from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.index_processor.constant.query_type import QueryType from core.rag.retrieval.retrieval_methods import RetrievalMethod -from core.tools.signature import sign_upload_file +from core.tools.signature import sign_upload_file_preview_url from extensions.ext_storage import storage from libs.uuid_utils import uuidv7 @@ -441,23 +440,27 @@ class Dataset(Base): return f"{dify_config.VECTOR_INDEX_NAME_PREFIX}_{normalized_dataset_id}_Node" -class DatasetProcessRule(Base): # bug +class DatasetProcessRule(TypeBase): __tablename__ = "dataset_process_rules" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="dataset_process_rule_pkey"), sa.Index("dataset_process_rule_dataset_id_idx", "dataset_id"), ) - id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) - dataset_id = mapped_column(StringUUID, nullable=False) - mode = mapped_column(EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'")) - rules = mapped_column(LongText, nullable=True) - created_by = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False) + dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + mode: Mapped[ProcessRuleMode] = mapped_column( + EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'") + ) + rules: Mapped[str | None] = mapped_column(LongText, nullable=True) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) MODES = ["automatic", "custom", "hierarchical"] PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"] - AUTOMATIC_RULES: AutomaticRulesConfig = { + AUTOMATIC_RULES: ClassVar[AutomaticRulesConfig] = { "pre_processing_rules": [ {"id": "remove_extra_spaces", "enabled": True}, {"id": "remove_urls_emails", "enabled": False}, @@ -827,7 +830,7 @@ class Document(Base): ) -class DocumentSegment(Base): +class DocumentSegment(TypeBase): __tablename__ = "document_segments" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="document_segment_pkey"), @@ -840,35 +843,40 @@ class DocumentSegment(Base): ) # initial fields - id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) - tenant_id = mapped_column(StringUUID, nullable=False) - dataset_id = mapped_column(StringUUID, nullable=False) - document_id = mapped_column(StringUUID, nullable=False) + id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + document_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position: Mapped[int] - content = mapped_column(LongText, nullable=False) - answer = mapped_column(LongText, nullable=True) + content: Mapped[str] = mapped_column(LongText, nullable=False) word_count: Mapped[int] tokens: Mapped[int] - # indexing fields - keywords = mapped_column(sa.JSON, nullable=True) - index_node_id = mapped_column(String(255), nullable=True) - index_node_hash = mapped_column(String(255), nullable=True) - + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) # basic fields + # indexing fields + index_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + index_node_hash: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"), default=True) + answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + keywords: Mapped[Any] = mapped_column(sa.JSON, nullable=True, default=None) + disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + disabled_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + status: Mapped[SegmentStatus] = mapped_column( + EnumText(SegmentStatus, length=255), server_default=sa.text("'waiting'"), default=SegmentStatus.WAITING + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + error: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + stopped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) - enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) - disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - disabled_by = mapped_column(StringUUID, nullable=True) - status: Mapped[str] = mapped_column(EnumText(SegmentStatus, length=255), server_default=sa.text("'waiting'")) - created_by = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) - updated_by = mapped_column(StringUUID, nullable=True) - updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) - indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - error = mapped_column(LongText, nullable=True) - stopped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) @property def dataset(self): @@ -895,7 +903,7 @@ class DocumentSegment(Base): ) @property - def child_chunks(self) -> Sequence[Any]: + def child_chunks(self): if not self.document: return [] process_rule = self.document.dataset_process_rule @@ -910,7 +918,7 @@ class DocumentSegment(Base): return child_chunks or [] return [] - def get_child_chunks(self) -> Sequence[Any]: + def get_child_chunks(self): if not self.document: return [] process_rule = self.document.dataset_process_rule @@ -941,7 +949,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -958,7 +966,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -977,7 +985,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -1015,12 +1023,12 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() params = f"timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" - reference_url = dify_config.CONSOLE_API_URL or "" + reference_url = dify_config.FILES_URL or dify_config.CONSOLE_API_URL or "" base_url = f"{reference_url}/files/{upload_file_id}/image-preview" source_url = f"{base_url}?{params}" attachment_list.append( @@ -1162,7 +1170,7 @@ class DatasetQuery(TypeBase): "size": file_info.size, "extension": file_info.extension, "mime_type": file_info.mime_type, - "source_url": sign_upload_file(file_info.id, file_info.extension), + "source_url": sign_upload_file_preview_url(file_info.id, file_info.extension), } else: query["file_info"] = None diff --git a/api/models/model.py b/api/models/model.py index 25c330b062..f7f90465cf 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -878,6 +878,7 @@ class RecommendedApp(TypeBase): copyright: Mapped[str] = mapped_column(String(255), nullable=False) privacy_policy: Mapped[str] = mapped_column(String(255), nullable=False) category: Mapped[str] = mapped_column(String(255), nullable=False) + categories: Mapped[list[str] | None] = mapped_column(sa.JSON, nullable=True, default=None) custom_disclaimer: Mapped[str] = mapped_column(LongText, default="") position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True) diff --git a/api/models/provider.py b/api/models/provider.py index 2bb67d605b..8dc3ce4ff6 100644 --- a/api/models/provider.py +++ b/api/models/provider.py @@ -9,11 +9,11 @@ import sqlalchemy as sa from sqlalchemy import DateTime, String, func, select, text from sqlalchemy.orm import Mapped, mapped_column +from core.db.session_factory import session_factory from graphon.model_runtime.entities.model_entities import ModelType from libs.uuid_utils import uuidv7 from .base import TypeBase -from .engine import db from .enums import CredentialSourceType, PaymentStatus, ProviderQuotaType from .types import EnumText, LongText, StringUUID @@ -82,7 +82,8 @@ class Provider(TypeBase): @cached_property def credential(self): if self.credential_id: - return db.session.scalar(select(ProviderCredential).where(ProviderCredential.id == self.credential_id)) + with session_factory.create_session() as session: + return session.scalar(select(ProviderCredential).where(ProviderCredential.id == self.credential_id)) @property def credential_name(self): @@ -145,9 +146,10 @@ class ProviderModel(TypeBase): @cached_property def credential(self): if self.credential_id: - return db.session.scalar( - select(ProviderModelCredential).where(ProviderModelCredential.id == self.credential_id) - ) + with session_factory.create_session() as session: + return session.scalar( + select(ProviderModelCredential).where(ProviderModelCredential.id == self.credential_id) + ) @property def credential_name(self): diff --git a/api/models/utils/file_input_compat.py b/api/models/utils/file_input_compat.py index 77dcbd13d4..04aea9f7f6 100644 --- a/api/models/utils/file_input_compat.py +++ b/api/models/utils/file_input_compat.py @@ -48,20 +48,23 @@ def build_file_from_mapping_without_lookup(*, file_mapping: Mapping[str, Any]) - """Build a graph `File` directly from serialized metadata.""" def _coerce_file_type(value: Any) -> FileType: - if isinstance(value, FileType): - return value - if isinstance(value, str): - return FileType.value_of(value) - raise ValueError("file type is required in file mapping") + match value: + case FileType(): + return value + case str(): + return FileType.value_of(value) + case _: + raise ValueError("file type is required in file mapping") mapping = dict(file_mapping) transfer_method_value = mapping.get("transfer_method") - if isinstance(transfer_method_value, FileTransferMethod): - transfer_method = transfer_method_value - elif isinstance(transfer_method_value, str): - transfer_method = FileTransferMethod.value_of(transfer_method_value) - else: - raise ValueError("transfer_method is required in file mapping") + match transfer_method_value: + case FileTransferMethod(): + transfer_method = transfer_method_value + case str(): + transfer_method = FileTransferMethod.value_of(transfer_method_value) + case _: + raise ValueError("transfer_method is required in file mapping") file_id = mapping.get("file_id") if not isinstance(file_id, str) or not file_id: @@ -151,15 +154,15 @@ def rebuild_serialized_graph_files_without_lookup(value: Any) -> Any: so historical JSON blobs remain readable without reintroducing global graph patches or test-local coercion. """ - if isinstance(value, list): - return [rebuild_serialized_graph_files_without_lookup(item) for item in value] - - if isinstance(value, dict): - if maybe_file_object(value): - return build_file_from_mapping_without_lookup(file_mapping=value) - return {key: rebuild_serialized_graph_files_without_lookup(item) for key, item in value.items()} - - return value + match value: + case list(): + return [rebuild_serialized_graph_files_without_lookup(item) for item in value] + case dict(): + if maybe_file_object(value): + return build_file_from_mapping_without_lookup(file_mapping=value) + return {key: rebuild_serialized_graph_files_without_lookup(item) for key, item in value.items()} + case _: + return value def build_file_from_stored_mapping( diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md new file mode 100644 index 0000000000..dc055c5823 --- /dev/null +++ b/api/openapi/markdown/console-swagger.md @@ -0,0 +1,14728 @@ +# Console API +Console management APIs for app configuration, monitoring, and administration + +## Version: 1.0 + +### Security +**Bearer** + +| apiKey | *API Key* | +| ------ | --------- | +| Description | Type: Bearer {your-api-key} | +| In | header | +| Name | Authorization | + +--- +## console +Console management API operations + +### /account/avatar + +#### GET +##### Description + +Get account avatar url + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountAvatarQuery](#accountavatarquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountAvatarPayload](#accountavatarpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/change-email + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChangeEmailSendPayload](#changeemailsendpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/change-email/check-email-unique + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CheckEmailUniquePayload](#checkemailuniquepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/change-email/reset + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChangeEmailResetPayload](#changeemailresetpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/change-email/validity + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChangeEmailValidityPayload](#changeemailvaliditypayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/delete + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountDeletePayload](#accountdeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/delete/feedback + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountDeletionFeedbackPayload](#accountdeletionfeedbackpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/delete/verify + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/education + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [EducationStatusResponse](#educationstatusresponse) | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EducationActivatePayload](#educationactivatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/education/autocomplete + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EducationAutocompleteQuery](#educationautocompletequery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [EducationAutocompleteResponse](#educationautocompleteresponse) | + +### /account/education/verify + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [EducationVerifyResponse](#educationverifyresponse) | + +### /account/init + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountInitPayload](#accountinitpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /account/integrates + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [AccountIntegrateListResponse](#accountintegratelistresponse) | + +### /account/interface-language + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountInterfaceLanguagePayload](#accountinterfacelanguagepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/interface-theme + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountInterfaceThemePayload](#accountinterfacethemepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/name + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountNamePayload](#accountnamepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/password + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountPasswordPayload](#accountpasswordpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/profile + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /account/timezone + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AccountTimezonePayload](#accounttimezonepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [Account](#account) | + +### /activate + +#### POST +##### Description + +Activate account with invitation token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ActivatePayload](#activatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Account activated successfully | [ActivationResponse](#activationresponse) | +| 400 | Already activated or invalid token | | + +### /activate/check + +#### GET +##### Description + +Check if activation token is valid + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ActivateCheckQuery](#activatecheckquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ActivationCheckResponse](#activationcheckresponse) | + +### /all-workspaces + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkspaceListQuery](#workspacelistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /api-based-extension + +#### GET +##### Description + +Get all API-based extensions for current tenant + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [APIBasedExtensionListResponse](#apibasedextensionlistresponse) | + +#### POST +##### Description + +Create a new API-based extension + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [APIBasedExtensionPayload](#apibasedextensionpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Extension created successfully | [APIBasedExtensionResponse](#apibasedextensionresponse) | + +### /api-based-extension/{id} + +#### DELETE +##### Description + +Delete API-based extension + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | Extension ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Extension deleted successfully | + +#### GET +##### Description + +Get API-based extension by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | Extension ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [APIBasedExtensionResponse](#apibasedextensionresponse) | + +#### POST +##### Description + +Update API-based extension + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [APIBasedExtensionPayload](#apibasedextensionpayload) | +| id | path | Extension ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Extension updated successfully | [APIBasedExtensionResponse](#apibasedextensionresponse) | + +### /api-key-auth/data-source + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /api-key-auth/data-source/binding + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ApiKeyAuthBindingPayload](#apikeyauthbindingpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /api-key-auth/data-source/{binding_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| binding_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /app/prompt-templates + +#### GET +##### Description + +Get advanced prompt templates based on app mode and model configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AdvancedPromptTemplateQuery](#advancedprompttemplatequery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Prompt templates retrieved successfully | [ object ] | +| 400 | Invalid request parameters | | + +### /apps + +#### GET +##### Summary + +Get app list + +##### Description + +Get list of applications with pagination and filtering + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppListQuery](#applistquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [AppPagination](#apppagination) | + +#### POST +##### Summary + +Create app + +##### Description + +Create a new application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CreateAppPayload](#createapppayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | App created successfully | [AppDetail](#appdetail) | +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + +### /apps/imports + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppImportPayload](#appimportpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Import completed | [Import](#import) | +| 202 | Import pending confirmation | [Import](#import) | +| 400 | Import failed | [Import](#import) | + +### /apps/imports/{app_id}/check-dependencies + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) | + +### /apps/imports/{import_id}/confirm + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| import_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Import confirmed | [Import](#import) | +| 400 | Import failed | [Import](#import) | + +### /apps/workflows/online-users + +#### POST +##### Description + +Get workflow online users + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowOnlineUsersPayload](#workflowonlineuserspayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow online users retrieved successfully | [WorkflowOnlineUsersResponse](#workflowonlineusersresponse) | + +### /apps/{app_id} + +#### DELETE +##### Summary + +Delete app + +##### Description + +Delete application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | App deleted successfully | +| 403 | Insufficient permissions | + +#### GET +##### Summary + +Get app detail + +##### Description + +Get application details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [AppDetailWithSite](#appdetailwithsite) | + +#### PUT +##### Summary + +Update app + +##### Description + +Update application details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [UpdateAppPayload](#updateapppayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App updated successfully | [AppDetailWithSite](#appdetailwithsite) | +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/advanced-chat/workflow-runs + +#### GET +##### Summary + +Get advanced chat app workflow run list + +##### Description + +Get advanced chat workflow run list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| last_id | query | Last run ID for pagination | No | string | +| limit | query | Number of items per page (1-100) | No | integer | +| status | query | Workflow run status filter | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs retrieved successfully | [AdvancedChatWorkflowRunPaginationResponse](#advancedchatworkflowrunpaginationresponse) | + +### /apps/{app_id}/advanced-chat/workflow-runs/count + +#### GET +##### Summary + +Get advanced chat workflow runs count statistics + +##### Description + +Get advanced chat workflow runs count statistics + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| status | query | Workflow run status filter | No | string | +| time_range | query | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs count retrieved successfully | [WorkflowRunCountResponse](#workflowruncountresponse) | + +### /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/preview + +#### POST +##### Summary + +Preview human input form content and placeholders + +##### Description + +Get human input form preview for advanced chat workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HumanInputFormPreviewPayload](#humaninputformpreviewpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/advanced-chat/workflows/draft/human-input/nodes/{node_id}/form/run + +#### POST +##### Summary + +Submit human input form preview + +##### Description + +Submit human input form preview for advanced chat workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/advanced-chat/workflows/draft/iteration/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow iteration node + +##### Description + +Run draft workflow iteration node for advanced chat + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [IterationNodeRunPayload](#iterationnoderunpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Iteration node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | + +### /apps/{app_id}/advanced-chat/workflows/draft/loop/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow loop node + +##### Description + +Run draft workflow loop node for advanced chat + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [LoopNodeRunPayload](#loopnoderunpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Loop node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | + +### /apps/{app_id}/advanced-chat/workflows/draft/run + +#### POST +##### Summary + +Run draft workflow + +##### Description + +Run draft workflow for advanced chat application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AdvancedChatWorkflowRunPayload](#advancedchatworkflowrunpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow run started successfully | +| 400 | Invalid request parameters | +| 403 | Permission denied | + +### /apps/{app_id}/agent/logs + +#### GET +##### Summary + +Get agent logs + +##### Description + +Get agent execution logs for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AgentLogQuery](#agentlogquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent logs retrieved successfully | [ object ] | +| 400 | Invalid request parameters | | + +### /apps/{app_id}/annotation-reply/{action} + +#### POST +##### Description + +Enable or disable annotation reply for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AnnotationReplyPayload](#annotationreplypayload) | +| action | path | Action to perform (enable/disable) | Yes | string | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Action completed successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/annotation-reply/{action}/status/{job_id} + +#### GET +##### Description + +Get status of annotation reply action job + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action type | Yes | string | +| app_id | path | Application ID | Yes | string | +| job_id | path | Job ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Job status retrieved successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/annotation-setting + +#### GET +##### Description + +Get annotation settings for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Annotation settings retrieved successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/annotation-settings/{annotation_setting_id} + +#### POST +##### Description + +Update annotation settings for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AnnotationSettingUpdatePayload](#annotationsettingupdatepayload) | +| annotation_setting_id | path | Annotation setting ID | Yes | string | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Settings updated successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/annotations + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Description + +Get annotations for an app with pagination + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AnnotationListQuery](#annotationlistquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Annotations retrieved successfully | +| 403 | Insufficient permissions | + +#### POST +##### Description + +Create a new annotation for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CreateAnnotationPayload](#createannotationpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Annotation created successfully | [Annotation](#annotation) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/annotations/batch-import + +#### POST +##### Description + +Batch import annotations from CSV file with rate limiting and security checks + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Batch import started successfully | +| 400 | No file uploaded or too many files | +| 403 | Insufficient permissions | +| 413 | File too large | +| 429 | Too many requests or concurrent imports | + +### /apps/{app_id}/annotations/batch-import-status/{job_id} + +#### GET +##### Description + +Get status of batch import job + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| job_id | path | Job ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Job status retrieved successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/annotations/count + +#### GET +##### Description + +Get count of message annotations for the app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation count retrieved successfully | [AnnotationCountResponse](#annotationcountresponse) | + +### /apps/{app_id}/annotations/export + +#### GET +##### Description + +Export all annotations for an app with CSV injection protection + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotations exported successfully | [AnnotationExportList](#annotationexportlist) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/annotations/{annotation_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| annotation_id | path | | Yes | string | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Description + +Update or delete an annotation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [UpdateAnnotationPayload](#updateannotationpayload) | +| annotation_id | path | Annotation ID | Yes | string | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation updated successfully | [Annotation](#annotation) | +| 204 | Annotation deleted successfully | | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/annotations/{annotation_id}/hit-histories + +#### GET +##### Description + +Get hit histories for an annotation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| annotation_id | path | Annotation ID | Yes | string | +| app_id | path | Application ID | Yes | string | +| limit | query | Page size | No | integer | +| page | query | Page number | No | integer | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Hit histories retrieved successfully | [AnnotationHitHistoryList](#annotationhithistorylist) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/api-enable + +#### POST +##### Description + +Enable or disable app API + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppApiStatusPayload](#appapistatuspayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | API status updated successfully | [AppDetail](#appdetail) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/audio-to-text + +#### POST +##### Description + +Transcript audio to text for chat messages + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | App ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Audio transcription successful | [AudioTranscriptResponse](#audiotranscriptresponse) | +| 400 | Bad request - No audio uploaded or unsupported type | | +| 413 | Audio file too large | | + +### /apps/{app_id}/chat-conversations + +#### GET +##### Description + +Get chat conversations with pagination, filtering and summary + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChatConversationQuery](#chatconversationquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ConversationWithSummaryPagination](#conversationwithsummarypagination) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/chat-conversations/{conversation_id} + +#### DELETE +##### Description + +Delete a chat conversation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| conversation_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Conversation deleted successfully | +| 403 | Insufficient permissions | +| 404 | Conversation not found | + +#### GET +##### Description + +Get chat conversation details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| conversation_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ConversationDetail](#conversationdetail) | +| 403 | Insufficient permissions | | +| 404 | Conversation not found | | + +### /apps/{app_id}/chat-messages + +#### GET +##### Description + +Get chat messages for a conversation with pagination + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChatMessagesQuery](#chatmessagesquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse) | +| 404 | Conversation not found | | + +### /apps/{app_id}/chat-messages/{message_id}/suggested-questions + +#### GET +##### Description + +Get suggested questions for a message + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| message_id | path | Message ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Suggested questions retrieved successfully | [SuggestedQuestionsResponse](#suggestedquestionsresponse) | +| 404 | Message or conversation not found | | + +### /apps/{app_id}/chat-messages/{task_id}/stop + +#### POST +##### Description + +Stop a running chat message generation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| task_id | path | Task ID to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | + +### /apps/{app_id}/completion-conversations + +#### GET +##### Description + +Get completion conversations with pagination and filtering + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CompletionConversationQuery](#completionconversationquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ConversationPagination](#conversationpagination) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/completion-conversations/{conversation_id} + +#### DELETE +##### Description + +Delete a completion conversation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| conversation_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Conversation deleted successfully | +| 403 | Insufficient permissions | +| 404 | Conversation not found | + +#### GET +##### Description + +Get completion conversation details with messages + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| conversation_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ConversationMessageDetail](#conversationmessagedetail) | +| 403 | Insufficient permissions | | +| 404 | Conversation not found | | + +### /apps/{app_id}/completion-messages + +#### POST +##### Description + +Generate completion message for debugging + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CompletionMessagePayload](#completionmessagepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Completion generated successfully | +| 400 | Invalid request parameters | +| 404 | App not found | + +### /apps/{app_id}/completion-messages/{task_id}/stop + +#### POST +##### Description + +Stop a running completion message generation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| task_id | path | Task ID to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | + +### /apps/{app_id}/conversation-variables + +#### GET +##### Description + +Get conversation variables for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConversationVariablesQuery](#conversationvariablesquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation variables retrieved successfully | [PaginatedConversationVariableResponse](#paginatedconversationvariableresponse) | + +### /apps/{app_id}/convert-to-workflow + +#### POST +##### Summary + +Convert basic mode of chatbot app to workflow mode + +##### Description + +Convert application to workflow mode +Convert expert mode of chatbot app to workflow mode +Convert Completion App to Workflow App + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConvertToWorkflowPayload](#converttoworkflowpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Application converted to workflow successfully | +| 400 | Application cannot be converted | +| 403 | Permission denied | + +### /apps/{app_id}/copy + +#### POST +##### Summary + +Copy app + +##### Description + +Create a copy of an existing application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CopyAppPayload](#copyapppayload) | +| app_id | path | Application ID to copy | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | App copied successfully | [AppDetailWithSite](#appdetailwithsite) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/export + +#### GET +##### Summary + +Export app + +##### Description + +Export application configuration as DSL + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppExportQuery](#appexportquery) | +| app_id | path | Application ID to export | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App exported successfully | [AppExportResponse](#appexportresponse) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/feedbacks + +#### POST +##### Description + +Create or update message feedback (like/dislike) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MessageFeedbackPayload](#messagefeedbackpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Feedback updated successfully | +| 403 | Insufficient permissions | +| 404 | Message not found | + +### /apps/{app_id}/feedbacks/export + +#### GET +##### Description + +Export user feedback data for Google Sheets + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [FeedbackExportQuery](#feedbackexportquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Feedback data exported successfully | +| 400 | Invalid parameters | +| 500 | Internal server error | + +### /apps/{app_id}/icon + +#### POST +##### Description + +Update application icon + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppIconPayload](#appiconpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Icon updated successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/messages/{message_id} + +#### GET +##### Description + +Get message details by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| message_id | path | Message ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Message retrieved successfully | [MessageDetailResponse](#messagedetailresponse) | +| 404 | Message not found | | + +### /apps/{app_id}/model-config + +#### POST +##### Summary + +Modify app model config + +##### Description + +Update application model configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ModelConfigRequest](#modelconfigrequest) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Model configuration updated successfully | +| 400 | Invalid configuration | +| 404 | App not found | + +### /apps/{app_id}/name + +#### POST +##### Description + +Check if app name is available + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppNamePayload](#appnamepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Name availability checked | [AppDetail](#appdetail) | + +### /apps/{app_id}/publish-to-creators-platform + +#### POST +##### Summary + +Publish app to Creators Platform + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/server + +#### GET +##### Description + +Get MCP server configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | MCP server configuration retrieved successfully | [AppMCPServerResponse](#appmcpserverresponse) | + +#### POST +##### Description + +Create MCP server configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MCPServerCreatePayload](#mcpservercreatepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | MCP server configuration created successfully | [AppMCPServerResponse](#appmcpserverresponse) | +| 403 | Insufficient permissions | | + +#### PUT +##### Description + +Update MCP server configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MCPServerUpdatePayload](#mcpserverupdatepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | MCP server configuration updated successfully | [AppMCPServerResponse](#appmcpserverresponse) | +| 403 | Insufficient permissions | | +| 404 | Server not found | | + +### /apps/{app_id}/site + +#### POST +##### Description + +Update application site configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppSiteUpdatePayload](#appsiteupdatepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Site configuration updated successfully | [AppSiteResponse](#appsiteresponse) | +| 403 | Insufficient permissions | | +| 404 | App not found | | + +### /apps/{app_id}/site-enable + +#### POST +##### Description + +Enable or disable app site + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppSiteStatusPayload](#appsitestatuspayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Site status updated successfully | [AppDetail](#appdetail) | +| 403 | Insufficient permissions | | + +### /apps/{app_id}/site/access-token-reset + +#### POST +##### Description + +Reset access token for application site + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Access token reset successfully | [AppSiteResponse](#appsiteresponse) | +| 403 | Insufficient permissions (admin/owner required) | | +| 404 | App or site not found | | + +### /apps/{app_id}/statistics/average-response-time + +#### GET +##### Description + +Get average response time statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Average response time statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/average-session-interactions + +#### GET +##### Description + +Get average session interaction statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Average session interaction statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/daily-conversations + +#### GET +##### Description + +Get daily conversation statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily conversation statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/daily-end-users + +#### GET +##### Description + +Get daily terminal/end-user statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily terminal statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/daily-messages + +#### GET +##### Description + +Get daily message statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily message statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/token-costs + +#### GET +##### Description + +Get daily token cost statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Daily token cost statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/tokens-per-second + +#### GET +##### Description + +Get tokens per second statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tokens per second statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/statistics/user-satisfaction-rate + +#### GET +##### Description + +Get user satisfaction rate statistics for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [StatisticTimeRangeQuery](#statistictimerangequery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | User satisfaction rate statistics retrieved successfully | [ object ] | + +### /apps/{app_id}/text-to-audio + +#### POST +##### Description + +Convert text to speech for chat messages + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TextToSpeechPayload](#texttospeechpayload) | +| app_id | path | App ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Text to speech conversion successful | +| 400 | Bad request - Invalid parameters | + +### /apps/{app_id}/text-to-audio/voices + +#### GET +##### Description + +Get available TTS voices for a specific language + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TextToSpeechVoiceQuery](#texttospeechvoicequery) | +| app_id | path | App ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | TTS voices retrieved successfully | [ object ] | +| 400 | Invalid language parameter | | + +### /apps/{app_id}/trace + +#### GET +##### Summary + +Get app trace + +##### Description + +Get app tracing configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Trace configuration retrieved successfully | + +#### POST +##### Description + +Update app tracing configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AppTracePayload](#apptracepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Trace configuration updated successfully | +| 403 | Insufficient permissions | + +### /apps/{app_id}/trace-config + +#### DELETE +##### Summary + +Delete an existing trace app configuration + +##### Description + +Delete an existing tracing configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TraceProviderQuery](#traceproviderquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Tracing configuration deleted successfully | +| 400 | Invalid request parameters or configuration not found | + +#### GET +##### Description + +Get tracing configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TraceProviderQuery](#traceproviderquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tracing configuration retrieved successfully | object | +| 400 | Invalid request parameters | | + +#### PATCH +##### Summary + +Update an existing trace app configuration + +##### Description + +Update an existing tracing configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TraceConfigPayload](#traceconfigpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tracing configuration updated successfully | object | +| 400 | Invalid request parameters or configuration not found | | + +#### POST +##### Summary + +Create a new trace app configuration + +##### Description + +Create a new tracing configuration for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TraceConfigPayload](#traceconfigpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Tracing configuration created successfully | object | +| 400 | Invalid request parameters or configuration already exists | | + +### /apps/{app_id}/trigger-enable + +#### POST +##### Summary + +Update app trigger (enable/disable) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [ParserEnable](#parserenable) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [WorkflowTriggerResponse](#workflowtriggerresponse) | + +### /apps/{app_id}/triggers + +#### GET +##### Summary + +Get app triggers list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [WorkflowTriggerListResponse](#workflowtriggerlistresponse) | + +### /apps/{app_id}/workflow-app-logs + +#### GET +##### Summary + +Get workflow app logs + +##### Description + +Get workflow application execution logs + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowAppLogQuery](#workflowapplogquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow app logs retrieved successfully | [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse) | + +### /apps/{app_id}/workflow-archived-logs + +#### GET +##### Summary + +Get workflow archived logs + +##### Description + +Get workflow archived execution logs + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowAppLogQuery](#workflowapplogquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow archived logs retrieved successfully | [WorkflowArchivedLogPaginationResponse](#workflowarchivedlogpaginationresponse) | + +### /apps/{app_id}/workflow-runs + +#### GET +##### Summary + +Get workflow run list + +##### Description + +Get workflow run list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| last_id | query | Last run ID for pagination | No | string | +| limit | query | Number of items per page (1-100) | No | integer | +| status | query | Workflow run status filter | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | + +### /apps/{app_id}/workflow-runs/count + +#### GET +##### Summary + +Get workflow runs count statistics + +##### Description + +Get workflow runs count statistics + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| status | query | Workflow run status filter | No | string | +| time_range | query | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | string | +| triggered_from | query | Filter by trigger source: debugging or app-run. Default: debugging | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs count retrieved successfully | [WorkflowRunCountResponse](#workflowruncountresponse) | + +### /apps/{app_id}/workflow-runs/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop workflow task + +##### Description + +Stop running workflow task + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| task_id | path | Task ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | +| 403 | Permission denied | +| 404 | Task not found | + +### /apps/{app_id}/workflow-runs/{run_id} + +#### GET +##### Summary + +Get workflow run detail + +##### Description + +Get workflow run detail + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | +| 404 | Workflow run not found | | + +### /apps/{app_id}/workflow-runs/{run_id}/export + +#### GET +##### Description + +Generate a download URL for an archived workflow run. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Export URL generated | [WorkflowRunExportResponse](#workflowrunexportresponse) | + +### /apps/{app_id}/workflow-runs/{run_id}/node-executions + +#### GET +##### Summary + +Get workflow run node execution list + +##### Description + +Get workflow run node execution list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | +| 404 | Workflow run not found | | + +### /apps/{app_id}/workflow/comments + +#### GET +##### Summary + +Get all comments for a workflow + +##### Description + +Get all comments for a workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Comments retrieved successfully | [WorkflowCommentBasicList](#workflowcommentbasiclist) | + +#### POST +##### Summary + +Create a new workflow comment + +##### Description + +Create a new workflow comment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowCommentCreatePayload](#workflowcommentcreatepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Comment created successfully | [WorkflowCommentCreate](#workflowcommentcreate) | + +### /apps/{app_id}/workflow/comments/mention-users + +#### GET +##### Summary + +Get all users in current tenant for mentions + +##### Description + +Get all users in current tenant for mentions + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Mentionable users retrieved successfully | [WorkflowCommentMentionUsersPayload](#workflowcommentmentionuserspayload) | + +### /apps/{app_id}/workflow/comments/{comment_id} + +#### DELETE +##### Summary + +Delete a workflow comment + +##### Description + +Delete a workflow comment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Comment deleted successfully | + +#### GET +##### Summary + +Get a specific workflow comment + +##### Description + +Get a specific workflow comment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Comment retrieved successfully | [WorkflowCommentDetail](#workflowcommentdetail) | + +#### PUT +##### Summary + +Update a workflow comment + +##### Description + +Update a workflow comment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowCommentUpdatePayload](#workflowcommentupdatepayload) | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Comment updated successfully | [WorkflowCommentUpdate](#workflowcommentupdate) | + +### /apps/{app_id}/workflow/comments/{comment_id}/replies + +#### POST +##### Summary + +Add a reply to a workflow comment + +##### Description + +Add a reply to a workflow comment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowCommentReplyPayload](#workflowcommentreplypayload) | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Reply created successfully | [WorkflowCommentReplyCreate](#workflowcommentreplycreate) | + +### /apps/{app_id}/workflow/comments/{comment_id}/replies/{reply_id} + +#### DELETE +##### Summary + +Delete a comment reply + +##### Description + +Delete a comment reply + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | +| reply_id | path | Reply ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Reply deleted successfully | + +#### PUT +##### Summary + +Update a comment reply + +##### Description + +Update a comment reply + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowCommentReplyPayload](#workflowcommentreplypayload) | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | +| reply_id | path | Reply ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Reply updated successfully | [WorkflowCommentReplyUpdate](#workflowcommentreplyupdate) | + +### /apps/{app_id}/workflow/comments/{comment_id}/resolve + +#### POST +##### Summary + +Resolve a workflow comment + +##### Description + +Resolve a workflow comment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| comment_id | path | Comment ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Comment resolved successfully | [WorkflowCommentResolve](#workflowcommentresolve) | + +### /apps/{app_id}/workflow/statistics/average-app-interactions + +#### GET +##### Description + +Get workflow average app interaction statistics + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Average app interaction statistics retrieved successfully | + +### /apps/{app_id}/workflow/statistics/daily-conversations + +#### GET +##### Description + +Get workflow daily runs statistics + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Daily runs statistics retrieved successfully | + +### /apps/{app_id}/workflow/statistics/daily-terminals + +#### GET +##### Description + +Get workflow daily terminals statistics + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Daily terminals statistics retrieved successfully | + +### /apps/{app_id}/workflow/statistics/token-costs + +#### GET +##### Description + +Get workflow daily token cost statistics + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowStatisticQuery](#workflowstatisticquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Daily token cost statistics retrieved successfully | + +### /apps/{app_id}/workflows + +#### GET +##### Summary + +Get published workflows + +##### Description + +Get all published workflows for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowListQuery](#workflowlistquery) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflows retrieved successfully | [WorkflowPaginationResponse](#workflowpaginationresponse) | + +### /apps/{app_id}/workflows/default-workflow-block-configs + +#### GET +##### Summary + +Get default block config + +##### Description + +Get default block configurations for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Default block configurations retrieved successfully | + +### /apps/{app_id}/workflows/default-workflow-block-configs/{block_type} + +#### GET +##### Summary + +Get default block config + +##### Description + +Get default block configuration by type + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DefaultBlockConfigQuery](#defaultblockconfigquery) | +| app_id | path | Application ID | Yes | string | +| block_type | path | Block type | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Default block configuration retrieved successfully | +| 404 | Block type not found | + +### /apps/{app_id}/workflows/draft + +#### GET +##### Summary + +Get draft workflow + +##### Description + +Get draft workflow for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow retrieved successfully | [WorkflowResponse](#workflowresponse) | +| 404 | Draft workflow not found | | + +#### POST +##### Summary + +Sync draft workflow + +##### Description + +Sync draft workflow configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SyncDraftWorkflowPayload](#syncdraftworkflowpayload) | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow synced successfully | [SyncDraftWorkflowResponse](#syncdraftworkflowresponse) | +| 400 | Invalid workflow configuration | | +| 403 | Permission denied | | + +### /apps/{app_id}/workflows/draft/conversation-variables + +#### GET +##### Description + +Get conversation variables for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | +| 404 | Draft workflow not found | | + +#### POST +##### Description + +Update conversation variables for workflow draft + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConversationVariableUpdatePayload](#conversationvariableupdatepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Conversation variables updated successfully | + +### /apps/{app_id}/workflows/draft/environment-variables + +#### GET +##### Summary + +Get draft workflow + +##### Description + +Get environment variables for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Environment variables retrieved successfully | +| 404 | Draft workflow not found | + +#### POST +##### Description + +Update environment variables for workflow draft + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EnvironmentVariableUpdatePayload](#environmentvariableupdatepayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Environment variables updated successfully | + +### /apps/{app_id}/workflows/draft/features + +#### POST +##### Description + +Update draft workflow features + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowFeaturesPayload](#workflowfeaturespayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow features updated successfully | + +### /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test + +#### POST +##### Summary + +Test human input delivery + +##### Description + +Test human input delivery for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HumanInputDeliveryTestPayload](#humaninputdeliverytestpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview + +#### POST +##### Summary + +Preview human input form content and placeholders + +##### Description + +Get human input form preview for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HumanInputFormPreviewPayload](#humaninputformpreviewpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/run + +#### POST +##### Summary + +Submit human input form preview + +##### Description + +Submit human input form preview for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/draft/iteration/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow iteration node + +##### Description + +Run draft workflow iteration node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [IterationNodeRunPayload](#iterationnoderunpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow iteration node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | + +### /apps/{app_id}/workflows/draft/loop/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow loop node + +##### Description + +Run draft workflow loop node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [LoopNodeRunPayload](#loopnoderunpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow loop node run started successfully | +| 403 | Permission denied | +| 404 | Node not found | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run + +#### GET +##### Description + +Get last run result for draft workflow node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 403 | Permission denied | | +| 404 | Node last run not found | | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow node + +##### Description + +Run draft workflow node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DraftWorkflowNodeRunPayload](#draftworkflownoderunpayload) | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node run started successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 403 | Permission denied | | +| 404 | Node not found | | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/trigger/run + +#### POST +##### Summary + +Poll for trigger events and execute single node when event arrives + +##### Description + +Poll for trigger events and execute single node when event arrives + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Trigger event received and node executed successfully | +| 403 | Permission denied | +| 500 | Internal server error | + +### /apps/{app_id}/workflows/draft/nodes/{node_id}/variables + +#### DELETE +##### Description + +Delete all variables for a specific node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| node_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Node variables deleted successfully | + +#### GET +##### Description + +Get variables for a specific node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| node_id | path | Node ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | + +### /apps/{app_id}/workflows/draft/run + +#### POST +##### Summary + +Run draft workflow + +##### Description + +Run draft workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DraftWorkflowRunPayload](#draftworkflowrunpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Draft workflow run started successfully | +| 403 | Permission denied | + +### /apps/{app_id}/workflows/draft/system-variables + +#### GET +##### Description + +Get system variables for workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | + +### /apps/{app_id}/workflows/draft/trigger/run + +#### POST +##### Summary + +Poll for trigger events and execute full workflow when event arrives + +##### Description + +Poll for trigger events and execute full workflow when event arrives + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DraftWorkflowTriggerRunRequest](#draftworkflowtriggerrunrequest) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Trigger event received and workflow executed successfully | +| 403 | Permission denied | +| 500 | Internal server error | + +### /apps/{app_id}/workflows/draft/trigger/run-all + +#### POST +##### Summary + +Full workflow debug when the start node is a trigger + +##### Description + +Full workflow debug when the start node is a trigger + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DraftWorkflowTriggerRunAllPayload](#draftworkflowtriggerrunallpayload) | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow executed successfully | +| 403 | Permission denied | +| 500 | Internal server error | + +### /apps/{app_id}/workflows/draft/variables + +#### DELETE +##### Description + +Delete all draft workflow variables + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Workflow variables deleted successfully | + +#### GET +##### Summary + +Get draft workflow + +##### Description + +Get draft workflow variables + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowDraftVariableListQuery](#workflowdraftvariablelistquery) | +| app_id | path | Application ID | Yes | string | +| limit | query | Number of items per page (1-100) | No | string | +| page | query | Page number (1-100000) | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow variables retrieved successfully | [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue) | + +### /apps/{app_id}/workflows/draft/variables/{variable_id} + +#### DELETE +##### Description + +Delete a workflow variable + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Variable deleted successfully | +| 404 | Variable not found | + +#### GET +##### Description + +Get a specific workflow variable + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| variable_id | path | Variable ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable retrieved successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 404 | Variable not found | | + +#### PATCH +##### Description + +Update a workflow variable + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowDraftVariableUpdatePayload](#workflowdraftvariableupdatepayload) | +| app_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable updated successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 404 | Variable not found | | + +### /apps/{app_id}/workflows/draft/variables/{variable_id}/reset + +#### PUT +##### Description + +Reset a workflow variable to its default value + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| variable_id | path | Variable ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable reset successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 204 | Variable reset (no content) | | +| 404 | Variable not found | | + +### /apps/{app_id}/workflows/publish + +#### GET +##### Summary + +Get published workflow + +##### Description + +Get published workflow for an application + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflow retrieved successfully, or null if not found | [WorkflowResponse](#workflowresponse) | + +#### POST +##### Summary + +Publish workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [PublishWorkflowPayload](#publishworkflowpayload) | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /apps/{app_id}/workflows/triggers/webhook + +#### GET +##### Summary + +Get webhook trigger for a node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [Parser](#parser) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [WebhookTriggerResponse](#webhooktriggerresponse) | + +### /apps/{app_id}/workflows/{workflow_id} + +#### DELETE +##### Summary + +Delete workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Summary + +Update workflow attributes + +##### Description + +Update workflow by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowUpdatePayload](#workflowupdatepayload) | +| app_id | path | Application ID | Yes | string | +| workflow_id | path | Workflow ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow updated successfully | [WorkflowResponse](#workflowresponse) | +| 403 | Permission denied | | +| 404 | Workflow not found | | + +### /apps/{app_id}/workflows/{workflow_id}/restore + +#### POST +##### Description + +Restore a published workflow version into the draft workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | Application ID | Yes | string | +| workflow_id | path | Published workflow ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow restored successfully | +| 400 | Source workflow must be published | +| 404 | Workflow not found | + +### /apps/{resource_id}/api-keys + +#### GET +##### Summary + +Get all API keys for an app + +##### Description + +Get all API keys for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resource_id | path | App ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | API keys retrieved successfully | [ApiKeyList](#apikeylist) | + +#### POST +##### Summary + +Create a new API key for an app + +##### Description + +Create a new API key for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resource_id | path | App ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | API key created successfully | [ApiKeyItem](#apikeyitem) | +| 400 | Maximum keys exceeded | | + +### /apps/{resource_id}/api-keys/{api_key_id} + +#### DELETE +##### Summary + +Delete an API key for an app + +##### Description + +Delete an API key for an app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| api_key_id | path | API key ID | Yes | string | +| resource_id | path | App ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | API key deleted successfully | + +### /apps/{server_id}/server/refresh + +#### GET +##### Description + +Refresh MCP server configuration and regenerate server code + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| server_id | path | Server ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | MCP server refreshed successfully | [AppMCPServerResponse](#appmcpserverresponse) | +| 403 | Insufficient permissions | | +| 404 | Server not found | | + +### /auth/plugin/datasource/default-list + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/list + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/{provider_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceCredentialPayload](#datasourcecredentialpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/{provider_id}/custom-client + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceCustomClientPayload](#datasourcecustomclientpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/{provider_id}/default + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceDefaultPayload](#datasourcedefaultpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/{provider_id}/delete + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceCredentialDeletePayload](#datasourcecredentialdeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/{provider_id}/update + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceCredentialUpdatePayload](#datasourcecredentialupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/plugin/datasource/{provider_id}/update-name + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceUpdateNamePayload](#datasourceupdatenamepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /billing/invoices + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /billing/partners/{partner_key}/tenants + +#### PUT +##### Description + +Sync partner tenants bindings + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [PartnerTenantsPayload](#partnertenantspayload) | +| partner_key | path | Partner key | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Tenants synced to partner successfully | +| 400 | Invalid partner information | + +### /billing/subscription + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /code-based-extension + +#### GET +##### Description + +Get code-based extension data by module name + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| module | query | Extension module name | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [CodeBasedExtensionResponse](#codebasedextensionresponse) | + +### /compliance/download + +#### GET +##### Description + +Get compliance document download link + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ComplianceDownloadQuery](#compliancedownloadquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /data-source/integrates + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /data-source/integrates/{binding_id}/{action} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | | Yes | string | +| binding_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | | Yes | string | +| binding_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets + +#### GET +##### Description + +Get list of datasets + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| ids | query | Filter by dataset IDs (list) | No | string | +| include_all | query | Include all datasets (default: false) | No | string | +| keyword | query | Search keyword | No | string | +| limit | query | Number of items per page (default: 20) | No | string | +| page | query | Page number (default: 1) | No | string | +| tag_ids | query | Filter by tag IDs (list) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Datasets retrieved successfully | + +#### POST +##### Description + +Create a new dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DatasetCreatePayload](#datasetcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 201 | Dataset created successfully | +| 400 | Invalid request parameters | + +### /datasets/api-base-info + +#### GET +##### Description + +Get dataset API base information + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | API base info retrieved successfully | + +### /datasets/api-keys + +#### GET +##### Description + +Get dataset API keys + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | API keys retrieved successfully | [ApiKeyList](#apikeylist) | + +#### POST +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | API key created successfully | [ApiKeyItem](#apikeyitem) | +| 400 | Maximum keys exceeded | | + +### /datasets/api-keys/{api_key_id} + +#### DELETE +##### Description + +Delete dataset API key + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| api_key_id | path | API key ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | API key deleted successfully | + +### /datasets/batch_import_status/{job_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| job_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| job_id | path | | Yes | string | +| payload | body | | Yes | [BatchImportPayload](#batchimportpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/external + +#### POST +##### Description + +Create external knowledge dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ExternalDatasetCreatePayload](#externaldatasetcreatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | External dataset created successfully | [DatasetDetail](#datasetdetail) | +| 400 | Invalid parameters | | +| 403 | Permission denied | | + +### /datasets/external-knowledge-api + +#### GET +##### Description + +Get external knowledge API templates + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| keyword | query | Search keyword | No | string | +| limit | query | Number of items per page (default: 20) | No | string | +| page | query | Page number (default: 1) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | External API templates retrieved successfully | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ExternalKnowledgeApiPayload](#externalknowledgeapipayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/external-knowledge-api/{external_knowledge_api_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| external_knowledge_api_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Description + +Get external knowledge API template details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| external_knowledge_api_id | path | External knowledge API ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | External API template retrieved successfully | +| 404 | Template not found | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ExternalKnowledgeApiPayload](#externalknowledgeapipayload) | +| external_knowledge_api_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/external-knowledge-api/{external_knowledge_api_id}/use-check + +#### GET +##### Description + +Check if external knowledge API is being used + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| external_knowledge_api_id | path | External knowledge API ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Usage check completed successfully | + +### /datasets/indexing-estimate + +#### POST +##### Description + +Estimate dataset indexing cost + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [IndexingEstimatePayload](#indexingestimatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Indexing estimate calculated successfully | + +### /datasets/init + +#### POST +##### Description + +Initialize dataset with documents + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [KnowledgeConfig](#knowledgeconfig) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Dataset initialized successfully | [DatasetAndDocumentResponse](#datasetanddocumentresponse) | +| 400 | Invalid request parameters | | + +### /datasets/metadata/built-in + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/notion-indexing-estimate + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [NotionEstimatePayload](#notionestimatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/process-rule + +#### GET +##### Description + +Get dataset document processing rules + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| document_id | query | Document ID (optional) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Process rules retrieved successfully | + +### /datasets/retrieval-setting + +#### GET +##### Description + +Get dataset retrieval settings + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Retrieval settings retrieved successfully | + +### /datasets/retrieval-setting/{vector_type} + +#### GET +##### Description + +Get mock dataset retrieval settings by vector type + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| vector_type | path | Vector store type | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Mock retrieval settings retrieved successfully | + +### /datasets/{dataset_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Description + +Get dataset details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Dataset retrieved successfully | [DatasetDetail](#datasetdetail) | +| 403 | Permission denied | | +| 404 | Dataset not found | | + +#### PATCH +##### Description + +Update dataset details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DatasetUpdatePayload](#datasetupdatepayload) | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Dataset updated successfully | [DatasetDetail](#datasetdetail) | +| 403 | Permission denied | | +| 404 | Dataset not found | | + +### /datasets/{dataset_id}/api-keys/{status} + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| status | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/auto-disable-logs + +#### GET +##### Description + +Get dataset auto disable logs + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Auto disable logs retrieved successfully | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/batch/{batch}/indexing-estimate + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| batch | path | | Yes | string | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/batch/{batch}/indexing-status + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| batch | path | | Yes | string | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Description + +Get documents in a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| fetch | query | Fetch full details (default: false) | No | string | +| keyword | query | Search keyword | No | string | +| limit | query | Number of items per page (default: 20) | No | string | +| page | query | Page number (default: 1) | No | string | +| sort | query | Sort order (default: -created_at) | No | string | +| status | query | Filter documents by display status | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Documents retrieved successfully | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [KnowledgeConfig](#knowledgeconfig) | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Documents created successfully | [DatasetAndDocumentResponse](#datasetanddocumentresponse) | + +### /datasets/{dataset_id}/documents/download-zip + +#### POST +##### Summary + +Stream a ZIP archive containing the requested uploaded documents + +##### Description + +Download selected dataset documents as a single ZIP archive (upload-file only) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| payload | body | | Yes | [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/generate-summary + +#### POST +##### Summary + +Generate summary index for specified documents + +##### Description + +Generate summary index for documents +This endpoint checks if the dataset configuration supports summary generation +(indexing_technique must be 'high_quality' and summary_index_setting.enable must be true), +then asynchronously generates summary indexes for the provided documents. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [GenerateSummaryPayload](#generatesummarypayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Summary generation started successfully | +| 400 | Invalid request or dataset configuration | +| 403 | Permission denied | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/documents/metadata + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| payload | body | | Yes | [MetadataOperationData](#metadataoperationdata) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/status/{action}/batch + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | | Yes | string | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Description + +Get document details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| metadata | query | Metadata inclusion (all/only/without) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document retrieved successfully | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/download + +#### GET +##### Description + +Get a signed download URL for a dataset document's original uploaded file + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/indexing-estimate + +#### GET +##### Description + +Estimate document indexing cost + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Indexing estimate calculated successfully | +| 400 | Document already finished | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/indexing-status + +#### GET +##### Description + +Get document indexing status + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Indexing status retrieved successfully | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/metadata + +#### PUT +##### Description + +Update document metadata + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DocumentMetadataUpdatePayload](#documentmetadataupdatepayload) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document metadata updated successfully | +| 403 | Permission denied | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/notion/sync + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/pipeline-execution-log + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/processing/pause + +#### PATCH +##### Summary + +pause document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/processing/resume + +#### PATCH +##### Summary + +recover document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/processing/{action} + +#### PATCH +##### Description + +Update document processing status (pause/resume) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action to perform (pause/resume) | Yes | string | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Processing status updated successfully | +| 400 | Invalid action | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/rename + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| payload | body | | Yes | [DocumentRenamePayload](#documentrenamepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document renamed successfully | [DocumentResponse](#documentresponse) | + +### /datasets/{dataset_id}/documents/{document_id}/segment + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| payload | body | | Yes | [SegmentCreatePayload](#segmentcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/segment/{action} + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | | Yes | string | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/segments + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/segments/batch_import + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| payload | body | | Yes | [BatchImportPayload](#batchimportpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | +| payload | body | | Yes | [SegmentUpdatePayload](#segmentupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | +| payload | body | | Yes | [ChildChunkCreatePayload](#childchunkcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| child_chunk_id | path | | Yes | string | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| child_chunk_id | path | | Yes | string | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | +| payload | body | | Yes | [ChildChunkUpdatePayload](#childchunkupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/documents/{document_id}/summary-status + +#### GET +##### Summary + +Get summary index generation status for a document + +##### Description + +Get summary index generation status for a document +Returns: +- total_segments: Total number of segments in the document +- summary_status: Dictionary with status counts + - completed: Number of summaries completed + - generating: Number of summaries being generated + - error: Number of summaries with errors + - not_started: Number of segments without summary records +- summaries: List of summary records with status and content preview + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Summary status retrieved successfully | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/website-sync + +#### GET +##### Summary + +sync website document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/error-docs + +#### GET +##### Description + +Get dataset error documents + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Error documents retrieved successfully | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/external-hit-testing + +#### POST +##### Description + +Test external knowledge retrieval for dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ExternalHitTestingPayload](#externalhittestingpayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | External hit testing completed successfully | +| 400 | Invalid parameters | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/hit-testing + +#### POST +##### Description + +Test dataset knowledge retrieval + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HitTestingPayload](#hittestingpayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Hit testing completed successfully | [HitTestingResponse](#hittestingresponse) | +| 400 | Invalid parameters | | +| 404 | Dataset not found | | + +### /datasets/{dataset_id}/indexing-status + +#### GET +##### Description + +Get dataset indexing status + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Indexing status retrieved successfully | + +### /datasets/{dataset_id}/metadata + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| payload | body | | Yes | [MetadataArgs](#metadataargs) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/metadata/built-in/{action} + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | | Yes | string | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/metadata/{metadata_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| metadata_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| metadata_id | path | | Yes | string | +| payload | body | | Yes | [MetadataUpdatePayload](#metadataupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/notion/sync + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/permission-part-users + +#### GET +##### Description + +Get dataset permission user list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Permission users retrieved successfully | +| 403 | Permission denied | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/queries + +#### GET +##### Description + +Get dataset query history + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Query history retrieved successfully | [DatasetQueryDetail](#datasetquerydetail) | + +### /datasets/{dataset_id}/related-apps + +#### GET +##### Description + +Get applications related to dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Related apps retrieved successfully | [RelatedAppList](#relatedapplist) | + +### /datasets/{dataset_id}/retry + +#### POST +##### Summary + +retry document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| payload | body | | Yes | [DocumentRetryPayload](#documentretrypayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /datasets/{dataset_id}/use-check + +#### GET +##### Description + +Check if dataset is in use + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Dataset use status retrieved successfully | + +### /datasets/{resource_id}/api-keys + +#### GET +##### Summary + +Get all API keys for a dataset + +##### Description + +Get all API keys for a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resource_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | API keys retrieved successfully | [ApiKeyList](#apikeylist) | + +#### POST +##### Summary + +Create a new API key for a dataset + +##### Description + +Create a new API key for a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| resource_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | API key created successfully | [ApiKeyItem](#apikeyitem) | +| 400 | Maximum keys exceeded | | + +### /datasets/{resource_id}/api-keys/{api_key_id} + +#### DELETE +##### Summary + +Delete an API key for a dataset + +##### Description + +Delete an API key for a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| api_key_id | path | API key ID | Yes | string | +| resource_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | API key deleted successfully | + +### /email-code-login + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EmailPayload](#emailpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /email-code-login/validity + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EmailCodeLoginPayload](#emailcodeloginpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /email-register + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /email-register/send-email + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /email-register/validity + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /explore/apps + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| language | query | Language code for recommended app localization | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [RecommendedAppListResponse](#recommendedapplistresponse) | + +### /explore/apps/{app_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /features + +#### GET +##### Summary + +Get feature configuration for current tenant + +##### Description + +Get feature configuration for current tenant + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [FeatureResponse](#featureresponse) | + +### /files/support-type + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /files/upload + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [UploadConfig](#uploadconfig) | + +#### POST +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | [FileResponse](#fileresponse) | + +### /files/{file_id}/preview + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| file_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /forgot-password + +#### POST +##### Description + +Send password reset email + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ForgotPasswordSendPayload](#forgotpasswordsendpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Email sent successfully | [ForgotPasswordEmailResponse](#forgotpasswordemailresponse) | +| 400 | Invalid email or rate limit exceeded | | + +### /forgot-password/resets + +#### POST +##### Description + +Reset password with verification token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ForgotPasswordResetPayload](#forgotpasswordresetpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Password reset successfully | [ForgotPasswordResetResponse](#forgotpasswordresetresponse) | +| 400 | Invalid token or password mismatch | | + +### /forgot-password/validity + +#### POST +##### Description + +Verify password reset code + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ForgotPasswordCheckPayload](#forgotpasswordcheckpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Code verified successfully | [ForgotPasswordCheckResponse](#forgotpasswordcheckresponse) | +| 400 | Invalid code or token | | + +### /form/human_input/{form_token} + +#### GET +##### Summary + +Get human input form definition by form token + +##### Description + +GET /console/api/form/human_input/ + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Summary + +Submit human input form by form token + +##### Description + +POST /console/api/form/human_input/ + +Request body: +{ + "inputs": { + "content": "User input content" + }, + "action": "Approve" +} + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /info + +#### POST +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [TenantInfoResponse](#tenantinforesponse) | + +### /installed-apps + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [InstalledAppListResponse](#installedapplistresponse) | + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/audio-to-text + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/chat-messages + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [ChatMessagePayload](#chatmessagepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/chat-messages/{task_id}/stop + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/completion-messages + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [CompletionMessageExplorePayload](#completionmessageexplorepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/completion-messages/{task_id}/stop + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/conversations + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [ConversationListQuery](#conversationlistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/conversations/{c_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | | Yes | string | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/conversations/{c_id}/name + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | | Yes | string | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [ConversationRenamePayload](#conversationrenamepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/conversations/{c_id}/pin + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | | Yes | string | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/conversations/{c_id}/unpin + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | | Yes | string | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/messages + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [MessageListQuery](#messagelistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/messages/{message_id}/feedbacks + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| message_id | path | | Yes | string | +| payload | body | | Yes | [MessageFeedbackPayload](#messagefeedbackpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/messages/{message_id}/more-like-this + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| message_id | path | | Yes | string | +| payload | body | | Yes | [MoreLikeThisQuery](#morelikethisquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/messages/{message_id}/suggested-questions + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| message_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/meta + +#### GET +##### Summary + +Get app meta + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/parameters + +#### GET +##### Summary + +Retrieve app parameters + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/saved-messages + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [SavedMessageListQuery](#savedmessagelistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [SavedMessageCreatePayload](#savedmessagecreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/saved-messages/{message_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| message_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/text-to-audio + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [TextToAudioPayload](#texttoaudiopayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/workflows/run + +#### POST +##### Summary + +Run workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /installed-apps/{installed_app_id}/workflows/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop workflow task + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| installed_app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /instruction-generate + +#### POST +##### Description + +Generate instruction for workflow nodes or general use + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [InstructionGeneratePayload](#instructiongeneratepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Instruction generated successfully | +| 400 | Invalid request parameters or flow/workflow not found | +| 402 | Provider quota exceeded | + +### /instruction-generate/template + +#### POST +##### Description + +Get instruction generation template + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [InstructionTemplatePayload](#instructiontemplatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Template retrieved successfully | +| 400 | Invalid request parameters | + +### /login + +#### POST +##### Summary + +Authenticate user and login + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [LoginPayload](#loginpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /logout + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /mcp/oauth/callback + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /notification + +#### GET +##### Description + +Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success — inspect should_show to decide whether to render the modal | +| 401 | Unauthorized | + +### /notification/dismiss + +#### POST +##### Description + +Mark a notification as dismissed for the current user. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 401 | Unauthorized | + +### /notion/pages/{page_id}/{page_type}/preview + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| page_id | path | | Yes | string | +| page_type | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| page_id | path | | Yes | string | +| page_type | path | | Yes | string | +| payload | body | | Yes | [NotionEstimatePayload](#notionestimatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /notion/pre-import/pages + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/authorize/{provider} + +#### GET +##### Description + +Handle OAuth callback and complete login process + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | OAuth provider name (github/google) | Yes | string | +| code | query | Authorization code from OAuth provider | No | string | +| state | query | Optional state parameter (used for invite token) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to console with access token | +| 400 | OAuth process failed | + +### /oauth/data-source/binding/{provider} + +#### GET +##### Description + +Bind OAuth data source with authorization code + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | Data source provider name (notion) | Yes | string | +| code | query | Authorization code from OAuth provider | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Data source binding success | [OAuthDataSourceBindingResponse](#oauthdatasourcebindingresponse) | +| 400 | Invalid provider or code | | + +### /oauth/data-source/callback/{provider} + +#### GET +##### Description + +Handle OAuth callback from data source provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | Data source provider name (notion) | Yes | string | +| code | query | Authorization code from OAuth provider | No | string | +| error | query | Error message from OAuth provider | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to console with result | +| 400 | Invalid provider | + +### /oauth/data-source/{provider} + +#### GET +##### Description + +Get OAuth authorization URL for data source provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | Data source provider name (notion) | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Authorization URL or internal setup success | [OAuthDataSourceResponse](#oauthdatasourceresponse) | +| 400 | Invalid provider | | +| 403 | Admin privileges required | | + +### /oauth/data-source/{provider}/{binding_id}/sync + +#### GET +##### Description + +Sync data from OAuth data source + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| binding_id | path | Data source binding ID | Yes | string | +| provider | path | Data source provider name (notion) | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Data source sync success | [OAuthDataSourceSyncResponse](#oauthdatasourcesyncresponse) | +| 400 | Invalid provider or sync failed | | + +### /oauth/login/{provider} + +#### GET +##### Description + +Initiate OAuth login process + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | OAuth provider name (github/google) | Yes | string | +| invite_token | query | Optional invitation token | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 302 | Redirect to OAuth authorization URL | +| 400 | Invalid provider | + +### /oauth/plugin/{provider_id}/datasource/callback + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/plugin/{provider_id}/datasource/get-authorization-url + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/plugin/{provider}/tool/authorization-url + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/plugin/{provider}/tool/callback + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/plugin/{provider}/trigger/callback + +#### GET +##### Summary + +Handle OAuth callback for trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/provider + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/provider/account + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/provider/authorize + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /oauth/provider/token + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipeline/customized/templates/{template_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| template_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| template_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| template_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipeline/dataset + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [RagPipelineDatasetImportPayload](#ragpipelinedatasetimportpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipeline/empty-dataset + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipeline/templates + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipeline/templates/{template_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| template_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/datasource-plugins + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/imports + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [RagPipelineImportPayload](#ragpipelineimportpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/imports/{import_id}/confirm + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| import_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/imports/{pipeline_id}/check-dependencies + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/recommended-plugins + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/transform/datasets/{dataset_id} + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/customized/publish + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [Payload](#payload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/exports + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflow-runs + +#### GET +##### Summary + +Get workflow run list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | + +### /rag/pipelines/{pipeline_id}/workflow-runs/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop workflow task + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflow-runs/{run_id} + +#### GET +##### Summary + +Get workflow run detail + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| run_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | + +### /rag/pipelines/{pipeline_id}/workflow-runs/{run_id}/node-executions + +#### GET +##### Summary + +Get workflow run node execution list + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| run_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | + +### /rag/pipelines/{pipeline_id}/workflows + +#### GET +##### Summary + +Get published workflows + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflows retrieved successfully | [WorkflowPaginationResponse](#workflowpaginationresponse) | +| 403 | Permission denied | | + +### /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs + +#### GET +##### Summary + +Get default block config + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs/{block_type} + +#### GET +##### Summary + +Get default block config + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| block_type | path | | Yes | string | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft + +#### GET +##### Summary + +Get draft rag pipeline's workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow retrieved successfully | [WorkflowResponse](#workflowresponse) | +| 404 | Draft workflow not found | | + +#### POST +##### Summary + +Sync draft workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run + +#### POST +##### Summary + +Run rag pipeline datasource + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceNodeRunPayload](#datasourcenoderunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/datasource/variables-inspect + +#### POST +##### Summary + +Set datasource variables + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceVariablesPayload](#datasourcevariablespayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Datasource variables set successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | + +### /rag/pipelines/{pipeline_id}/workflows/draft/environment-variables + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow iteration node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [NodeRunPayload](#noderunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow loop node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [NodeRunPayload](#noderunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/last-run + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | + +### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/run + +#### POST +##### Summary + +Run draft workflow node + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [NodeRunRequiredPayload](#noderunrequiredpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node run started successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | + +### /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/pre-processing/parameters + +#### GET +##### Summary + +Get first step parameters of rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters + +#### GET +##### Summary + +Get second step parameters of rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/run + +#### POST +##### Summary + +Run draft workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [DraftWorkflowRunPayload](#draftworkflowrunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/system-variables + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/variables + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/publish + +#### GET +##### Summary + +Get published pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflow retrieved successfully, or null if not exist | [WorkflowResponse](#workflowresponse) | + +#### POST +##### Summary + +Publish workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/preview + +#### POST +##### Summary + +Run datasource content preview + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [Parser](#parser) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run + +#### POST +##### Summary + +Run rag pipeline datasource + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [DatasourceNodeRunPayload](#datasourcenoderunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/published/pre-processing/parameters + +#### GET +##### Summary + +Get first step parameters of rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/published/processing/parameters + +#### GET +##### Summary + +Get second step parameters of rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/published/run + +#### POST +##### Summary + +Run published workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| payload | body | | Yes | [PublishedWorkflowRunPayload](#publishedworkflowrunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rag/pipelines/{pipeline_id}/workflows/{workflow_id} + +#### DELETE +##### Summary + +Delete a published workflow version that is not currently active on the pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Summary + +Update workflow attributes + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow updated successfully | [WorkflowResponse](#workflowresponse) | +| 400 | No valid fields to update | | +| 403 | Permission denied | | +| 404 | Workflow not found | | + +### /rag/pipelines/{pipeline_id}/workflows/{workflow_id}/restore + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| pipeline_id | path | | Yes | string | +| workflow_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /refresh-token + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /remote-files/upload + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /remote-files/{url} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| url | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /reset-password + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EmailPayload](#emailpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /rule-code-generate + +#### POST +##### Description + +Generate code rules using LLM + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [RuleCodeGeneratePayload](#rulecodegeneratepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Code rules generated successfully | +| 400 | Invalid request parameters | +| 402 | Provider quota exceeded | + +### /rule-generate + +#### POST +##### Description + +Generate rule configuration using LLM + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [RuleGeneratePayload](#rulegeneratepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Rule configuration generated successfully | +| 400 | Invalid request parameters | +| 402 | Provider quota exceeded | + +### /rule-structured-output-generate + +#### POST +##### Description + +Generate structured output rules using LLM + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [RuleStructuredOutputPayload](#rulestructuredoutputpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Structured output generated successfully | +| 400 | Invalid request parameters | +| 402 | Provider quota exceeded | + +### /spec/schema-definitions + +#### GET +##### Summary + +Get system JSON Schema definitions specification + +##### Description + +Used for frontend component type mapping + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /system-features + +#### GET +##### Summary + +Get system-wide feature configuration + +##### Description + +Get system-wide feature configuration +NOTE: This endpoint is unauthenticated by design, as it provides system features +data required for dashboard initialization. + +Authentication would create circular dependency (can't login without dashboard loading). + +Only non-sensitive configuration data should be returned by this endpoint. + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [SystemFeatureResponse](#systemfeatureresponse) | + +### /tag-bindings + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagBindingPayload](#tagbindingpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /tag-bindings/remove + +#### POST +##### Description + +Remove one or more tag bindings from a target. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagBindingRemovePayload](#tagbindingremovepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /tags + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| keyword | query | Search keyword for tag name. | No | string | +| type | query | Tag type filter. Can be "knowledge" or "app". | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ [TagResponse](#tagresponse) ] | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagBasePayload](#tagbasepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /tags/{tag_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| tag_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| tag_id | path | | Yes | string | +| payload | body | | Yes | [TagUpdateRequestPayload](#tagupdaterequestpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /test/retrieval + +#### POST +##### Description + +Bedrock retrieval test (internal use only) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [BedrockRetrievalPayload](#bedrockretrievalpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Bedrock retrieval test completed | + +### /trial-apps/{app_id} + +#### GET +##### Summary + +Get app detail + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/audio-to-text + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/chat-messages + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [ChatRequest](#chatrequest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/completion-messages + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [CompletionRequest](#completionrequest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/datasets + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/messages/{message_id}/suggested-questions + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| message_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/parameters + +#### GET +##### Summary + +Retrieve app parameters + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/site + +#### GET +##### Summary + +Retrieve app site info + +##### Description + +Returns the site configuration for the application including theme, icons, and text. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/text-to-audio + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [TextToSpeechRequest](#texttospeechrequest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/workflows + +#### GET +##### Summary + +Get workflow detail + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/workflows/run + +#### POST +##### Summary + +Run workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [WorkflowRunRequest](#workflowrunrequest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /trial-apps/{app_id}/workflows/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop workflow task + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /website/crawl + +#### POST +##### Description + +Crawl website content + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WebsiteCrawlPayload](#websitecrawlpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Website crawl initiated successfully | +| 400 | Invalid crawl parameters | + +### /website/crawl/status/{job_id} + +#### GET +##### Description + +Get website crawl status + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WebsiteCrawlStatusQuery](#websitecrawlstatusquery) | +| job_id | path | Crawl job ID | Yes | string | +| provider | query | Crawl provider (firecrawl/watercrawl/jinareader) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Crawl status retrieved successfully | +| 400 | Invalid provider | +| 404 | Crawl job not found | + +### /workflow/{workflow_run_id}/events + +#### GET +##### Summary + +Get workflow execution events stream after resume + +##### Description + +GET /console/api/workflow//events + +Returns Server-Sent Events stream. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workflow_run_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workflow/{workflow_run_id}/pause-details + +#### GET +##### Summary + +Get workflow pause details + +##### Description + +Get workflow pause details +GET /console/api/workflow//pause-details + +Returns information about why and where the workflow is paused. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workflow_run_id | path | Workflow run ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow pause details retrieved successfully | [WorkflowPauseDetailsResponse](#workflowpausedetailsresponse) | +| 404 | Workflow run not found | | + +### /workspaces + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current + +#### POST +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [TenantInfoResponse](#tenantinforesponse) | + +### /workspaces/current/agent-provider/{provider_name} + +#### GET +##### Description + +Get specific agent provider details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_name | path | Agent provider name | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | object | + +### /workspaces/current/agent-providers + +#### GET +##### Description + +Get list of available agent providers + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ object ] | + +### /workspaces/current/dataset-operators + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [AccountWithRoleList](#accountwithrolelist) | + +### /workspaces/current/default-model + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserGetDefault](#parsergetdefault) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPostDefault](#parserpostdefault) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/endpoints + +#### POST +##### Description + +Create a new plugin endpoint + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointCreatePayload](#endpointcreatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint created successfully | [EndpointCreateResponse](#endpointcreateresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/endpoints/create + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointCreatePayload](#endpointcreatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint created successfully | [EndpointCreateResponse](#endpointcreateresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/endpoints/delete + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for deleting a plugin endpoint. Use DELETE /workspaces/current/endpoints/{id} instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointIdPayload](#endpointidpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint deleted successfully | [EndpointDeleteResponse](#endpointdeleteresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/endpoints/disable + +#### POST +##### Description + +Disable a plugin endpoint + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointIdPayload](#endpointidpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint disabled successfully | [EndpointDisableResponse](#endpointdisableresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/endpoints/enable + +#### POST +##### Description + +Enable a plugin endpoint + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointIdPayload](#endpointidpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint enabled successfully | [EndpointEnableResponse](#endpointenableresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/endpoints/list + +#### GET +##### Description + +List plugin endpoints with pagination + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointListQuery](#endpointlistquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [EndpointListResponse](#endpointlistresponse) | + +### /workspaces/current/endpoints/list/plugin + +#### GET +##### Description + +List endpoints for a specific plugin + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointListForPluginQuery](#endpointlistforpluginquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [PluginEndpointListResponse](#pluginendpointlistresponse) | + +### /workspaces/current/endpoints/update + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [LegacyEndpointUpdatePayload](#legacyendpointupdatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint updated successfully | [EndpointUpdateResponse](#endpointupdateresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/endpoints/{id} + +#### DELETE +##### Description + +Delete a plugin endpoint + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| id | path | Endpoint ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint deleted successfully | [EndpointDeleteResponse](#endpointdeleteresponse) | +| 403 | Admin privileges required | | + +#### PATCH +##### Description + +Update a plugin endpoint + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EndpointUpdatePayload](#endpointupdatepayload) | +| id | path | Endpoint ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Endpoint updated successfully | [EndpointUpdateResponse](#endpointupdateresponse) | +| 403 | Admin privileges required | | + +### /workspaces/current/members + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [AccountWithRoleList](#accountwithrolelist) | + +### /workspaces/current/members/invite-email + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MemberInvitePayload](#memberinvitepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/members/owner-transfer-check + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [OwnerTransferCheckPayload](#ownertransfercheckpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/members/send-owner-transfer-confirm-email + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [OwnerTransferEmailPayload](#ownertransferemailpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/members/{member_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/members/{member_id}/owner-transfer + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string | +| payload | body | | Yes | [OwnerTransferPayload](#ownertransferpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/members/{member_id}/update-role + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string | +| payload | body | | Yes | [MemberRoleUpdatePayload](#memberroleupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserModelList](#parsermodellist) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/checkout-url + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/credentials + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCredentialDelete](#parsercredentialdelete) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCredentialId](#parsercredentialid) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCredentialCreate](#parsercredentialcreate) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCredentialUpdate](#parsercredentialupdate) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/credentials/switch + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCredentialSwitch](#parsercredentialswitch) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/credentials/validate + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCredentialValidate](#parsercredentialvalidate) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserDeleteModels](#parserdeletemodels) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserPostModels](#parserpostmodels) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/credentials + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserDeleteCredential](#parserdeletecredential) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserGetCredentials](#parsergetcredentials) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserCreateCredential](#parsercreatecredential) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserUpdateCredential](#parserupdatecredential) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/credentials/switch + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserSwitch](#parserswitch) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/credentials/validate + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserValidate](#parservalidate) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/disable + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserDeleteModels](#parserdeletemodels) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/enable + +#### PATCH +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserDeleteModels](#parserdeletemodels) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/load-balancing-configs/credentials-validate + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [LoadBalancingCredentialPayload](#loadbalancingcredentialpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/load-balancing-configs/{config_id}/credentials-validate + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| config_id | path | | Yes | string | +| provider | path | | Yes | string | +| payload | body | | Yes | [LoadBalancingCredentialPayload](#loadbalancingcredentialpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/models/parameter-rules + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserParameter](#parserparameter) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/model-providers/{provider}/preferred-provider-type + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ParserPreferredProviderType](#parserpreferredprovidertype) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/models/model-types/{model_type} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| model_type | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/permission + +#### GET +##### Summary + +Get workspace permission settings + +##### Description + +Returns permission flags that control workspace features like member invitations and owner transfer. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/asset + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserAsset](#parserasset) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/debugging-key + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/fetch-manifest + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPluginIdentifierQuery](#parserpluginidentifierquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/icon + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserIcon](#parsericon) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/install/github + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserGithubInstall](#parsergithubinstall) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/install/marketplace + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPluginIdentifiers](#parserpluginidentifiers) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/install/pkg + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPluginIdentifiers](#parserpluginidentifiers) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/list + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserList](#parserlist) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/list/installations/ids + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserLatest](#parserlatest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/list/latest-versions + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserLatest](#parserlatest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/marketplace/pkg + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPluginIdentifierQuery](#parserpluginidentifierquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/parameters/dynamic-options + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserDynamicOptions](#parserdynamicoptions) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/parameters/dynamic-options-with-credentials + +#### POST +##### Summary + +Fetch dynamic options using credentials directly (for edit mode) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserDynamicOptionsWithCredentials](#parserdynamicoptionswithcredentials) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/permission/change + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPermissionChange](#parserpermissionchange) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/permission/fetch + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/preferences/autoupgrade/exclude + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserExcludePlugin](#parserexcludeplugin) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/preferences/change + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserPreferencesChange](#parserpreferenceschange) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/preferences/fetch + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/readme + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserReadme](#parserreadme) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/tasks + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserTasks](#parsertasks) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/tasks/delete_all + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/tasks/{task_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/tasks/{task_id}/delete + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/tasks/{task_id}/delete/{identifier} + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| identifier | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/uninstall + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserUninstall](#parseruninstall) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/upgrade/github + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserGithubUpgrade](#parsergithubupgrade) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/upgrade/marketplace + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserMarketplaceUpgrade](#parsermarketplaceupgrade) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/upload/bundle + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/upload/github + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ParserGithubUpload](#parsergithubupload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/plugin/upload/pkg + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-labels + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/add + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ApiToolProviderAddPayload](#apitoolprovideraddpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/delete + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ApiToolProviderDeletePayload](#apitoolproviderdeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/get + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/remote + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/schema + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ApiToolSchemaPayload](#apitoolschemapayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/test/pre + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ApiToolTestPayload](#apitooltestpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/tools + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/api/update + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ApiToolProviderUpdatePayload](#apitoolproviderupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/add + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [BuiltinToolAddPayload](#builtintooladdpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/credential/info + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/credential/schema/{credential_type} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| credential_type | path | | Yes | string | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/credentials + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/default-credential + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [BuiltinProviderDefaultCredentialPayload](#builtinproviderdefaultcredentialpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/delete + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [BuiltinToolCredentialDeletePayload](#builtintoolcredentialdeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/icon + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/info + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/oauth/client-schema + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/oauth/custom-client + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [ToolOAuthCustomClientPayload](#tooloauthcustomclientpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/tools + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/builtin/{provider}/update + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [BuiltinToolUpdatePayload](#builtintoolupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/mcp + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MCPProviderDeletePayload](#mcpproviderdeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MCPProviderCreatePayload](#mcpprovidercreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MCPProviderUpdatePayload](#mcpproviderupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/mcp/auth + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MCPAuthPayload](#mcpauthpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/mcp/tools/{provider_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/mcp/update/{provider_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/workflow/create + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowToolCreatePayload](#workflowtoolcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/workflow/delete + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowToolDeletePayload](#workflowtooldeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/workflow/get + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/workflow/tools + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-provider/workflow/update + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowToolUpdatePayload](#workflowtoolupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tool-providers + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tools/api + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tools/builtin + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tools/mcp + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/tools/workflow + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/icon + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/info + +#### GET +##### Summary + +Get info for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/oauth/client + +#### DELETE +##### Summary + +Remove custom OAuth client configuration + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Summary + +Get OAuth client configuration for a provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Summary + +Configure custom OAuth client for a provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [TriggerOAuthClientPayload](#triggeroauthclientpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/build/{subscription_builder_id} + +#### POST +##### Summary + +Build a subscription instance for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | +| payload | body | | Yes | [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/create + +#### POST +##### Summary + +Add a new subscription instance for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| payload | body | | Yes | [TriggerSubscriptionBuilderCreatePayload](#triggersubscriptionbuildercreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/logs/{subscription_builder_id} + +#### GET +##### Summary + +Get the request logs for a subscription instance for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/update/{subscription_builder_id} + +#### POST +##### Summary + +Update a subscription instance for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | +| payload | body | | Yes | [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/verify-and-update/{subscription_builder_id} + +#### POST +##### Summary + +Verify and update a subscription instance for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | +| payload | body | | Yes | [TriggerSubscriptionBuilderVerifyPayload](#triggersubscriptionbuilderverifypayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/builder/{subscription_builder_id} + +#### GET +##### Summary + +Get a subscription instance for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_builder_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/list + +#### GET +##### Summary + +List all trigger subscriptions for the current tenant's provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/oauth/authorize + +#### GET +##### Summary + +Initiate OAuth authorization flow for a trigger provider + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{provider}/subscriptions/verify/{subscription_id} + +#### POST +##### Summary + +Verify credentials for an existing subscription (edit mode only) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | +| subscription_id | path | | Yes | string | +| payload | body | | Yes | [TriggerSubscriptionBuilderVerifyPayload](#triggersubscriptionbuilderverifypayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{subscription_id}/subscriptions/delete + +#### POST +##### Summary + +Delete a subscription instance + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| subscription_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/trigger-provider/{subscription_id}/subscriptions/update + +#### POST +##### Summary + +Update a subscription instance + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| subscription_id | path | | Yes | string | +| payload | body | | Yes | [TriggerSubscriptionBuilderUpdatePayload](#triggersubscriptionbuilderupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/current/triggers + +#### GET +##### Summary + +List all trigger providers for the current tenant + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/custom-config + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkspaceCustomConfigPayload](#workspacecustomconfigpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/custom-config/webapp-logo/upload + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/info + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkspaceInfoPayload](#workspaceinfopayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/switch + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SwitchWorkspacePayload](#switchworkspacepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| icon_type | path | | Yes | string | +| lang | path | | Yes | string | +| provider | path | | Yes | string | +| tenant_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +## default +Default namespace + +### /explore/banners + +#### GET +##### Summary + +Get banner list + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +### Models + +#### APIBasedExtensionListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| APIBasedExtensionListResponse | array | | | + +#### APIBasedExtensionPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_endpoint | string | API endpoint URL | Yes | +| api_key | string | API key for authentication | Yes | +| name | string | Extension name | Yes | + +#### APIBasedExtensionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| api_endpoint | string | | Yes | +| api_key | string | | Yes | +| created_at | integer | | No | +| id | string | | Yes | +| name | string | | Yes | + +#### Account + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| created_at | integer | | No | +| email | string | | Yes | +| id | string | | Yes | +| interface_language | string | | No | +| interface_theme | string | | No | +| is_password_set | boolean | | Yes | +| last_login_at | integer | | No | +| last_login_ip | string | | No | +| name | string | | Yes | +| timezone | string | | No | + +#### AccountAvatarPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | Yes | + +#### AccountAvatarQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | Avatar file ID | Yes | + +#### AccountDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| token | string | | Yes | + +#### AccountDeletionFeedbackPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| feedback | string | | Yes | + +#### AccountInitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| interface_language | string | | Yes | +| invitation_code | string | | No | +| timezone | string | | Yes | + +#### AccountIntegrateListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccountIntegrateResponse](#accountintegrateresponse) ] | | Yes | + +#### AccountIntegrateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| is_bound | boolean | | Yes | +| link | string | | No | +| provider | string | | Yes | + +#### AccountInterfaceLanguagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| interface_language | string | | Yes | + +#### AccountInterfaceThemePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| interface_theme | string | *Enum:* `"dark"`, `"light"` | Yes | + +#### AccountNamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### AccountPasswordPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_password | string | | Yes | +| password | string | | No | +| repeat_new_password | string | | Yes | + +#### AccountTimezonePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| timezone | string | | Yes | + +#### AccountWithRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| created_at | integer | | No | +| email | string | | Yes | +| id | string | | Yes | +| last_active_at | integer | | No | +| last_login_at | integer | | No | +| name | string | | Yes | +| role | string | | Yes | +| status | string | | Yes | + +#### AccountWithRoleList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| accounts | [ [AccountWithRole](#accountwithrole) ] | | Yes | + +#### ActivateCheckQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| token | string | | Yes | +| workspace_id | string | | No | + +#### ActivatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| interface_language | string | | Yes | +| name | string | | Yes | +| timezone | string | | Yes | +| token | string | | Yes | +| workspace_id | string | | No | + +#### ActivationCheckResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | object | Activation data if valid | No | +| is_valid | boolean | Whether token is valid | Yes | + +#### ActivationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Operation result | Yes | + +#### AdvancedChatWorkflowRunForListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| elapsed_time | number | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| message_id | string | | No | +| retry_index | integer | | No | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| version | string | | No | + +#### AdvancedChatWorkflowRunPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AdvancedChatWorkflowRunForListResponse](#advancedchatworkflowrunforlistresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### AdvancedChatWorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| files | [ object ] | | No | +| inputs | object | | No | +| parent_message_id | string | | No | +| query | string | | No | + +#### AdvancedPromptTemplateQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_mode | string | Application mode | Yes | +| has_context | string | Whether has context | No | +| model_mode | string | Model mode | Yes | +| model_name | string | Model name | Yes | + +#### AgentLogQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation UUID | Yes | +| message_id | string | Message UUID | Yes | + +#### AgentThought + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chain_id | string | | No | +| created_at | integer | | No | +| files | [ string ] | | Yes | +| id | string | | Yes | +| message_chain_id | string | | No | +| message_id | string | | Yes | +| observation | string | | No | +| position | integer | | Yes | +| thought | string | | No | +| tool | string | | No | +| tool_input | string | | No | +| tool_labels | [JSONValue](#jsonvalue) | | Yes | + +#### Annotation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| created_at | integer | | No | +| hit_count | integer | | No | +| id | string | | Yes | +| question | string | | No | + +#### AnnotationCountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| count | integer | Number of annotations | Yes | + +#### AnnotationExportList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [Annotation](#annotation) ] | | Yes | + +#### AnnotationFilePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID | Yes | + +#### AnnotationHitHistory + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_content | string | | No | +| annotation_question | string | | No | +| created_at | integer | | No | +| id | string | | Yes | +| question | string | | No | +| score | number | | No | +| source | string | | No | + +#### AnnotationHitHistoryList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AnnotationHitHistory](#annotationhithistory) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AnnotationList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [Annotation](#annotation) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AnnotationListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Search keyword | No | +| limit | integer | Page size | No | +| page | integer | Page number | No | + +#### AnnotationReplyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | Embedding model name | Yes | +| embedding_provider_name | string | Embedding provider name | Yes | +| score_threshold | number | Score threshold for annotation matching | Yes | + +#### AnnotationReplyStatusQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | *Enum:* `"disable"`, `"enable"` | Yes | + +#### AnnotationSettingUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| score_threshold | number | Score threshold | Yes | + +#### ApiKeyAuthBindingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | Yes | +| credentials | object | | Yes | +| provider | string | | Yes | + +#### ApiKeyItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | +| last_used_at | integer | | No | +| token | string | | Yes | +| type | string | | Yes | + +#### ApiKeyList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ApiKeyItem](#apikeyitem) ] | | Yes | + +#### ApiProviderSchemaType + +Enum class for api provider schema type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ApiProviderSchemaType | string | Enum class for api provider schema type. | | + +#### ApiToolProviderAddPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| custom_disclaimer | string | | No | +| icon | object | | Yes | +| labels | [ string ] | | No | +| privacy_policy | string | | No | +| provider | string | | Yes | +| schema | string | | Yes | +| schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | + +#### ApiToolProviderDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| provider | string | | Yes | + +#### ApiToolProviderUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| custom_disclaimer | string | | No | +| icon | object | | Yes | +| labels | [ string ] | | No | +| original_provider | string | | Yes | +| privacy_policy | string | | No | +| provider | string | | Yes | +| schema | string | | Yes | +| schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | + +#### ApiToolSchemaPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| schema | string | | Yes | + +#### ApiToolTestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| parameters | object | | Yes | +| provider_name | string | | No | +| schema | string | | Yes | +| schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | +| tool_name | string | | Yes | + +#### AppApiStatusPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enable_api | boolean | Enable or disable API | Yes | + +#### AppDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| app_model_config | [ModelConfig](#modelconfig) | | No | +| created_at | integer | | No | +| created_by | string | | No | +| description | string | | No | +| enable_api | boolean | | Yes | +| enable_site | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| id | string | | Yes | +| mode_compatible_with_agent | string | | Yes | +| name | string | | Yes | +| tags | [ [Tag](#tag) ] | | No | +| tracing | [JSONValue](#jsonvalue) | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [WorkflowPartial](#workflowpartial) | | No | + +#### AppDetailKernel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | object | | No | +| id | string | | No | +| mode | string | | No | +| name | string | | No | + +#### AppDetailWithSite + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| api_base_url | string | | No | +| app_model_config | [ModelConfig](#modelconfig) | | No | +| created_at | integer | | No | +| created_by | string | | No | +| deleted_tools | [ [DeletedTool](#deletedtool) ] | | No | +| description | string | | No | +| enable_api | boolean | | Yes | +| enable_site | boolean | | Yes | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| id | string | | Yes | +| max_active_requests | integer | | No | +| mode_compatible_with_agent | string | | Yes | +| name | string | | Yes | +| site | [Site](#site) | | No | +| tags | [ [Tag](#tag) ] | | No | +| tracing | [JSONValue](#jsonvalue) | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [WorkflowPartial](#workflowpartial) | | No | + +#### AppExportQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| include_secret | boolean | Include secrets in export | No | +| workflow_id | string | Specific workflow ID to export | No | + +#### AppExportResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | string | | Yes | + +#### AppIconPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | Icon data | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | + +#### AppImportPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| mode | string | Import mode | Yes | +| name | string | | No | +| yaml_content | string | | No | +| yaml_url | string | | No | + +#### AppListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| is_created_by_me | boolean | Filter by creator | No | +| limit | integer | Page size (1-100) | No | +| mode | string | App mode filter
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | +| name | string | Filter by app name | No | +| page | integer | Page number (1-99999) | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + +#### AppMCPServerResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| description | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| parameters | | | Yes | +| server_code | string | | Yes | +| status | [AppMCPServerStatus](#appmcpserverstatus) | | Yes | +| updated_at | integer | | No | + +#### AppMCPServerStatus + +AppMCPServer Status Enum + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AppMCPServerStatus | string | AppMCPServer Status Enum | | + +#### AppNamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | Name to check | Yes | + +#### AppPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| has_next | boolean | | Yes | +| items | [ [AppPartial](#apppartial) ] | | Yes | +| page | integer | | Yes | +| per_page | integer | | Yes | +| total | integer | | Yes | + +#### AppPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| app_model_config | [ModelConfigPartial](#modelconfigpartial) | | No | +| author_name | string | | No | +| create_user_name | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| desc_or_prompt | string | | No | +| has_draft_trigger | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| id | string | | Yes | +| max_active_requests | integer | | No | +| mode_compatible_with_agent | string | | Yes | +| name | string | | Yes | +| tags | [ [Tag](#tag) ] | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [WorkflowPartial](#workflowpartial) | | No | + +#### AppSiteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| code | string | | No | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| customize_domain | string | | No | +| customize_token_strategy | string | | Yes | +| default_language | string | | Yes | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| privacy_policy | string | | No | +| prompt_public | boolean | | Yes | +| show_workflow_steps | boolean | | Yes | +| title | string | | Yes | +| use_icon_as_answer_icon | boolean | | Yes | + +#### AppSiteStatusPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enable_site | boolean | Enable or disable site | Yes | + +#### AppSiteUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| customize_domain | string | | No | +| customize_token_strategy | string | *Enum:* `"allow"`, `"must"`, `"not_allow"` | No | +| default_language | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| privacy_policy | string | | No | +| prompt_public | boolean | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### AppTracePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | Enable or disable tracing | Yes | +| tracing_provider | string | Tracing provider | No | + +#### AudioTranscriptResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| text | string | Transcribed text from audio | Yes | + +#### BatchImportPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| upload_file_id | string | | Yes | + +#### BedrockRetrievalPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| knowledge_id | string | | Yes | +| query | string | | Yes | +| retrieval_setting | [BedrockRetrievalSetting](#bedrockretrievalsetting) | | Yes | + +#### BedrockRetrievalSetting + +Retrieval settings for Amazon Bedrock knowledge base queries. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| score_threshold | number | Minimum relevance score threshold | No | +| top_k | integer | Maximum number of results to retrieve | No | + +#### BuiltinProviderDefaultCredentialPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | + +#### BuiltinToolAddPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| name | string | | No | +| type | [CredentialType](#credentialtype) | | Yes | + +#### BuiltinToolCredentialDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | + +#### BuiltinToolUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| credentials | object | | No | +| name | string | | No | + +#### ButtonStyle + +Button styles for user actions. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ButtonStyle | string | Button styles for user actions. | | + +#### ChangeEmailResetPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_email | string | | Yes | +| token | string | | Yes | + +#### ChangeEmailSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | +| phase | string | | No | +| token | string | | No | + +#### ChangeEmailValidityPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### ChatConversationQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_status | string | Annotation status filter
*Enum:* `"all"`, `"annotated"`, `"not_annotated"` | No | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| keyword | string | Search keyword | No | +| limit | integer | Page size (1-100) | No | +| page | integer | Page number | No | +| sort_by | string | Sort field and direction
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + +#### ChatMessagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation ID | No | +| files | [ ] | Uploaded files | No | +| inputs | object | | Yes | +| model_config | object | | Yes | +| parent_message_id | string | Parent message ID | No | +| query | string | User query | Yes | +| response_mode | string | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | Retriever source | No | + +#### ChatMessagesQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation ID | Yes | +| first_id | string | First message ID for pagination | No | +| limit | integer | Number of messages to return (1-100) | No | + +#### ChatRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| files | [ ] | | No | +| inputs | object | | Yes | +| parent_message_id | string | | No | +| query | string | | Yes | +| retriever_from | string | | No | + +#### CheckDependenciesResult + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No | + +#### CheckEmailUniquePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | + +#### ChildChunkBatchUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chunks | [ [ChildChunkUpdateArgs](#childchunkupdateargs) ] | | Yes | + +#### ChildChunkCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | + +#### ChildChunkUpdateArgs + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | +| id | string | | No | + +#### ChildChunkUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | + +#### CodeBasedExtensionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | | Extension data | Yes | +| module | string | Module name | Yes | + +#### CompletionConversationQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_status | string | Annotation status filter
*Enum:* `"all"`, `"annotated"`, `"not_annotated"` | No | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| keyword | string | Search keyword | No | +| limit | integer | Page size (1-100) | No | +| page | integer | Page number | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + +#### CompletionMessageExplorePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | +| query | string | | No | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | | No | + +#### CompletionMessagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ ] | Uploaded files | No | +| inputs | object | | Yes | +| model_config | object | | Yes | +| query | string | Query text | No | +| response_mode | string | Response mode
*Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | Retriever source | No | + +#### CompletionRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ ] | | No | +| inputs | object | | Yes | +| query | string | | No | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | | No | + +#### ComplianceDownloadQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| doc_name | string | Compliance document name | Yes | + +#### Condition + +Condition detail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| comparison_operator | string | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| name | string | | Yes | +| value | | | No | + +#### ConsoleDatasetListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ids | [ string ] | Filter by dataset IDs | No | +| include_all | boolean | Include all datasets | No | +| keyword | string | Search keyword | No | +| limit | integer | Number of items per page | No | +| page | integer | Page number | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + +#### Conversation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| admin_feedback_stats | [FeedbackStat](#feedbackstat) | | No | +| annotation | [ConversationAnnotation](#conversationannotation) | | No | +| created_at | integer | | No | +| first_message | [SimpleMessageDetail](#simplemessagedetail) | | No | +| from_account_id | string | | No | +| from_account_name | string | | No | +| from_end_user_id | string | | No | +| from_end_user_session_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| model_config | [SimpleModelConfig](#simplemodelconfig) | | No | +| read_at | integer | | No | +| status | string | | Yes | +| updated_at | integer | | No | +| user_feedback_stats | [FeedbackStat](#feedbackstat) | | No | + +#### ConversationAnnotation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account | [SimpleAccount](#simpleaccount) | | No | +| content | string | | Yes | +| created_at | integer | | No | +| id | string | | Yes | +| question | string | | No | + +#### ConversationAnnotationHitHistory + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_create_account | [SimpleAccount](#simpleaccount) | | No | +| created_at | integer | | No | +| id | string | | Yes | + +#### ConversationDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| admin_feedback_stats | [FeedbackStat](#feedbackstat) | | No | +| annotated | boolean | | Yes | +| created_at | integer | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| introduction | string | | No | +| message_count | integer | | Yes | +| model_config | [ModelConfig](#modelconfig) | | No | +| status | string | | Yes | +| updated_at | integer | | No | +| user_feedback_stats | [FeedbackStat](#feedbackstat) | | No | + +#### ConversationListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer | | No | +| pinned | boolean | | No | + +#### ConversationMessageDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| first_message | [MessageDetail](#messagedetail) | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| model_config | [ModelConfig](#modelconfig) | | No | +| status | string | | Yes | + +#### ConversationPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| has_next | boolean | | Yes | +| items | [ [Conversation](#conversation) ] | | Yes | +| page | integer | | Yes | +| per_page | integer | | Yes | +| total | integer | | Yes | + +#### ConversationRenamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | boolean | | No | +| name | string | | No | + +#### ConversationVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| description | string | | No | +| id | string | | Yes | +| name | string | | Yes | +| updated_at | integer | | No | +| value | string | | No | +| value_type | string | | Yes | + +#### ConversationVariableUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ object ] | Conversation variables for the draft workflow | Yes | + +#### ConversationVariablesQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation ID to filter variables | Yes | + +#### ConversationWithSummary + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| admin_feedback_stats | [FeedbackStat](#feedbackstat) | | No | +| annotated | boolean | | Yes | +| created_at | integer | | No | +| from_account_id | string | | No | +| from_account_name | string | | No | +| from_end_user_id | string | | No | +| from_end_user_session_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| message_count | integer | | Yes | +| model_config | [SimpleModelConfig](#simplemodelconfig) | | No | +| name | string | | Yes | +| read_at | integer | | No | +| status | string | | Yes | +| status_count | [StatusCount](#statuscount) | | No | +| summary_or_query | string | | Yes | +| updated_at | integer | | No | +| user_feedback_stats | [FeedbackStat](#feedbackstat) | | No | + +#### ConversationWithSummaryPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| has_next | boolean | | Yes | +| items | [ [ConversationWithSummary](#conversationwithsummary) ] | | Yes | +| page | integer | | Yes | +| per_page | integer | | Yes | +| total | integer | | Yes | + +#### ConvertToWorkflowPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| name | string | | No | + +#### CopyAppPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Description for the copied app | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| name | string | Name for the copied app | No | + +#### CreateAnnotationPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_reply | object | Annotation reply data | No | +| answer | string | Answer text | No | +| content | string | Content text | No | +| message_id | string | Message ID | No | +| question | string | Question text | No | + +#### CreateAppPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | App description (max 400 chars) | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| mode | string | App mode
*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | +| name | string | App name | Yes | + +#### CredentialType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| CredentialType | string | | | + +#### DataSource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| info_list | [InfoList](#infolist) | | Yes | + +#### DataSourceIntegrate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | object | | No | +| disabled | boolean | | No | +| id | string | | No | +| is_bound | boolean | | No | +| link | string | | No | +| provider | string | | No | +| source_info | [DataSourceIntegrateWorkspace](#datasourceintegrateworkspace) | | No | + +#### DataSourceIntegrateIcon + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| emoji | string | | No | +| type | string | | No | +| url | string | | No | + +#### DataSourceIntegrateList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [DataSourceIntegrate](#datasourceintegrate) ] | | No | + +#### DataSourceIntegratePage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page_icon | [DataSourceIntegrateIcon](#datasourceintegrateicon) | | No | +| page_id | string | | No | +| page_name | string | | No | +| parent_id | string | | No | +| type | string | | No | + +#### DataSourceIntegrateWorkspace + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| pages | [ [DataSourceIntegratePage](#datasourceintegratepage) ] | | No | +| total | integer | | No | +| workspace_icon | string | | No | +| workspace_id | string | | No | +| workspace_name | string | | No | + +#### DatasetAndDocumentResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| batch | string | | Yes | +| dataset | [DatasetResponse](#datasetresponse) | | Yes | +| documents | [ [DocumentResponse](#documentresponse) ] | | Yes | + +#### DatasetBase + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | object | | No | +| created_by | string | | No | +| data_source_type | string | | No | +| description | string | | No | +| id | string | | No | +| indexing_technique | string | | No | +| name | string | | No | +| permission | string | | No | + +#### DatasetContent + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| content_type | string | | No | +| file_info | [DatasetFileInfo](#datasetfileinfo) | | No | + +#### DatasetCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| external_knowledge_api_id | string | | No | +| external_knowledge_id | string | | No | +| indexing_technique | string | | No | +| name | string | | Yes | +| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No | +| provider | string | | No | + +#### DatasetDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_count | integer | | No | +| author_name | string | | No | +| built_in_field_enabled | boolean | | No | +| chunk_structure | string | | No | +| created_at | object | | No | +| created_by | string | | No | +| data_source_type | string | | No | +| description | string | | No | +| doc_form | string | | No | +| doc_metadata | [ [DatasetDocMetadata](#datasetdocmetadata) ] | | No | +| document_count | integer | | No | +| embedding_available | boolean | | No | +| embedding_model | string | | No | +| embedding_model_provider | string | | No | +| enable_api | boolean | | No | +| external_knowledge_info | [ExternalKnowledgeInfo](#externalknowledgeinfo) | | No | +| external_retrieval_model | [ExternalRetrievalModel](#externalretrievalmodel) | | No | +| icon_info | [DatasetIconInfo](#dataseticoninfo) | | No | +| id | string | | No | +| indexing_technique | string | | No | +| is_multimodal | boolean | | No | +| is_published | boolean | | No | +| name | string | | No | +| permission | string | | No | +| pipeline_id | string | | No | +| provider | string | | No | +| retrieval_model_dict | [DatasetRetrievalModel](#datasetretrievalmodel) | | No | +| runtime_mode | string | | No | +| summary_index_setting | [_AnonymousInlineModel_b1954337d565](#_anonymousinlinemodel_b1954337d565) | | No | +| tags | [ [Tag](#tag) ] | | No | +| total_available_documents | integer | | No | +| total_documents | integer | | No | +| updated_at | object | | No | +| updated_by | string | | No | +| word_count | integer | | No | + +#### DatasetDocMetadata + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | No | +| name | string | | No | +| type | string | | No | + +#### DatasetFileInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| extension | string | | No | +| id | string | | No | +| mime_type | string | | No | +| name | string | | No | +| size | integer | | No | +| source_url | string | | No | + +#### DatasetIconInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | string | | No | + +#### DatasetKeywordSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_weight | number | | No | + +#### DatasetPermissionEnum + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasetPermissionEnum | string | | | + +#### DatasetQueryDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | object | | No | +| created_by | string | | No | +| created_by_role | string | | No | +| id | string | | No | +| queries | [DatasetContent](#datasetcontent) | | No | +| source | string | | No | +| source_app_id | string | | No | + +#### DatasetRerankingModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reranking_model_name | string | | No | +| reranking_provider_name | string | | No | + +#### DatasetResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by | string | | No | +| data_source_type | string | | No | +| description | string | | No | +| id | string | | Yes | +| indexing_technique | string | | No | +| name | string | | Yes | +| permission | string | | No | + +#### DatasetRetrievalModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reranking_enable | boolean | | No | +| reranking_mode | string | | No | +| reranking_model | [DatasetRerankingModel](#datasetrerankingmodel) | | No | +| score_threshold | number | | No | +| score_threshold_enabled | boolean | | No | +| search_method | string | | No | +| top_k | integer | | No | +| weights | [DatasetWeightedScore](#datasetweightedscore) | | No | + +#### DatasetUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| embedding_model | string | | No | +| embedding_model_provider | string | | No | +| external_knowledge_api_id | string | | No | +| external_knowledge_id | string | | No | +| external_retrieval_model | object | | No | +| icon_info | object | | No | +| indexing_technique | string | | No | +| is_multimodal | boolean | | No | +| name | string | | No | +| partial_member_list | [ object ] | | No | +| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No | +| retrieval_model | object | | No | +| summary_index_setting | object | | No | + +#### DatasetVectorSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | | No | +| embedding_provider_name | string | | No | +| vector_weight | number | | No | + +#### DatasetWeightedScore + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_setting | [DatasetKeywordSetting](#datasetkeywordsetting) | | No | +| vector_setting | [DatasetVectorSetting](#datasetvectorsetting) | | No | +| weight_type | string | | No | + +#### DatasourceCredentialDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | + +#### DatasourceCredentialPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| name | string | | No | + +#### DatasourceCredentialUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| credentials | object | | No | +| name | string | | No | + +#### DatasourceCustomClientPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_params | object | | No | +| enable_oauth_custom_client | boolean | | No | + +#### DatasourceDefaultPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | + +#### DatasourceNodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | No | +| datasource_type | string | | Yes | +| inputs | object | | Yes | + +#### DatasourceUpdateNamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| name | string | | Yes | + +#### DatasourceVariablesPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasource_info | object | | Yes | +| datasource_type | string | | Yes | +| start_node_id | string | | Yes | +| start_node_title | string | | Yes | + +#### DebugPermission + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DebugPermission | string | | | + +#### DefaultBlockConfigQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| q | string | | No | + +#### DeletedTool + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| provider_id | string | | Yes | +| tool_name | string | | Yes | +| type | string | | Yes | + +#### DocumentBatchDownloadZipPayload + +Request payload for bulk downloading documents as a zip archive. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_ids | [ string (uuid) ] | | Yes | + +#### DocumentMetadataOperation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_id | string | | Yes | +| metadata_list | [ [MetadataDetail](#metadatadetail) ] | | Yes | +| partial_update | boolean | | No | + +#### DocumentMetadataResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | +| type | string | | Yes | +| value | string | | No | + +#### DocumentMetadataUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| doc_metadata | | | No | +| doc_type | string | | No | + +#### DocumentRenamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### DocumentResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| archived | boolean | | No | +| created_at | integer | | No | +| created_by | string | | No | +| created_from | string | | No | +| data_source_detail_dict | | | No | +| data_source_info_dict | | | No | +| data_source_type | string | | No | +| dataset_process_rule_id | string | | No | +| disabled_at | integer | | No | +| disabled_by | string | | No | +| display_status | string | | No | +| doc_form | string | | No | +| doc_metadata_details | [ [DocumentMetadataResponse](#documentmetadataresponse) ] | | No | +| enabled | boolean | | No | +| error | string | | No | +| hit_count | integer | | No | +| id | string | | Yes | +| indexing_status | string | | No | +| name | string | | Yes | +| need_summary | boolean | | No | +| position | integer | | No | +| summary_index_status | string | | No | +| tokens | integer | | No | +| word_count | integer | | No | + +#### DocumentRetryPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_ids | [ string ] | | Yes | + +#### DocumentWithSegmentsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| archived | boolean | | No | +| completed_segments | integer | | No | +| created_at | integer | | No | +| created_by | string | | No | +| created_from | string | | No | +| data_source_detail_dict | | | No | +| data_source_info_dict | | | No | +| data_source_type | string | | No | +| dataset_process_rule_id | string | | No | +| disabled_at | integer | | No | +| disabled_by | string | | No | +| display_status | string | | No | +| doc_form | string | | No | +| doc_metadata_details | [ [DocumentMetadataResponse](#documentmetadataresponse) ] | | No | +| enabled | boolean | | No | +| error | string | | No | +| hit_count | integer | | No | +| id | string | | Yes | +| indexing_status | string | | No | +| name | string | | Yes | +| need_summary | boolean | | No | +| position | integer | | No | +| process_rule_dict | | | No | +| summary_index_status | string | | No | +| tokens | integer | | No | +| total_segments | integer | | No | +| word_count | integer | | No | + +#### DraftWorkflowNodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | +| query | string | | No | + +#### DraftWorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasource_info_list | [ object ] | | Yes | +| datasource_type | string | | Yes | +| inputs | object | | Yes | +| start_node_id | string | | Yes | + +#### DraftWorkflowSyncPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ object ] | | No | +| environment_variables | [ object ] | | No | +| features | object | | No | +| graph | object | | Yes | +| hash | string | | No | +| rag_pipeline_variables | [ object ] | | No | + +#### DraftWorkflowTriggerRunAllPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_ids | [ string ] | | Yes | + +#### DraftWorkflowTriggerRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_id | string | | Yes | + +#### DraftWorkflowTriggerRunRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_id | string | Node ID | Yes | + +#### EducationActivatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| institution | string | | Yes | +| role | string | | Yes | +| token | string | | Yes | + +#### EducationAutocompleteQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keywords | string | | Yes | +| limit | integer | | No | +| page | integer | | No | + +#### EducationAutocompleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| curr_page | integer | | No | +| data | [ string ] | | No | +| has_next | boolean | | No | + +#### EducationStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allow_refresh | boolean | | No | +| expire_at | integer | | No | +| is_student | boolean | | No | +| result | boolean | | No | + +#### EducationVerifyResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| token | string | | No | + +#### EmailCodeLoginPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| language | string | | No | +| timezone | string | | No | +| token | string | | Yes | + +#### EmailPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | + +#### EmailRegisterResetPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| language | string | | No | +| new_password | string | | Yes | +| password_confirm | string | | Yes | +| timezone | string | | No | +| token | string | | Yes | + +#### EmailRegisterSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | Email address | Yes | +| language | string | Language code | No | + +#### EmailRegisterValidityPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### EndpointCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| settings | object | | Yes | + +#### EndpointCreateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| success | boolean | Operation success | Yes | + +#### EndpointDeleteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| success | boolean | Operation success | Yes | + +#### EndpointDisableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| success | boolean | Operation success | Yes | + +#### EndpointEnableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| success | boolean | Operation success | Yes | + +#### EndpointIdPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| endpoint_id | string | | Yes | + +#### EndpointListForPluginQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page | integer | | Yes | +| page_size | integer | | Yes | +| plugin_id | string | | Yes | + +#### EndpointListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page | integer | | Yes | +| page_size | integer | | Yes | + +#### EndpointListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| endpoints | [ object ] | Endpoint information | Yes | + +#### EndpointUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | +| settings | object | | Yes | + +#### EndpointUpdateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| success | boolean | Operation success | Yes | + +#### EnvironmentVariableUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| environment_variables | [ object ] | Environment variables for the draft workflow | Yes | + +#### ExecutionContentType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ExecutionContentType | string | | | + +#### ExternalApiTemplateListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Search keyword | No | +| limit | integer | Number of items per page | No | +| page | integer | Page number | No | + +#### ExternalDatasetCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| external_knowledge_api_id | string | | Yes | +| external_knowledge_id | string | | Yes | +| external_retrieval_model | object | | No | +| name | string | | Yes | + +#### ExternalHitTestingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| external_retrieval_model | object | | No | +| metadata_filtering_conditions | object | | No | +| query | string | | Yes | + +#### ExternalKnowledgeApiPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | +| settings | object | | Yes | + +#### ExternalKnowledgeInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| external_knowledge_api_endpoint | string | | No | +| external_knowledge_api_id | string | | No | +| external_knowledge_api_name | string | | No | +| external_knowledge_id | string | | No | + +#### ExternalRetrievalModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| score_threshold | number | | No | +| score_threshold_enabled | boolean | | No | +| top_k | integer | | No | + +#### FeatureResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| features | object | Feature configuration object | No | + +#### Feedback + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| from_account | [SimpleAccount](#simpleaccount) | | No | +| from_end_user_id | string | | No | +| from_source | string | | Yes | +| rating | string | | Yes | + +#### FeedbackExportQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end_date | string | End date (YYYY-MM-DD) | No | +| format | string | Export format
*Enum:* `"csv"`, `"json"` | No | +| from_source | string | Filter by feedback source
*Enum:* `"admin"`, `"user"` | No | +| has_comment | boolean | Only include feedback with comments | No | +| rating | string | Filter by rating
*Enum:* `"dislike"`, `"like"` | No | +| start_date | string | Start date (YYYY-MM-DD) | No | + +#### FeedbackStat + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dislike | integer | | Yes | +| like | integer | | Yes | + +#### FileInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_ids | [ string ] | | Yes | + +#### FileInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### FileListInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ [FileType](#filetype) ] | | No | +| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No | +| number_limits | integer | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### FileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| extension | string | | No | +| file_key | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| original_url | string | | No | +| preview_url | string | | No | +| size | integer | | Yes | +| source_url | string | | No | +| tenant_id | string | | No | +| user_id | string | | No | + +#### FileTransferMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FileTransferMethod | string | | | + +#### FileType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FileType | string | | | + +#### ForgotPasswordCheckPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### ForgotPasswordCheckResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | Email address | Yes | +| is_valid | boolean | Whether code is valid | Yes | +| token | string | New reset token | Yes | + +#### ForgotPasswordEmailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | Error code if account not found | No | +| data | string | Reset token | No | +| result | string | Operation result | Yes | + +#### ForgotPasswordResetPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_password | string | | Yes | +| password_confirm | string | | Yes | +| token | string | | Yes | + +#### ForgotPasswordResetResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Operation result | Yes | + +#### ForgotPasswordSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | + +#### FormInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig)
[SelectInputConfig](#selectinputconfig)
[FileInputConfig](#fileinputconfig)
[FileListInputConfig](#filelistinputconfig) | | | + +#### GenerateSummaryPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_list | [ string ] | | Yes | + +#### Github + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| github_plugin_unique_identifier | string | | Yes | +| package | string | | Yes | +| repo | string | | Yes | +| version | string | | Yes | + +#### HitTestingChildChunk + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| id | string | | No | +| position | integer | | No | +| score | number | | No | + +#### HitTestingDocument + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data_source_type | string | | No | +| doc_metadata | | | No | +| doc_type | string | | No | +| id | string | | No | +| name | string | | No | + +#### HitTestingFile + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| extension | string | | No | +| id | string | | No | +| mime_type | string | | No | +| name | string | | No | +| size | integer | | No | +| source_url | string | | No | + +#### HitTestingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachment_ids | [ string ] | | No | +| external_retrieval_model | object | | No | +| query | string | | Yes | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | + +#### HitTestingRecord + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| child_chunks | [ [HitTestingChildChunk](#hittestingchildchunk) ] | | No | +| files | [ [HitTestingFile](#hittestingfile) ] | | No | +| score | number | | No | +| segment | [HitTestingSegment](#hittestingsegment) | | No | +| summary | string | | No | +| tsne_position | | | No | + +#### HitTestingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| query | string | | Yes | +| records | [ [HitTestingRecord](#hittestingrecord) ] | | No | + +#### HitTestingSegment + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | No | +| completed_at | integer | | No | +| content | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| disabled_at | integer | | No | +| disabled_by | string | | No | +| document | [HitTestingDocument](#hittestingdocument) | | No | +| document_id | string | | No | +| enabled | boolean | | No | +| error | string | | No | +| hit_count | integer | | No | +| id | string | | No | +| index_node_hash | string | | No | +| index_node_id | string | | No | +| indexing_at | integer | | No | +| keywords | [ string ] | | No | +| position | integer | | No | +| sign_content | string | | No | +| status | string | | No | +| stopped_at | integer | | No | +| tokens | integer | | No | +| word_count | integer | | No | + +#### HumanInputContent + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| form_definition | [HumanInputFormDefinition](#humaninputformdefinition) | | No | +| form_submission_data | [HumanInputFormSubmissionData](#humaninputformsubmissiondata) | | No | +| submitted | boolean | | Yes | +| type | [ExecutionContentType](#executioncontenttype) | | No | +| workflow_run_id | string | | Yes | + +#### HumanInputDeliveryTestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| delivery_method_id | string | Delivery method ID | Yes | +| inputs | object | Values used to fill missing upstream variables referenced in form_content | No | + +#### HumanInputFormDefinition + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| actions | [ [UserActionConfig](#useractionconfig) ] | | No | +| display_in_ui | boolean | | No | +| expiration_time | integer | | Yes | +| form_content | string | | Yes | +| form_id | string | | Yes | +| form_token | string | | No | +| inputs | [ [FormInputConfig](#forminputconfig) ] | | No | +| node_id | string | | Yes | +| node_title | string | | Yes | +| resolved_default_values | object | | No | + +#### HumanInputFormPreviewPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | Values used to fill missing upstream variables referenced in form_content | No | + +#### HumanInputFormSubmissionData + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action_id | string | | Yes | +| action_text | string | | Yes | +| node_id | string | | Yes | +| node_title | string | | Yes | +| rendered_content | string | | Yes | + +#### HumanInputFormSubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | Selected action ID | Yes | +| form_inputs | object | Values the user provides for the form's own fields | Yes | +| inputs | object | Values used to fill missing upstream variables referenced in form_content | Yes | + +#### HumanInputPauseTypeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| backstage_input_url | string | | No | +| form_id | string | | Yes | +| type | string | | Yes | + +#### IconType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| IconType | string | | | + +#### Import + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| app_mode | string | | No | +| current_dsl_version | string | | No | +| error | string | | No | +| id | string | | Yes | +| imported_dsl_version | string | | No | +| status | [ImportStatus](#importstatus) | | Yes | + +#### ImportStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ImportStatus | string | | | + +#### IncludeSecretQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| include_secret | string | | No | + +#### IndexingEstimatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dataset_id | string | | No | +| doc_form | string | | No | +| doc_language | string | | No | +| indexing_technique | string | | Yes | +| info_list | object | | Yes | +| process_rule | object | | Yes | + +#### InfoList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data_source_type | string | *Enum:* `"notion_import"`, `"upload_file"`, `"website_crawl"` | Yes | +| file_info_list | [FileInfo](#fileinfo) | | No | +| notion_info_list | [ [NotionInfo](#notioninfo) ] | | No | +| website_info_list | [WebsiteInfo](#websiteinfo) | | No | + +#### Inner + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | No | +| model_type | [ModelType](#modeltype) | | Yes | +| provider | string | | No | + +#### InstallPermission + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| InstallPermission | string | | | + +#### InstalledAppCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | + +#### InstalledAppInfoResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| id | string | | Yes | +| mode | string | | No | +| name | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### InstalledAppListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| installed_apps | [ [InstalledAppResponse](#installedappresponse) ] | | Yes | + +#### InstalledAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app | [InstalledAppInfoResponse](#installedappinforesponse) | | Yes | +| app_owner_tenant_id | string | | Yes | +| editable | boolean | | Yes | +| id | string | | Yes | +| is_pinned | boolean | | Yes | +| last_used_at | integer | | No | +| uninstallable | boolean | | Yes | + +#### InstalledAppUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| is_pinned | boolean | | No | + +#### InstalledAppsListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | App ID to filter by | No | + +#### InstructionGeneratePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current | string | Current instruction text | No | +| flow_id | string | Workflow/Flow ID | Yes | +| ideal_output | string | Expected ideal output | No | +| instruction | string | Instruction for generation | Yes | +| language | string | Programming language (javascript/python) | No | +| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | +| node_id | string | Node ID for workflow context | No | + +#### InstructionTemplatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string | Instruction template type | Yes | + +#### IterationNodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | | No | + +#### JSONValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JSONValue | | | | + +#### KnowledgeConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data_source | [DataSource](#datasource) | | No | +| doc_form | string | | No | +| doc_language | string | | No | +| duplicate | boolean | | No | +| embedding_model | string | | No | +| embedding_model_provider | string | | No | +| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | Yes | +| is_multimodal | boolean | | No | +| name | string | | No | +| original_document_id | string | | No | +| process_rule | [ProcessRule](#processrule) | | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| summary_index_setting | object | | No | + +#### LLMMode + +Enum class for large language model mode. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| LLMMode | string | Enum class for large language model mode. | | + +#### LegacyEndpointUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| endpoint_id | string | | Yes | +| name | string | | Yes | +| settings | object | | Yes | + +#### LoadBalancingCredentialPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### LoadBalancingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| configs | [ object ] | | No | +| enabled | boolean | | No | + +#### LoginPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| invite_token | string | Invitation token | No | +| password | string | | Yes | +| remember_me | boolean | Remember me flag | No | + +#### LoopNodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | | No | + +#### MCPAuthPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorization_code | string | | No | +| provider_id | string | | Yes | + +#### MCPProviderCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authentication | object | | No | +| configuration | object | | No | +| headers | object | | No | +| icon | string | | Yes | +| icon_background | string | | No | +| icon_type | string | | Yes | +| name | string | | Yes | +| server_identifier | string | | Yes | +| server_url | string | | Yes | + +#### MCPProviderDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| provider_id | string | | Yes | + +#### MCPProviderUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authentication | object | | No | +| configuration | object | | No | +| headers | object | | No | +| icon | string | | Yes | +| icon_background | string | | No | +| icon_type | string | | Yes | +| name | string | | Yes | +| provider_id | string | | Yes | +| server_identifier | string | | Yes | +| server_url | string | | Yes | + +#### MCPServerCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Server description | No | +| parameters | object | Server parameters configuration | Yes | + +#### MCPServerUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Server description | No | +| id | string | Server ID | Yes | +| parameters | object | Server parameters configuration | Yes | +| status | string | Server status | No | + +#### Marketplace + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marketplace_plugin_unique_identifier | string | | Yes | +| version | string | | No | + +#### MemberInvitePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| emails | [ string ] | | No | +| language | string | | No | +| role | [TenantAccountRole](#tenantaccountrole) | | Yes | + +#### MemberRoleUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| role | string | | Yes | + +#### MessageDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | +| annotation | [ConversationAnnotation](#conversationannotation) | | No | +| annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No | +| answer_tokens | integer | | Yes | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| error | string | | No | +| feedbacks | [ [Feedback](#feedback) ] | | Yes | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| inputs | object | | Yes | +| message | [JSONValue](#jsonvalue) | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| message_metadata_dict | [JSONValue](#jsonvalue) | | Yes | +| message_tokens | integer | | Yes | +| parent_message_id | string | | No | +| provider_response_latency | number | | Yes | +| query | string | | Yes | +| re_sign_file_url_answer | string | | Yes | +| status | string | | Yes | +| workflow_run_id | string | | No | + +#### MessageDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | No | +| annotation | [ConversationAnnotation](#conversationannotation) | | No | +| annotation_hit_history | [ConversationAnnotationHitHistory](#conversationannotationhithistory) | | No | +| answer_tokens | integer | | No | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| error | string | | No | +| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | No | +| feedbacks | [ [Feedback](#feedback) ] | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| from_source | string | | Yes | +| id | string | | Yes | +| inputs | object | | Yes | +| message | [JSONValue](#jsonvalue) | | No | +| message_files | [ [MessageFile](#messagefile) ] | | No | +| message_metadata_dict | [JSONValue](#jsonvalue) | | No | +| message_tokens | integer | | No | +| parent_message_id | string | | No | +| provider_response_latency | number | | No | +| query | string | | Yes | +| re_sign_file_url_answer | string | | Yes | +| status | string | | Yes | +| workflow_run_id | string | | No | + +#### MessageFeedbackPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| message_id | string | Message ID | Yes | +| rating | string | *Enum:* `"dislike"`, `"like"` | No | + +#### MessageFile + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| belongs_to | string | | No | +| filename | string | | Yes | +| id | string | | Yes | +| mime_type | string | | No | +| size | integer | | No | +| transfer_method | string | | Yes | +| type | string | | Yes | +| upload_file_id | string | | No | +| url | string | | No | + +#### MessageInfiniteScrollPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [MessageDetailResponse](#messagedetailresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### MessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation UUID | Yes | +| first_id | string | First message ID for pagination | No | +| limit | integer | Number of messages to return (1-100) | No | + +#### MetadataArgs + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | +| type | string | *Enum:* `"number"`, `"string"`, `"time"` | Yes | + +#### MetadataDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | +| value | | | No | + +#### MetadataFilteringCondition + +Metadata Filtering Condition. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conditions | [ [Condition](#condition) ] | | No | +| logical_operator | string | *Enum:* `"and"`, `"or"` | No | + +#### MetadataOperationData + +Metadata operation data + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| operation_data | [ [DocumentMetadataOperation](#documentmetadataoperation) ] | | Yes | + +#### MetadataUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### ModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completion_params | object | | No | +| mode | [LLMMode](#llmmode) | | Yes | +| name | string | | Yes | +| provider | string | | Yes | + +#### ModelConfigPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by | string | | No | +| model_dict | [JSONValue](#jsonvalue) | | No | +| pre_prompt | string | | No | +| updated_at | integer | | No | +| updated_by | string | | No | + +#### ModelConfigRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_mode | object | Agent mode configuration | No | +| configs | object | Model configuration parameters | No | +| dataset_configs | object | Dataset configurations | No | +| model | string | Model name | No | +| more_like_this | object | More like this configuration | No | +| opening_statement | string | Opening statement | No | +| provider | string | Model provider | No | +| retrieval_model | object | Retrieval model configuration | No | +| speech_to_text | object | Speech to text configuration | No | +| suggested_questions | [ string ] | Suggested questions | No | +| text_to_speech | object | Text to speech configuration | No | +| tools | [ object ] | Available tools | No | + +#### ModelType + +Enum class for model type. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ModelType | string | Enum class for model type. | | + +#### MoreLikeThisQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | Yes | + +#### NodeIdQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_id | string | | Yes | + +#### NodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | | No | + +#### NodeRunRequiredPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | | Yes | + +#### NotionEstimatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| doc_form | string | | No | +| doc_language | string | | No | +| notion_info_list | [ object ] | | Yes | +| process_rule | object | | Yes | + +#### NotionIcon + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| emoji | string | | No | +| type | string | | Yes | +| url | string | | No | + +#### NotionInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| pages | [ [NotionPage](#notionpage) ] | | Yes | +| workspace_id | string | | Yes | + +#### NotionIntegrateInfoList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| notion_info | [ [NotionIntegrateWorkspace](#notionintegrateworkspace) ] | | No | + +#### NotionIntegratePage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| is_bound | boolean | | No | +| page_icon | [DataSourceIntegrateIcon](#datasourceintegrateicon) | | No | +| page_id | string | | No | +| page_name | string | | No | +| parent_id | string | | No | +| type | string | | No | + +#### NotionIntegrateWorkspace + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| pages | [ [NotionIntegratePage](#notionintegratepage) ] | | No | +| workspace_icon | string | | No | +| workspace_id | string | | No | +| workspace_name | string | | No | + +#### NotionPage + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page_icon | [NotionIcon](#notionicon) | | No | +| page_id | string | | Yes | +| page_name | string | | Yes | +| type | string | | Yes | + +#### OAuthDataSourceBindingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Operation result | Yes | + +#### OAuthDataSourceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | string | Authorization URL or 'internal' for internal setup | Yes | + +#### OAuthDataSourceSyncResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Operation result | Yes | + +#### OwnerTransferCheckPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| token | string | | Yes | + +#### OwnerTransferEmailPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| language | string | | No | + +#### OwnerTransferPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| token | string | | Yes | + +#### Package + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_unique_identifier | string | | Yes | +| version | string | | No | + +#### PaginatedConversationVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ConversationVariableResponse](#conversationvariableresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### ParagraphInputConfig + +Form input definition. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | [StringSource](#stringsource) | | No | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### Parser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | No | +| datasource_type | string | | Yes | +| inputs | object | | Yes | + +#### ParserAsset + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_name | string | | Yes | +| plugin_unique_identifier | string | | Yes | + +#### ParserCreateCredential + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| name | string | | No | + +#### ParserCredentialCreate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| name | string | | No | + +#### ParserCredentialDelete + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | + +#### ParserCredentialId + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | No | + +#### ParserCredentialSwitch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | + +#### ParserCredentialUpdate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| credentials | object | | Yes | +| name | string | | No | + +#### ParserCredentialValidate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | + +#### ParserDeleteCredential + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ParserDeleteModels + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ParserDynamicOptions + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| credential_id | string | | No | +| parameter | string | | Yes | +| plugin_id | string | | Yes | +| provider | string | | Yes | +| provider_type | string | *Enum:* `"tool"`, `"trigger"` | Yes | + +#### ParserDynamicOptionsWithCredentials + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| credential_id | string | | Yes | +| credentials | object | | Yes | +| parameter | string | | Yes | +| plugin_id | string | | Yes | +| provider | string | | Yes | + +#### ParserEnable + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enable_trigger | boolean | | Yes | +| trigger_id | string | | Yes | + +#### ParserExcludePlugin + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_id | string | | Yes | + +#### ParserGetCredentials + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_from | string | | No | +| credential_id | string | | No | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ParserGetDefault + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ParserGithubInstall + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| package | string | | Yes | +| plugin_unique_identifier | string | | Yes | +| repo | string | | Yes | +| version | string | | Yes | + +#### ParserGithubUpgrade + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_plugin_unique_identifier | string | | Yes | +| original_plugin_unique_identifier | string | | Yes | +| package | string | | Yes | +| repo | string | | Yes | +| version | string | | Yes | + +#### ParserGithubUpload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| package | string | | Yes | +| repo | string | | Yes | +| version | string | | Yes | + +#### ParserIcon + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| filename | string | | Yes | +| tenant_id | string | | Yes | + +#### ParserLatest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_ids | [ string ] | | Yes | + +#### ParserList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page | integer | Page number | No | +| page_size | integer | Page size (1-256) | No | + +#### ParserMarketplaceUpgrade + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_plugin_unique_identifier | string | | Yes | +| original_plugin_unique_identifier | string | | Yes | + +#### ParserModelList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model_type | [ModelType](#modeltype) | | No | + +#### ParserParameter + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model | string | | Yes | + +#### ParserPermissionChange + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| debug_permission | [DebugPermission](#debugpermission) | | Yes | +| install_permission | [InstallPermission](#installpermission) | | Yes | + +#### ParserPluginIdentifierQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_unique_identifier | string | | Yes | + +#### ParserPluginIdentifiers + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_unique_identifiers | [ string ] | | Yes | + +#### ParserPostDefault + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model_settings | [ [Inner](#inner) ] | | Yes | + +#### ParserPostModels + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| config_from | string | | No | +| credential_id | string | | No | +| load_balancing | [LoadBalancingPayload](#loadbalancingpayload) | | No | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ParserPreferencesChange + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes | +| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes | + +#### ParserPreferredProviderType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| preferred_provider_type | string | *Enum:* `"custom"`, `"system"` | Yes | + +#### ParserReadme + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| language | string | | No | +| plugin_unique_identifier | string | | Yes | + +#### ParserSwitch + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### ParserTasks + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| page | integer | Page number | No | +| page_size | integer | Page size (1-256) | No | + +#### ParserUninstall + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| plugin_installation_id | string | | Yes | + +#### ParserUpdateCredential + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | Yes | +| credentials | object | | Yes | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | +| name | string | | No | + +#### ParserValidate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | +| model | string | | Yes | +| model_type | [ModelType](#modeltype) | | Yes | + +#### PartnerTenantsPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| click_id | string | Click Id from partner referral link | Yes | + +#### PausedNodeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| node_id | string | | Yes | +| node_title | string | | Yes | +| pause_type | [HumanInputPauseTypeResponse](#humaninputpausetyperesponse) | | Yes | + +#### Payload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon_info | object | | No | +| name | string | | Yes | + +#### PipelineVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_extensions | [ string ] | | No | +| allowed_file_types | [ string ] | | No | +| allowed_file_upload_methods | [ string ] | | No | +| belong_to_node_id | string | | Yes | +| default_value | object | | No | +| label | string | | Yes | +| max_length | integer | | No | +| options | [ string ] | | No | +| placeholder | string | | No | +| required | boolean | | Yes | +| tooltips | string | | No | +| type | string | | Yes | +| unit | string | | No | +| variable | string | | Yes | + +#### PluginAutoUpgradeSettingsPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| exclude_plugins | [ string ] | | No | +| include_plugins | [ string ] | | No | +| strategy_setting | [StrategySetting](#strategysetting) | | No | +| upgrade_mode | [UpgradeMode](#upgrademode) | | No | +| upgrade_time_of_day | integer | | No | + +#### PluginDependency + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_identifier | string | | No | +| type | [Type](#type) | | Yes | +| value | | | Yes | + +#### PluginEndpointListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| endpoints | [ object ] | Endpoint information | Yes | + +#### PluginPermissionSettingsPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| debug_permission | [DebugPermission](#debugpermission) | | No | +| install_permission | [InstallPermission](#installpermission) | | No | + +#### PreProcessingRule + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | Yes | +| id | string | | Yes | + +#### ProcessRule + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | string | *Enum:* `"automatic"`, `"custom"`, `"hierarchical"` | Yes | +| rules | [Rule](#rule) | | No | + +#### PublishWorkflowPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marked_comment | string | | No | +| marked_name | string | | No | + +#### PublishedWorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasource_info_list | [ object ] | | Yes | +| datasource_type | string | | Yes | +| inputs | object | | Yes | +| is_preview | boolean | | No | +| original_document_id | string | | No | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| start_node_id | string | | Yes | + +#### RagPipelineDatasetImportPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| yaml_content | string | | Yes | + +#### RagPipelineImport + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_dsl_version | string | | No | +| dataset_id | string | | No | +| error | string | | No | +| id | string | | No | +| imported_dsl_version | string | | No | +| pipeline_id | string | | No | +| status | string | | No | + +#### RagPipelineImportCheckDependencies + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| leaked_dependencies | [ [RagPipelineLeakedDependency](#ragpipelineleakeddependency) ] | | No | + +#### RagPipelineImportPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| mode | string | | Yes | +| name | string | | No | +| pipeline_id | string | | No | +| yaml_content | string | | No | +| yaml_url | string | | No | + +#### RagPipelineLeakedDependency + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_identifier | string | | No | +| type | string | | No | +| value | object | | No | + +#### RagPipelineRecommendedPluginQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| type | string | | No | + +#### RecommendedAppInfoResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| id | string | | Yes | +| mode | string | | No | +| name | string | | No | + +#### RecommendedAppListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| categories | [ string ] | | Yes | +| recommended_apps | [ [RecommendedAppResponse](#recommendedappresponse) ] | | Yes | + +#### RecommendedAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app | [RecommendedAppInfoResponse](#recommendedappinforesponse) | | No | +| app_id | string | | Yes | +| can_trial | boolean | | No | +| categories | [ string ] | | No | +| copyright | string | | No | +| custom_disclaimer | string | | No | +| description | string | | No | +| is_listed | boolean | | No | +| position | integer | | No | +| privacy_policy | string | | No | + +#### RecommendedAppsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| language | string | Language code for recommended app localization | No | + +#### RelatedAppList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AppDetailKernel](#appdetailkernel) ] | | No | +| total | integer | | No | + +#### RerankingModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reranking_model_name | string | | No | +| reranking_provider_name | string | | No | + +#### ResultResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | | Yes | + +#### RetrievalMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RetrievalMethod | string | | | + +#### RetrievalModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | | No | +| reranking_enable | boolean | | Yes | +| reranking_mode | string | | No | +| reranking_model | [RerankingModel](#rerankingmodel) | | No | +| score_threshold | number | | No | +| score_threshold_enabled | boolean | | Yes | +| search_method | [RetrievalMethod](#retrievalmethod) | | Yes | +| top_k | integer | | Yes | +| weights | [WeightModel](#weightmodel) | | No | + +#### Rule + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| parent_mode | string | *Enum:* `"full-doc"`, `"paragraph"` | No | +| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | | No | +| segmentation | [Segmentation](#segmentation) | | No | +| subchunk_segmentation | [Segmentation](#segmentation) | | No | + +#### RuleCodeGeneratePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code_language | string | Programming language for code generation | No | +| instruction | string | Rule generation instruction | Yes | +| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | +| no_variable | boolean | Whether to exclude variables | No | + +#### RuleGeneratePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| instruction | string | Rule generation instruction | Yes | +| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | +| no_variable | boolean | Whether to exclude variables | No | + +#### RuleStructuredOutputPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| instruction | string | Structured output generation instruction | Yes | +| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes | + +#### SavedMessageCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | | Yes | + +#### SavedMessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer | | No | + +#### SegmentCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | No | +| attachment_ids | [ string ] | | No | +| content | string | | Yes | +| keywords | [ string ] | | No | + +#### SegmentListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | string | | No | +| hit_count_gte | integer | | No | +| keyword | string | | No | +| limit | integer | | No | +| page | integer | | No | +| status | [ string ] | | No | + +#### SegmentUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | No | +| attachment_ids | [ string ] | | No | +| content | string | | Yes | +| keywords | [ string ] | | No | +| regenerate_child_chunks | boolean | | No | +| summary | string | | No | + +#### Segmentation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chunk_overlap | integer | | No | +| max_tokens | integer | | Yes | +| separator | string | | No | + +#### SelectInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| option_source | [StringListSource](#stringlistsource) | | Yes | +| output_variable_name | string | | Yes | +| type | string | | No | + +#### SimpleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + +#### SimpleEndUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| is_anonymous | boolean | | Yes | +| session_id | string | | No | +| type | string | | Yes | + +#### SimpleMessageDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| inputs | object | | Yes | +| message | string | | Yes | +| query | string | | Yes | + +#### SimpleModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| model_dict | [JSONValue](#jsonvalue) | | No | +| pre_prompt | string | | No | + +#### Site + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_base_url | string | | No | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| code | string | | No | +| copyright | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| custom_disclaimer | string | | No | +| customize_domain | string | | No | +| customize_token_strategy | string | | No | +| default_language | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | | | No | +| privacy_policy | string | | No | +| prompt_public | boolean | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### StatisticTimeRangeQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + +#### StatusCount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| failed | integer | | Yes | +| partial_success | integer | | Yes | +| paused | integer | | Yes | +| success | integer | | Yes | + +#### StrategySetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| StrategySetting | string | | | + +#### StringListSource + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | No | +| type | [ValueSourceType](#valuesourcetype) | | Yes | +| value | [ string ] | | No | + +#### StringSource + +Default configuration for form inputs. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| selector | [ string ] | | No | +| type | [ValueSourceType](#valuesourcetype) | | Yes | +| value | string | | No | + +#### SubscriptionQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| interval | string | Billing interval
*Enum:* `"month"`, `"year"` | Yes | +| plan | string | Subscription plan
*Enum:* `"professional"`, `"team"` | Yes | + +#### SuggestedQuestionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ string ] | Suggested question | Yes | + +#### SwitchWorkspacePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tenant_id | string | | Yes | + +#### SyncDraftWorkflowPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ object ] | | No | +| environment_variables | [ object ] | | No | +| features | object | | Yes | +| graph | object | | Yes | +| hash | string | | No | + +#### SyncDraftWorkflowResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| hash | string | | No | +| result | string | | No | +| updated_at | string | | No | + +#### SystemFeatureResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| features | object | System feature configuration object | No | + +#### Tag + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | +| type | string | | Yes | + +#### TagBasePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | Tag name | Yes | +| type | [TagType](#tagtype) | | Yes | + +#### TagBindingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tag_ids | [ string ] | Tag IDs to bind | Yes | +| target_id | string | Target ID to bind tags to | Yes | +| type | [TagType](#tagtype) | | Yes | + +#### TagBindingRemovePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tag_ids | [ string ] | Tag IDs to remove | Yes | +| target_id | string | Target ID to unbind tag from | Yes | +| type | [TagType](#tagtype) | | Yes | + +#### TagListQueryParam + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Search keyword | No | +| type | string | Tag type filter
*Enum:* `""`, `"app"`, `"knowledge"` | No | + +#### TagResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_count | string | | No | +| id | string | | Yes | +| name | string | | Yes | +| type | string | | No | + +#### TagType + +Tag type + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TagType | string | Tag type | | + +#### TagUpdateRequestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | Tag name | Yes | + +#### TenantAccountRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| TenantAccountRole | string | | | + +#### TenantInfoResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| custom_config | object | | No | +| id | string | | Yes | +| in_trial | boolean | | No | +| name | string | | No | +| next_credit_reset_date | integer | | No | +| plan | string | | No | +| role | string | | No | +| status | string | | No | +| trial_credits | integer | | No | +| trial_credits_used | integer | | No | +| trial_end_reason | string | | No | + +#### TextToAudioPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID | No | +| streaming | boolean | Enable streaming response | No | +| text | string | Text to convert to audio | No | +| voice | string | Voice to use for TTS | No | + +#### TextToSpeechPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID | No | +| streaming | boolean | Whether to stream audio | No | +| text | string | Text to convert | Yes | +| voice | string | Voice name | No | + +#### TextToSpeechRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | | No | +| streaming | boolean | | No | +| text | string | | No | +| voice | string | | No | + +#### TextToSpeechVoiceQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| language | string | Language code | Yes | + +#### ToolOAuthCustomClientPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_params | object | | No | +| enable_oauth_custom_client | boolean | | No | + +#### ToolParameterForm + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ToolParameterForm | string | | | + +#### TraceConfigPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tracing_config | object | Tracing configuration data | Yes | +| tracing_provider | string | Tracing provider name | Yes | + +#### TraceProviderQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tracing_provider | string | Tracing provider name | Yes | + +#### TrialAppDetailWithSite + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| api_base_url | string | | No | +| created_at | object | | No | +| created_by | string | | No | +| deleted_tools | [ [TrialDeletedTool](#trialdeletedtool) ] | | No | +| description | string | | No | +| enable_api | boolean | | No | +| enable_site | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | object | | No | +| id | string | | No | +| max_active_requests | integer | | No | +| mode | string | | No | +| model_config | [TrialAppModelConfig](#trialappmodelconfig) | | No | +| name | string | | No | +| site | [TrialSite](#trialsite) | | No | +| tags | [ [TrialTag](#trialtag) ] | | No | +| updated_at | object | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [TrialWorkflowPartial](#trialworkflowpartial) | | No | + +#### TrialAppModelConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_mode | object | | No | +| annotation_reply | object | | No | +| chat_prompt_config | object | | No | +| completion_prompt_config | object | | No | +| created_at | object | | No | +| created_by | string | | No | +| dataset_configs | object | | No | +| dataset_query_variable | string | | No | +| external_data_tools | object | | No | +| file_upload | object | | No | +| model | object | | No | +| more_like_this | object | | No | +| opening_statement | string | | No | +| pre_prompt | string | | No | +| prompt_type | string | | No | +| retriever_resource | object | | No | +| sensitive_word_avoidance | object | | No | +| speech_to_text | object | | No | +| suggested_questions | object | | No | +| suggested_questions_after_answer | object | | No | +| text_to_speech | object | | No | +| updated_at | object | | No | +| updated_by | string | | No | +| user_input_form | object | | No | + +#### TrialConversationVariable + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| id | string | | No | +| name | string | | No | +| value | object | | No | +| value_type | string | | No | + +#### TrialDeletedTool + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| provider_id | string | | No | +| tool_name | string | | No | +| type | string | | No | + +#### TrialPipelineVariable + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allow_file_extension | [ string ] | | No | +| allow_file_upload_methods | [ string ] | | No | +| allowed_file_types | [ string ] | | No | +| belong_to_node_id | string | | No | +| default_value | object | | No | +| label | string | | No | +| max_length | integer | | No | +| options | [ string ] | | No | +| placeholder | string | | No | +| required | boolean | | No | +| tooltips | string | | No | +| type | string | | No | +| unit | string | | No | +| variable | string | | No | + +#### TrialSimpleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| id | string | | No | +| name | string | | No | + +#### TrialSite + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_token | string | | No | +| app_base_url | string | | No | +| chat_color_theme | string | | No | +| chat_color_theme_inverted | boolean | | No | +| code | string | | No | +| copyright | string | | No | +| created_at | object | | No | +| created_by | string | | No | +| custom_disclaimer | string | | No | +| customize_domain | string | | No | +| customize_token_strategy | string | | No | +| default_language | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | object | | No | +| privacy_policy | string | | No | +| prompt_public | boolean | | No | +| show_workflow_steps | boolean | | No | +| title | string | | No | +| updated_at | object | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | + +#### TrialTag + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | No | +| name | string | | No | +| type | string | | No | + +#### TrialWorkflow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ [TrialConversationVariable](#trialconversationvariable) ] | | No | +| created_at | object | | No | +| created_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | +| environment_variables | [ object ] | | No | +| features | object | | No | +| graph | object | | No | +| hash | string | | No | +| id | string | | No | +| marked_comment | string | | No | +| marked_name | string | | No | +| rag_pipeline_variables | [ [TrialPipelineVariable](#trialpipelinevariable) ] | | No | +| tool_published | boolean | | No | +| updated_at | object | | No | +| updated_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | +| version | string | | No | + +#### TrialWorkflowPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | object | | No | +| created_by | string | | No | +| id | string | | No | +| updated_at | object | | No | +| updated_by | string | | No | + +#### TriggerOAuthClientPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_params | object | | No | +| enabled | boolean | | No | + +#### TriggerSubscriptionBuilderCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_type | string | | No | + +#### TriggerSubscriptionBuilderUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | No | +| name | string | | No | +| parameters | object | | No | +| properties | object | | No | + +#### TriggerSubscriptionBuilderVerifyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credentials | object | | Yes | + +#### Type + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| Type | string | | | + +#### UpdateAnnotationPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| annotation_reply | object | | No | +| answer | string | | No | +| content | string | | No | +| question | string | | No | + +#### UpdateAppPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | App description (max 400 chars) | No | +| icon | string | Icon | No | +| icon_background | string | Icon background color | No | +| icon_type | [IconType](#icontype) | Icon type | No | +| max_active_requests | integer | Maximum active requests | No | +| name | string | App name | Yes | +| use_icon_as_answer_icon | boolean | Use icon as answer icon | No | + +#### UpgradeMode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| UpgradeMode | string | | | + +#### UploadConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachment_image_file_size_limit | integer | | No | +| audio_file_size_limit | integer | | Yes | +| batch_count_limit | integer | | Yes | +| file_size_limit | integer | | Yes | +| file_upload_limit | integer | | No | +| image_file_batch_limit | integer | | Yes | +| image_file_size_limit | integer | | Yes | +| single_chunk_attachment_limit | integer | | Yes | +| video_file_size_limit | integer | | Yes | +| workflow_file_upload_limit | integer | | Yes | + +#### UserActionConfig + +User action configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| button_style | [ButtonStyle](#buttonstyle) | | No | +| id | string | | Yes | +| title | string | | Yes | + +#### ValueSourceType + +ValueSourceType records whether the value comes from a static setting +in form definiton, or a variable while the workflow is running. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | | + +#### WebhookTriggerResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | dateTime | | No | +| id | string | | Yes | +| node_id | string | | Yes | +| webhook_debug_url | string | | Yes | +| webhook_id | string | | Yes | +| webhook_url | string | | Yes | + +#### WebsiteCrawlPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| options | object | | Yes | +| provider | string | *Enum:* `"firecrawl"`, `"jinareader"`, `"watercrawl"` | Yes | +| url | string | | Yes | + +#### WebsiteCrawlStatusQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| provider | string | *Enum:* `"firecrawl"`, `"jinareader"`, `"watercrawl"` | Yes | + +#### WebsiteInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| job_id | string | | Yes | +| only_main_content | boolean | | No | +| provider | string | | Yes | +| urls | [ string ] | | Yes | + +#### WeightKeywordSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_weight | number | | Yes | + +#### WeightModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | | No | +| vector_setting | [WeightVectorSetting](#weightvectorsetting) | | No | +| weight_type | string | *Enum:* `"customized"`, `"keyword_first"`, `"semantic_first"` | No | + +#### WeightVectorSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | | Yes | +| embedding_provider_name | string | | Yes | +| vector_weight | number | | Yes | + +#### WorkflowAppLogPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowAppLogPartialResponse](#workflowapplogpartialresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### WorkflowAppLogPartialResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| created_by_role | string | | No | +| created_from | string | | No | +| details | | | No | +| id | string | | Yes | +| workflow_run | [WorkflowRunForLogResponse](#workflowrunforlogresponse) | | No | + +#### WorkflowAppLogQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at__after | dateTime | Filter logs created after this timestamp | No | +| created_at__before | dateTime | Filter logs created before this timestamp | No | +| created_by_account | string | Filter by account | No | +| created_by_end_user_session_id | string | Filter by end user session ID | No | +| detail | boolean | Whether to return detailed logs | No | +| keyword | string | Search keyword for filtering logs | No | +| limit | integer | Number of items per page (1-100) | No | +| page | integer | Page number (1-99999) | No | +| status | [WorkflowExecutionStatus](#workflowexecutionstatus) | Execution status filter (succeeded, failed, stopped, partial-succeeded) | No | + +#### WorkflowArchivedLogPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowArchivedLogPartialResponse](#workflowarchivedlogpartialresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### WorkflowArchivedLogPartialResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| id | string | | Yes | +| trigger_metadata | | | No | +| workflow_run | [WorkflowRunForArchivedLogResponse](#workflowrunforarchivedlogresponse) | | No | + +#### WorkflowCommentAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar_url | string | | Yes | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + +#### WorkflowCommentBasic + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | +| created_at | integer | | No | +| created_by | string | | Yes | +| created_by_account | [WorkflowCommentAccount](#workflowcommentaccount) | | No | +| id | string | | Yes | +| mention_count | integer | | Yes | +| participants | [ [WorkflowCommentAccount](#workflowcommentaccount) ] | | Yes | +| position_x | number | | Yes | +| position_y | number | | Yes | +| reply_count | integer | | Yes | +| resolved | boolean | | Yes | +| resolved_at | integer | | No | +| resolved_by | string | | No | +| resolved_by_account | [WorkflowCommentAccount](#workflowcommentaccount) | | No | +| updated_at | integer | | No | + +#### WorkflowCommentBasicList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowCommentBasic](#workflowcommentbasic) ] | | Yes | + +#### WorkflowCommentCreate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | + +#### WorkflowCommentCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Comment content | Yes | +| mentioned_user_ids | [ string ] | Mentioned user IDs | No | +| position_x | number | Comment X position | Yes | +| position_y | number | Comment Y position | Yes | + +#### WorkflowCommentDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | +| created_at | integer | | No | +| created_by | string | | Yes | +| created_by_account | [WorkflowCommentAccount](#workflowcommentaccount) | | No | +| id | string | | Yes | +| mentions | [ [WorkflowCommentMention](#workflowcommentmention) ] | | Yes | +| position_x | number | | Yes | +| position_y | number | | Yes | +| replies | [ [WorkflowCommentReply](#workflowcommentreply) ] | | Yes | +| resolved | boolean | | Yes | +| resolved_at | integer | | No | +| resolved_by | string | | No | +| resolved_by_account | [WorkflowCommentAccount](#workflowcommentaccount) | | No | +| updated_at | integer | | No | + +#### WorkflowCommentMention + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mentioned_user_account | [WorkflowCommentAccount](#workflowcommentaccount) | | No | +| mentioned_user_id | string | | Yes | +| reply_id | string | | No | + +#### WorkflowCommentMentionUsersPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| users | [ [AccountWithRole](#accountwithrole) ] | | Yes | + +#### WorkflowCommentReply + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | +| created_at | integer | | No | +| created_by | string | | Yes | +| created_by_account | [WorkflowCommentAccount](#workflowcommentaccount) | | No | +| id | string | | Yes | + +#### WorkflowCommentReplyCreate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| id | string | | Yes | + +#### WorkflowCommentReplyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Reply content | Yes | +| mentioned_user_ids | [ string ] | Mentioned user IDs | No | + +#### WorkflowCommentReplyUpdate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| updated_at | integer | | No | + +#### WorkflowCommentResolve + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| resolved | boolean | | Yes | +| resolved_at | integer | | No | +| resolved_by | string | | No | + +#### WorkflowCommentUpdate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| updated_at | integer | | No | + +#### WorkflowCommentUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Comment content | Yes | +| mentioned_user_ids | [ string ] | Mentioned user IDs. Omit to keep existing mentions. | No | +| position_x | number | Comment X position | No | +| position_y | number | Comment Y position | No | + +#### WorkflowConversationVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| value | object | | Yes | +| value_type | string | | Yes | + +#### WorkflowDraftEnvVariable + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| edited | boolean | | No | +| id | string | | No | +| name | string | | No | +| selector | [ string ] | | No | +| type | string | | No | +| value_type | string | | No | +| visible | boolean | | No | + +#### WorkflowDraftEnvVariableList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [WorkflowDraftEnvVariable](#workflowdraftenvvariable) ] | | No | + +#### WorkflowDraftVariable + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| edited | boolean | | No | +| full_content | object | | No | +| id | string | | No | +| is_truncated | boolean | | No | +| name | string | | No | +| selector | [ string ] | | No | +| type | string | | No | +| value | object | | No | +| value_type | string | | No | +| visible | boolean | | No | + +#### WorkflowDraftVariableList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [WorkflowDraftVariable](#workflowdraftvariable) ] | | No | + +#### WorkflowDraftVariableListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | Items per page | No | +| page | integer | Page number | No | + +#### WorkflowDraftVariableListWithoutValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [WorkflowDraftVariableWithoutValue](#workflowdraftvariablewithoutvalue) ] | | No | +| total | object | | No | + +#### WorkflowDraftVariablePatchPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | No | +| value | | | No | + +#### WorkflowDraftVariableUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | Variable name | No | +| value | | Variable value | No | + +#### WorkflowDraftVariableWithoutValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| edited | boolean | | No | +| id | string | | No | +| is_truncated | boolean | | No | +| name | string | | No | +| selector | [ string ] | | No | +| type | string | | No | +| value_type | string | | No | +| visible | boolean | | No | + +#### WorkflowEnvironmentVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| value | object | | Yes | +| value_type | string | | Yes | + +#### WorkflowExecutionStatus + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| WorkflowExecutionStatus | string | | | + +#### WorkflowFeaturesPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| features | object | Workflow feature configuration | Yes | + +#### WorkflowListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | | No | +| named_only | boolean | | No | +| page | integer | | No | +| user_id | string | | No | + +#### WorkflowOnlineUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| avatar | string | | No | +| user_id | string | | Yes | +| username | string | | Yes | + +#### WorkflowOnlineUsersByApp + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | Yes | +| users | [ [WorkflowOnlineUser](#workflowonlineuser) ] | | Yes | + +#### WorkflowOnlineUsersPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_ids | [ string ] | App IDs | No | + +#### WorkflowOnlineUsersResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowOnlineUsersByApp](#workflowonlineusersbyapp) ] | | Yes | + +#### WorkflowPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| has_more | boolean | | Yes | +| items | [ [WorkflowResponse](#workflowresponse) ] | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | + +#### WorkflowPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by | string | | No | +| id | string | | Yes | +| updated_at | integer | | No | +| updated_by | string | | No | + +#### WorkflowPauseDetailsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| paused_at | string | | No | +| paused_nodes | [ [PausedNodeResponse](#pausednoderesponse) ] | | Yes | + +#### WorkflowResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ [WorkflowConversationVariableResponse](#workflowconversationvariableresponse) ] | | Yes | +| created_at | integer | | Yes | +| created_by | [SimpleAccount](#simpleaccount) | | No | +| environment_variables | [ [WorkflowEnvironmentVariableResponse](#workflowenvironmentvariableresponse) ] | | Yes | +| features | object | | Yes | +| graph | object | | Yes | +| hash | string | | Yes | +| id | string | | Yes | +| marked_comment | string | | Yes | +| marked_name | string | | Yes | +| rag_pipeline_variables | [ [PipelineVariableResponse](#pipelinevariableresponse) ] | | Yes | +| tool_published | boolean | | Yes | +| updated_at | integer | | Yes | +| updated_by | [SimpleAccount](#simpleaccount) | | No | +| version | string | | Yes | + +#### WorkflowRunCountQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | string | Workflow run status filter
*Enum:* `"failed"`, `"partial-succeeded"`, `"running"`, `"stopped"`, `"succeeded"` | No | +| time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No | +| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging
*Enum:* `"app-run"`, `"debugging"` | No | + +#### WorkflowRunCountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| failed | integer | | Yes | +| partial_succeeded | integer | | Yes | +| running | integer | | Yes | +| stopped | integer | | Yes | +| succeeded | integer | | Yes | +| total | integer | | Yes | + +#### WorkflowRunDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| created_by_role | string | | No | +| elapsed_time | number | | No | +| error | string | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| graph | | | Yes | +| id | string | | Yes | +| inputs | | | Yes | +| outputs | | | Yes | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| version | string | | No | + +#### WorkflowRunExportResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| presigned_url | string | Pre-signed URL for download | No | +| presigned_url_expires_at | string | Pre-signed URL expiration time | No | +| status | string | Export status: success/failed | Yes | + +#### WorkflowRunForArchivedLogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| elapsed_time | number | | No | +| id | string | | Yes | +| status | string | | No | +| total_tokens | integer | | No | +| triggered_from | string | | No | + +#### WorkflowRunForListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| elapsed_time | number | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| retry_index | integer | | No | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| version | string | | No | + +#### WorkflowRunForLogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| elapsed_time | number | | No | +| error | string | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| triggered_from | string | | No | +| version | string | | No | + +#### WorkflowRunListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | Last run ID for pagination | No | +| limit | integer | Number of items per page (1-100) | No | +| status | string | Workflow run status filter
*Enum:* `"failed"`, `"partial-succeeded"`, `"running"`, `"stopped"`, `"succeeded"` | No | +| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging
*Enum:* `"app-run"`, `"debugging"` | No | + +#### WorkflowRunNodeExecutionListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) ] | | Yes | + +#### WorkflowRunNodeExecutionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| created_by_role | string | | No | +| elapsed_time | number | | No | +| error | string | | No | +| execution_metadata | | | No | +| extras | | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| index | integer | | No | +| inputs | | | No | +| inputs_truncated | boolean | | No | +| node_id | string | | No | +| node_type | string | | No | +| outputs | | | No | +| outputs_truncated | boolean | | No | +| predecessor_node_id | string | | No | +| process_data | | | No | +| process_data_truncated | boolean | | No | +| status | string | | No | +| title | string | | No | + +#### WorkflowRunPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowRunForListResponse](#workflowrunforlistresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### WorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | + +#### WorkflowRunQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer | | No | + +#### WorkflowRunRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ ] | | No | +| inputs | object | | Yes | + +#### WorkflowStatisticQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date and time (YYYY-MM-DD HH:MM) | No | +| start | string | Start date and time (YYYY-MM-DD HH:MM) | No | + +#### WorkflowToolCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| icon | object | | Yes | +| label | string | | Yes | +| labels | [ string ] | | No | +| name | string | | Yes | +| parameters | [ [WorkflowToolParameterConfiguration](#workflowtoolparameterconfiguration) ] | | No | +| privacy_policy | string | | No | +| workflow_app_id | string | | Yes | + +#### WorkflowToolDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| workflow_tool_id | string | | Yes | + +#### WorkflowToolParameterConfiguration + +Workflow tool configuration + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | The description of the parameter | Yes | +| form | [ToolParameterForm](#toolparameterform) | The form of the parameter | Yes | +| name | string | The name of the parameter | Yes | + +#### WorkflowToolUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | Yes | +| icon | object | | Yes | +| label | string | | Yes | +| labels | [ string ] | | No | +| name | string | | Yes | +| parameters | [ [WorkflowToolParameterConfiguration](#workflowtoolparameterconfiguration) ] | | No | +| privacy_policy | string | | No | +| workflow_tool_id | string | | Yes | + +#### WorkflowTriggerListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowTriggerResponse](#workflowtriggerresponse) ] | | Yes | + +#### WorkflowTriggerResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | dateTime | | No | +| icon | string | | Yes | +| id | string | | Yes | +| node_id | string | | Yes | +| provider_name | string | | Yes | +| status | string | | Yes | +| title | string | | Yes | +| trigger_type | string | | Yes | +| updated_at | dateTime | | No | + +#### WorkflowUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| marked_comment | string | | No | +| marked_name | string | | No | + +#### WorkspaceCustomConfigPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| remove_webapp_brand | boolean | | No | +| replace_webapp_logo | string | | No | + +#### WorkspaceInfoPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### WorkspaceListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | | No | +| page | integer | | No | + +#### _AnonymousInlineModel_b1954337d565 + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enable | boolean | | No | +| model_name | string | | No | +| model_provider_name | string | | No | +| summary_prompt | string | | No | + +## FastOpenAPI Preview (OpenAPI 3.0) + +### Dify API (FastOpenAPI PoC) +FastOpenAPI proof of concept for Dify API + +#### Version: 1.0 + +--- + +##### [GET] /console/api/init +**Get initialization validation status.** + +###### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | OK | **application/json**: [InitStatusResponse](#initstatusresponse)
| + +##### [POST] /console/api/init +**Validate initialization password.** + +###### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [InitValidatePayload](#initvalidatepayload)
| + +###### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Created | **application/json**: [InitValidateResponse](#initvalidateresponse)
| + +##### [GET] /console/api/ping +**Health check endpoint for connection testing.** + +###### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | OK | **application/json**: [PingResponse](#pingresponse)
| + +##### [GET] /console/api/setup +**Get system setup status. + + NOTE: This endpoint is unauthenticated by design. + + During first-time bootstrap there is no admin account yet, so frontend initialization must be + able to query setup progress before any login flow exists. + + Only bootstrap-safe status information should be returned by this endpoint. + ** + +###### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | OK | **application/json**: [SetupStatusResponse](#setupstatusresponse)
| + +##### [POST] /console/api/setup +**Initialize system setup with admin account. + + NOTE: This endpoint is unauthenticated by design for first-time bootstrap. + Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards, + and init-password validation rather than user session authentication. + ** + +###### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SetupRequestPayload](#setuprequestpayload)
| + +###### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Created | **application/json**: [SetupResponse](#setupresponse)
| + +##### [GET] /console/api/version +**Check for application version updates.** + +###### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| current_version | query | | Yes | string | + +###### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | OK | **application/json**: [VersionResponse](#versionresponse)
| + +--- +##### Schemas + +###### ErrorSchema + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| error | { **"details"**: string, **"message"**: string, **"status"**: integer, **"type"**: string } | | Yes | + +###### InitStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | string,
**Available values:** "finished", "not_started" | Initialization status
*Enum:* `"finished"`, `"not_started"` | Yes | + +###### InitValidatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| password | string | Initialization password | Yes | + +###### InitValidateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Operation result | Yes | + +###### PingResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Health check result | Yes | + +###### SetupRequestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | Admin email address | Yes | +| language | | Admin language | No | +| name | string | Admin name (max 30 characters) | Yes | +| password | string | Admin password | Yes | + +###### SetupResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| result | string | Setup result | Yes | + +###### SetupStatusResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| setup_at | | Setup completion time (ISO format) | No | +| step | string,
**Available values:** "finished", "not_started" | Setup step status
*Enum:* `"finished"`, `"not_started"` | Yes | + +###### VersionFeatures + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| can_replace_logo | boolean | Whether logo replacement is supported | Yes | +| model_load_balancing_enabled | boolean | Whether model load balancing is enabled | Yes | + +###### VersionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| can_auto_update | boolean | Whether auto-update is supported | Yes | +| features | [VersionFeatures](#versionfeatures) | Feature flags and capabilities | Yes | +| release_date | string | Release date of latest version | Yes | +| release_notes | string | Release notes for latest version | Yes | +| version | string | Latest version number | Yes | diff --git a/api/openapi/markdown/service-swagger.md b/api/openapi/markdown/service-swagger.md new file mode 100644 index 0000000000..72bea2a1aa --- /dev/null +++ b/api/openapi/markdown/service-swagger.md @@ -0,0 +1,2754 @@ +# Service API +API for application services + +## Version: 1.0 + +### Security +**Bearer** + +| apiKey | *API Key* | +| ------ | --------- | +| Description | Type: Bearer {your-api-key} | +| In | header | +| Name | Authorization | + +--- +## service_api +Service operations + +### / + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /app/feedbacks + +#### GET +##### Summary + +Get all feedbacks for the application + +##### Description + +Get all feedbacks for the application +Returns paginated list of all feedback submitted for messages in this app. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [FeedbackListQuery](#feedbacklistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Feedbacks retrieved successfully | +| 401 | Unauthorized - invalid API token | + +### /apps/annotation-reply/{action} + +#### POST +##### Summary + +Enable or disable annotation reply feature + +##### Description + +Enable or disable annotation reply feature + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AnnotationReplyActionPayload](#annotationreplyactionpayload) | +| action | path | Action to perform: 'enable' or 'disable' | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Action completed successfully | +| 401 | Unauthorized - invalid API token | + +### /apps/annotation-reply/{action}/status/{job_id} + +#### GET +##### Summary + +Get the status of an annotation reply action job + +##### Description + +Get the status of an annotation reply action job + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action type | Yes | string | +| job_id | path | Job ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Job status retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Job not found | + +### /apps/annotations + +#### GET +##### Summary + +List annotations for the application + +##### Description + +List annotations for the application + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotations retrieved successfully | [AnnotationList](#annotationlist) | +| 401 | Unauthorized - invalid API token | | + +#### POST +##### Summary + +Create a new annotation + +##### Description + +Create a new annotation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AnnotationCreatePayload](#annotationcreatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Annotation created successfully | [Annotation](#annotation) | +| 401 | Unauthorized - invalid API token | | + +### /apps/annotations/{annotation_id} + +#### DELETE +##### Summary + +Delete an annotation + +##### Description + +Delete an annotation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| annotation_id | path | Annotation ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Annotation deleted successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Annotation not found | + +#### PUT +##### Summary + +Update an existing annotation + +##### Description + +Update an existing annotation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [AnnotationCreatePayload](#annotationcreatepayload) | +| annotation_id | path | Annotation ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation updated successfully | [Annotation](#annotation) | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - insufficient permissions | | +| 404 | Annotation not found | | + +### /audio-to-text + +#### POST +##### Summary + +Convert audio to text using speech-to-text + +##### Description + +Convert audio to text using speech-to-text +Accepts an audio file upload and returns the transcribed text. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Audio successfully transcribed | +| 400 | Bad request - no audio or invalid audio | +| 401 | Unauthorized - invalid API token | +| 413 | Audio file too large | +| 415 | Unsupported audio type | +| 500 | Internal server error | + +### /chat-messages + +#### POST +##### Summary + +Send a message in a chat conversation + +##### Description + +Send a message in a chat conversation +This endpoint handles chat messages for chat, agent chat, and advanced chat applications. +Supports conversation management and both blocking and streaming response modes. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChatRequestPayload](#chatrequestpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Message sent successfully | +| 400 | Bad request - invalid parameters or workflow issues | +| 401 | Unauthorized - invalid API token | +| 404 | Conversation or workflow not found | +| 429 | Rate limit exceeded | +| 500 | Internal server error | + +### /chat-messages/{task_id}/stop + +#### POST +##### Summary + +Stop a running chat message generation + +##### Description + +Stop a running chat message generation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | The ID of the task to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Task not found | + +### /completion-messages + +#### POST +##### Summary + +Create a completion for the given prompt + +##### Description + +Create a completion for the given prompt +This endpoint generates a completion based on the provided inputs and query. +Supports both blocking and streaming response modes. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CompletionRequestPayload](#completionrequestpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Completion created successfully | +| 400 | Bad request - invalid parameters | +| 401 | Unauthorized - invalid API token | +| 404 | Conversation not found | +| 500 | Internal server error | + +### /completion-messages/{task_id}/stop + +#### POST +##### Summary + +Stop a running completion task + +##### Description + +Stop a running completion task + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | The ID of the task to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Task not found | + +### /conversations + +#### GET +##### Summary + +List all conversations for the current user + +##### Description + +List all conversations for the current user +Supports pagination using last_id and limit parameters. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConversationListQuery](#conversationlistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Conversations retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Last conversation not found | + +### /conversations/{c_id} + +#### DELETE +##### Summary + +Delete a specific conversation + +##### Description + +Delete a specific conversation + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Conversation deleted successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Conversation not found | + +### /conversations/{c_id}/name + +#### POST +##### Summary + +Rename a conversation or auto-generate a name + +##### Description + +Rename a conversation or auto-generate a name + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConversationRenamePayload](#conversationrenamepayload) | +| c_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Conversation renamed successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Conversation not found | + +### /conversations/{c_id}/variables + +#### GET +##### Summary + +List all variables for a conversation + +##### Description + +List all variables for a conversation +Conversational variables are only available for chat applications. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConversationVariablesQuery](#conversationvariablesquery) | +| c_id | path | Conversation ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variables retrieved successfully | [ConversationVariableInfiniteScrollPaginationResponse](#conversationvariableinfinitescrollpaginationresponse) | +| 401 | Unauthorized - invalid API token | | +| 404 | Conversation not found | | + +### /conversations/{c_id}/variables/{variable_id} + +#### PUT +##### Summary + +Update a conversation variable's value + +##### Description + +Update a conversation variable's value +Allows updating the value of a specific conversation variable. +The value must match the variable's expected type. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ConversationVariableUpdatePayload](#conversationvariableupdatepayload) | +| c_id | path | Conversation ID | Yes | string | +| variable_id | path | Variable ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable updated successfully | [ConversationVariableResponse](#conversationvariableresponse) | +| 400 | Bad request - type mismatch | | +| 401 | Unauthorized - invalid API token | | +| 404 | Conversation or variable not found | | + +### /datasets + +#### GET +##### Summary + +Resource for getting datasets + +##### Description + +List all datasets + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Datasets retrieved successfully | +| 401 | Unauthorized - invalid API token | + +#### POST +##### Summary + +Resource for creating datasets + +##### Description + +Create a new dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DatasetCreatePayload](#datasetcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Dataset created successfully | +| 400 | Bad request - invalid parameters | +| 401 | Unauthorized - invalid API token | + +### /datasets/pipeline/file-upload + +#### POST +##### Summary + +Upload a file for use in conversations + +##### Description + +Upload a file to a knowledgebase pipeline +Accepts a single file upload via multipart/form-data. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 201 | File uploaded successfully | +| 400 | Bad request - no file or invalid file | +| 401 | Unauthorized - invalid API token | +| 413 | File too large | +| 415 | Unsupported file type | + +### /datasets/tags + +#### DELETE +##### Summary + +Delete a knowledge type tag + +##### Description + +Delete a knowledge type tag + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagDeletePayload](#tagdeletepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Tag deleted successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +#### GET +##### Summary + +Get all knowledge type tags + +##### Description + +Get all knowledge type tags + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Tags retrieved successfully | +| 401 | Unauthorized - invalid API token | + +#### PATCH +##### Description + +Update a knowledge type tag + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagUpdatePayload](#tagupdatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Tag updated successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +#### POST +##### Summary + +Add a knowledge type tag + +##### Description + +Add a knowledge type tag + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagCreatePayload](#tagcreatepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Tag created successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +### /datasets/tags/binding + +#### POST +##### Description + +Bind tags to a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagBindingPayload](#tagbindingpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Tags bound successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +### /datasets/tags/unbinding + +#### POST +##### Description + +Unbind tags from a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TagUnbindingPayload](#tagunbindingpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Tags unbound successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +### /datasets/{dataset_id} + +#### DELETE +##### Summary + +Deletes a dataset given its ID + +##### Description + +Delete a dataset +Args: + _: ignore + dataset_id (UUID): The ID of the dataset to be deleted. + +Returns: + dict: A dictionary with a key 'result' and a value 'success' + if the dataset was successfully deleted. Omitted in HTTP response. + int: HTTP status code 204 indicating that the operation was successful. + +Raises: + NotFound: If the dataset with the given ID does not exist. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Dataset deleted successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | +| 409 | Conflict - dataset is in use | + +#### GET +##### Description + +Get a specific dataset by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Dataset retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Dataset not found | + +#### PATCH +##### Description + +Update an existing dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DatasetUpdatePayload](#datasetupdatepayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Dataset updated successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/document/create-by-file + +#### POST +##### Description + +Create a new document by uploading a file + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document created successfully | +| 400 | Bad request - invalid file or parameters | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/document/create-by-text + +#### POST +##### Description + +Create a new document by providing text content + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DocumentTextCreatePayload](#documenttextcreatepayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document created successfully | +| 400 | Bad request - invalid parameters | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/document/create_by_file + +#### POST +##### Description + +Create a new document by uploading a file + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document created successfully | +| 400 | Bad request - invalid file or parameters | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/document/create_by_text + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for creating a new document by providing text content. Use /datasets/{dataset_id}/document/create-by-text instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DocumentTextCreatePayload](#documenttextcreatepayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document created successfully | +| 400 | Bad request - invalid parameters | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/documents + +#### GET +##### Description + +List all documents in a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Documents retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/documents/download-zip + +#### POST +##### Description + +Download selected uploaded documents as a single ZIP archive + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | ZIP archive generated successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Document or dataset not found | + +### /datasets/{dataset_id}/documents/metadata + +#### POST +##### Summary + +Update metadata for multiple documents + +##### Description + +Update metadata for multiple documents + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MetadataOperationData](#metadataoperationdata) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Documents metadata updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/documents/status/{action} + +#### PATCH +##### Summary + +Batch update document status + +##### Description + +Batch update document status +Args: + tenant_id: tenant id + dataset_id: dataset id + action: action to perform (Literal["enable", "disable", "archive", "un_archive"]) + +Returns: + dict: A dictionary with a key 'result' and a value 'success' + int: HTTP status code 200 indicating that the operation was successful. + +Raises: + NotFound: If the dataset with the given ID does not exist. + Forbidden: If the user does not have permission. + InvalidActionError: If the action is invalid or cannot be performed. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action to perform: 'enable', 'disable', 'archive', or 'un_archive' | Yes | string | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document status updated successfully | +| 400 | Bad request - invalid action | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/documents/{batch}/indexing-status + +#### GET +##### Description + +Get indexing status for documents in a batch + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| batch | path | Batch ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Indexing status retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset or documents not found | + +### /datasets/{dataset_id}/documents/{document_id} + +#### DELETE +##### Summary + +Delete document + +##### Description + +Delete a document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Document deleted successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - document is archived | +| 404 | Document not found | + +#### GET +##### Description + +Get a specific document by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Document not found | + +#### PATCH +##### Description + +Update an existing document by uploading a file + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/download + +#### GET +##### Description + +Get a signed download URL for a document's original uploaded file + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Download URL generated successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | +| 404 | Document or upload file not found | + +### /datasets/{dataset_id}/documents/{document_id}/segments + +#### GET +##### Description + +List segments in a document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SegmentListQuery](#segmentlistquery) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Segments retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset or document not found | + +#### POST +##### Description + +Create segments in a document + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SegmentCreatePayload](#segmentcreatepayload) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Segments created successfully | +| 400 | Bad request - segments data is missing | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset or document not found | + +### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} + +#### DELETE +##### Description + +Delete a specific segment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| segment_id | path | Segment ID to delete | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Segment deleted successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, or segment not found | + +#### GET +##### Description + +Get a specific segment by ID + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| document_id | path | | Yes | string | +| segment_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Segment retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, or segment not found | + +#### POST +##### Description + +Update a specific segment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SegmentUpdatePayload](#segmentupdatepayload) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| segment_id | path | Segment ID to update | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Segment updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, or segment not found | + +### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks + +#### GET +##### Description + +List child chunks for a segment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChildChunkListQuery](#childchunklistquery) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| segment_id | path | Parent segment ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Child chunks retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, or segment not found | + +#### POST +##### Description + +Create a new child chunk for a segment + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChildChunkCreatePayload](#childchunkcreatepayload) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| segment_id | path | Parent segment ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Child chunk created successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, or segment not found | + +### /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} + +#### DELETE +##### Description + +Delete a specific child chunk + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| child_chunk_id | path | Child chunk ID to delete | Yes | string | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| segment_id | path | Parent segment ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Child chunk deleted successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, segment, or child chunk not found | + +#### PATCH +##### Description + +Update a specific child chunk + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChildChunkUpdatePayload](#childchunkupdatepayload) | +| child_chunk_id | path | Child chunk ID to update | Yes | string | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | +| segment_id | path | Parent segment ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Child chunk updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset, document, segment, or child chunk not found | + +### /datasets/{dataset_id}/documents/{document_id}/update-by-file + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/update-by-text + +#### POST +##### Description + +Update an existing document by providing text content + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DocumentTextUpdate](#documenttextupdate) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/update_by_file + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Document not found | + +### /datasets/{dataset_id}/documents/{document_id}/update_by_text + +#### POST +***DEPRECATED*** +##### Description + +Deprecated legacy alias for updating an existing document by providing text content. Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DocumentTextUpdate](#documenttextupdate) | +| dataset_id | path | Dataset ID | Yes | string | +| document_id | path | Document ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Document updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Document not found | + +### /datasets/{dataset_id}/hit-testing + +#### POST +##### Summary + +Perform hit testing on a dataset + +##### Description + +Perform hit testing on a dataset +Tests retrieval performance for the specified dataset. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HitTestingPayload](#hittestingpayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Hit testing results | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/metadata + +#### GET +##### Summary + +Get all metadata for a dataset + +##### Description + +Get all metadata for a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Metadata retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +#### POST +##### Summary + +Create metadata for a dataset + +##### Description + +Create metadata for a dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MetadataArgs](#metadataargs) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 201 | Metadata created successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/metadata/built-in + +#### GET +##### Summary + +Get all built-in metadata fields + +##### Description + +Get all built-in metadata fields + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Built-in fields retrieved successfully | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/metadata/built-in/{action} + +#### POST +##### Summary + +Enable or disable built-in metadata field + +##### Description + +Enable or disable built-in metadata field + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action to perform: 'enable' or 'disable' | Yes | string | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Action completed successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/metadata/{metadata_id} + +#### DELETE +##### Summary + +Delete metadata + +##### Description + +Delete metadata + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | +| metadata_id | path | Metadata ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Metadata deleted successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset or metadata not found | + +#### PATCH +##### Summary + +Update metadata name + +##### Description + +Update metadata name + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MetadataUpdatePayload](#metadataupdatepayload) | +| dataset_id | path | Dataset ID | Yes | string | +| metadata_id | path | Metadata ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Metadata updated successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset or metadata not found | + +### /datasets/{dataset_id}/pipeline/datasource-plugins + +#### GET +##### Summary + +Resource for getting datasource plugins + +##### Description + +List all datasource plugins for a rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| is_published | query | Whether to get published or draft datasource plugins (true for published, false for draft, default: true) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Datasource plugins retrieved successfully | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/pipeline/datasource/nodes/{node_id}/run + +#### POST +##### Summary + +Resource for getting datasource plugins + +##### Description + +Run a datasource node for a rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | +| node_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Datasource node run successfully | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/pipeline/run + +#### POST +##### Summary + +Resource for running a rag pipeline + +##### Description + +Run a datasource node for a rag pipeline + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Pipeline run successfully | +| 401 | Unauthorized - invalid API token | + +### /datasets/{dataset_id}/retrieve + +#### POST +##### Summary + +Perform hit testing on a dataset + +##### Description + +Perform hit testing on a dataset +Tests retrieval performance for the specified dataset. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HitTestingPayload](#hittestingpayload) | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Hit testing results | +| 401 | Unauthorized - invalid API token | +| 404 | Dataset not found | + +### /datasets/{dataset_id}/tags + +#### GET +##### Summary + +Get all knowledge type tags + +##### Description + +Get tags bound to a specific dataset + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Dataset ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Tags retrieved successfully | +| 401 | Unauthorized - invalid API token | + +### /end-users/{end_user_id} + +#### GET +##### Summary + +Get end user detail + +##### Description + +Get an end user by ID +This endpoint is scoped to the current app token's tenant/app to prevent +cross-tenant/app access when an end-user ID is known. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end_user_id | path | End user ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | End user retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | End user not found | + +### /files/upload + +#### POST +##### Summary + +Upload a file for use in conversations + +##### Description + +Upload a file for use in conversations +Accepts a single file upload via multipart/form-data. + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | [FileResponse](#fileresponse) | +| 400 | Bad request - no file or invalid file | | +| 401 | Unauthorized - invalid API token | | +| 413 | File too large | | +| 415 | Unsupported file type | | + +### /files/{file_id}/preview + +#### GET +##### Summary + +Preview/Download a file that was uploaded via Service API + +##### Description + +Preview or download a file uploaded via Service API +Provides secure file preview/download functionality. +Files can only be accessed if they belong to messages within the requesting app's context. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [FilePreviewQuery](#filepreviewquery) | +| file_id | path | UUID of the file to preview | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | File retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - file access denied | +| 404 | File not found | + +### /form/human_input/{form_token} + +#### GET +##### Description + +Get a paused human input form by token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | Human input form token | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Form retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Form not found | +| 412 | Form already submitted or expired | + +#### POST +##### Description + +Submit a paused human input form by token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | +| form_token | path | Human input form token | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Form submitted successfully | +| 400 | Bad request - invalid submission data | +| 401 | Unauthorized - invalid API token | +| 404 | Form not found | +| 412 | Form already submitted or expired | + +### /info + +#### GET +##### Summary + +Get app information + +##### Description + +Get basic application information +Returns basic information about the application including name, description, tags, and mode. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Application info retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Application not found | + +### /messages + +#### GET +##### Summary + +List messages in a conversation + +##### Description + +List messages in a conversation +Retrieves messages with pagination support using first_id. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MessageListQuery](#messagelistquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Messages retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Conversation or first message not found | + +### /messages/{message_id}/feedbacks + +#### POST +##### Summary + +Submit feedback for a message + +##### Description + +Submit feedback for a message +Allows users to rate messages as like/dislike and provide optional feedback content. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [MessageFeedbackPayload](#messagefeedbackpayload) | +| message_id | path | Message ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Feedback submitted successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Message not found | + +### /messages/{message_id}/suggested + +#### GET +##### Summary + +Get suggested follow-up questions for a message + +##### Description + +Get suggested follow-up questions for a message +Returns AI-generated follow-up questions based on the message content. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Suggested questions retrieved successfully | +| 400 | Suggested questions feature is disabled | +| 401 | Unauthorized - invalid API token | +| 404 | Message not found | +| 500 | Internal server error | + +### /meta + +#### GET +##### Summary + +Get app metadata + +##### Description + +Get application metadata +Returns metadata about the application including configuration and settings. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Metadata retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Application not found | + +### /parameters + +#### GET +##### Summary + +Retrieve app parameters + +##### Description + +Retrieve application input parameters and configuration +Returns the input form parameters and configuration for the application. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Parameters retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Application not found | + +### /site + +#### GET +##### Summary + +Retrieve app site info + +##### Description + +Get application site configuration +Returns the site configuration for the application including theme, icons, and text. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Site configuration retrieved successfully | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - site not found or tenant archived | + +### /text-to-audio + +#### POST +##### Summary + +Convert text to audio using text-to-speech + +##### Description + +Convert text to audio using text-to-speech +Converts the provided text to audio using the specified voice. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TextToAudioPayload](#texttoaudiopayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Text successfully converted to audio | +| 400 | Bad request - invalid parameters | +| 401 | Unauthorized - invalid API token | +| 500 | Internal server error | + +### /workflow/{task_id}/events + +#### GET +##### Description + +Get workflow execution events stream after resume + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Workflow run ID | Yes | string | +| continue_on_pause | query | Whether to keep the stream open across workflow_paused events,specify `"true"` to keep the stream open for `workflow_paused` events. | No | string | +| include_state_snapshot | query | Whether to replay from persisted state snapshot, specify `"true"` to include a status snapshot of executed nodes | No | string | +| user | query | End user identifier (query param) | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | SSE event stream | +| 401 | Unauthorized - invalid API token | +| 404 | Workflow run not found | + +### /workflows/logs + +#### GET +##### Summary + +Get workflow app logs + +##### Description + +Get workflow execution logs +Returns paginated workflow execution logs with filtering options. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowLogQuery](#workflowlogquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Logs retrieved successfully | [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse) | +| 401 | Unauthorized - invalid API token | | + +### /workflows/run + +#### POST +##### Summary + +Execute a workflow + +##### Description + +Execute a workflow +Runs a workflow with the provided inputs and returns the results. +Supports both blocking and streaming response modes. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow executed successfully | +| 400 | Bad request - invalid parameters or workflow issues | +| 401 | Unauthorized - invalid API token | +| 404 | Workflow not found | +| 429 | Rate limit exceeded | +| 500 | Internal server error | + +### /workflows/run/{workflow_run_id} + +#### GET +##### Summary + +Get a workflow task running detail + +##### Description + +Get workflow run details +Returns detailed information about a specific workflow run. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workflow_run_id | path | Workflow run ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run details retrieved successfully | [WorkflowRunResponse](#workflowrunresponse) | +| 401 | Unauthorized - invalid API token | | +| 404 | Workflow run not found | | + +### /workflows/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop a running workflow task + +##### Description + +Stop a running workflow task + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | +| 401 | Unauthorized - invalid API token | +| 404 | Task not found | + +### /workflows/{workflow_id}/run + +#### POST +##### Summary + +Run specific workflow by ID + +##### Description + +Execute a specific workflow by ID +Executes a specific workflow version identified by its ID. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | +| workflow_id | path | Workflow ID to execute | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow executed successfully | +| 400 | Bad request - invalid parameters or workflow issues | +| 401 | Unauthorized - invalid API token | +| 404 | Workflow not found | +| 429 | Rate limit exceeded | +| 500 | Internal server error | + +### /workspaces/current/models/model-types/{model_type} + +#### GET +##### Summary + +Get available models by model type + +##### Description + +Get available models by model type +Returns a list of available models for the specified model type. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| model_type | path | Type of model to retrieve | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Models retrieved successfully | +| 401 | Unauthorized - invalid API token | + +--- +### Models + +#### Annotation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| created_at | integer | | No | +| hit_count | integer | | No | +| id | string | | Yes | +| question | string | | No | + +#### AnnotationCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | Annotation answer | Yes | +| question | string | Annotation question | Yes | + +#### AnnotationList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [Annotation](#annotation) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AnnotationReplyActionPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | Embedding model name | Yes | +| embedding_provider_name | string | Embedding provider name | Yes | +| score_threshold | number | Score threshold for annotation matching | Yes | + +#### ChatRequestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate_name | boolean | Auto generate conversation name | No | +| conversation_id | string | Conversation UUID | No | +| files | [ object ] | | No | +| inputs | object | | Yes | +| query | string | | Yes | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | | No | +| workflow_id | string | Workflow ID for advanced chat | No | + +#### ChildChunkCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | + +#### ChildChunkListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | | No | +| limit | integer | | No | +| page | integer | | No | + +#### ChildChunkUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | Yes | + +#### CompletionRequestPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | +| query | string | | No | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | | No | + +#### Condition + +Condition detail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| comparison_operator | string | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| name | string | | Yes | +| value | | | No | + +#### ConversationListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | Last conversation ID for pagination | No | +| limit | integer | Number of conversations to return | No | +| sort_by | string | Sort order for conversations
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | + +#### ConversationRenamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | boolean | | No | +| name | string | | No | + +#### ConversationVariableInfiniteScrollPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ConversationVariableResponse](#conversationvariableresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### ConversationVariableResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| description | string | | No | +| id | string | | Yes | +| name | string | | Yes | +| updated_at | integer | | No | +| value | string | | No | +| value_type | string | | Yes | + +#### ConversationVariableUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| value | | | Yes | + +#### ConversationVariablesQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | Last variable ID for pagination | No | +| limit | integer | Number of variables to return | No | +| variable_name | string | Filter variables by name | No | + +#### DataSetTag + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_count | string | | No | +| id | string | | Yes | +| name | string | | Yes | +| type | string | | Yes | + +#### DatasetCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Dataset description (max 400 chars) | No | +| embedding_model | string | | No | +| embedding_model_provider | string | | No | +| external_knowledge_api_id | string | | No | +| external_knowledge_id | string | | No | +| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | No | +| name | string | | Yes | +| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No | +| provider | string | | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| summary_index_setting | object | | No | + +#### DatasetListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| include_all | boolean | Include all datasets | No | +| keyword | string | Search keyword | No | +| limit | integer | Number of items per page | No | +| page | integer | Page number | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + +#### DatasetPermissionEnum + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DatasetPermissionEnum | string | | | + +#### DatasetUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Dataset description (max 400 chars) | No | +| embedding_model | string | | No | +| embedding_model_provider | string | | No | +| external_knowledge_api_id | string | | No | +| external_knowledge_id | string | | No | +| external_retrieval_model | object | | No | +| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | No | +| name | string | | No | +| partial_member_list | [ object ] | | No | +| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | + +#### DatasourceNodeRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| credential_id | string | | No | +| datasource_type | string | | Yes | +| inputs | object | | Yes | +| is_published | boolean | | Yes | + +#### DocumentBatchDownloadZipPayload + +Request payload for bulk downloading documents as a zip archive. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_ids | [ string (uuid) ] | | Yes | + +#### DocumentListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | Search keyword | No | +| limit | integer | Number of items per page | No | +| page | integer | Page number | No | +| status | string | Document status filter | No | + +#### DocumentMetadataOperation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| document_id | string | | Yes | +| metadata_list | [ [MetadataDetail](#metadatadetail) ] | | Yes | +| partial_update | boolean | | No | + +#### DocumentTextCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| doc_form | string | | No | +| doc_language | string | | No | +| embedding_model | string | | No | +| embedding_model_provider | string | | No | +| indexing_technique | string | | No | +| name | string | | Yes | +| original_document_id | string | | No | +| process_rule | [ProcessRule](#processrule) | | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| text | string | | Yes | + +#### DocumentTextUpdate + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| doc_form | string | | No | +| doc_language | string | | No | +| name | string | | No | +| process_rule | [ProcessRule](#processrule) | | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| text | string | | No | + +#### FeedbackListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | Number of feedbacks per page | No | +| page | integer | Page number | No | + +#### FilePreviewQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| as_attachment | boolean | Download as attachment | No | + +#### FileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| extension | string | | No | +| file_key | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| original_url | string | | No | +| preview_url | string | | No | +| size | integer | | Yes | +| source_url | string | | No | +| tenant_id | string | | No | +| user_id | string | | No | + +#### HitTestingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| attachment_ids | [ string ] | | No | +| external_retrieval_model | object | | No | +| query | string | | Yes | +| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | + +#### HumanInputFormSubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| inputs | object | | Yes | + +#### JsonValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JsonValue | | | | + +#### MessageFeedbackPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| rating | string | *Enum:* `"dislike"`, `"like"` | No | + +#### MessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation UUID | Yes | +| first_id | string | First message ID for pagination | No | +| limit | integer | Number of messages to return (1-100) | No | + +#### MetadataArgs + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | +| type | string | *Enum:* `"number"`, `"string"`, `"time"` | Yes | + +#### MetadataDetail + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | +| value | | | No | + +#### MetadataFilteringCondition + +Metadata Filtering Condition. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conditions | [ [Condition](#condition) ] | | No | +| logical_operator | string | *Enum:* `"and"`, `"or"` | No | + +#### MetadataOperationData + +Metadata operation data + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| operation_data | [ [DocumentMetadataOperation](#documentmetadataoperation) ] | | Yes | + +#### MetadataUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### PipelineRunApiEntity + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| datasource_info_list | [ object ] | | Yes | +| datasource_type | string | | Yes | +| inputs | object | | Yes | +| is_published | boolean | | Yes | +| response_mode | string | | Yes | +| start_node_id | string | | Yes | + +#### PreProcessingRule + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| enabled | boolean | | Yes | +| id | string | | Yes | + +#### ProcessRule + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| mode | string | *Enum:* `"automatic"`, `"custom"`, `"hierarchical"` | Yes | +| rules | [Rule](#rule) | | No | + +#### RerankingModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| reranking_model_name | string | | No | +| reranking_provider_name | string | | No | + +#### RetrievalMethod + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| RetrievalMethod | string | | | + +#### RetrievalModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | | No | +| reranking_enable | boolean | | Yes | +| reranking_mode | string | | No | +| reranking_model | [RerankingModel](#rerankingmodel) | | No | +| score_threshold | number | | No | +| score_threshold_enabled | boolean | | Yes | +| search_method | [RetrievalMethod](#retrievalmethod) | | Yes | +| top_k | integer | | Yes | +| weights | [WeightModel](#weightmodel) | | No | + +#### Rule + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| parent_mode | string | *Enum:* `"full-doc"`, `"paragraph"` | No | +| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | | No | +| segmentation | [Segmentation](#segmentation) | | No | +| subchunk_segmentation | [Segmentation](#segmentation) | | No | + +#### SegmentCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| segments | [ object ] | | No | + +#### SegmentListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword | string | | No | +| status | [ string ] | | No | + +#### SegmentUpdateArgs + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | No | +| attachment_ids | [ string ] | | No | +| content | string | | No | +| enabled | boolean | | No | +| keywords | [ string ] | | No | +| regenerate_child_chunks | boolean | | No | +| summary | string | | No | + +#### SegmentUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| segment | [SegmentUpdateArgs](#segmentupdateargs) | | Yes | + +#### Segmentation + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| chunk_overlap | integer | | No | +| max_tokens | integer | | Yes | +| separator | string | | No | + +#### SimpleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + +#### SimpleEndUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| is_anonymous | boolean | | Yes | +| session_id | string | | No | +| type | string | | Yes | + +#### TagBindingPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tag_ids | [ string ] | | Yes | +| target_id | string | | Yes | + +#### TagCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### TagDeletePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tag_id | string | | Yes | + +#### TagUnbindingPayload + +Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| tag_id | string | | No | +| tag_ids | [ string ] | | No | +| target_id | string | | Yes | + +#### TagUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | +| tag_id | string | | Yes | + +#### TextToAudioPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID | No | +| streaming | boolean | Enable streaming response | No | +| text | string | Text to convert to audio | No | +| voice | string | Voice to use for TTS | No | + +#### WeightKeywordSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_weight | number | | Yes | + +#### WeightModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | | No | +| vector_setting | [WeightVectorSetting](#weightvectorsetting) | | No | +| weight_type | string | *Enum:* `"customized"`, `"keyword_first"`, `"semantic_first"` | No | + +#### WeightVectorSetting + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| embedding_model_name | string | | Yes | +| embedding_provider_name | string | | Yes | +| vector_weight | number | | Yes | + +#### WorkflowAppLogPaginationResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [WorkflowAppLogPartialResponse](#workflowapplogpartialresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### WorkflowAppLogPartialResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by_account | [SimpleAccount](#simpleaccount) | | No | +| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No | +| created_by_role | string | | No | +| created_from | string | | No | +| details | | | No | +| id | string | | Yes | +| workflow_run | [WorkflowRunForLogResponse](#workflowrunforlogresponse) | | No | + +#### WorkflowLogQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at__after | string | | No | +| created_at__before | string | | No | +| created_by_account | string | | No | +| created_by_end_user_session_id | string | | No | +| keyword | string | | No | +| limit | integer | | No | +| page | integer | | No | +| status | string | *Enum:* `"failed"`, `"stopped"`, `"succeeded"` | No | + +#### WorkflowRunForLogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| elapsed_time | | | No | +| error | string | | No | +| exceptions_count | integer | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| status | string | | No | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| triggered_from | string | | No | +| version | string | | No | + +#### WorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | +| response_mode | string | *Enum:* `"blocking"`, `"streaming"` | No | + +#### WorkflowRunResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| elapsed_time | | | No | +| error | string | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| inputs | | | No | +| outputs | object | | No | +| status | string | | Yes | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| workflow_id | string | | Yes | diff --git a/api/openapi/markdown/web-swagger.md b/api/openapi/markdown/web-swagger.md new file mode 100644 index 0000000000..f8745d3135 --- /dev/null +++ b/api/openapi/markdown/web-swagger.md @@ -0,0 +1,1224 @@ +# Web API +Public APIs for web applications including file uploads, chat interactions, and app management + +## Version: 1.0 + +### Security +**Bearer** + +| apiKey | *API Key* | +| ------ | --------- | +| Description | Type: Bearer {your-api-key} | +| In | header | +| Name | Authorization | + +--- +## web +Web application API operations + +### /audio-to-text + +#### POST +##### Summary + +Convert audio to text + +##### Description + +Convert audio file to text using speech-to-text service. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 413 | Audio file too large | +| 415 | Unsupported audio type | +| 500 | Internal Server Error | + +### /chat-messages + +#### POST +##### Description + +Create a chat message for conversational applications. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ChatMessagePayload](#chatmessagepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +### /chat-messages/{task_id}/stop + +#### POST +##### Description + +Stop a running chat message task. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Task Not Found | +| 500 | Internal Server Error | + +### /completion-messages + +#### POST +##### Description + +Create a completion message for text generation applications. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CompletionMessagePayload](#completionmessagepayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +### /completion-messages/{task_id}/stop + +#### POST +##### Description + +Stop a running completion message task. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Task Not Found | +| 500 | Internal Server Error | + +### /conversations + +#### GET +##### Description + +Retrieve paginated list of conversations for a chat application. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | Last conversation ID for pagination | No | string | +| limit | query | Number of conversations to return (1-100) | No | integer | +| pinned | query | Filter by pinned status | No | string | +| sort_by | query | Sort order | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### /conversations/{c_id} + +#### DELETE +##### Description + +Delete a specific conversation. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Conversation deleted successfully | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Conversation Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### /conversations/{c_id}/name + +#### POST +##### Description + +Rename a specific conversation with a custom name or auto-generate one. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | +| auto_generate | query | Auto-generate conversation name | No | boolean | +| name | query | New conversation name | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Conversation renamed successfully | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Conversation Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### /conversations/{c_id}/pin + +#### PATCH +##### Description + +Pin a specific conversation to keep it at the top of the list. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Conversation pinned successfully | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Conversation Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### /conversations/{c_id}/unpin + +#### PATCH +##### Description + +Unpin a specific conversation to remove it from the top of the list. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation UUID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Conversation unpinned successfully | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Conversation Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### /email-code-login + +#### POST +##### Description + +Send email verification code for login + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EmailCodeLoginSendPayload](#emailcodeloginsendpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Email code sent successfully | +| 400 | Bad request - invalid email format | +| 404 | Account not found | + +### /email-code-login/validity + +#### POST +##### Description + +Verify email code and complete login + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [EmailCodeLoginVerifyPayload](#emailcodeloginverifypayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Email code verified and login successful | +| 400 | Bad request - invalid code or token | +| 401 | Invalid token or expired code | +| 404 | Account not found | + +### /files/upload + +#### POST +##### Summary + +Upload a file for use in web applications + +##### Description + +Upload a file for use in web applications +Accepts file uploads for use within web applications, supporting +multiple file types with automatic validation and storage. + +Args: + app_model: The associated application model + end_user: The end user uploading the file + +Form Parameters: + file: The file to upload (required) + source: Optional source type (datasets or None) + +Returns: + dict: File information including ID, URL, and metadata + int: HTTP status code 201 for success + +Raises: + NoFileUploadedError: No file provided in request + TooManyFilesError: Multiple files provided (only one allowed) + FilenameNotExistsError: File has no filename + FileTooLargeError: File exceeds size limit + UnsupportedFileTypeError: File type not supported + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | [FileResponse](#fileresponse) | +| 400 | Bad request - invalid file or parameters | | +| 413 | File too large | | +| 415 | Unsupported file type | | + +### /forgot-password + +#### POST +##### Description + +Send password reset email + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ForgotPasswordSendPayload](#forgotpasswordsendpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Password reset email sent successfully | +| 400 | Bad request - invalid email format | +| 404 | Account not found | +| 429 | Too many requests - rate limit exceeded | + +### /forgot-password/resets + +#### POST +##### Description + +Reset user password with verification token + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ForgotPasswordResetPayload](#forgotpasswordresetpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Password reset successfully | +| 400 | Bad request - invalid parameters or password mismatch | +| 401 | Invalid or expired token | +| 404 | Account not found | + +### /forgot-password/validity + +#### POST +##### Description + +Verify password reset token validity + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [ForgotPasswordCheckPayload](#forgotpasswordcheckpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Token is valid | +| 400 | Bad request - invalid token format | +| 401 | Invalid or expired token | + +### /form/human_input/{form_token} + +#### GET +##### Summary + +Get human input form definition by token + +##### Description + +GET /api/form/human_input/ + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### POST +##### Summary + +Submit human input form by token + +##### Description + +POST /api/form/human_input/ + +Request body: +{ + "inputs": { + "content": "User input content" + }, + "action": "Approve" +} + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /login + +#### POST +##### Summary + +Authenticate user and login + +##### Description + +Authenticate user for web application access + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [LoginPayload](#loginpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Authentication successful | +| 400 | Bad request - invalid email or password format | +| 401 | Authentication failed - email or password mismatch | +| 403 | Account banned or login disabled | +| 404 | Account not found | + +### /login/status + +#### GET +##### Description + +Check login status + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Login status | +| 401 | Login status | + +### /logout + +#### POST +##### Description + +Logout user from web application + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Logout successful | + +### /messages + +#### GET +##### Description + +Retrieve paginated list of messages from a conversation in a chat application. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| conversation_id | query | Conversation UUID | Yes | string | +| first_id | query | First message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Conversation Not Found or Not a Chat App | +| 500 | Internal Server Error | + +### /messages/{message_id}/feedbacks + +#### POST +##### Description + +Submit feedback (like/dislike) for a specific message. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message UUID | Yes | string | +| content | query | Feedback content | No | string | +| rating | query | Feedback rating | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Feedback submitted successfully | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | + +### /messages/{message_id}/more-like-this + +#### GET +##### Description + +Generate a new completion similar to an existing message (completion apps only). + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | | Yes | string | +| payload | body | | Yes | [MessageMoreLikeThisQuery](#messagemorelikethisquery) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request - Not a completion app or feature disabled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | + +### /messages/{message_id}/suggested-questions + +#### GET +##### Description + +Get suggested follow-up questions after a message (chat apps only). + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message UUID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request - Not a chat app or feature disabled | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found or Conversation Not Found | +| 500 | Internal Server Error | + +### /meta + +#### GET +##### Summary + +Get app meta + +##### Description + +Retrieve the metadata for a specific app. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +### /parameters + +#### GET +##### Summary + +Retrieve app parameters + +##### Description + +Retrieve the parameters for a specific app. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +### /passport + +#### GET +##### Description + +Get authentication passport for web application access + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Passport retrieved successfully | +| 401 | Unauthorized - missing app code or invalid authentication | +| 404 | Application or user not found | + +### /remote-files/upload + +#### POST +##### Summary + +Upload a file from a remote URL + +##### Description + +Upload a file from a remote URL +Downloads a file from the provided remote URL and uploads it +to the platform storage for use in web applications. + +Args: + app_model: The associated application model + end_user: The end user making the request + +JSON Parameters: + url: The remote URL to download the file from (required) + +Returns: + dict: File information including ID, signed URL, and metadata + int: HTTP status code 201 for success + +Raises: + RemoteFileUploadError: Failed to fetch file from remote URL + FileTooLargeError: File exceeds size limit + UnsupportedFileTypeError: File type not supported + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Remote file uploaded successfully | [FileWithSignedUrl](#filewithsignedurl) | +| 400 | Bad request - invalid URL or parameters | | +| 413 | File too large | | +| 415 | Unsupported file type | | +| 500 | Failed to fetch remote file | | + +### /remote-files/{url} + +#### GET +##### Summary + +Get information about a remote file + +##### Description + +Get information about a remote file +Retrieves basic information about a file located at a remote URL, +including content type and content length. + +Args: + app_model: The associated application model + end_user: The end user making the request + url: URL-encoded path to the remote file + +Returns: + dict: Remote file information including type and length + +Raises: + HTTPException: If the remote file cannot be accessed + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| url | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Remote file information retrieved successfully | [RemoteFileInfo](#remotefileinfo) | +| 400 | Bad request - invalid URL | | +| 404 | Remote file not found | | +| 500 | Failed to fetch remote file | | + +### /saved-messages + +#### GET +##### Description + +Retrieve paginated list of saved messages for a completion application. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | Last message ID for pagination | No | string | +| limit | query | Number of messages to return (1-100) | No | integer | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request - Not a completion app | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +#### POST +##### Description + +Save a specific message for later reference. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | query | Message UUID to save | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Message saved successfully | +| 400 | Bad Request - Not a completion app | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | + +### /saved-messages/{message_id} + +#### DELETE +##### Description + +Remove a message from saved messages. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message UUID to delete | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Message removed successfully | +| 400 | Bad Request - Not a completion app | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Message Not Found | +| 500 | Internal Server Error | + +### /site + +#### GET +##### Summary + +Retrieve app site info + +##### Description + +Retrieve app site information and configuration. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +### /system-features + +#### GET +##### Summary + +Get system feature flags and configuration + +##### Description + +Get system feature flags and configuration +Returns the current system feature flags and configuration +that control various functionalities across the platform. + +Returns: + dict: System feature configuration object + +This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py, +except it is intended for use by the web app, instead of the console dashboard. + +NOTE: This endpoint is unauthenticated by design, as it provides system features +data required for webapp initialization. + +Authentication would create circular dependency (can't authenticate without webapp loading). + +Only non-sensitive configuration data should be returned by this endpoint. + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | System features retrieved successfully | +| 500 | Internal server error | + +### /text-to-audio + +#### POST +##### Summary + +Convert text to audio + +##### Description + +Convert text to audio using text-to-speech service. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [TextToAudioPayload](#texttoaudiopayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal Server Error | + +### /webapp/access-mode + +#### GET +##### Description + +Retrieve the access mode for a web application (public or restricted). + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| appCode | query | Application code | No | string | +| appId | query | Application ID | No | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 500 | Internal Server Error | + +### /webapp/permission + +#### GET +##### Description + +Check if user has permission to access a web application. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| appId | query | Application ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 500 | Internal Server Error | + +### /workflows/run + +#### POST +##### Summary + +Run workflow + +##### Description + +Execute a workflow with provided inputs and files. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [WorkflowRunPayload](#workflowrunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | App Not Found | +| 500 | Internal Server Error | + +### /workflows/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop workflow task + +##### Description + +Stop a running workflow task. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID to stop | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 403 | Forbidden | +| 404 | Task Not Found | +| 500 | Internal Server Error | + +--- +## default +Default namespace + +### /workflow/{task_id}/events + +#### GET +##### Summary + +Get workflow execution events stream after resume + +##### Description + +GET /api/workflow//events + +Returns Server-Sent Events stream. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +### Models + +#### AppAccessModeQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| appCode | string | Application code | No | +| appId | string | Application ID | No | + +#### ChatMessagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation ID | No | +| files | [ object ] | Files to be processed | No | +| inputs | object | Input variables for the chat | Yes | +| parent_message_id | string | Parent message ID | No | +| query | string | User query/message | Yes | +| response_mode | string | Response mode: blocking or streaming
*Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | Source of retriever | No | + +#### CompletionMessagePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | Files to be processed | No | +| inputs | object | Input variables for the completion | Yes | +| query | string | Query text for completion | No | +| response_mode | string | Response mode: blocking or streaming
*Enum:* `"blocking"`, `"streaming"` | No | +| retriever_from | string | Source of retriever | No | + +#### ConversationListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer | | No | +| pinned | boolean | | No | +| sort_by | string | *Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | + +#### ConversationRenamePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | boolean | | No | +| name | string | | No | + +#### EmailCodeLoginSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | + +#### EmailCodeLoginVerifyPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### FileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| extension | string | | No | +| file_key | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| original_url | string | | No | +| preview_url | string | | No | +| size | integer | | Yes | +| source_url | string | | No | +| tenant_id | string | | No | +| user_id | string | | No | + +#### FileWithSignedUrl + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| created_by | string | | No | +| extension | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| size | integer | | Yes | +| url | string | | No | + +#### ForgotPasswordCheckPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| code | string | | Yes | +| email | string | | Yes | +| token | string | | Yes | + +#### ForgotPasswordResetPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| new_password | string | | Yes | +| password_confirm | string | | Yes | +| token | string | | Yes | + +#### ForgotPasswordSendPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| language | string | | No | + +#### LoginPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| password | string | | Yes | + +#### MessageFeedbackPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | | No | +| rating | string | *Enum:* `"dislike"`, `"like"` | No | + +#### MessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | Conversation UUID | Yes | +| first_id | string | First message ID for pagination | No | +| limit | integer | Number of messages to return (1-100) | No | + +#### MessageMoreLikeThisQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| response_mode | string | Response mode
*Enum:* `"blocking"`, `"streaming"` | Yes | + +#### RemoteFileInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| file_length | integer | | Yes | +| file_type | string | | Yes | + +#### RemoteFileUploadPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| url | string (uri) | Remote file URL | Yes | + +#### SavedMessageCreatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | | Yes | + +#### SavedMessageListQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| last_id | string | | No | +| limit | integer | | No | + +#### TextToAudioPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID | No | +| streaming | boolean | Enable streaming response | No | +| text | string | Text to convert to audio | No | +| voice | string | Voice to use for TTS | No | + +#### WorkflowRunPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py index 1b97746dea..0900dfda97 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from typing import Any, cast from unittest.mock import MagicMock +import pytest from dify_trace_aliyun.entities.semconv import ( GEN_AI_FRAMEWORK, GEN_AI_SESSION_ID, @@ -31,7 +32,7 @@ from graphon.enums import WorkflowNodeExecutionStatus from models import EndUser -def test_get_user_id_from_message_data_no_end_user(monkeypatch): +def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.from_account_id = "account_id" message_data.from_end_user_id = None @@ -39,7 +40,7 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch): assert get_user_id_from_message_data(message_data) == "account_id" -def test_get_user_id_from_message_data_with_end_user(monkeypatch): +def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.from_account_id = "account_id" message_data.from_end_user_id = "end_user_id" @@ -57,7 +58,7 @@ def test_get_user_id_from_message_data_with_end_user(monkeypatch): assert get_user_id_from_message_data(message_data) == "session_id" -def test_get_user_id_from_message_data_end_user_not_found(monkeypatch): +def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.from_account_id = "account_id" message_data.from_end_user_id = "end_user_id" @@ -111,7 +112,7 @@ def test_get_workflow_node_status(): assert status.status_code == StatusCode.UNSET -def test_create_links_from_trace_id(monkeypatch): +def test_create_links_from_trace_id(monkeypatch: pytest.MonkeyPatch): # Mock create_link mock_link = MagicMock(spec=Link) import dify_trace_aliyun.data_exporter.traceclient diff --git a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py index 96df49ed0e..9b35612b52 100644 --- a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py @@ -1,9 +1,11 @@ import json import logging import os +import re import traceback +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any, Union, cast +from typing import Any, Protocol, Union, cast from urllib.parse import urlparse from openinference.semconv.trace import ( @@ -13,13 +15,14 @@ from openinference.semconv.trace import ( SpanAttributes, ToolCallAttributes, ) +from opentelemetry.context import Context from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcOTLPSpanExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpOTLPSpanExporter from opentelemetry.sdk import trace as trace_sdk from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.semconv.attributes import exception_attributes -from opentelemetry.trace import Span, Status, StatusCode, set_span_in_context, use_span +from opentelemetry.trace import Span, Status, StatusCode, get_current_span, set_span_in_context, use_span from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from opentelemetry.util.types import AttributeValue from sqlalchemy.orm import sessionmaker @@ -36,16 +39,148 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) +from core.ops.exceptions import PendingTraceParentContextError from core.ops.utils import JSON_DICT_ADAPTER from core.repositories import DifyCoreRepositoryFactory from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig from extensions.ext_database import db +from extensions.ext_redis import redis_client from graphon.enums import WorkflowNodeExecutionStatus -from models.model import EndUser, MessageFile -from models.workflow import WorkflowNodeExecutionTriggeredFrom +from models.model import App, EndUser, MessageFile +from models.workflow import WorkflowNodeExecutionTriggeredFrom, WorkflowRun logger = logging.getLogger(__name__) +# This parent-span carrier store is intentionally Phoenix-local for the current +# nested workflow tracing feature. If other trace providers need the same +# cross-task parent restoration behavior, move the storage and retry signaling +# behind a core trace coordination interface instead of duplicating it here. +_PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS = 300 +_TRACEPARENT_PATTERN = re.compile( + r"^(?P[0-9a-f]{2})-(?P[0-9a-f]{32})-(?P[0-9a-f]{16})-(?P[0-9a-f]{2})$" +) + + +def _phoenix_parent_span_redis_key(parent_node_execution_id: str) -> str: + """Build the Redis key that stores a restorable Phoenix parent span carrier.""" + return f"trace:phoenix:parent_span:{parent_node_execution_id}" + + +def _publish_parent_span_context(parent_node_execution_id: str, carrier: Mapping[str, str]) -> None: + """Persist a tracecontext carrier so nested workflow spans can restore the tool span parent.""" + redis_client.setex( + _phoenix_parent_span_redis_key(parent_node_execution_id), + _PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS, + safe_json_dumps(dict(carrier)), + ) + + +def _resolve_published_parent_span_context(parent_node_execution_id: str) -> dict[str, str]: + """Load a previously published tool-span carrier for nested workflow parenting.""" + raw_carrier = redis_client.get(_phoenix_parent_span_redis_key(parent_node_execution_id)) + if raw_carrier is None: + raise PendingTraceParentContextError(parent_node_execution_id) + + if isinstance(raw_carrier, bytes): + raw_carrier = raw_carrier.decode("utf-8") + + carrier = json.loads(raw_carrier) + if not isinstance(carrier, dict): + raise ValueError( + "Phoenix parent span context must be stored as a JSON object: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + normalized_carrier = {str(key): str(value) for key, value in carrier.items()} + if not normalized_carrier: + raise ValueError( + f"Phoenix parent span context payload is empty: parent_node_execution_id={parent_node_execution_id}" + ) + + traceparent = normalized_carrier.get("traceparent") + if not isinstance(traceparent, str): + raise ValueError( + "Phoenix parent span context payload is missing traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + traceparent_match = _TRACEPARENT_PATTERN.fullmatch(traceparent) + if traceparent_match is None: + raise ValueError( + "Phoenix parent span context payload has invalid traceparent format: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("version") == "ff": + raise ValueError( + "Phoenix parent span context payload has unsupported traceparent version: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("trace_id") == "0" * 32: + raise ValueError( + "Phoenix parent span context payload has zero trace_id in traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("span_id") == "0" * 16: + raise ValueError( + "Phoenix parent span context payload has zero span_id in traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + extracted_context = TraceContextTextMapPropagator().extract(carrier=normalized_carrier) + extracted_span_context = get_current_span(extracted_context).get_span_context() + if not extracted_span_context.is_valid or not extracted_span_context.is_remote: + raise ValueError( + "Phoenix parent span context payload could not be restored into a valid parent span: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + return normalized_carrier + + +def _app_uses_phoenix_provider(app_tracing_config: Mapping[str, Any] | None) -> bool: + if not app_tracing_config or not app_tracing_config.get("enabled"): + return False + return app_tracing_config.get("tracing_provider") in {"arize", "phoenix"} + + +def _parent_workflow_can_publish_span_context(parent_workflow_run_id: str) -> bool: + parent_run = db.session.query(WorkflowRun).where(WorkflowRun.id == parent_workflow_run_id).first() + if parent_run is None: + return True + + parent_app = db.session.query(App).where(App.id == parent_run.app_id).first() + if parent_app is None or not parent_app.tracing: + return False + + try: + app_tracing_config = json.loads(parent_app.tracing) + except (TypeError, json.JSONDecodeError): + return False + if not isinstance(app_tracing_config, Mapping): + return False + + return _app_uses_phoenix_provider(app_tracing_config) + + +def _resolve_workflow_parent_carrier( + parent_node_execution_id: str, parent_workflow_run_id: str | None +) -> dict[str, str] | None: + try: + return _resolve_published_parent_span_context(parent_node_execution_id) + except PendingTraceParentContextError: + if parent_workflow_run_id and not _parent_workflow_can_publish_span_context(parent_workflow_run_id): + logger.info( + "[Arize/Phoenix] Parent workflow cannot publish Phoenix span context; falling back to root span: " + "parent_workflow_run_id=%s parent_node_execution_id=%s", + parent_workflow_run_id, + parent_node_execution_id, + ) + return None + raise + def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]: """Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix.""" @@ -177,6 +312,246 @@ def _get_node_span_kind(node_type: str) -> OpenInferenceSpanKindValues: return _NODE_TYPE_TO_SPAN_KIND.get(node_type, OpenInferenceSpanKindValues.CHAIN) +def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str: + """Resolve the workflow session ID for Phoenix workflow spans.""" + if trace_info.conversation_id: + return trace_info.conversation_id + + parent_workflow_run_id, _ = _resolve_workflow_parent_context(trace_info) + if parent_workflow_run_id: + return parent_workflow_run_id + + return trace_info.workflow_run_id + + +def _resolve_workflow_parent_context(trace_info: BaseTraceInfo) -> tuple[str | None, str | None]: + """Expose the typed parent context already resolved on the trace info.""" + return trace_info.resolved_parent_context + + +def _resolve_workflow_root_trace_id(trace_info: WorkflowTraceInfo) -> str: + """Resolve the canonical root trace ID for Phoenix workflow spans.""" + trace_correlation_override, _ = _resolve_workflow_parent_context(trace_info) + return trace_correlation_override or trace_info.resolved_trace_id or trace_info.workflow_run_id + + +class _NodeExecutionIdentityLike(Protocol): + @property + def node_execution_id(self) -> str | None: ... + + @property + def node_id(self) -> str: ... + + @property + def predecessor_node_id(self) -> str | None: ... + + +class _NodeExecutionLike(_NodeExecutionIdentityLike, Protocol): + @property + def id(self) -> str: ... + + @property + def node_type(self) -> str: ... + + @property + def title(self) -> str | None: ... + + @property + def inputs(self) -> Mapping[str, Any] | None: ... + + @property + def process_data(self) -> Mapping[str, Any] | None: ... + + @property + def outputs(self) -> Mapping[str, Any] | None: ... + + @property + def status(self) -> WorkflowNodeExecutionStatus: ... + + @property + def error(self) -> str | None: ... + + @property + def elapsed_time(self) -> float | None: ... + + @property + def metadata(self) -> Mapping[Any, Any] | None: ... + + @property + def created_at(self) -> datetime | None: ... + + +_PHOENIX_STRUCTURED_NODE_TYPES = frozenset({"start", "end", "loop", "iteration"}) + + +def _resolve_workflow_span_name(trace_info: WorkflowTraceInfo) -> str: + """Resolve the Phoenix workflow span display name.""" + workflow_run_id = trace_info.workflow_run_id.strip() if trace_info.workflow_run_id else "" + if workflow_run_id: + return f"{TraceTaskName.WORKFLOW_TRACE.value}_{workflow_run_id}" + return TraceTaskName.WORKFLOW_TRACE.value + + +def _build_node_title_by_id(trace_info: WorkflowTraceInfo) -> dict[str, str]: + """Build an authoritative node-title index from the persisted workflow graph.""" + workflow_data = trace_info.workflow_data + workflow_graph = getattr(workflow_data, "graph_dict", None) + if not isinstance(workflow_graph, Mapping): + workflow_graph = workflow_data.get("graph") if isinstance(workflow_data, Mapping) else None + if not isinstance(workflow_graph, Mapping): + return {} + + graph_nodes = workflow_graph.get("nodes") + if not isinstance(graph_nodes, Sequence): + return {} + + node_title_by_id: dict[str, str] = {} + for graph_node in graph_nodes: + if not isinstance(graph_node, Mapping): + continue + node_id = graph_node.get("id") + node_data = graph_node.get("data") + if not isinstance(node_id, str) or not isinstance(node_data, Mapping): + continue + node_title = node_data.get("title") + if isinstance(node_title, str) and node_title.strip(): + node_title_by_id[node_id] = node_title.strip() + + return node_title_by_id + + +def _resolve_workflow_node_span_name( + node_execution: _NodeExecutionLike, + node_title_by_id: Mapping[str, str] | None = None, +) -> str: + """Resolve the Phoenix workflow node span display name.""" + node_type = str(node_execution.node_type or "") + graph_node_title = None + if node_title_by_id is not None and isinstance(node_execution.node_id, str): + graph_node_title = node_title_by_id.get(node_execution.node_id) + + node_title = graph_node_title or (node_execution.title.strip() if isinstance(node_execution.title, str) else "") + if node_title: + return f"{node_type}_{node_title}" + return node_type + + +def _get_node_execution_id(node_execution: _NodeExecutionIdentityLike) -> str: + """Return the stable execution identifier for a workflow node execution.""" + return str(getattr(node_execution, "id", None) or node_execution.node_execution_id) + + +def _build_execution_id_by_node_id(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]: + """Index unique workflow graph node ids by execution id. + + This Phoenix-local hierarchy reconstruction intentionally drops ambiguous + node ids instead of guessing based on repository order. That keeps parent + selection deterministic until upstream tracing exposes explicit parent span + data for repeated executions. + """ + execution_id_by_node_id: dict[str, str] = {} + ambiguous_node_ids: set[str] = set() + + for node_execution in node_executions: + node_id = node_execution.node_id + if not isinstance(node_id, str): + continue + execution_id = _get_node_execution_id(node_execution) + + if node_id in ambiguous_node_ids: + continue + + existing_execution_id = execution_id_by_node_id.get(node_id) + if existing_execution_id is None: + execution_id_by_node_id[node_id] = execution_id + continue + + if existing_execution_id != execution_id: + ambiguous_node_ids.add(node_id) + execution_id_by_node_id.pop(node_id, None) + + return execution_id_by_node_id + + +def _build_graph_parent_index(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]: + """Build an execution-id parent index from predecessor node ids.""" + execution_id_by_node_id = _build_execution_id_by_node_id(node_executions) + graph_parent_index: dict[str, str] = {} + + for node_execution in node_executions: + predecessor_node_id = node_execution.predecessor_node_id + if not isinstance(predecessor_node_id, str): + continue + + predecessor_execution_id = execution_id_by_node_id.get(predecessor_node_id) + if predecessor_execution_id is not None: + execution_id = _get_node_execution_id(node_execution) + graph_parent_index[execution_id] = predecessor_execution_id + + return graph_parent_index + + +def _resolve_structured_parent_execution_id( + node_execution: object, execution_id_by_node_id: Mapping[str, str] +) -> str | None: + """Resolve Phoenix-local structured parents from loop/iteration node ids. + + Any execution carrying ``iteration_id`` or ``loop_id`` belongs to an + enclosing structured node. When predecessor node ids are ambiguous because + the graph node repeats inside that structure, Phoenix can still keep the + child span under the enclosing loop/iteration span without relying on + execution-order heuristics. + """ + execution_metadata = getattr(node_execution, "execution_metadata_dict", None) + if not isinstance(execution_metadata, Mapping): + execution_metadata = getattr(node_execution, "metadata", None) + if not isinstance(execution_metadata, Mapping): + execution_metadata = {} + + for enclosing_node_id in ( + getattr(node_execution, "iteration_id", None), + getattr(node_execution, "loop_id", None), + execution_metadata.get("iteration_id"), + execution_metadata.get("loop_id"), + ): + if not isinstance(enclosing_node_id, str): + continue + + enclosing_execution_id = execution_id_by_node_id.get(enclosing_node_id) + if enclosing_execution_id is not None: + return enclosing_execution_id + + return None + + +def _resolve_node_parent( + execution_id: str, + predecessor_execution_id: str | None, + structured_parent_execution_id: str | None, + span_by_execution_id: Mapping[str, Span], + graph_parent_index: Mapping[str, str], + workflow_span: Span, +) -> Span: + """Resolve the parent span for a workflow node execution.""" + if predecessor_execution_id is not None: + predecessor_span = span_by_execution_id.get(predecessor_execution_id) + if predecessor_span is not None: + return predecessor_span + + graph_parent_execution_id = graph_parent_index.get(execution_id) + if graph_parent_execution_id is not None: + graph_parent_span = span_by_execution_id.get(graph_parent_execution_id) + if graph_parent_span is not None: + return graph_parent_span + + if structured_parent_execution_id is not None: + structured_parent_span = span_by_execution_id.get(structured_parent_execution_id) + if structured_parent_span is not None: + return structured_parent_span + + return workflow_span + + class ArizePhoenixDataTrace(BaseTraceInstance): def __init__( self, @@ -189,6 +564,8 @@ class ArizePhoenixDataTrace(BaseTraceInstance): self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") self.propagator = TraceContextTextMapPropagator() self.dify_trace_ids: set[str] = set() + self.root_span_carriers: dict[str, dict[str, str]] = {} + self.carrier: dict[str, str] = {} def trace(self, trace_info: BaseTraceInfo): logger.info("[Arize/Phoenix] Trace Entity Info: %s", trace_info) @@ -235,13 +612,43 @@ class ArizePhoenixDataTrace(BaseTraceInstance): file_list=safe_json_dumps(file_list), query=trace_info.query or "", ) + workflow_session_id = _resolve_workflow_session_id(trace_info) + parent_workflow_run_id, parent_node_execution_id = _resolve_workflow_parent_context(trace_info) + logger.info( + "[Arize/Phoenix] Workflow session resolution: workflow_run_id=%s conversation_id=%s " + "parent_workflow_run_id=%s parent_node_execution_id=%s resolved_session_id=%s", + trace_info.workflow_run_id, + trace_info.conversation_id, + parent_workflow_run_id, + parent_node_execution_id, + workflow_session_id, + ) - dify_trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id - self.ensure_root_span(dify_trace_id) - root_span_context = self.propagator.extract(carrier=self.carrier) + workflow_parent_carrier: dict[str, str] | None = None + if parent_node_execution_id: + workflow_parent_carrier = _resolve_workflow_parent_carrier(parent_node_execution_id, parent_workflow_run_id) + + if workflow_parent_carrier is None: + root_trace_id = _resolve_workflow_root_trace_id(trace_info) + workflow_root_span_name: str | None = trace_info.workflow_run_id + if not isinstance(workflow_root_span_name, str) or not workflow_root_span_name.strip(): + workflow_root_span_name = None + + workflow_parent_carrier = self.ensure_root_span( + root_trace_id, + root_span_name=workflow_root_span_name, + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, + }, + ) + + workflow_span_context = self.propagator.extract(carrier=workflow_parent_carrier) workflow_span = self.tracer.start_span( - name=TraceTaskName.WORKFLOW_TRACE.value, + name=_resolve_workflow_span_name(trace_info), attributes={ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs), @@ -249,10 +656,10 @@ class ArizePhoenixDataTrace(BaseTraceInstance): SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs), SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, SpanAttributes.METADATA: safe_json_dumps(metadata), - SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + SpanAttributes.SESSION_ID: workflow_session_id or "", }, start_time=datetime_to_nanos(trace_info.start_time), - context=root_span_context, + context=workflow_span_context, ) # Through workflow_run_id, get all_nodes_execution using repository @@ -276,16 +683,50 @@ class ArizePhoenixDataTrace(BaseTraceInstance): workflow_node_executions = workflow_node_execution_repository.get_by_workflow_execution( workflow_execution_id=trace_info.workflow_run_id ) + node_title_by_id = _build_node_title_by_id(trace_info) + execution_id_by_node_id = _build_execution_id_by_node_id(workflow_node_executions) + graph_parent_index = _build_graph_parent_index(workflow_node_executions) + node_execution_by_execution_id = { + _get_node_execution_id(node_execution): node_execution for node_execution in workflow_node_executions + } + span_by_execution_id: dict[str, Span] = {} + emitting_execution_ids: set[str] = set() + workflow_span_error: Exception | str | None = trace_info.error try: - for node_execution in workflow_node_executions: + + def emit_node_span(node_execution: _NodeExecutionLike) -> Span: + execution_id = _get_node_execution_id(node_execution) + existing_span = span_by_execution_id.get(execution_id) + if existing_span is not None: + return existing_span + + graph_parent_execution_id = graph_parent_index.get(execution_id) + structured_parent_execution_id = _resolve_structured_parent_execution_id( + node_execution, execution_id_by_node_id + ) + + if execution_id not in emitting_execution_ids: + emitting_execution_ids.add(execution_id) + try: + for parent_execution_id in (graph_parent_execution_id, structured_parent_execution_id): + if parent_execution_id is None or parent_execution_id == execution_id: + continue + if parent_execution_id in span_by_execution_id: + continue + parent_node_execution = node_execution_by_execution_id.get(parent_execution_id) + if parent_node_execution is not None: + emit_node_span(parent_node_execution) + finally: + emitting_execution_ids.discard(execution_id) + tenant_id = trace_info.tenant_id # Use from trace_info instead app_id = trace_info.metadata.get("app_id") # Use from trace_info instead inputs_value = node_execution.inputs or {} outputs_value = node_execution.outputs or {} created_at = node_execution.created_at or datetime.now() - elapsed_time = node_execution.elapsed_time + elapsed_time = node_execution.elapsed_time or 0 finished_at = created_at + timedelta(seconds=elapsed_time) process_data = node_execution.process_data or {} @@ -324,9 +765,17 @@ class ArizePhoenixDataTrace(BaseTraceInstance): node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0) node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0) - workflow_span_context = set_span_in_context(workflow_span) + parent_span = _resolve_node_parent( + execution_id=execution_id, + predecessor_execution_id=None, + structured_parent_execution_id=structured_parent_execution_id, + span_by_execution_id=span_by_execution_id, + graph_parent_index=graph_parent_index, + workflow_span=workflow_span, + ) + workflow_span_context = set_span_in_context(parent_span) node_span = self.tracer.start_span( - name=node_execution.node_type, + name=_resolve_workflow_node_span_name(node_execution, node_title_by_id), attributes={ SpanAttributes.OPENINFERENCE_SPAN_KIND: span_kind.value, SpanAttributes.INPUT_VALUE: safe_json_dumps(inputs_value), @@ -334,13 +783,20 @@ class ArizePhoenixDataTrace(BaseTraceInstance): SpanAttributes.OUTPUT_VALUE: safe_json_dumps(outputs_value), SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, SpanAttributes.METADATA: safe_json_dumps(node_metadata), - SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + SpanAttributes.SESSION_ID: workflow_session_id or "", }, start_time=datetime_to_nanos(created_at), context=workflow_span_context, ) - + span_by_execution_id[execution_id] = node_span + node_span_error: Exception | str | None = None try: + if node_execution.node_type == "tool": + parent_span_carrier: dict[str, str] = {} + with use_span(node_span, end_on_exit=False): + self.propagator.inject(carrier=parent_span_carrier) + _publish_parent_span_context(execution_id, parent_span_carrier) + if node_execution.node_type == "llm": llm_attributes: dict[str, Any] = { SpanAttributes.INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), @@ -362,17 +818,26 @@ class ArizePhoenixDataTrace(BaseTraceInstance): ) llm_attributes.update(self._construct_llm_attributes(process_data.get("prompts", []))) node_span.set_attributes(llm_attributes) + except Exception as e: + node_span_error = e + raise finally: - if node_execution.status == WorkflowNodeExecutionStatus.FAILED: + if node_span_error is not None: + set_span_status(node_span, node_span_error) + elif node_execution.status == WorkflowNodeExecutionStatus.FAILED: set_span_status(node_span, node_execution.error) else: set_span_status(node_span) node_span.end(end_time=datetime_to_nanos(finished_at)) + return node_span + + for node_execution in workflow_node_executions: + emit_node_span(node_execution) + except Exception as e: + workflow_span_error = e + raise finally: - if trace_info.error: - set_span_status(workflow_span, trace_info.error) - else: - set_span_status(workflow_span) + set_span_status(workflow_span, workflow_span_error) workflow_span.end(end_time=datetime_to_nanos(trace_info.end_time)) def message_trace(self, trace_info: MessageTraceInfo): @@ -735,22 +1200,39 @@ class ArizePhoenixDataTrace(BaseTraceInstance): finally: span.end(end_time=datetime_to_nanos(trace_info.end_time)) - def ensure_root_span(self, dify_trace_id: str | None): + def ensure_root_span( + self, + dify_trace_id: str | None, + *, + root_span_name: str | None = None, + root_span_attributes: Mapping[str, AttributeValue] | None = None, + ): """Ensure a unique root span exists for the given Dify trace ID.""" - if str(dify_trace_id) not in self.dify_trace_ids: - self.carrier: dict[str, str] = {} + trace_key = str(dify_trace_id) + if trace_key not in self.dify_trace_ids: + carrier: dict[str, str] = {} - root_span = self.tracer.start_span(name="Dify") - root_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKindValues.CHAIN.value) - root_span.set_attribute("dify_project_name", str(self.project)) - root_span.set_attribute("dify_trace_id", str(dify_trace_id)) + span_name = root_span_name.strip() if isinstance(root_span_name, str) and root_span_name.strip() else "Dify" + root_span_attributes_dict: dict[str, AttributeValue] = { + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + "dify_project_name": str(self.project), + "dify_trace_id": trace_key, + } + if root_span_attributes: + root_span_attributes_dict.update(root_span_attributes) + + root_span = self.tracer.start_span(name=span_name, attributes=root_span_attributes_dict, context=Context()) with use_span(root_span, end_on_exit=False): - self.propagator.inject(carrier=self.carrier) + self.propagator.inject(carrier=carrier) set_span_status(root_span) root_span.end() - self.dify_trace_ids.add(str(dify_trace_id)) + self.dify_trace_ids.add(trace_key) + self.root_span_carriers[trace_key] = carrier + + self.carrier = self.root_span_carriers[trace_key] + return self.carrier def api_check(self): try: diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py index e9ecc2e083..9b244e3008 100644 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py @@ -1,10 +1,25 @@ +import json +from collections.abc import Sequence from datetime import UTC, datetime, timedelta -from typing import cast +from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock, patch +import dify_trace_arize_phoenix.arize_phoenix_trace as arize_phoenix_trace_module import pytest from dify_trace_arize_phoenix.arize_phoenix_trace import ( + _NODE_TYPE_TO_SPAN_KIND, ArizePhoenixDataTrace, + _app_uses_phoenix_provider, + _build_graph_parent_index, + _get_node_span_kind, + _parent_workflow_can_publish_span_context, + _phoenix_parent_span_redis_key, + _resolve_node_parent, + _resolve_published_parent_span_context, + _resolve_structured_parent_execution_id, + _resolve_workflow_parent_context, + _resolve_workflow_session_id, datetime_to_nanos, error_to_string, safe_json_dumps, @@ -13,9 +28,14 @@ from dify_trace_arize_phoenix.arize_phoenix_trace import ( wrap_span_metadata, ) from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig -from opentelemetry.sdk.trace import Tracer +from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes +from opentelemetry.context import Context +from opentelemetry.sdk import trace as trace_sdk +from opentelemetry.sdk.trace import ReadableSpan, Tracer +from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes -from opentelemetry.trace import StatusCode +from opentelemetry.trace import NonRecordingSpan, SpanContext, StatusCode, TraceFlags, TraceState, use_span +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, @@ -24,8 +44,12 @@ from core.ops.entities.trace_entity import ( ModerationTraceInfo, SuggestedQuestionTraceInfo, ToolTraceInfo, + TraceTaskName, + WorkflowNodeTraceInfo, WorkflowTraceInfo, ) +from core.ops.exceptions import PendingTraceParentContextError +from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes # --- Helpers --- @@ -73,6 +97,106 @@ def _make_message_info(**kwargs): return MessageTraceInfo(**defaults) +def _get_start_span_call(start_span_mock, *, span_name: str): + for call in start_span_mock.call_args_list: + if call.kwargs.get("name") == span_name: + return call + raise AssertionError(f"Could not find start_span call with name={span_name!r}") + + +class _FakeQuery: + def __init__(self, result): + self._result = result + + def filter(self, *args, **kwargs): + return self + + def where(self, *args, **kwargs): + return self + + def first(self): + return self._result + + +class _CollectingSpanExporter(SpanExporter): + def __init__(self): + self.spans: list[ReadableSpan] = [] + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + self.spans.extend(spans) + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + return None + + +def _make_node_execution(**kwargs): + defaults = { + "node_type": "tool", + "status": "succeeded", + "inputs": {}, + "outputs": {}, + "created_at": _dt(), + "elapsed_time": 1.0, + "process_data": {}, + "metadata": {}, + "title": "Node", + "id": "node-execution-1", + "node_execution_id": "node-execution-1", + "node_id": "node-1", + "predecessor_node_id": None, + "iteration_id": None, + "loop_id": None, + "error": None, + } + defaults.update(kwargs) + node_execution = MagicMock() + for key, value in defaults.items(): + setattr(node_execution, key, value) + return node_execution + + +def _make_workflow_trace_info(**kwargs) -> WorkflowTraceInfo: + defaults = { + "workflow_id": "workflow-1", + "tenant_id": "tenant-1", + "workflow_run_id": "workflow-run-1", + "workflow_run_elapsed_time": 1.0, + "workflow_run_status": "succeeded", + "workflow_run_inputs": {"input": "value"}, + "workflow_run_outputs": {"output": "value"}, + "workflow_run_version": "1.0", + "total_tokens": 10, + "file_list": ["file-1"], + "query": "hello", + "metadata": {"app_id": "app-1"}, + "start_time": _dt(), + "end_time": _dt() + timedelta(seconds=1), + } + defaults.update(kwargs) + return WorkflowTraceInfo(**defaults) + + +def _make_workflow_node_trace_info(**kwargs) -> WorkflowNodeTraceInfo: + defaults = { + "workflow_id": "workflow-1", + "workflow_run_id": "workflow-run-1", + "tenant_id": "tenant-1", + "node_execution_id": "node-execution-1", + "node_id": "node-1", + "node_type": "tool", + "title": "Node 1", + "status": "succeeded", + "elapsed_time": 1.0, + "index": 1, + "metadata": {"app_id": "app-1"}, + "start_time": _dt(), + "end_time": _dt() + timedelta(seconds=1), + } + defaults.update(kwargs) + return WorkflowNodeTraceInfo(**defaults) + + # --- Utility Function Tests --- @@ -143,6 +267,297 @@ def test_wrap_span_metadata(): assert res == {"a": 1, "b": 2, "created_from": "Dify"} +def test_app_uses_phoenix_provider_only_for_enabled_arize_or_phoenix(): + assert _app_uses_phoenix_provider({"enabled": True, "tracing_provider": "phoenix"}) is True + assert _app_uses_phoenix_provider({"enabled": True, "tracing_provider": "arize"}) is True + assert _app_uses_phoenix_provider({"enabled": False, "tracing_provider": "phoenix"}) is False + assert _app_uses_phoenix_provider({"enabled": True, "tracing_provider": "langfuse"}) is False + assert _app_uses_phoenix_provider(None) is False + + +def test_parent_workflow_can_publish_span_context_keeps_unknown_parent_retryable(monkeypatch): + monkeypatch.setattr( + "dify_trace_arize_phoenix.arize_phoenix_trace.db.session.query", + lambda model: _FakeQuery(None), + ) + + assert _parent_workflow_can_publish_span_context("missing-run") is True + + +def test_parent_workflow_can_publish_span_context_checks_parent_app_tracing(monkeypatch): + parent_run = SimpleNamespace(app_id="parent-app") + parent_app = SimpleNamespace(tracing=json.dumps({"enabled": True, "tracing_provider": "phoenix"})) + + def fake_query(model): + if getattr(model, "__tablename__", None) == "workflow_runs": + return _FakeQuery(parent_run) + if getattr(model, "__tablename__", None) == "apps": + return _FakeQuery(parent_app) + raise AssertionError(f"Unexpected model query: {model}") + + monkeypatch.setattr("dify_trace_arize_phoenix.arize_phoenix_trace.db.session.query", fake_query) + + assert _parent_workflow_can_publish_span_context("parent-run") is True + + parent_app.tracing = json.dumps({"enabled": False, "tracing_provider": "phoenix"}) + assert _parent_workflow_can_publish_span_context("parent-run") is False + + parent_app.tracing = json.dumps({"enabled": True, "tracing_provider": "langfuse"}) + assert _parent_workflow_can_publish_span_context("parent-run") is False + + +class TestGetNodeSpanKind: + def test_all_node_types_are_mapped_correctly(self): + special_mappings = { + BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM, + BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER, + BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL, + BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT, + } + + for node_type in BUILT_IN_NODE_TYPES: + expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN) + actual_span_kind = _get_node_span_kind(node_type) + assert actual_span_kind == expected_span_kind, ( + f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected." + ) + + def test_unknown_string_defaults_to_chain(self): + assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN + + def test_stale_dataset_retrieval_not_in_mapping(self): + assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND + + +class TestWorkflowSessionResolution: + def test_prefers_conversation_id(self): + info = _make_workflow_trace_info(conversation_id="conversation-1") + + assert _resolve_workflow_session_id(info) == "conversation-1" + + def test_nested_workflow_keeps_own_conversation_id_when_parent_context_exists(self): + info = _make_workflow_trace_info( + conversation_id="conversation-1", + metadata={ + "app_id": "app-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + + assert _resolve_workflow_session_id(info) == "conversation-1" + + def test_uses_parent_workflow_run_id_for_nested_parent_trace_context(self): + info = _make_workflow_trace_info( + conversation_id=None, + metadata={ + "app_id": "app-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + + assert _resolve_workflow_session_id(info) == "outer-workflow-run-1" + + def test_falls_back_to_workflow_run_id(self): + info = _make_workflow_trace_info(conversation_id=None) + + assert _resolve_workflow_session_id(info) == "workflow-run-1" + + def test_parent_context_helper_delegates_to_resolved_parent_context(self): + info = MagicMock() + info.resolved_parent_context = ("outer-workflow-run-1", "outer-node-execution-1") + + assert _resolve_workflow_parent_context(info) == info.resolved_parent_context + + +class TestPhoenixParentSpanBridgeHelpers: + def test_parent_span_redis_key_is_stable(self): + assert _phoenix_parent_span_redis_key("outer-node-execution-1") == ( + "trace:phoenix:parent_span:outer-node-execution-1" + ) + + def test_pending_parent_exception_exposes_execution_id(self): + error = PendingTraceParentContextError("outer-node-execution-1") + + assert error.parent_node_execution_id == "outer-node-execution-1" + assert "outer-node-execution-1" in str(error) + + def test_resolve_parent_span_context_rejects_payload_without_traceparent(self, monkeypatch): + mock_redis = MagicMock() + mock_redis.get.return_value = '{"tracestate": "vendor=value"}' + monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) + + with pytest.raises(ValueError, match="traceparent"): + _resolve_published_parent_span_context("outer-node-execution-1") + + @pytest.mark.parametrize( + "stored_payload", + [ + '{"traceparent": ""}', + '{"traceparent": "not-a-traceparent"}', + '{"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb"}', + ], + ) + def test_resolve_parent_span_context_rejects_malformed_traceparent(self, monkeypatch, stored_payload): + mock_redis = MagicMock() + mock_redis.get.return_value = stored_payload + monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) + + with pytest.raises(ValueError, match="traceparent"): + _resolve_published_parent_span_context("outer-node-execution-1") + + +class TestWorkflowHierarchyHelpers: + def test_build_graph_parent_index_uses_predecessor_nodes_without_order_heuristics(self): + later_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-3", + node_id="node-3", + predecessor_node_id="node-2", + index=3, + ) + root_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-1", + node_id="node-1", + predecessor_node_id=None, + index=1, + ) + middle_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-2", + node_id="node-2", + predecessor_node_id="node-1", + index=2, + ) + + graph_parent_index = _build_graph_parent_index([later_node, root_node, middle_node]) + + assert graph_parent_index == { + "node-execution-2": "node-execution-1", + "node-execution-3": "node-execution-2", + } + + def test_build_graph_parent_index_drops_ambiguous_parallel_like_predecessors(self): + first_parallel_node = _make_workflow_node_trace_info( + node_execution_id="parallel-node-execution-1", + node_id="parallel-node-1", + predecessor_node_id=None, + index=1, + parallel_id="parallel-1", + ) + second_parallel_node = _make_workflow_node_trace_info( + node_execution_id="parallel-node-execution-2", + node_id="parallel-node-1", + predecessor_node_id=None, + index=2, + parallel_id="parallel-2", + ) + child_node = _make_workflow_node_trace_info( + node_execution_id="child-node-execution-1", + node_id="child-node-1", + predecessor_node_id="parallel-node-1", + index=3, + ) + + graph_parent_index = _build_graph_parent_index([child_node, first_parallel_node, second_parallel_node]) + + assert graph_parent_index == {} + + def test_resolve_node_parent_prefers_predecessor_span(self): + workflow_span = MagicMock(name="workflow-span") + predecessor_span = MagicMock(name="predecessor-span") + graph_parent_span = MagicMock(name="graph-parent-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id="node-execution-1", + structured_parent_execution_id=None, + span_by_execution_id={ + "node-execution-1": predecessor_span, + "node-execution-0": graph_parent_span, + }, + graph_parent_index={ + "node-execution-2": "node-execution-0", + }, + workflow_span=workflow_span, + ) + + assert parent is predecessor_span + + def test_resolve_node_parent_falls_back_to_graph_parent_span(self): + workflow_span = MagicMock(name="workflow-span") + graph_parent_span = MagicMock(name="graph-parent-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id="missing-predecessor", + structured_parent_execution_id=None, + span_by_execution_id={ + "node-execution-0": graph_parent_span, + }, + graph_parent_index={ + "node-execution-2": "node-execution-0", + }, + workflow_span=workflow_span, + ) + + assert parent is graph_parent_span + + def test_resolve_node_parent_falls_back_to_workflow_span(self): + workflow_span = MagicMock(name="workflow-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id=None, + structured_parent_execution_id=None, + span_by_execution_id={}, + graph_parent_index={}, + workflow_span=workflow_span, + ) + + assert parent is workflow_span + + def test_resolve_structured_parent_execution_id_allows_body_nodes_to_use_enclosing_structure(self): + body_node = _make_workflow_node_trace_info( + node_execution_id="body-execution-1", + node_id="body-node-1", + node_type="tool", + loop_id="loop-node-1", + ) + + structured_parent_execution_id = _resolve_structured_parent_execution_id( + body_node, + execution_id_by_node_id={ + "loop-node-1": "loop-execution-1", + }, + ) + + assert structured_parent_execution_id == "loop-execution-1" + + def test_resolve_structured_parent_execution_id_reads_execution_metadata_dict_for_models(self): + body_node = SimpleNamespace( + node_execution_id="body-execution-1", + node_id="body-node-1", + execution_metadata_dict={ + "iteration_id": "iteration-node-1", + "loop_id": "loop-node-1", + }, + ) + + structured_parent_execution_id = _resolve_structured_parent_execution_id( + body_node, + execution_id_by_node_id={ + "iteration-node-1": "iteration-execution-1", + "loop-node-1": "loop-execution-1", + }, + ) + + assert structured_parent_execution_id == "iteration-execution-1" + + @patch("dify_trace_arize_phoenix.arize_phoenix_trace.GrpcOTLPSpanExporter") @patch("dify_trace_arize_phoenix.arize_phoenix_trace.trace_sdk.TracerProvider") def test_setup_tracer_arize(mock_provider, mock_exporter): @@ -173,12 +588,17 @@ def test_setup_tracer_exception(): @pytest.fixture def trace_instance(): - with patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup: + with ( + patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup, + patch("dify_trace_arize_phoenix.arize_phoenix_trace.redis_client", new=MagicMock()) as mock_redis, + ): mock_tracer = MagicMock(spec=Tracer) mock_processor = MagicMock() mock_setup.return_value = (mock_tracer, mock_processor) config = ArizeConfig(endpoint="http://a.com", api_key="k", space_id="s", project="p") - return ArizePhoenixDataTrace(config) + instance = ArizePhoenixDataTrace(config) + cast(Any, instance)._mock_redis_client = mock_redis + yield instance def test_trace_dispatch(trace_instance): @@ -273,23 +693,921 @@ def test_workflow_trace_no_app_id(mock_db, trace_instance): @patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") -def test_message_trace_success(mock_db, trace_instance): +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_canonical_root_context_for_top_level_workflow( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info(message_id="message-1", workflow_run_id="workflow-run-1") + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + root_carrier = {} + root_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=root_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=root_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + info.resolved_trace_id, + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=root_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is root_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_workflow_run_id_for_root_span_and_populates_root_inputs_outputs( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + workflow_run_inputs={"prompt": "hello"}, + workflow_run_outputs={"result": "world"}, + metadata={ + "app_id": "app1", + "app_name": "Workflow Name", + }, + workflow_run_id="workflow-run-xyz", + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + root_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow-run-xyz") + assert root_span_call.kwargs["attributes"][SpanAttributes.INPUT_VALUE] == safe_json_dumps(info.workflow_run_inputs) + assert root_span_call.kwargs["attributes"][SpanAttributes.OUTPUT_VALUE] == safe_json_dumps( + info.workflow_run_outputs + ) + assert root_span_call.kwargs["attributes"][SpanAttributes.INPUT_MIME_TYPE] == "application/json" + assert root_span_call.kwargs["attributes"][SpanAttributes.OUTPUT_MIME_TYPE] == "application/json" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_dify_name_when_workflow_run_id_is_blank( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + metadata={ + "app_id": "app1", + "app_name": "", + }, + workflow_run_id="", + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + root_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="Dify") + assert root_span_call.kwargs["attributes"]["dify_trace_id"] == "" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_reuses_upstream_parent_workflow_context_when_no_parent_node_execution_id_is_available( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_published_parent_node_context_for_nested_workflow( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + stored_carrier = '{"traceparent":"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}' + trace_instance._mock_redis_client.get.return_value = stored_carrier + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + trace_instance._mock_redis_client.get.assert_called_once_with( + _phoenix_parent_span_redis_key("outer-node-execution-1") + ) + mock_ensure_root_span.assert_not_called() + mock_extract.assert_called_once_with( + carrier={"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"} + ) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_raises_pending_parent_error_when_parent_node_context_is_missing( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + trace_instance._mock_redis_client.get.return_value = None + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace._parent_workflow_can_publish_span_context", + return_value=True, + ), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + pytest.raises(PendingTraceParentContextError) as exc_info, + ): + trace_instance.workflow_trace(info) + + assert exc_info.value.parent_node_execution_id == "outer-node-execution-1" + trace_instance._mock_redis_client.get.assert_called_once_with( + _phoenix_parent_span_redis_key("outer-node-execution-1") + ) + mock_ensure_root_span.assert_not_called() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_when_parent_app_tracing_cannot_publish_parent_context( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + trace_instance._mock_redis_client.get.return_value = None + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace._parent_workflow_can_publish_span_context", + return_value=False, + ), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_still_retries_when_parent_app_can_publish_parent_context( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + trace_instance._mock_redis_client.get.return_value = None + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace._parent_workflow_can_publish_span_context", + return_value=True, + ), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + pytest.raises(PendingTraceParentContextError) as exc_info, + ): + trace_instance.workflow_trace(info) + + assert exc_info.value.parent_node_execution_id == "outer-node-execution-1" + mock_ensure_root_span.assert_not_called() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_parent_workflow_run_id_for_workflow_and_nodes_when_nested_context_is_present( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + conversation_id=None, + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + node_execution = MagicMock() + node_execution.node_type = "tool" + node_execution.status = "succeeded" + node_execution.inputs = {"tool_input": "value"} + node_execution.outputs = {"tool_output": "value"} + node_execution.created_at = _dt() + node_execution.elapsed_time = 1.0 + node_execution.process_data = {} + node_execution.metadata = {} + node_execution.title = "Tool node" + node_execution.id = "node-1" + node_execution.error = None + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_r1") + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Tool node") + + assert workflow_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "outer-workflow-run-1" + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "outer-workflow-run-1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_node_type_when_node_title_is_blank( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + title=" ", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool") + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "r1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_prefers_workflow_graph_node_title_over_execution_title( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + workflow_data={ + "graph": { + "nodes": [ + { + "id": "nested-tool-node", + "data": { + "type": "tool", + "title": "nested workflow tool", + }, + } + ] + } + } + ) + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="nested-tool-node", + node_type="tool", + title="2", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_nested workflow tool") + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "r1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_keeps_nested_conversation_session_while_reusing_parent_root_context( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + conversation_id="conversation-1", + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Node") + assert workflow_span_call.kwargs["context"] is parent_context + assert workflow_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-1" + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_publishes_tool_node_parent_span_context_to_redis( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="tool-execution-1", + node_execution_id="tool-execution-1", + node_id="tool-node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + tool_span = MagicMock(name="tool-span") + tool_span._context_label = "tool" + trace_instance.tracer.start_span.side_effect = [workflow_span, tool_span] + + def inject_side_effect(carrier): + carrier["traceparent"] = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch.object(trace_instance.propagator, "inject", side_effect=inject_side_effect) as mock_inject, + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + mock_inject.assert_called_once() + trace_instance._mock_redis_client.setex.assert_called_once_with( + _phoenix_parent_span_redis_key("tool-execution-1"), + 300, + '{"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}', + ) + + +@pytest.mark.parametrize( + ("failing_step", "expected_message"), + [ + ("inject", "inject failed"), + ("publish", "publish failed"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_cleans_up_tool_span_when_parent_context_publish_fails( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + failing_step, + expected_message, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="tool-execution-1", + node_execution_id="tool-execution-1", + node_id="tool-node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + tool_span = MagicMock(name="tool-span") + tool_span._context_label = "tool" + trace_instance.tracer.start_span.side_effect = [workflow_span, tool_span] + + inject_side_effect = None + if failing_step == "inject": + inject_side_effect = RuntimeError(expected_message) + else: + trace_instance._mock_redis_client.setex.side_effect = RuntimeError(expected_message) + + def inject_side_effect(carrier): + carrier["traceparent"] = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch.object(trace_instance.propagator, "inject", side_effect=inject_side_effect), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + pytest.raises(RuntimeError, match=expected_message), + ): + trace_instance.workflow_trace(info) + + tool_span.end.assert_called_once() + workflow_span.end.assert_called_once() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_parents_serial_nodes_to_resolved_predecessor_span( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + second_node = _make_node_execution( + id="node-execution-2", + node_execution_id="node-execution-2", + node_id="node-2", + node_type="llm", + predecessor_node_id="node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + ) + first_node = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [second_node, first_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + first_node_span = MagicMock(name="first-node-span") + first_node_span._context_label = "node-1" + second_node_span = MagicMock(name="second-node-span") + second_node_span._context_label = "node-2" + trace_instance.tracer.start_span.side_effect = [workflow_span, first_node_span, second_node_span] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + first_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Node") + second_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert first_node_call.kwargs["context"] == "context:workflow" + assert second_node_call.kwargs["context"] == "context:node-1" + + +@pytest.mark.parametrize( + ("enclosing_node_type", "structured_field"), + [ + ("loop", "loop_id"), + ("iteration", "iteration_id"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_parents_structured_start_nodes_to_enclosing_structure_span( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + enclosing_node_type, + structured_field, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + enclosing_node = _make_node_execution( + id=f"{enclosing_node_type}-execution-1", + node_execution_id=f"{enclosing_node_type}-execution-1", + node_id=f"{enclosing_node_type}-node-1", + node_type=enclosing_node_type, + ) + structured_kwargs = {structured_field: f"{enclosing_node_type}-node-1"} + start_node = _make_node_execution( + id="start-execution-1", + node_execution_id="start-execution-1", + node_id="start-node-1", + node_type="start", + **structured_kwargs, + ) + repo.get_by_workflow_execution.return_value = [start_node, enclosing_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + enclosing_node_span = MagicMock(name="enclosing-node-span") + enclosing_node_span._context_label = enclosing_node_type + start_node_span = MagicMock(name="start-node-span") + start_node_span._context_label = "start" + trace_instance.tracer.start_span.side_effect = [workflow_span, enclosing_node_span, start_node_span] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + start_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="start_Node") + assert start_node_call.kwargs["context"] == f"context:{enclosing_node_type}" + + +@pytest.mark.parametrize( + ("enclosing_node_type", "structured_field"), + [ + ("loop", "loop_id"), + ("iteration", "iteration_id"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_keeps_duplicate_body_node_children_under_enclosing_structure( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + enclosing_node_type, + structured_field, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + enclosing_node = _make_node_execution( + id=f"{enclosing_node_type}-execution-1", + node_execution_id=f"{enclosing_node_type}-execution-1", + node_id=f"{enclosing_node_type}-node-1", + node_type=enclosing_node_type, + ) + structured_kwargs = {structured_field: f"{enclosing_node_type}-node-1"} + repeated_body_node_1 = _make_node_execution( + id="body-execution-1", + node_execution_id="body-execution-1", + node_id="body-node-1", + node_type="tool", + **structured_kwargs, + ) + repeated_body_node_2 = _make_node_execution( + id="body-execution-2", + node_execution_id="body-execution-2", + node_id="body-node-1", + node_type="tool", + **structured_kwargs, + ) + child_node = _make_node_execution( + id="child-execution-1", + node_execution_id="child-execution-1", + node_id="child-node-1", + node_type="llm", + predecessor_node_id="body-node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + **structured_kwargs, + ) + repo.get_by_workflow_execution.return_value = [ + child_node, + repeated_body_node_1, + repeated_body_node_2, + enclosing_node, + ] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + enclosing_node_span = MagicMock(name="enclosing-node-span") + enclosing_node_span._context_label = enclosing_node_type + child_node_span = MagicMock(name="child-node-span") + child_node_span._context_label = "child" + repeated_body_node_1_span = MagicMock(name="repeated-body-node-1-span") + repeated_body_node_1_span._context_label = "body-1" + repeated_body_node_2_span = MagicMock(name="repeated-body-node-2-span") + repeated_body_node_2_span._context_label = "body-2" + trace_instance.tracer.start_span.side_effect = [ + workflow_span, + enclosing_node_span, + child_node_span, + repeated_body_node_1_span, + repeated_body_node_2_span, + ] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + child_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert child_node_call.kwargs["context"] == f"context:{enclosing_node_type}" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_workflow_span_for_parallel_like_ambiguous_predecessors( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + child_node = _make_node_execution( + id="child-execution-1", + node_execution_id="child-execution-1", + node_id="child-node-1", + node_type="llm", + predecessor_node_id="parallel-node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + ) + first_parallel_node = _make_node_execution( + id="parallel-execution-1", + node_execution_id="parallel-execution-1", + node_id="parallel-node-1", + node_type="tool", + parallel_id="parallel-1", + ) + second_parallel_node = _make_node_execution( + id="parallel-execution-2", + node_execution_id="parallel-execution-2", + node_id="parallel-node-1", + node_type="tool", + parallel_id="parallel-2", + ) + repo.get_by_workflow_execution.return_value = [child_node, first_parallel_node, second_parallel_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + child_node_span = MagicMock(name="child-node-span") + child_node_span._context_label = "child" + first_parallel_node_span = MagicMock(name="first-parallel-node-span") + first_parallel_node_span._context_label = "parallel-1" + second_parallel_node_span = MagicMock(name="second-parallel-node-span") + second_parallel_node_span._context_label = "parallel-2" + trace_instance.tracer.start_span.side_effect = [ + workflow_span, + child_node_span, + first_parallel_node_span, + second_parallel_node_span, + ] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + child_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert child_node_call.kwargs["context"] == "context:workflow" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +def test_message_trace_keeps_conversation_id_as_session(mock_db, trace_instance): mock_db.engine = MagicMock() info = _make_message_info() info.message_data = MagicMock() - info.message_data.from_account_id = "acc1" + info.message_data.conversation_id = "conversation-2" + info.message_data.from_account_id = "acc2" info.message_data.from_end_user_id = None - info.message_data.query = "q" - info.message_data.answer = "a" - info.message_data.status = "s" - info.message_data.model_id = "m" - info.message_data.model_provider = "p" + info.message_data.query = "q2" + info.message_data.answer = "a2" + info.message_data.status = "s2" + info.message_data.model_id = "m2" + info.message_data.model_provider = "p2" info.message_data.message_metadata = "{}" info.message_data.error = None info.error = None + root_span = MagicMock() + message_span = MagicMock() + llm_span = MagicMock() + trace_instance.tracer.start_span.side_effect = [root_span, message_span, llm_span] + trace_instance.message_trace(info) - assert trace_instance.tracer.start_span.call_count >= 1 + + message_span_call = _get_start_span_call( + trace_instance.tracer.start_span, span_name=TraceTaskName.MESSAGE_TRACE.value + ) + assert message_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-2" @patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") @@ -397,3 +1715,63 @@ def test_api_check_success(trace_instance): def test_ensure_root_span_basic(trace_instance): trace_instance.ensure_root_span("tid") assert "tid" in trace_instance.dify_trace_ids + + +def test_ensure_root_span_ignores_unsampled_ambient_otel_parent(): + exporter = _CollectingSpanExporter() + provider = trace_sdk.TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + trace_instance = ArizePhoenixDataTrace.__new__(ArizePhoenixDataTrace) + trace_instance.tracer = cast(Tracer, provider.get_tracer("test-phoenix-root-span")) + trace_instance.propagator = TraceContextTextMapPropagator() + trace_instance.project = "p" + trace_instance.dify_trace_ids = set() + trace_instance.root_span_carriers = {} + trace_instance.carrier = {} + + ambient_span_context = SpanContext( + trace_id=0x11111111111111111111111111111111, + span_id=0x2222222222222222, + is_remote=True, + trace_flags=TraceFlags(0), + trace_state=TraceState(), + ) + + with use_span(NonRecordingSpan(ambient_span_context), end_on_exit=False): + carrier = trace_instance.ensure_root_span("tid") + + assert len(exporter.spans) == 1 + root_span = exporter.spans[0] + root_span_context = root_span.get_span_context() + assert root_span_context is not None + assert root_span.parent is None + assert root_span_context.trace_id != ambient_span_context.trace_id + assert carrier["traceparent"].split("-")[1] == f"{root_span_context.trace_id:032x}" + + +def test_ensure_root_span_uses_custom_name_and_attributes(trace_instance): + root_attributes = { + SpanAttributes.INPUT_VALUE: '{"input":"value"}', + SpanAttributes.OUTPUT_VALUE: '{"output":"value"}', + } + + trace_instance.ensure_root_span("tid", root_span_name="Workflow Name", root_span_attributes=root_attributes) + + trace_instance.tracer.start_span.assert_called_once_with( + name="Workflow Name", + attributes={ + SpanAttributes.OPENINFERENCE_SPAN_KIND: "CHAIN", + "dify_project_name": "p", + "dify_trace_id": "tid", + SpanAttributes.INPUT_VALUE: '{"input":"value"}', + SpanAttributes.OUTPUT_VALUE: '{"output":"value"}', + }, + context=Context(), + ) + + +def test_ensure_root_span_falls_back_to_dify_name_when_custom_name_is_blank(trace_instance): + trace_instance.ensure_root_span("tid", root_span_name=" ") + + trace_instance.tracer.start_span.assert_called_once() + assert trace_instance.tracer.start_span.call_args.kwargs["name"] == "Dify" diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py deleted file mode 100644 index a01c63ae61..0000000000 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py +++ /dev/null @@ -1,36 +0,0 @@ -from dify_trace_arize_phoenix.arize_phoenix_trace import _NODE_TYPE_TO_SPAN_KIND, _get_node_span_kind -from openinference.semconv.trace import OpenInferenceSpanKindValues - -from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes - - -class TestGetNodeSpanKind: - """Tests for _get_node_span_kind helper.""" - - def test_all_node_types_are_mapped_correctly(self): - """Ensure every built-in node type is mapped to the correct span kind.""" - # Mappings for node types that have a specialised span kind. - special_mappings = { - BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM, - BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER, - BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL, - BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT, - } - - # Test that every built-in node type is mapped to the correct span kind. - # Node types not in `special_mappings` should default to CHAIN. - for node_type in BUILT_IN_NODE_TYPES: - expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN) - actual_span_kind = _get_node_span_kind(node_type) - assert actual_span_kind == expected_span_kind, ( - f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected." - ) - - def test_unknown_string_defaults_to_chain(self): - """An unrecognised node type string should still return CHAIN.""" - assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN - - def test_stale_dataset_retrieval_not_in_mapping(self): - """The old 'dataset_retrieval' string was never a valid NodeType value; - make sure it is not present in the mapping dictionary.""" - assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND diff --git a/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py index 68881378a7..9b19b1100c 100644 --- a/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py +++ b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/langfuse_trace.py @@ -13,6 +13,8 @@ from langfuse.api import ( TraceBody, ) from langfuse.api.commons.types.usage import Usage +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider from sqlalchemy.orm import sessionmaker from core.ops.base_trace_instance import BaseTraceInstance @@ -52,13 +54,40 @@ class LangFuseDataTrace(BaseTraceInstance): langfuse_config: LangfuseConfig, ): super().__init__(langfuse_config) + # Isolated TracerProvider prevents the langfuse v3 SDK from attaching its + # SpanProcessor to the global OpenTelemetry TracerProvider, which would + # otherwise siphon every Flask/Celery/SQLAlchemy span in the process into + # this tenant's Langfuse project. See langfuse upgrade guide v2 -> v3. + self._tracer_provider: TracerProvider | None = TracerProvider( + resource=Resource.create({"service.name": "dify-langfuse-app-trace"}), + ) self.langfuse_client = Langfuse( public_key=langfuse_config.public_key, secret_key=langfuse_config.secret_key, host=langfuse_config.host, + tracer_provider=self._tracer_provider, ) self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") + def close(self) -> None: + """Flush and shut down the isolated TracerProvider. + + Called explicitly when the trace instance is evicted from the cache, or + implicitly via ``__del__`` on garbage collection. Idempotent. + """ + provider = getattr(self, "_tracer_provider", None) + if provider is None: + return + try: + provider.shutdown() + except Exception: + logger.debug("Failed to shut down Langfuse TracerProvider", exc_info=True) + finally: + self._tracer_provider = None + + def __del__(self) -> None: + self.close() + @staticmethod def _get_completion_start_time( start_time: datetime | None, time_to_first_token: float | int | None diff --git a/api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py b/api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py index 952f10c34f..0580051f54 100644 --- a/api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py +++ b/api/providers/trace/trace-langfuse/tests/unit_tests/langfuse_trace/test_langfuse_trace.py @@ -40,7 +40,7 @@ def langfuse_config(): @pytest.fixture -def trace_instance(langfuse_config, monkeypatch): +def trace_instance(langfuse_config, monkeypatch: pytest.MonkeyPatch): # Mock Langfuse client to avoid network calls mock_client = MagicMock() monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: mock_client) @@ -49,22 +49,94 @@ def trace_instance(langfuse_config, monkeypatch): return instance -def test_init(langfuse_config, monkeypatch): +def test_init(langfuse_config, monkeypatch: pytest.MonkeyPatch): + from opentelemetry.sdk.trace import TracerProvider + mock_langfuse = MagicMock() monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", mock_langfuse) monkeypatch.setenv("FILES_URL", "http://test.url") instance = LangFuseDataTrace(langfuse_config) - mock_langfuse.assert_called_once_with( - public_key=langfuse_config.public_key, - secret_key=langfuse_config.secret_key, - host=langfuse_config.host, - ) + mock_langfuse.assert_called_once() + kwargs = mock_langfuse.call_args.kwargs + assert kwargs["public_key"] == langfuse_config.public_key + assert kwargs["secret_key"] == langfuse_config.secret_key + assert kwargs["host"] == langfuse_config.host + assert isinstance(kwargs["tracer_provider"], TracerProvider) + assert kwargs["tracer_provider"] is instance._tracer_provider assert instance.file_base_url == "http://test.url" -def test_trace_dispatch(trace_instance, monkeypatch): +def test_init_passes_isolated_tracer_provider_to_langfuse(langfuse_config, monkeypatch: pytest.MonkeyPatch): + """Regression test for langfuse v3 SDK side effect. + + Without an explicit ``tracer_provider=`` kwarg, the Langfuse v3 SDK + attaches a ``LangfuseSpanProcessor`` to the *global* OpenTelemetry + TracerProvider — siphoning every Flask / Celery / SQLAlchemy span in the + process into the tenant's Langfuse project. See langfuse upgrade-path + docs (v2 -> v3) and GitHub discussion #9136. + + The fix is to construct an isolated ``TracerProvider`` and pass it via + ``tracer_provider=`` so the SDK never touches the global one. + """ + from opentelemetry import trace as otel_trace_api + from opentelemetry.sdk.trace import TracerProvider + + captured: dict[str, object] = {} + + def fake_langfuse(**kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", fake_langfuse) + + instance = LangFuseDataTrace(langfuse_config) + + # 1. tracer_provider kwarg must be supplied (drives the no-pollution branch + # in langfuse.LangfuseResourceManager._init_tracer_provider). + assert "tracer_provider" in captured, ( + "Langfuse() must receive an explicit tracer_provider=; without it the " + "v3 SDK attaches its SpanProcessor to the global OTEL TracerProvider." + ) + + passed_provider = captured["tracer_provider"] + assert isinstance(passed_provider, TracerProvider) + assert passed_provider is instance._tracer_provider + + # 2. The instance's provider must not be the global one. + global_provider = otel_trace_api.get_tracer_provider() + assert passed_provider is not global_provider + + +def test_close_shuts_down_tracer_provider(langfuse_config, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock()) + + instance = LangFuseDataTrace(langfuse_config) + provider = instance._tracer_provider + provider_shutdown = MagicMock() + monkeypatch.setattr(provider, "shutdown", provider_shutdown) + + instance.close() + + provider_shutdown.assert_called_once() + assert instance._tracer_provider is None + + +def test_close_is_idempotent(langfuse_config, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock()) + + instance = LangFuseDataTrace(langfuse_config) + provider_shutdown = MagicMock() + monkeypatch.setattr(instance._tracer_provider, "shutdown", provider_shutdown) + + instance.close() + instance.close() + + provider_shutdown.assert_called_once() + + +def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch): methods = [ "workflow_trace", "message_trace", @@ -114,7 +186,7 @@ def test_trace_dispatch(trace_instance, monkeypatch): mocks["generate_name_trace"].assert_called_once_with(info) -def test_workflow_trace_with_message_id(trace_instance, monkeypatch): +def test_workflow_trace_with_message_id(trace_instance, monkeypatch: pytest.MonkeyPatch): # Setup trace info trace_info = WorkflowTraceInfo( workflow_id="wf-1", @@ -218,7 +290,7 @@ def test_workflow_trace_with_message_id(trace_instance, monkeypatch): assert other_span.level == LevelEnum.ERROR -def test_workflow_trace_no_message_id(trace_instance, monkeypatch): +def test_workflow_trace_no_message_id(trace_instance, monkeypatch: pytest.MonkeyPatch): trace_info = WorkflowTraceInfo( workflow_id="wf-1", tenant_id="tenant-1", @@ -259,7 +331,7 @@ def test_workflow_trace_no_message_id(trace_instance, monkeypatch): assert trace_data.name == TraceTaskName.WORKFLOW_TRACE -def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): +def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch): trace_info = WorkflowTraceInfo( workflow_id="wf-1", tenant_id="tenant-1", @@ -287,7 +359,7 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): trace_instance.workflow_trace(trace_info) -def test_message_trace_basic(trace_instance, monkeypatch): +def test_message_trace_basic(trace_instance, monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.id = "msg-1" message_data.from_account_id = "acc-1" @@ -331,7 +403,7 @@ def test_message_trace_basic(trace_instance, monkeypatch): assert gen_data.usage.total == 30 -def test_message_trace_with_end_user(trace_instance, monkeypatch): +def test_message_trace_with_end_user(trace_instance, monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.id = "msg-1" message_data.from_account_id = "acc-1" @@ -636,7 +708,7 @@ def test_langfuse_trace_entity_with_list_dict_input(): assert data.input[0]["content"] == "hello" -def test_workflow_trace_handles_usage_extraction_error(trace_instance, monkeypatch, caplog): +def test_workflow_trace_handles_usage_extraction_error(trace_instance, monkeypatch: pytest.MonkeyPatch, caplog): # Setup trace info to trigger LLM node usage extraction trace_info = WorkflowTraceInfo( workflow_id="wf-1", diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py index f73ba01c8b..be9d64ae01 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py @@ -65,35 +65,18 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): } file_list = values.get("file_list", []) if isinstance(v, str): - if field_name == "inputs": - return { - "messages": { - "role": "user", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - elif field_name == "outputs": - return { - "choices": { - "role": "ai", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - elif isinstance(v, list): - data = {} - if len(v) > 0 and isinstance(v[0], dict): - # rename text to content - v = replace_text_with_content(data=v) - if field_name == "inputs": - data = { - "messages": v, + match field_name: + case "inputs": + return { + "messages": { + "role": "user", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, } - elif field_name == "outputs": - data = { + case "outputs": + return { "choices": { "role": "ai", "content": v, @@ -101,6 +84,29 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): "file_list": file_list, }, } + case _: + pass + elif isinstance(v, list): + data = {} + if len(v) > 0 and isinstance(v[0], dict): + # rename text to content + v = replace_text_with_content(data=v) + match field_name: + case "inputs": + data = { + "messages": v, + } + case "outputs": + data = { + "choices": { + "role": "ai", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, + } + case _: + pass return data else: return { diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py index 145bd70dbc..045ec44e4e 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py @@ -64,7 +64,9 @@ class LangSmithDataTrace(BaseTraceInstance): self.generate_name_trace(trace_info) def workflow_trace(self, trace_info: WorkflowTraceInfo): - trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id + # trace_id must equal the root run's run_id (LangSmith protocol); external trace_id + # cannot be used here as it would cause HTTP 400. + trace_id = trace_info.message_id or trace_info.workflow_run_id if trace_info.start_time is None: trace_info.start_time = datetime.now() message_dotted_order = ( @@ -77,6 +79,8 @@ class LangSmithDataTrace(BaseTraceInstance): ) metadata = trace_info.metadata metadata["workflow_app_log_id"] = trace_info.workflow_app_log_id + if trace_info.trace_id: + metadata["external_trace_id"] = trace_info.trace_id if trace_info.message_id: message_run = LangSmithRunModel( diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py index 45e5894e4a..edc4aafd87 100644 --- a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py @@ -35,7 +35,7 @@ def langsmith_config(): @pytest.fixture -def trace_instance(langsmith_config, monkeypatch): +def trace_instance(langsmith_config, monkeypatch: pytest.MonkeyPatch): # Mock LangSmith client mock_client = MagicMock() monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.Client", lambda **kwargs: mock_client) @@ -44,7 +44,7 @@ def trace_instance(langsmith_config, monkeypatch): return instance -def test_init(langsmith_config, monkeypatch): +def test_init(langsmith_config, monkeypatch: pytest.MonkeyPatch): mock_client_class = MagicMock() monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.Client", mock_client_class) monkeypatch.setenv("FILES_URL", "http://test.url") @@ -57,7 +57,7 @@ def test_init(langsmith_config, monkeypatch): assert instance.file_base_url == "http://test.url" -def test_trace_dispatch(trace_instance, monkeypatch): +def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch): methods = [ "workflow_trace", "message_trace", @@ -107,7 +107,7 @@ def test_trace_dispatch(trace_instance, monkeypatch): mocks["generate_name_trace"].assert_called_once_with(info) -def test_workflow_trace(trace_instance, monkeypatch): +def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): # Setup trace info workflow_data = MagicMock() workflow_data.created_at = _dt() @@ -208,13 +208,17 @@ def test_workflow_trace(trace_instance, monkeypatch): assert call_args[0].id == "msg-1" assert call_args[0].name == TraceTaskName.MESSAGE_TRACE + # trace_id must equal root run's id (message_id), not the external trace_id "trace-1" + assert call_args[0].trace_id == "msg-1" assert call_args[1].id == "run-1" assert call_args[1].name == TraceTaskName.WORKFLOW_TRACE assert call_args[1].parent_run_id == "msg-1" + assert call_args[1].trace_id == "msg-1" assert call_args[2].id == "node-llm" assert call_args[2].run_type == LangSmithRunType.llm + assert call_args[2].trace_id == "msg-1" assert call_args[3].id == "node-other" assert call_args[3].run_type == LangSmithRunType.tool @@ -223,7 +227,7 @@ def test_workflow_trace(trace_instance, monkeypatch): assert call_args[4].run_type == LangSmithRunType.retriever -def test_workflow_trace_no_start_time(trace_instance, monkeypatch): +def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch): workflow_data = MagicMock() workflow_data.created_at = _dt() workflow_data.finished_at = _dt() + timedelta(seconds=1) @@ -266,7 +270,7 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch): assert trace_instance.add_run.called -def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): +def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch): trace_info = MagicMock(spec=WorkflowTraceInfo) trace_info.trace_id = "trace-1" trace_info.message_id = None @@ -290,7 +294,7 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): trace_instance.workflow_trace(trace_info) -def test_message_trace(trace_instance, monkeypatch): +def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.id = "msg-1" message_data.from_account_id = "acc-1" @@ -516,7 +520,7 @@ def test_update_run_error(trace_instance): trace_instance.update_run(update_data) -def test_workflow_trace_usage_extraction_error(trace_instance, monkeypatch, caplog): +def test_workflow_trace_usage_extraction_error(trace_instance, monkeypatch: pytest.MonkeyPatch, caplog): workflow_data = MagicMock() workflow_data.created_at = _dt() workflow_data.finished_at = _dt() + timedelta(seconds=1) @@ -604,3 +608,83 @@ def test_get_project_url_error(trace_instance): trace_instance.langsmith_client.get_run_url.side_effect = Exception("error") with pytest.raises(ValueError, match="LangSmith get run url failed: error"): trace_instance.get_project_url() + + +def _make_workflow_trace_info( + *, message_id: str | None, workflow_run_id: str, trace_id: str | None +) -> WorkflowTraceInfo: + workflow_data = MagicMock() + workflow_data.created_at = _dt() + workflow_data.finished_at = _dt() + timedelta(seconds=1) + return WorkflowTraceInfo( + tenant_id="tenant-1", + workflow_id="wf-1", + workflow_run_id=workflow_run_id, + workflow_run_inputs={}, + workflow_run_outputs={}, + workflow_run_status="succeeded", + workflow_run_version="1.0", + workflow_run_elapsed_time=1.0, + total_tokens=0, + file_list=[], + query="q", + message_id=message_id, + conversation_id="conv-1" if message_id else None, + start_time=_dt(), + end_time=_dt() + timedelta(seconds=1), + trace_id=trace_id, + metadata={"app_id": "app-1"}, + workflow_app_log_id=None, + error=None, + workflow_data=workflow_data, + ) + + +def _patch_workflow_trace_deps(monkeypatch, trace_instance): + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + factory = MagicMock() + factory.create_workflow_node_execution_repository.return_value = repo + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", factory) + monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) + trace_instance.add_run = MagicMock() + + +def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch): + """Chatflow with external trace_id: LangSmith trace_id must be message_id, not external.""" + trace_info = _make_workflow_trace_info( + message_id="msg-abc", + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # message run (root) and workflow run (child) must both use message_id as trace_id + assert calls[0].id == "msg-abc" + assert calls[0].trace_id == "msg-abc" + assert calls[1].id == "run-xyz" + assert calls[1].trace_id == "msg-abc" + # external_trace_id preserved in metadata + assert trace_info.metadata.get("external_trace_id") == "external-999" + + +def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch): + """Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id.""" + trace_info = _make_workflow_trace_info( + message_id=None, + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # workflow run is the root; trace_id must equal its run_id + assert calls[0].id == "run-xyz" + assert calls[0].trace_id == "run-xyz" diff --git a/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py index 4e4c45a532..e36b3dee40 100644 --- a/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py @@ -89,20 +89,21 @@ class MLflowDataTrace(BaseTraceInstance): def trace(self, trace_info: BaseTraceInfo): """Simple dispatch to trace methods""" try: - if isinstance(trace_info, WorkflowTraceInfo): - self.workflow_trace(trace_info) - elif isinstance(trace_info, MessageTraceInfo): - self.message_trace(trace_info) - elif isinstance(trace_info, ToolTraceInfo): - self.tool_trace(trace_info) - elif isinstance(trace_info, ModerationTraceInfo): - self.moderation_trace(trace_info) - elif isinstance(trace_info, DatasetRetrievalTraceInfo): - self.dataset_retrieval_trace(trace_info) - elif isinstance(trace_info, SuggestedQuestionTraceInfo): - self.suggested_question_trace(trace_info) - elif isinstance(trace_info, GenerateNameTraceInfo): - self.generate_name_trace(trace_info) + match trace_info: + case WorkflowTraceInfo(): + self.workflow_trace(trace_info) + case MessageTraceInfo(): + self.message_trace(trace_info) + case ToolTraceInfo(): + self.tool_trace(trace_info) + case ModerationTraceInfo(): + self.moderation_trace(trace_info) + case DatasetRetrievalTraceInfo(): + self.dataset_retrieval_trace(trace_info) + case SuggestedQuestionTraceInfo(): + self.suggested_question_trace(trace_info) + case GenerateNameTraceInfo(): + self.generate_name_trace(trace_info) except Exception: logger.exception("[MLflow] Trace error") raise @@ -480,14 +481,15 @@ class MLflowDataTrace(BaseTraceInstance): def _parse_prompts(self, prompts): """Postprocess prompts format to be standard chat messages""" - if isinstance(prompts, str): - return prompts - elif isinstance(prompts, dict): - return self._parse_single_message(prompts) - elif isinstance(prompts, list): - messages = [self._parse_single_message(item) for item in prompts] - messages = self._resolve_tool_call_ids(messages) - return messages + match prompts: + case str(): + return prompts + case dict(): + return self._parse_single_message(prompts) + case list(): + messages = [self._parse_single_message(item) for item in prompts] + messages = self._resolve_tool_call_ids(messages) + return messages return prompts # Fallback to original format def _parse_single_message(self, item: dict[str, Any]): diff --git a/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py index 46c9750a5d..324f894b25 100644 --- a/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py @@ -614,7 +614,7 @@ class TestMessageTrace: span.set_status.assert_called_once() span.add_event.assert_called_once() - def test_message_trace_with_file_data(self, trace_instance, mock_tracing, mock_db, monkeypatch): + def test_message_trace_with_file_data(self, trace_instance, mock_tracing, mock_db, monkeypatch: pytest.MonkeyPatch): span = MagicMock() mock_tracing["start"].return_value = span mock_tracing["set"].return_value = "token" diff --git a/api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py b/api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py index eefed3c78c..5daaa7132c 100644 --- a/api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py +++ b/api/providers/trace/trace-opik/tests/unit_tests/opik_trace/test_opik_trace.py @@ -35,7 +35,7 @@ def opik_config(): @pytest.fixture -def trace_instance(opik_config, monkeypatch): +def trace_instance(opik_config, monkeypatch: pytest.MonkeyPatch): mock_client = MagicMock() monkeypatch.setattr("dify_trace_opik.opik_trace.Opik", lambda **kwargs: mock_client) @@ -65,7 +65,7 @@ def test_prepare_opik_uuid(): assert result is not None -def test_init(opik_config, monkeypatch): +def test_init(opik_config, monkeypatch: pytest.MonkeyPatch): mock_opik = MagicMock() monkeypatch.setattr("dify_trace_opik.opik_trace.Opik", mock_opik) monkeypatch.setenv("FILES_URL", "http://test.url") @@ -82,7 +82,7 @@ def test_init(opik_config, monkeypatch): assert instance.project == opik_config.project -def test_trace_dispatch(trace_instance, monkeypatch): +def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch): methods = [ "workflow_trace", "message_trace", @@ -132,7 +132,7 @@ def test_trace_dispatch(trace_instance, monkeypatch): mocks["generate_name_trace"].assert_called_once_with(info) -def test_workflow_trace_with_message_id(trace_instance, monkeypatch): +def test_workflow_trace_with_message_id(trace_instance, monkeypatch: pytest.MonkeyPatch): # Define constants for better readability WORKFLOW_ID = "fb05c7cd-6cec-4add-8a84-df03a408b4ce" WORKFLOW_RUN_ID = "33c67568-7a8a-450e-8916-a5f135baeaef" @@ -221,7 +221,7 @@ def test_workflow_trace_with_message_id(trace_instance, monkeypatch): assert trace_instance.add_span.call_count >= 1 -def test_workflow_trace_no_message_id(trace_instance, monkeypatch): +def test_workflow_trace_no_message_id(trace_instance, monkeypatch: pytest.MonkeyPatch): # Define constants for better readability WORKFLOW_ID = "f0708b36-b1d7-42b3-a876-1d01b7d8f1a3" WORKFLOW_RUN_ID = "d42ec285-c2fd-4248-8866-5c9386b101ac" @@ -265,7 +265,7 @@ def test_workflow_trace_no_message_id(trace_instance, monkeypatch): trace_instance.add_trace.assert_called_once() -def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): +def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch): trace_info = WorkflowTraceInfo( workflow_id="5745f1b8-f8e6-4859-8110-996acb6c8d6a", tenant_id="tenant-1", @@ -293,7 +293,7 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch): trace_instance.workflow_trace(trace_info) -def test_message_trace_basic(trace_instance, monkeypatch): +def test_message_trace_basic(trace_instance, monkeypatch: pytest.MonkeyPatch): # Define constants for better readability MESSAGE_DATA_ID = "e3a26712-8cac-4a25-94a4-a3bff21ee3ab" CONVERSATION_ID = "9d3f3751-7521-4c19-9307-20e3cf6789a3" @@ -340,7 +340,7 @@ def test_message_trace_basic(trace_instance, monkeypatch): trace_instance.add_span.assert_called_once() -def test_message_trace_with_end_user(trace_instance, monkeypatch): +def test_message_trace_with_end_user(trace_instance, monkeypatch: pytest.MonkeyPatch): message_data = MagicMock() message_data.id = "85411059-79fb-4deb-a76c-c2e215f1b97e" message_data.from_account_id = "acc-1" @@ -614,7 +614,7 @@ def test_get_project_url_error(trace_instance): trace_instance.get_project_url() -def test_workflow_trace_usage_extraction_error_fixed(trace_instance, monkeypatch, caplog): +def test_workflow_trace_usage_extraction_error_fixed(trace_instance, monkeypatch: pytest.MonkeyPatch, caplog): trace_info = WorkflowTraceInfo( workflow_id="86a52565-4a6b-4a1b-9bfd-98e4595e70de", tenant_id="66e8e918-472e-4b69-8051-12502c34fc07", diff --git a/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py index a8c480e4a5..3e1dc1d9f6 100644 --- a/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py +++ b/api/providers/trace/trace-tencent/src/dify_trace_tencent/tencent_trace.py @@ -58,20 +58,21 @@ class TencentDataTrace(BaseTraceInstance): def trace(self, trace_info: BaseTraceInfo) -> None: """Main tracing entry point - coordinates different trace types.""" - if isinstance(trace_info, WorkflowTraceInfo): - self.workflow_trace(trace_info) - elif isinstance(trace_info, MessageTraceInfo): - self.message_trace(trace_info) - elif isinstance(trace_info, ModerationTraceInfo): - pass - elif isinstance(trace_info, SuggestedQuestionTraceInfo): - self.suggested_question_trace(trace_info) - elif isinstance(trace_info, DatasetRetrievalTraceInfo): - self.dataset_retrieval_trace(trace_info) - elif isinstance(trace_info, ToolTraceInfo): - self.tool_trace(trace_info) - elif isinstance(trace_info, GenerateNameTraceInfo): - pass + match trace_info: + case WorkflowTraceInfo(): + self.workflow_trace(trace_info) + case MessageTraceInfo(): + self.message_trace(trace_info) + case ModerationTraceInfo(): + pass + case SuggestedQuestionTraceInfo(): + self.suggested_question_trace(trace_info) + case DatasetRetrievalTraceInfo(): + self.dataset_retrieval_trace(trace_info) + case ToolTraceInfo(): + self.tool_trace(trace_info) + case GenerateNameTraceInfo(): + pass def api_check(self) -> bool: return self.trace_client.api_check() diff --git a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py index 3cd918f408..b6bc5b8606 100644 --- a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py @@ -94,29 +94,29 @@ class PatchedCoreComponents(TypedDict): def _add_stub_modules(monkeypatch: pytest.MonkeyPatch) -> None: """Drop fake metric modules into sys.modules so the client imports resolve.""" - metrics_module = types.ModuleType("opentelemetry.sdk.metrics") + metrics_module = cast(Any, types.ModuleType("opentelemetry.sdk.metrics")) metrics_module.Histogram = DummyHistogram metrics_module.MeterProvider = DummyMeterProvider monkeypatch.setitem(sys.modules, "opentelemetry.sdk.metrics", metrics_module) - metrics_export_module = types.ModuleType("opentelemetry.sdk.metrics.export") + metrics_export_module = cast(Any, types.ModuleType("opentelemetry.sdk.metrics.export")) metrics_export_module.AggregationTemporality = AggregationTemporality metrics_export_module.PeriodicExportingMetricReader = DummyMetricReader monkeypatch.setitem(sys.modules, "opentelemetry.sdk.metrics.export", metrics_export_module) - grpc_module = types.ModuleType("opentelemetry.exporter.otlp.proto.grpc.metric_exporter") + grpc_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.proto.grpc.metric_exporter")) grpc_module.OTLPMetricExporter = DummyGrpcMetricExporter monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.proto.grpc.metric_exporter", grpc_module) - http_module = types.ModuleType("opentelemetry.exporter.otlp.proto.http.metric_exporter") + http_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.proto.http.metric_exporter")) http_module.OTLPMetricExporter = DummyHttpMetricExporter monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.proto.http.metric_exporter", http_module) - http_json_module = types.ModuleType("opentelemetry.exporter.otlp.http.json.metric_exporter") + http_json_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.http.json.metric_exporter")) http_json_module.OTLPMetricExporter = DummyJsonMetricExporter monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.http.json.metric_exporter", http_json_module) - legacy_json_module = types.ModuleType("opentelemetry.exporter.otlp.json.metric_exporter") + legacy_json_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.json.metric_exporter")) legacy_json_module.OTLPMetricExporter = DummyJsonMetricExporter monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.json.metric_exporter", legacy_json_module) diff --git a/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py b/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py index 6028d0c550..30646815d8 100644 --- a/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py +++ b/api/providers/trace/trace-weave/tests/unit_tests/weave_trace/test_weave_trace.py @@ -267,14 +267,14 @@ class TestInit: with pytest.raises(ValueError, match="Weave login failed"): WeaveDataTrace(config) - def test_init_files_url_from_env(self, mock_wandb, mock_weave, monkeypatch): + def test_init_files_url_from_env(self, mock_wandb, mock_weave, monkeypatch: pytest.MonkeyPatch): """Test FILES_URL is read from environment.""" monkeypatch.setenv("FILES_URL", "http://files.example.com") config = _make_weave_config() instance = WeaveDataTrace(config) assert instance.file_base_url == "http://files.example.com" - def test_init_files_url_default(self, mock_wandb, mock_weave, monkeypatch): + def test_init_files_url_default(self, mock_wandb, mock_weave, monkeypatch: pytest.MonkeyPatch): """Test FILES_URL defaults to http://127.0.0.1:5001.""" monkeypatch.delenv("FILES_URL", raising=False) config = _make_weave_config() @@ -302,7 +302,7 @@ class TestGetProjectUrl: url = instance.get_project_url() assert url == "https://wandb.ai/my-project" - def test_get_project_url_exception_raises(self, trace_instance, monkeypatch): + def test_get_project_url_exception_raises(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Raises ValueError when exception occurs in get_project_url.""" monkeypatch.setattr(trace_instance, "entity", None) monkeypatch.setattr(trace_instance, "project_name", None) @@ -583,7 +583,7 @@ class TestFinishCall: class TestWorkflowTrace: - def _setup_repo(self, monkeypatch, nodes=None): + def _setup_repo(self, monkeypatch: pytest.MonkeyPatch, nodes=None): """Helper to patch session/repo dependencies.""" if nodes is None: nodes = [] @@ -599,7 +599,7 @@ class TestWorkflowTrace: monkeypatch.setattr("dify_trace_weave.weave_trace.db", MagicMock(engine="engine")) return repo - def test_workflow_trace_no_nodes_no_message_id(self, trace_instance, monkeypatch): + def test_workflow_trace_no_nodes_no_message_id(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Workflow trace with no nodes and no message_id.""" self._setup_repo(monkeypatch, nodes=[]) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) @@ -614,7 +614,7 @@ class TestWorkflowTrace: assert trace_instance.start_call.call_count == 1 assert trace_instance.finish_call.call_count == 1 - def test_workflow_trace_with_message_id(self, trace_instance, monkeypatch): + def test_workflow_trace_with_message_id(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Workflow trace with message_id creates both message and workflow runs.""" self._setup_repo(monkeypatch, nodes=[]) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) @@ -629,7 +629,7 @@ class TestWorkflowTrace: assert trace_instance.start_call.call_count == 2 assert trace_instance.finish_call.call_count == 2 - def test_workflow_trace_with_node_execution(self, trace_instance, monkeypatch): + def test_workflow_trace_with_node_execution(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Workflow trace iterates node executions and creates node runs.""" node = _make_node( id="node-1", @@ -652,7 +652,7 @@ class TestWorkflowTrace: # workflow run + node run = 2 calls assert trace_instance.start_call.call_count == 2 - def test_workflow_trace_with_llm_node(self, trace_instance, monkeypatch): + def test_workflow_trace_with_llm_node(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """LLM node uses process_data prompts as inputs.""" node = _make_node( node_type=BuiltinNodeTypes.LLM, @@ -680,7 +680,7 @@ class TestWorkflowTrace: # The key "messages" should be present (validator transforms the list) assert "messages" in node_run.inputs - def test_workflow_trace_with_non_llm_node_uses_inputs(self, trace_instance, monkeypatch): + def test_workflow_trace_with_non_llm_node_uses_inputs(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Non-LLM node uses node_execution.inputs directly.""" node = _make_node( node_type=BuiltinNodeTypes.TOOL, @@ -701,7 +701,7 @@ class TestWorkflowTrace: node_run = node_call_args[0][0] assert node_run.inputs.get("tool_input") == "val" - def test_workflow_trace_missing_app_id_raises(self, trace_instance, monkeypatch): + def test_workflow_trace_missing_app_id_raises(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Raises ValueError when app_id is missing from metadata.""" monkeypatch.setattr("dify_trace_weave.weave_trace.sessionmaker", lambda bind: MagicMock()) monkeypatch.setattr("dify_trace_weave.weave_trace.db", MagicMock(engine="engine")) @@ -714,7 +714,7 @@ class TestWorkflowTrace: with pytest.raises(ValueError, match="No app_id found in trace_info metadata"): trace_instance.workflow_trace(trace_info) - def test_workflow_trace_start_time_none_defaults_to_now(self, trace_instance, monkeypatch): + def test_workflow_trace_start_time_none_defaults_to_now(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """start_time defaults to datetime.now() when None.""" self._setup_repo(monkeypatch, nodes=[]) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) @@ -727,7 +727,7 @@ class TestWorkflowTrace: assert trace_instance.start_call.call_count == 1 - def test_workflow_trace_node_created_at_none(self, trace_instance, monkeypatch): + def test_workflow_trace_node_created_at_none(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Node with created_at=None uses datetime.now().""" node = _make_node(created_at=None, elapsed_time=0.5) self._setup_repo(monkeypatch, nodes=[node]) @@ -740,7 +740,7 @@ class TestWorkflowTrace: trace_instance.workflow_trace(trace_info) assert trace_instance.start_call.call_count == 2 - def test_workflow_trace_chat_mode_llm_node_adds_provider(self, trace_instance, monkeypatch): + def test_workflow_trace_chat_mode_llm_node_adds_provider(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Chat mode LLM node adds ls_provider and ls_model_name to attributes.""" node = _make_node( node_type=BuiltinNodeTypes.LLM, @@ -765,7 +765,7 @@ class TestWorkflowTrace: assert node_run.attributes.get("ls_provider") == "openai" assert node_run.attributes.get("ls_model_name") == "gpt-4" - def test_workflow_trace_nodes_sorted_by_created_at(self, trace_instance, monkeypatch): + def test_workflow_trace_nodes_sorted_by_created_at(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """Nodes are sorted by created_at before processing.""" node1 = _make_node(id="node-b", created_at=_dt() + timedelta(seconds=2)) node2 = _make_node(id="node-a", created_at=_dt()) @@ -799,7 +799,7 @@ class TestMessageTrace: trace_instance.message_trace(trace_info) trace_instance.start_call.assert_not_called() - def test_basic_message_trace(self, trace_instance, monkeypatch): + def test_basic_message_trace(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """message_trace creates message run and llm child run.""" monkeypatch.setattr( "dify_trace_weave.weave_trace.db.session.get", @@ -816,7 +816,7 @@ class TestMessageTrace: assert trace_instance.start_call.call_count == 2 assert trace_instance.finish_call.call_count == 2 - def test_message_trace_with_file_data(self, trace_instance, monkeypatch): + def test_message_trace_with_file_data(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """message_trace appends file URL to file_list.""" file_data = MagicMock() file_data.url = "path/to/file.png" @@ -839,7 +839,7 @@ class TestMessageTrace: message_run = trace_instance.start_call.call_args_list[0][0][0] assert "http://files.test/path/to/file.png" in message_run.file_list - def test_message_trace_with_end_user(self, trace_instance, monkeypatch): + def test_message_trace_with_end_user(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """message_trace looks up end user and sets end_user_id attribute.""" end_user = MagicMock() end_user.session_id = "session-xyz" @@ -862,7 +862,7 @@ class TestMessageTrace: message_run = trace_instance.start_call.call_args_list[0][0][0] assert message_run.attributes.get("end_user_id") == "session-xyz" - def test_message_trace_no_end_user(self, trace_instance, monkeypatch): + def test_message_trace_no_end_user(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """message_trace handles when from_end_user_id is None.""" mock_db = MagicMock() mock_db.session.get.return_value = None @@ -880,7 +880,7 @@ class TestMessageTrace: trace_instance.message_trace(trace_info) assert trace_instance.start_call.call_count == 2 - def test_message_trace_trace_id_fallback_to_message_id(self, trace_instance, monkeypatch): + def test_message_trace_trace_id_fallback_to_message_id(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """trace_id falls back to message_id when trace_id is None.""" mock_db = MagicMock() mock_db.session.get.return_value = None @@ -895,7 +895,7 @@ class TestMessageTrace: message_run = trace_instance.start_call.call_args_list[0][0][0] assert message_run.id == "msg-1" - def test_message_trace_file_list_none(self, trace_instance, monkeypatch): + def test_message_trace_file_list_none(self, trace_instance, monkeypatch: pytest.MonkeyPatch): """message_trace handles file_list=None gracefully.""" mock_db = MagicMock() mock_db.session.get.return_value = None diff --git a/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py b/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py index a907f918c3..37b2331f0f 100644 --- a/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py +++ b/api/providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py @@ -20,7 +20,7 @@ def test_validate_distance_function_rejects_unsupported_values(): factory._validate_distance_function("dot_product") -def test_factory_init_vector_uses_existing_index_struct_class_prefix(monkeypatch): +def test_factory_init_vector_uses_existing_index_struct_class_prefix(monkeypatch: pytest.MonkeyPatch): factory = AlibabaCloudMySQLVectorFactory() dataset = SimpleNamespace( id="dataset-1", @@ -45,7 +45,7 @@ def test_factory_init_vector_uses_existing_index_struct_class_prefix(monkeypatch assert vector_cls.call_args.kwargs["collection_name"] == "existing_collection" -def test_factory_init_vector_generates_collection_name_when_index_struct_is_missing(monkeypatch): +def test_factory_init_vector_generates_collection_name_when_index_struct_is_missing(monkeypatch: pytest.MonkeyPatch): factory = AlibabaCloudMySQLVectorFactory() dataset = SimpleNamespace( id="dataset-2", diff --git a/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py index d1d471761d..2e8052b7dc 100644 --- a/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py +++ b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py @@ -83,7 +83,7 @@ def test_get_type_is_analyticdb(): assert vector.get_type() == "analyticdb" -def test_factory_builds_openapi_config_when_host_is_missing(monkeypatch): +def test_factory_builds_openapi_config_when_host_is_missing(monkeypatch: pytest.MonkeyPatch): factory = AnalyticdbVectorFactory() dataset = SimpleNamespace(id="dataset-1", index_struct_dict=None, index_struct=None) @@ -109,7 +109,7 @@ def test_factory_builds_openapi_config_when_host_is_missing(monkeypatch): assert dataset.index_struct is not None -def test_factory_builds_sql_config_when_host_is_present(monkeypatch): +def test_factory_builds_sql_config_when_host_is_present(monkeypatch: pytest.MonkeyPatch): factory = AnalyticdbVectorFactory() dataset = SimpleNamespace( id="dataset-2", index_struct_dict={"vector_store": {"class_prefix": "EXISTING"}}, index_struct=None diff --git a/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py index d2d735ae3e..26bd385333 100644 --- a/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py +++ b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py @@ -24,7 +24,7 @@ def _request_class(name: str): return _Request -def _install_openapi_stubs(monkeypatch): +def _install_openapi_stubs(monkeypatch: pytest.MonkeyPatch): gpdb_package = types.ModuleType("alibabacloud_gpdb20160503") gpdb_package.__path__ = [] gpdb_models = types.ModuleType("alibabacloud_gpdb20160503.models") @@ -130,7 +130,7 @@ def test_openapi_config_to_client_params(): assert params["read_timeout"] == 60000 -def test_init_creates_openapi_client_and_runs_initialize(monkeypatch): +def test_init_creates_openapi_client_and_runs_initialize(monkeypatch: pytest.MonkeyPatch): stubs = _install_openapi_stubs(monkeypatch) initialize_mock = MagicMock() monkeypatch.setattr(openapi_module.AnalyticdbVectorOpenAPI, "_initialize", initialize_mock) @@ -145,7 +145,7 @@ def test_init_creates_openapi_client_and_runs_initialize(monkeypatch): initialize_mock.assert_called_once_with() -def test_initialize_skips_when_cached(monkeypatch): +def test_initialize_skips_when_cached(monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -164,7 +164,7 @@ def test_initialize_skips_when_cached(monkeypatch): vector._create_namespace_if_not_exists.assert_not_called() -def test_initialize_runs_when_cache_is_missing(monkeypatch): +def test_initialize_runs_when_cache_is_missing(monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -184,7 +184,7 @@ def test_initialize_runs_when_cache_is_missing(monkeypatch): openapi_module.redis_client.set.assert_called_once() -def test_initialize_vector_database_calls_openapi_client(monkeypatch): +def test_initialize_vector_database_calls_openapi_client(monkeypatch: pytest.MonkeyPatch): _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector.config = _config() @@ -199,7 +199,7 @@ def test_initialize_vector_database_calls_openapi_client(monkeypatch): assert request.manager_account_password == "password" -def test_create_namespace_creates_when_namespace_not_found(monkeypatch): +def test_create_namespace_creates_when_namespace_not_found(monkeypatch: pytest.MonkeyPatch): stubs = _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector.config = _config() @@ -211,7 +211,7 @@ def test_create_namespace_creates_when_namespace_not_found(monkeypatch): vector._client.create_namespace.assert_called_once() -def test_create_namespace_raises_on_unexpected_api_error(monkeypatch): +def test_create_namespace_raises_on_unexpected_api_error(monkeypatch: pytest.MonkeyPatch): stubs = _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector.config = _config() @@ -222,7 +222,7 @@ def test_create_namespace_raises_on_unexpected_api_error(monkeypatch): vector._create_namespace_if_not_exists() -def test_create_namespace_noop_when_namespace_exists(monkeypatch): +def test_create_namespace_noop_when_namespace_exists(monkeypatch: pytest.MonkeyPatch): _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector.config = _config() @@ -234,7 +234,7 @@ def test_create_namespace_noop_when_namespace_exists(monkeypatch): vector._client.create_namespace.assert_not_called() -def test_create_collection_if_not_exists_creates_when_missing(monkeypatch): +def test_create_collection_if_not_exists_creates_when_missing(monkeypatch: pytest.MonkeyPatch): stubs = _install_openapi_stubs(monkeypatch) lock = MagicMock() lock.__enter__.return_value = None @@ -255,7 +255,7 @@ def test_create_collection_if_not_exists_creates_when_missing(monkeypatch): openapi_module.redis_client.set.assert_called_once() -def test_create_collection_if_not_exists_skips_when_cached(monkeypatch): +def test_create_collection_if_not_exists_skips_when_cached(monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -274,7 +274,7 @@ def test_create_collection_if_not_exists_skips_when_cached(monkeypatch): vector._client.create_collection.assert_not_called() -def test_create_collection_if_not_exists_raises_on_non_404_errors(monkeypatch): +def test_create_collection_if_not_exists_raises_on_non_404_errors(monkeypatch: pytest.MonkeyPatch): stubs = _install_openapi_stubs(monkeypatch) lock = MagicMock() lock.__enter__.return_value = None @@ -293,7 +293,7 @@ def test_create_collection_if_not_exists_raises_on_non_404_errors(monkeypatch): vector.create_collection_if_not_exists(embedding_dimension=512) -def test_openapi_add_delete_and_search_methods(monkeypatch): +def test_openapi_add_delete_and_search_methods(monkeypatch: pytest.MonkeyPatch): _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector._collection_name = "collection_1" @@ -348,7 +348,7 @@ def test_openapi_add_delete_and_search_methods(monkeypatch): assert docs_by_text[0].page_content == "high" -def test_text_exists_returns_false_when_matches_empty(monkeypatch): +def test_text_exists_returns_false_when_matches_empty(monkeypatch: pytest.MonkeyPatch): _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector._collection_name = "collection_1" @@ -361,7 +361,7 @@ def test_text_exists_returns_false_when_matches_empty(monkeypatch): assert vector.text_exists("missing-id") is False -def test_openapi_delete_success(monkeypatch): +def test_openapi_delete_success(monkeypatch: pytest.MonkeyPatch): _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector._collection_name = "collection_1" @@ -372,7 +372,7 @@ def test_openapi_delete_success(monkeypatch): vector._client.delete_collection.assert_called_once() -def test_openapi_delete_propagates_errors(monkeypatch): +def test_openapi_delete_propagates_errors(monkeypatch: pytest.MonkeyPatch): _install_openapi_stubs(monkeypatch) vector = AnalyticdbVectorOpenAPI.__new__(AnalyticdbVectorOpenAPI) vector._collection_name = "collection_1" diff --git a/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py index 49a2ae72d0..cd255b37cf 100644 --- a/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py +++ b/api/providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py @@ -53,7 +53,7 @@ def test_sql_config_rejects_min_connection_greater_than_max_connection(): AnalyticdbVectorBySqlConfig.model_validate(values) -def test_initialize_skips_when_cache_exists(monkeypatch): +def test_initialize_skips_when_cache_exists(monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -70,7 +70,7 @@ def test_initialize_skips_when_cache_exists(monkeypatch): vector._initialize_vector_database.assert_not_called() -def test_initialize_runs_when_cache_is_missing(monkeypatch): +def test_initialize_runs_when_cache_is_missing(monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -88,7 +88,7 @@ def test_initialize_runs_when_cache_is_missing(monkeypatch): sql_module.redis_client.set.assert_called_once() -def test_create_connection_pool_uses_psycopg2_pool(monkeypatch): +def test_create_connection_pool_uses_psycopg2_pool(monkeypatch: pytest.MonkeyPatch): vector = AnalyticdbVectorBySql.__new__(AnalyticdbVectorBySql) vector.config = AnalyticdbVectorBySqlConfig(**_config_values()) vector.databaseName = "knowledgebase" @@ -119,7 +119,7 @@ def test_get_cursor_context_manager_handles_connection_lifecycle(): pool.putconn.assert_called_once_with(connection) -def test_add_texts_inserts_only_documents_with_metadata(monkeypatch): +def test_add_texts_inserts_only_documents_with_metadata(monkeypatch: pytest.MonkeyPatch): vector = AnalyticdbVectorBySql.__new__(AnalyticdbVectorBySql) vector.table_name = "dify.collection" @@ -273,7 +273,7 @@ def test_delete_drops_table(): cursor.execute.assert_called_once() -def test_init_normalizes_collection_name_and_creates_pool_when_missing(monkeypatch): +def test_init_normalizes_collection_name_and_creates_pool_when_missing(monkeypatch: pytest.MonkeyPatch): config = AnalyticdbVectorBySqlConfig(**_config_values()) created_pool = MagicMock() @@ -288,7 +288,7 @@ def test_init_normalizes_collection_name_and_creates_pool_when_missing(monkeypat assert vector.pool is created_pool -def test_initialize_vector_database_handles_existing_database_and_search_config(monkeypatch): +def test_initialize_vector_database_handles_existing_database_and_search_config(monkeypatch: pytest.MonkeyPatch): vector = AnalyticdbVectorBySql.__new__(AnalyticdbVectorBySql) vector.config = AnalyticdbVectorBySqlConfig(**_config_values()) vector.databaseName = "knowledgebase" @@ -326,7 +326,7 @@ def test_initialize_vector_database_handles_existing_database_and_search_config( assert any("CREATE SCHEMA IF NOT EXISTS dify" in call.args[0] for call in worker_cursor.execute.call_args_list) -def test_initialize_vector_database_raises_runtime_error_when_zhparser_fails(monkeypatch): +def test_initialize_vector_database_raises_runtime_error_when_zhparser_fails(monkeypatch: pytest.MonkeyPatch): vector = AnalyticdbVectorBySql.__new__(AnalyticdbVectorBySql) vector.config = AnalyticdbVectorBySqlConfig(**_config_values()) vector.databaseName = "knowledgebase" @@ -353,7 +353,7 @@ def test_initialize_vector_database_raises_runtime_error_when_zhparser_fails(mon worker_connection.rollback.assert_called_once() -def test_create_collection_if_not_exists_creates_table_indexes_and_cache(monkeypatch): +def test_create_collection_if_not_exists_creates_table_indexes_and_cache(monkeypatch: pytest.MonkeyPatch): vector = AnalyticdbVectorBySql.__new__(AnalyticdbVectorBySql) vector.config = AnalyticdbVectorBySqlConfig(**_config_values()) vector._collection_name = "collection" @@ -381,7 +381,7 @@ def test_create_collection_if_not_exists_creates_table_indexes_and_cache(monkeyp sql_module.redis_client.set.assert_called_once() -def test_create_collection_if_not_exists_raises_for_non_existing_error(monkeypatch): +def test_create_collection_if_not_exists_raises_for_non_existing_error(monkeypatch: pytest.MonkeyPatch): vector = AnalyticdbVectorBySql.__new__(AnalyticdbVectorBySql) vector.config = AnalyticdbVectorBySqlConfig(**_config_values()) vector._collection_name = "collection" diff --git a/api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py b/api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py index 851c09f47a..f0dddee3b9 100644 --- a/api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py +++ b/api/providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py @@ -121,7 +121,7 @@ def _build_fake_pymochow_modules(): @pytest.fixture -def baidu_module(monkeypatch): +def baidu_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_pymochow_modules().items(): monkeypatch.setitem(sys.modules, name, module) import dify_vdb_baidu.baidu_vector as module @@ -254,7 +254,7 @@ def test_search_methods_delegate_to_database_table(baidu_module): assert vector._get_search_res.call_count == 2 -def test_factory_initializes_collection_name_and_index_struct(baidu_module, monkeypatch): +def test_factory_initializes_collection_name_and_index_struct(baidu_module, monkeypatch: pytest.MonkeyPatch): factory = baidu_module.BaiduVectorFactory() dataset = SimpleNamespace(id="dataset-1", index_struct_dict=None, index_struct=None) monkeypatch.setattr(baidu_module.Dataset, "gen_collection_name_by_id", lambda _id: "AUTO_COLLECTION") @@ -279,7 +279,7 @@ def test_factory_initializes_collection_name_and_index_struct(baidu_module, monk assert dataset.index_struct is not None -def test_init_get_type_to_index_struct_and_create_delegate(baidu_module, monkeypatch): +def test_init_get_type_to_index_struct_and_create_delegate(baidu_module, monkeypatch: pytest.MonkeyPatch): init_client = MagicMock(return_value="client") init_database = MagicMock(return_value="database") monkeypatch.setattr(baidu_module.BaiduVector, "_init_client", init_client) @@ -372,7 +372,7 @@ def test_get_search_result_handles_invalid_metadata_json(baidu_module): assert "document_id" not in docs[0].metadata -def test_init_client_constructs_configuration_and_client(baidu_module, monkeypatch): +def test_init_client_constructs_configuration_and_client(baidu_module, monkeypatch: pytest.MonkeyPatch): credentials = MagicMock(return_value="credentials") configuration = MagicMock(return_value="configuration") client_cls = MagicMock(return_value="client") @@ -411,7 +411,7 @@ def test_init_database_raises_for_unknown_create_database_error(baidu_module): vector._init_database() -def test_create_table_handles_cache_and_validation_paths(baidu_module, monkeypatch): +def test_create_table_handles_cache_and_validation_paths(baidu_module, monkeypatch: pytest.MonkeyPatch): vector = baidu_module.BaiduVector.__new__(baidu_module.BaiduVector) vector._collection_name = "collection_1" vector._client_config = SimpleNamespace( @@ -460,7 +460,7 @@ def test_create_table_handles_cache_and_validation_paths(baidu_module, monkeypat vector._wait_for_index_ready.assert_called_once_with(table, 3600) -def test_create_table_raises_for_invalid_index_or_metric(baidu_module, monkeypatch): +def test_create_table_raises_for_invalid_index_or_metric(baidu_module, monkeypatch: pytest.MonkeyPatch): vector = baidu_module.BaiduVector.__new__(baidu_module.BaiduVector) vector._collection_name = "collection_1" vector._db = MagicMock() @@ -493,7 +493,7 @@ def test_create_table_raises_for_invalid_index_or_metric(baidu_module, monkeypat vector._create_table(3) -def test_create_table_raises_timeout_if_table_never_becomes_normal(baidu_module, monkeypatch): +def test_create_table_raises_timeout_if_table_never_becomes_normal(baidu_module, monkeypatch: pytest.MonkeyPatch): vector = baidu_module.BaiduVector.__new__(baidu_module.BaiduVector) vector._collection_name = "collection_1" vector._client_config = SimpleNamespace( @@ -524,7 +524,9 @@ def test_create_table_raises_timeout_if_table_never_becomes_normal(baidu_module, vector._create_table(3) -def test_factory_uses_existing_collection_prefix_when_index_struct_exists(baidu_module, monkeypatch): +def test_factory_uses_existing_collection_prefix_when_index_struct_exists( + baidu_module, monkeypatch: pytest.MonkeyPatch +): factory = baidu_module.BaiduVectorFactory() dataset = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py b/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py index 5b0cfbea15..754b1e8a89 100644 --- a/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py +++ b/api/providers/vdb/vdb-chroma/src/dify_vdb_chroma/chroma_vector.py @@ -2,7 +2,7 @@ import json from typing import Any, TypedDict import chromadb -from chromadb import QueryResult, Settings # pyright: ignore[reportPrivateImportUsage] +from chromadb import QueryResult, Settings from pydantic import BaseModel from configs import dify_config @@ -166,8 +166,8 @@ class ChromaVectorFactory(AbstractVectorFactory): config=ChromaConfig( host=dify_config.CHROMA_HOST or "", port=dify_config.CHROMA_PORT, - tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, # pyright: ignore[reportPrivateImportUsage] - database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, # pyright: ignore[reportPrivateImportUsage] + tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, + database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, auth_provider=dify_config.CHROMA_AUTH_PROVIDER, auth_credentials=dify_config.CHROMA_AUTH_CREDENTIALS, ), diff --git a/api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py b/api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py index b209c9df96..f18f9a6561 100644 --- a/api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py +++ b/api/providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py @@ -44,7 +44,7 @@ def _build_fake_chroma_modules(): @pytest.fixture -def chroma_module(monkeypatch): +def chroma_module(monkeypatch: pytest.MonkeyPatch): fake_chroma = _build_fake_chroma_modules() monkeypatch.setitem(sys.modules, "chromadb", fake_chroma) import dify_vdb_chroma.chroma_vector as module @@ -73,7 +73,7 @@ def test_chroma_config_to_params_builds_expected_payload(chroma_module): assert params["settings"].chroma_client_auth_credentials == "credentials" -def test_create_collection_uses_redis_lock_and_cache(chroma_module, monkeypatch): +def test_create_collection_uses_redis_lock_and_cache(chroma_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -173,7 +173,7 @@ def test_search_by_full_text_returns_empty_list(chroma_module): assert vector.search_by_full_text("query") == [] -def test_factory_init_vector_uses_existing_or_generated_collection(chroma_module, monkeypatch): +def test_factory_init_vector_uses_existing_or_generated_collection(chroma_module, monkeypatch: pytest.MonkeyPatch): factory = chroma_module.ChromaVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", index_struct_dict={"vector_store": {"class_prefix": "EXISTING"}}, index_struct=None diff --git a/api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py b/api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py index a7473f1b91..4f8395e475 100644 --- a/api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py +++ b/api/providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py @@ -45,7 +45,7 @@ def _build_fake_clickzetta_module(): @pytest.fixture -def clickzetta_module(monkeypatch): +def clickzetta_module(monkeypatch: pytest.MonkeyPatch): monkeypatch.setitem(sys.modules, "clickzetta", _build_fake_clickzetta_module()) import dify_vdb_clickzetta.clickzetta_vector as module @@ -218,7 +218,7 @@ def test_search_by_like_returns_documents_with_default_score(clickzetta_module): assert docs[0].metadata["score"] == 0.5 -def test_factory_initializes_clickzetta_vector(clickzetta_module, monkeypatch): +def test_factory_initializes_clickzetta_vector(clickzetta_module, monkeypatch: pytest.MonkeyPatch): factory = clickzetta_module.ClickzettaVectorFactory() dataset = SimpleNamespace(id="dataset-1") @@ -243,7 +243,7 @@ def test_factory_initializes_clickzetta_vector(clickzetta_module, monkeypatch): assert vector_cls.call_args.kwargs["collection_name"] == "collection" -def test_connection_pool_singleton_and_config_key(clickzetta_module, monkeypatch): +def test_connection_pool_singleton_and_config_key(clickzetta_module, monkeypatch: pytest.MonkeyPatch): clickzetta_module.ClickzettaConnectionPool._instance = None monkeypatch.setattr(clickzetta_module.ClickzettaConnectionPool, "_start_cleanup_thread", MagicMock()) @@ -255,7 +255,7 @@ def test_connection_pool_singleton_and_config_key(clickzetta_module, monkeypatch assert "username:instance:service:workspace:cluster:dify" in key -def test_connection_pool_create_connection_retries_and_configures(clickzetta_module, monkeypatch): +def test_connection_pool_create_connection_retries_and_configures(clickzetta_module, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(clickzetta_module.ClickzettaConnectionPool, "_start_cleanup_thread", MagicMock()) pool = clickzetta_module.ClickzettaConnectionPool() config = _config(clickzetta_module) @@ -274,7 +274,7 @@ def test_connection_pool_create_connection_retries_and_configures(clickzetta_mod pool._configure_connection.assert_called_once_with(connection) -def test_connection_pool_create_connection_raises_after_retries(clickzetta_module, monkeypatch): +def test_connection_pool_create_connection_raises_after_retries(clickzetta_module, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(clickzetta_module.ClickzettaConnectionPool, "_start_cleanup_thread", MagicMock()) pool = clickzetta_module.ClickzettaConnectionPool() config = _config(clickzetta_module) @@ -318,7 +318,7 @@ def test_connection_pool_configure_connection_swallows_errors(clickzetta_module) monkeypatch.undo() -def test_connection_pool_get_return_cleanup_and_shutdown(clickzetta_module, monkeypatch): +def test_connection_pool_get_return_cleanup_and_shutdown(clickzetta_module, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(clickzetta_module.ClickzettaConnectionPool, "_start_cleanup_thread", MagicMock()) pool = clickzetta_module.ClickzettaConnectionPool() config = _config(clickzetta_module) @@ -360,7 +360,7 @@ def test_connection_pool_get_return_cleanup_and_shutdown(clickzetta_module, monk assert pool._shutdown is True -def test_connection_pool_start_cleanup_thread_runs_worker_once(clickzetta_module, monkeypatch): +def test_connection_pool_start_cleanup_thread_runs_worker_once(clickzetta_module, monkeypatch: pytest.MonkeyPatch): pool = clickzetta_module.ClickzettaConnectionPool.__new__(clickzetta_module.ClickzettaConnectionPool) pool._shutdown = False pool._cleanup_expired_connections = MagicMock(side_effect=lambda: setattr(pool, "_shutdown", True)) @@ -384,7 +384,7 @@ def test_connection_pool_start_cleanup_thread_runs_worker_once(clickzetta_module pool._cleanup_expired_connections.assert_called_once() -def test_vector_init_connection_context_and_helpers(clickzetta_module, monkeypatch): +def test_vector_init_connection_context_and_helpers(clickzetta_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() pool.get_connection.return_value = "conn" monkeypatch.setattr(clickzetta_module.ClickzettaConnectionPool, "get_instance", MagicMock(return_value=pool)) @@ -405,7 +405,7 @@ def test_vector_init_connection_context_and_helpers(clickzetta_module, monkeypat assert vector._ensure_connection() == "conn" -def test_write_queue_initialization_worker_and_execute_write(clickzetta_module, monkeypatch): +def test_write_queue_initialization_worker_and_execute_write(clickzetta_module, monkeypatch: pytest.MonkeyPatch): class _Thread: def __init__(self, target, daemon): self.target = target @@ -579,7 +579,7 @@ def test_create_inverted_index_branches(clickzetta_module): vector._create_inverted_index(cursor) -def test_add_texts_batches_and_insert_batch_behaviors(clickzetta_module, monkeypatch): +def test_add_texts_batches_and_insert_batch_behaviors(clickzetta_module, monkeypatch: pytest.MonkeyPatch): vector = clickzetta_module.ClickzettaVector.__new__(clickzetta_module.ClickzettaVector) vector._config = _config(clickzetta_module) vector._config.batch_size = 2 @@ -811,7 +811,7 @@ def test_clickzetta_pool_cleanup_and_shutdown_edge_paths(clickzetta_module): assert pool._shutdown is True -def test_clickzetta_pool_cleanup_thread_and_worker_exception_paths(clickzetta_module, monkeypatch): +def test_clickzetta_pool_cleanup_thread_and_worker_exception_paths(clickzetta_module, monkeypatch: pytest.MonkeyPatch): pool = clickzetta_module.ClickzettaConnectionPool.__new__(clickzetta_module.ClickzettaConnectionPool) pool._shutdown = False diff --git a/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py index bab176e285..5deac59dc9 100644 --- a/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py +++ b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py @@ -59,7 +59,7 @@ class CouchbaseVector(BaseVector): auth = PasswordAuthenticator(config.user, config.password) options = ClusterOptions(auth) - self._cluster = Cluster(config.connection_string, options) # pyright: ignore[reportArgumentType] + self._cluster = Cluster(config.connection_string, options) self._bucket = self._cluster.bucket(config.bucket_name) self._scope = self._bucket.scope(config.scope_name) self._bucket_name = config.bucket_name @@ -306,7 +306,7 @@ class CouchbaseVector(BaseVector): def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: top_k = kwargs.get("top_k", 4) try: - CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyright: ignore[reportCallIssue] + CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyrefly: ignore[bad-argument-count] search_iter = self._scope.search( self._collection_name + "_search", CBrequest, SearchOptions(limit=top_k, fields=["*"]) ) diff --git a/api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py b/api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py index 7e5c40b8f2..d474b566d3 100644 --- a/api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py +++ b/api/providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py @@ -150,7 +150,7 @@ def _build_fake_couchbase_modules(): @pytest.fixture -def couchbase_module(monkeypatch): +def couchbase_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_couchbase_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -194,7 +194,7 @@ def test_init_sets_cluster_handles(couchbase_module): vector._cluster.wait_until_ready.assert_called_once() -def test_create_and_create_collection_branches(couchbase_module, monkeypatch): +def test_create_and_create_collection_branches(couchbase_module, monkeypatch: pytest.MonkeyPatch): vector = couchbase_module.CouchbaseVector.__new__(couchbase_module.CouchbaseVector) vector._collection_name = "collection_1" vector._client_config = _config(couchbase_module) @@ -319,7 +319,7 @@ def test_search_methods_and_format_metadata(couchbase_module): assert vector._format_metadata({"metadata.a": 1, "plain": 2}) == {"a": 1, "plain": 2} -def test_delete_collection_and_factory(couchbase_module, monkeypatch): +def test_delete_collection_and_factory(couchbase_module, monkeypatch: pytest.MonkeyPatch): vector = couchbase_module.CouchbaseVector("collection_1", _config(couchbase_module)) scopes = [ SimpleNamespace(collections=[SimpleNamespace(name="other")]), diff --git a/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py index f81ed6beea..91cc2e0fdb 100644 --- a/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py +++ b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py @@ -28,7 +28,7 @@ def _build_fake_elasticsearch_modules(): @pytest.fixture -def elasticsearch_ja_module(monkeypatch): +def elasticsearch_ja_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_elasticsearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -39,7 +39,7 @@ def elasticsearch_ja_module(monkeypatch): return importlib.reload(ja_module) -def test_create_collection_cache_hit(elasticsearch_ja_module, monkeypatch): +def test_create_collection_cache_hit(elasticsearch_ja_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -57,7 +57,7 @@ def test_create_collection_cache_hit(elasticsearch_ja_module, monkeypatch): elasticsearch_ja_module.redis_client.set.assert_not_called() -def test_create_collection_create_and_exists_paths(elasticsearch_ja_module, monkeypatch): +def test_create_collection_create_and_exists_paths(elasticsearch_ja_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -87,7 +87,7 @@ def test_create_collection_create_and_exists_paths(elasticsearch_ja_module, monk elasticsearch_ja_module.redis_client.set.assert_called_once() -def test_ja_factory_uses_existing_or_generated_collection(elasticsearch_ja_module, monkeypatch): +def test_ja_factory_uses_existing_or_generated_collection(elasticsearch_ja_module, monkeypatch: pytest.MonkeyPatch): factory = elasticsearch_ja_module.ElasticSearchJaVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py index 48f1f6dc26..d54c105a0f 100644 --- a/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py +++ b/api/providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py @@ -38,7 +38,7 @@ def _build_fake_elasticsearch_modules(): @pytest.fixture -def elasticsearch_module(monkeypatch): +def elasticsearch_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_elasticsearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -287,7 +287,7 @@ def test_search_by_vector_and_full_text(elasticsearch_module): assert "bool" in query -def test_create_and_create_collection_paths(elasticsearch_module, monkeypatch): +def test_create_and_create_collection_paths(elasticsearch_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -331,7 +331,7 @@ def test_create_and_create_collection_paths(elasticsearch_module, monkeypatch): elasticsearch_module.redis_client.set.assert_called_once() -def test_elasticsearch_factory_branches(elasticsearch_module, monkeypatch): +def test_elasticsearch_factory_branches(elasticsearch_module, monkeypatch: pytest.MonkeyPatch): factory = elasticsearch_module.ElasticSearchVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py b/api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py index f9a557ecce..8b197662e3 100644 --- a/api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py +++ b/api/providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py @@ -38,7 +38,7 @@ def _build_fake_hologres_modules(): @pytest.fixture -def hologres_module(monkeypatch): +def hologres_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_hologres_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -266,7 +266,7 @@ def test_delete_handles_existing_and_missing_tables(hologres_module): vector._client.drop_table.assert_called_once_with(vector.table_name) -def test_create_collection_returns_early_when_cache_hits(hologres_module, monkeypatch): +def test_create_collection_returns_early_when_cache_hits(hologres_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = False @@ -281,7 +281,7 @@ def test_create_collection_returns_early_when_cache_hits(hologres_module, monkey hologres_module.redis_client.set.assert_not_called() -def test_create_collection_creates_table_and_indexes(hologres_module, monkeypatch): +def test_create_collection_creates_table_and_indexes(hologres_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = False @@ -313,7 +313,7 @@ def test_create_collection_creates_table_and_indexes(hologres_module, monkeypatc hologres_module.redis_client.set.assert_called_once() -def test_create_collection_raises_when_table_never_becomes_ready(hologres_module, monkeypatch): +def test_create_collection_raises_when_table_never_becomes_ready(hologres_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = False @@ -331,7 +331,7 @@ def test_create_collection_raises_when_table_never_becomes_ready(hologres_module hologres_module.redis_client.set.assert_not_called() -def test_hologres_factory_uses_existing_or_generated_collection(hologres_module, monkeypatch): +def test_hologres_factory_uses_existing_or_generated_collection(hologres_module, monkeypatch: pytest.MonkeyPatch): factory = hologres_module.HologresVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py b/api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py index ba3f14912b..a1617b6d43 100644 --- a/api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py +++ b/api/providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py @@ -29,7 +29,7 @@ def _build_fake_elasticsearch_modules(): @pytest.fixture -def huawei_module(monkeypatch): +def huawei_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_elasticsearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -155,7 +155,7 @@ def test_search_by_vector_and_full_text(huawei_module): assert docs[0].page_content == "text-hit" -def test_search_by_vector_skips_hits_without_metadata(huawei_module, monkeypatch): +def test_search_by_vector_skips_hits_without_metadata(huawei_module, monkeypatch: pytest.MonkeyPatch): class FakeDocument: def __init__(self, page_content, vector, metadata): self.page_content = page_content @@ -185,7 +185,7 @@ def test_search_by_vector_skips_hits_without_metadata(huawei_module, monkeypatch assert docs == [] -def test_create_and_create_collection_paths(huawei_module, monkeypatch): +def test_create_and_create_collection_paths(huawei_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -218,7 +218,7 @@ def test_create_and_create_collection_paths(huawei_module, monkeypatch): huawei_module.redis_client.set.assert_called_once() -def test_huawei_factory_branches(huawei_module, monkeypatch): +def test_huawei_factory_branches(huawei_module, monkeypatch: pytest.MonkeyPatch): factory = huawei_module.HuaweiCloudVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py b/api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py index 8c038e82b9..b4ea6ea6c1 100644 --- a/api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py +++ b/api/providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py @@ -23,7 +23,7 @@ def _build_fake_iris_module(): @pytest.fixture -def iris_module(monkeypatch): +def iris_module(monkeypatch: pytest.MonkeyPatch): monkeypatch.setitem(sys.modules, "iris", _build_fake_iris_module()) import dify_vdb_iris.iris_vector as module @@ -249,7 +249,7 @@ def test_iris_vector_init_get_cursor_and_create(iris_module): vector._create_collection.assert_called_once_with(2) -def test_iris_vector_crud_and_vector_search(iris_module, monkeypatch): +def test_iris_vector_crud_and_vector_search(iris_module, monkeypatch: pytest.MonkeyPatch): with patch.object(iris_module, "get_iris_pool", return_value=MagicMock()): vector = iris_module.IrisVector("collection", _config(iris_module)) @@ -297,7 +297,7 @@ def test_iris_vector_crud_and_vector_search(iris_module, monkeypatch): assert docs[0].metadata["score"] == pytest.approx(0.9) -def test_iris_vector_full_text_search_paths(iris_module, monkeypatch): +def test_iris_vector_full_text_search_paths(iris_module, monkeypatch: pytest.MonkeyPatch): cfg = _config(iris_module, IRIS_TEXT_INDEX=True) with patch.object(iris_module, "get_iris_pool", return_value=MagicMock()): vector = iris_module.IrisVector("collection", cfg) @@ -344,7 +344,7 @@ def test_iris_vector_full_text_search_paths(iris_module, monkeypatch): assert vector_like.search_by_full_text("100%", top_k=1) == [] -def test_iris_vector_delete_create_collection_and_factory(iris_module, monkeypatch): +def test_iris_vector_delete_create_collection_and_factory(iris_module, monkeypatch: pytest.MonkeyPatch): with patch.object(iris_module, "get_iris_pool", return_value=MagicMock()): vector = iris_module.IrisVector("collection", _config(iris_module, IRIS_TEXT_INDEX=True)) diff --git a/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py b/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py index 238145c1d6..4a408d1b10 100644 --- a/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py +++ b/api/providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py @@ -47,7 +47,7 @@ def _build_fake_opensearch_modules(): @pytest.fixture -def lindorm_module(monkeypatch): +def lindorm_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_opensearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -100,7 +100,7 @@ def test_to_opensearch_params_and_init(lindorm_module): assert vector_ugc._routing == "route" -def test_create_refresh_and_add_texts_success(lindorm_module, monkeypatch): +def test_create_refresh_and_add_texts_success(lindorm_module, monkeypatch: pytest.MonkeyPatch): vector = lindorm_module.LindormVectorStore( "collection", _config(lindorm_module), using_ugc=True, routing_value="route" ) @@ -301,7 +301,7 @@ def test_search_by_full_text_success_and_error(lindorm_module): vector.search_by_full_text("hello") -def test_create_collection_paths(lindorm_module, monkeypatch): +def test_create_collection_paths(lindorm_module, monkeypatch: pytest.MonkeyPatch): vector = lindorm_module.LindormVectorStore("collection", _config(lindorm_module), using_ugc=False) with pytest.raises(ValueError, match="cannot be empty"): @@ -331,7 +331,7 @@ def test_create_collection_paths(lindorm_module, monkeypatch): vector._client.indices.create.assert_not_called() -def test_lindorm_factory_branches(lindorm_module, monkeypatch): +def test_lindorm_factory_branches(lindorm_module, monkeypatch: pytest.MonkeyPatch): factory = lindorm_module.LindormVectorStoreFactory() monkeypatch.setattr(lindorm_module.dify_config, "LINDORM_URL", "http://localhost:9200") diff --git a/api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py b/api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py index c22f4304e5..762ec330b2 100644 --- a/api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py +++ b/api/providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py @@ -32,7 +32,7 @@ def _build_fake_mo_vector_modules(): @pytest.fixture -def matrixone_module(monkeypatch): +def matrixone_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_mo_vector_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -70,7 +70,7 @@ def test_matrixone_config_validation(matrixone_module, field, value, message): matrixone_module.MatrixoneConfig.model_validate(values) -def test_get_client_creates_full_text_index_when_cache_misses(matrixone_module, monkeypatch): +def test_get_client_creates_full_text_index_when_cache_misses(matrixone_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -86,7 +86,7 @@ def test_get_client_creates_full_text_index_when_cache_misses(matrixone_module, matrixone_module.redis_client.set.assert_called_once() -def test_get_client_skips_index_creation_when_cache_hits(matrixone_module, monkeypatch): +def test_get_client_skips_index_creation_when_cache_hits(matrixone_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -146,7 +146,7 @@ def test_get_type_and_create_delegate_to_add_texts(matrixone_module): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_get_client_handles_full_text_index_creation_error(matrixone_module, monkeypatch): +def test_get_client_handles_full_text_index_creation_error(matrixone_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -165,7 +165,7 @@ def test_get_client_handles_full_text_index_creation_error(matrixone_module, mon matrixone_module.redis_client.set.assert_not_called() -def test_add_texts_generates_ids_and_inserts(matrixone_module, monkeypatch): +def test_add_texts_generates_ids_and_inserts(matrixone_module, monkeypatch: pytest.MonkeyPatch): vector = matrixone_module.MatrixoneVector("collection_1", _valid_config(matrixone_module)) vector.client = MagicMock() monkeypatch.setattr(matrixone_module.uuid, "uuid4", lambda: "generated-uuid") @@ -224,7 +224,7 @@ def test_search_by_vector_builds_documents(matrixone_module): assert vector.client.query.call_args.kwargs["filter"] == {"document_id": {"$in": ["d-1"]}} -def test_matrixone_factory_uses_existing_or_generated_collection(matrixone_module, monkeypatch): +def test_matrixone_factory_uses_existing_or_generated_collection(matrixone_module, monkeypatch: pytest.MonkeyPatch): factory = matrixone_module.MatrixoneVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py b/api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py index 36c0ed8f6f..730ff9f296 100644 --- a/api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py +++ b/api/providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py @@ -99,7 +99,7 @@ def _build_fake_pymilvus_modules(): @pytest.fixture -def milvus_module(monkeypatch): +def milvus_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_pymilvus_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -327,7 +327,7 @@ def test_process_search_results_and_search_methods(milvus_module): assert "document_id" in vector._client.search.call_args.kwargs["filter"] -def test_create_collection_cache_and_existing_collection(milvus_module, monkeypatch): +def test_create_collection_cache_and_existing_collection(milvus_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -351,7 +351,7 @@ def test_create_collection_cache_and_existing_collection(milvus_module, monkeypa milvus_module.redis_client.set.assert_called() -def test_create_collection_builds_schema_and_indexes(milvus_module, monkeypatch): +def test_create_collection_builds_schema_and_indexes(milvus_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -385,7 +385,7 @@ def test_create_collection_builds_schema_and_indexes(milvus_module, monkeypatch) assert call_kwargs["consistency_level"] == "Session" -def test_factory_initializes_milvus_vector(milvus_module, monkeypatch): +def test_factory_initializes_milvus_vector(milvus_module, monkeypatch: pytest.MonkeyPatch): factory = milvus_module.MilvusVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py b/api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py index 228ea92639..900c75fdab 100644 --- a/api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py +++ b/api/providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py @@ -38,7 +38,7 @@ def _build_fake_clickhouse_connect_module(): @pytest.fixture -def myscale_module(monkeypatch): +def myscale_module(monkeypatch: pytest.MonkeyPatch): fake_module = _build_fake_clickhouse_connect_module() monkeypatch.setitem(sys.modules, "clickhouse_connect", fake_module) @@ -90,7 +90,7 @@ def test_delete_by_ids_short_circuits_on_empty_list(myscale_module): vector._client.command.assert_not_called() -def test_factory_initializes_lower_case_collection_name(myscale_module, monkeypatch): +def test_factory_initializes_lower_case_collection_name(myscale_module, monkeypatch: pytest.MonkeyPatch): factory = myscale_module.MyScaleVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", @@ -160,7 +160,7 @@ def test_create_collection_builds_expected_sql(myscale_module): assert "INDEX text_idx text TYPE fts('tokenizer=unicode')" in sql -def test_add_texts_inserts_rows_and_returns_ids(myscale_module, monkeypatch): +def test_add_texts_inserts_rows_and_returns_ids(myscale_module, monkeypatch: pytest.MonkeyPatch): vector = myscale_module.MyScaleVector("collection_1", _config(myscale_module)) monkeypatch.setattr(myscale_module.uuid, "uuid4", lambda: "generated-uuid") docs = [ diff --git a/api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py b/api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py index 31f9ff3e56..36393cc486 100644 --- a/api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py +++ b/api/providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py @@ -53,7 +53,7 @@ def _build_fake_pyobvector_module(): @pytest.fixture -def oceanbase_module(monkeypatch): +def oceanbase_module(monkeypatch: pytest.MonkeyPatch): monkeypatch.setitem(sys.modules, "pyobvector", _build_fake_pyobvector_module()) import dify_vdb_oceanbase.oceanbase_vector as module @@ -208,7 +208,7 @@ def test_create_delegates_to_collection_and_insert(oceanbase_module): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_create_collection_cache_and_existing_table_short_circuits(oceanbase_module, monkeypatch): +def test_create_collection_cache_and_existing_table_short_circuits(oceanbase_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -234,7 +234,7 @@ def test_create_collection_cache_and_existing_table_short_circuits(oceanbase_mod vector.delete.assert_not_called() -def test_create_collection_happy_path_with_hybrid_and_index(oceanbase_module, monkeypatch): +def test_create_collection_happy_path_with_hybrid_and_index(oceanbase_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -271,7 +271,7 @@ def test_create_collection_happy_path_with_hybrid_and_index(oceanbase_module, mo oceanbase_module.redis_client.set.assert_called_once() -def test_create_collection_error_paths(oceanbase_module, monkeypatch): +def test_create_collection_error_paths(oceanbase_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -308,7 +308,7 @@ def test_create_collection_error_paths(oceanbase_module, monkeypatch): vector._create_collection() -def test_create_collection_fulltext_and_metadata_index_exceptions(oceanbase_module, monkeypatch): +def test_create_collection_fulltext_and_metadata_index_exceptions(oceanbase_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -517,7 +517,7 @@ def test_delete_success_and_exception(oceanbase_module): vector.delete() -def test_oceanbase_factory_uses_existing_or_generated_collection(oceanbase_module, monkeypatch): +def test_oceanbase_factory_uses_existing_or_generated_collection(oceanbase_module, monkeypatch: pytest.MonkeyPatch): factory = oceanbase_module.OceanBaseVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py b/api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py index 09abd625fc..57c9b14d9f 100644 --- a/api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py +++ b/api/providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py @@ -37,7 +37,7 @@ def _build_fake_psycopg2_modules(): @pytest.fixture -def opengauss_module(monkeypatch): +def opengauss_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_psycopg2_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -88,7 +88,7 @@ def test_opengauss_config_validation_rejects_min_greater_than_max(opengauss_modu opengauss_module.OpenGaussConfig.model_validate(values) -def test_init_sets_table_name_and_vector_type(opengauss_module, monkeypatch): +def test_init_sets_table_name_and_vector_type(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) @@ -99,7 +99,7 @@ def test_init_sets_table_name_and_vector_type(opengauss_module, monkeypatch): assert vector.pool is pool -def test_create_index_with_pq_executes_pq_sql(opengauss_module, monkeypatch): +def test_create_index_with_pq_executes_pq_sql(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) @@ -126,7 +126,7 @@ def test_create_index_with_pq_executes_pq_sql(opengauss_module, monkeypatch): opengauss_module.redis_client.set.assert_called_once() -def test_create_index_skips_index_sql_for_large_dimension(opengauss_module, monkeypatch): +def test_create_index_skips_index_sql_for_large_dimension(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) @@ -158,7 +158,7 @@ def test_search_by_vector_validates_top_k(opengauss_module): vector.search_by_vector([0.1, 0.2], top_k=0) -def test_delete_by_ids_short_circuits_with_empty_input(opengauss_module, monkeypatch): +def test_delete_by_ids_short_circuits_with_empty_input(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) vector = opengauss_module.OpenGauss("collection_1", _config(opengauss_module)) @@ -200,7 +200,7 @@ def test_create_calls_collection_insert_and_index(opengauss_module): vector._create_index.assert_called_once_with(2) -def test_create_index_returns_early_on_cache_hit(opengauss_module, monkeypatch): +def test_create_index_returns_early_on_cache_hit(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) @@ -220,7 +220,7 @@ def test_create_index_returns_early_on_cache_hit(opengauss_module, monkeypatch): opengauss_module.redis_client.set.assert_not_called() -def test_create_index_without_pq_executes_standard_index_sql(opengauss_module, monkeypatch): +def test_create_index_without_pq_executes_standard_index_sql(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) @@ -245,7 +245,7 @@ def test_create_index_without_pq_executes_standard_index_sql(opengauss_module, m assert any("embedding_cosine_embedding_collection_1_idx" in query for query in sql) -def test_add_texts_uses_execute_values(opengauss_module, monkeypatch): +def test_add_texts_uses_execute_values(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) vector = opengauss_module.OpenGauss("collection_1", _config(opengauss_module)) @@ -342,7 +342,7 @@ def test_search_by_full_text_validates_top_k(opengauss_module): vector.search_by_full_text("query", top_k=0) -def test_create_collection_cache_and_create_path(opengauss_module, monkeypatch): +def test_create_collection_cache_and_create_path(opengauss_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(opengauss_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) lock = MagicMock() @@ -370,7 +370,7 @@ def test_create_collection_cache_and_create_path(opengauss_module, monkeypatch): opengauss_module.redis_client.set.assert_called_once() -def test_opengauss_factory_uses_existing_or_generated_collection(opengauss_module, monkeypatch): +def test_opengauss_factory_uses_existing_or_generated_collection(opengauss_module, monkeypatch: pytest.MonkeyPatch): factory = opengauss_module.OpenGaussFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py index 843c495d82..d6998f6672 100644 --- a/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py +++ b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py @@ -81,14 +81,15 @@ class OpenSearchConfig(BaseModel): pool_maxsize=20, ) - if self.auth_method == "basic": - logger.info("Using basic authentication for OpenSearch Vector DB") + match self.auth_method: + case AuthMethod.BASIC: + logger.info("Using basic authentication for OpenSearch Vector DB") - params["http_auth"] = (self.user, self.password) - elif self.auth_method == "aws_managed_iam": - logger.info("Using AWS managed IAM role for OpenSearch Vector DB") + params["http_auth"] = (self.user, self.password) + case AuthMethod.AWS_MANAGED_IAM: + logger.info("Using AWS managed IAM role for OpenSearch Vector DB") - params["http_auth"] = self.create_aws_managed_iam_auth() + params["http_auth"] = self.create_aws_managed_iam_auth() return params diff --git a/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py index f2ed7cb6fb..b2b004a4de 100644 --- a/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py +++ b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py @@ -59,7 +59,7 @@ def _build_fake_opensearch_modules(): @pytest.fixture -def opensearch_module(monkeypatch): +def opensearch_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_opensearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -95,7 +95,7 @@ class TestOpenSearchConfig: assert params["connection_class"].__name__ == "Urllib3HttpConnection" assert params["http_auth"] == ("admin", "password") - def test_to_opensearch_params_with_aws_managed_iam(self, opensearch_module, monkeypatch): + def test_to_opensearch_params_with_aws_managed_iam(self, opensearch_module, monkeypatch: pytest.MonkeyPatch): class _Session: def get_credentials(self): return "creds" diff --git a/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py index 1c2921f85b..80bf20e820 100644 --- a/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py +++ b/api/providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py @@ -58,7 +58,7 @@ def _build_fake_opensearch_modules(): @pytest.fixture -def opensearch_module(monkeypatch): +def opensearch_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_opensearch_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -116,7 +116,7 @@ def test_config_validation_for_aws_auth_and_https_fields(opensearch_module): opensearch_module.OpenSearchConfig.model_validate(values) -def test_create_aws_managed_iam_auth(opensearch_module, monkeypatch): +def test_create_aws_managed_iam_auth(opensearch_module, monkeypatch: pytest.MonkeyPatch): class _Session: def get_credentials(self): return "creds" @@ -167,7 +167,7 @@ def test_init_and_create_delegate_calls(opensearch_module): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_add_texts_supports_regular_and_aoss_clients(opensearch_module, monkeypatch): +def test_add_texts_supports_regular_and_aoss_clients(opensearch_module, monkeypatch: pytest.MonkeyPatch): vector = opensearch_module.OpenSearchVector("Collection_1", _config(opensearch_module, aws_service="es")) docs = [ Document(page_content="a", metadata={"doc_id": "1"}), @@ -308,7 +308,7 @@ def test_search_by_full_text_and_filters(opensearch_module): assert query["query"]["bool"]["filter"] == [{"terms": {"metadata.document_id": ["d-1"]}}] -def test_create_collection_cache_and_create_path(opensearch_module, monkeypatch): +def test_create_collection_cache_and_create_path(opensearch_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -331,7 +331,7 @@ def test_create_collection_cache_and_create_path(opensearch_module, monkeypatch) opensearch_module.redis_client.set.assert_called() -def test_opensearch_factory_initializes_expected_collection_name(opensearch_module, monkeypatch): +def test_opensearch_factory_initializes_expected_collection_name(opensearch_module, monkeypatch: pytest.MonkeyPatch): factory = opensearch_module.OpenSearchVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py b/api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py index 678cf876b0..46027c7e44 100644 --- a/api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py +++ b/api/providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py @@ -51,7 +51,7 @@ def _connection_with_cursor(cursor): @pytest.fixture -def oracle_module(monkeypatch): +def oracle_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_oracle_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -94,7 +94,7 @@ def test_oracle_config_validation_autonomous_requirements(oracle_module): ) -def test_init_and_get_type(oracle_module, monkeypatch): +def test_init_and_get_type(oracle_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(oracle_module.oracledb, "create_pool", MagicMock(return_value=pool)) vector = oracle_module.OracleVector("collection_1", _config(oracle_module)) @@ -139,7 +139,7 @@ def test_numpy_converters_and_type_handlers(oracle_module): assert out_float64.dtype == numpy.float64 -def test_get_connection_supports_standard_and_autonomous_paths(oracle_module, monkeypatch): +def test_get_connection_supports_standard_and_autonomous_paths(oracle_module, monkeypatch: pytest.MonkeyPatch): connect = MagicMock(return_value="connection") monkeypatch.setattr(oracle_module.oracledb, "connect", connect) @@ -173,7 +173,7 @@ def test_create_delegates_collection_and_insert(oracle_module): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_add_texts_inserts_and_logs_on_failures(oracle_module, monkeypatch): +def test_add_texts_inserts_and_logs_on_failures(oracle_module, monkeypatch: pytest.MonkeyPatch): vector = oracle_module.OracleVector.__new__(oracle_module.OracleVector) vector.table_name = "embedding_collection_1" vector.input_type_handler = MagicMock() @@ -279,7 +279,7 @@ def _fake_nltk_module(*, missing_data=False): return nltk, nltk_corpus -def test_search_by_full_text_chinese_and_english_paths(oracle_module, monkeypatch): +def test_search_by_full_text_chinese_and_english_paths(oracle_module, monkeypatch: pytest.MonkeyPatch): vector = oracle_module.OracleVector.__new__(oracle_module.OracleVector) vector.table_name = "embedding_collection_1" @@ -305,7 +305,7 @@ def test_search_by_full_text_chinese_and_english_paths(oracle_module, monkeypatc assert "doc_id_0" in en_params -def test_search_by_full_text_empty_query_and_missing_nltk(oracle_module, monkeypatch): +def test_search_by_full_text_empty_query_and_missing_nltk(oracle_module, monkeypatch: pytest.MonkeyPatch): vector = oracle_module.OracleVector.__new__(oracle_module.OracleVector) vector.table_name = "embedding_collection_1" vector._get_connection = MagicMock() @@ -320,7 +320,7 @@ def test_search_by_full_text_empty_query_and_missing_nltk(oracle_module, monkeyp vector.search_by_full_text("english query") -def test_create_collection_cache_and_execute_path(oracle_module, monkeypatch): +def test_create_collection_cache_and_execute_path(oracle_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -346,7 +346,9 @@ def test_create_collection_cache_and_execute_path(oracle_module, monkeypatch): oracle_module.redis_client.set.assert_called_once() -def test_oracle_factory_init_vector_uses_existing_or_generated_collection(oracle_module, monkeypatch): +def test_oracle_factory_init_vector_uses_existing_or_generated_collection( + oracle_module, monkeypatch: pytest.MonkeyPatch +): factory = oracle_module.OracleVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py b/api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py index c3291f7f12..1841e88139 100644 --- a/api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py +++ b/api/providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py @@ -79,7 +79,7 @@ def _patch_both(monkeypatch, module, calls, execute_results=None): @pytest.fixture -def pgvecto_module(monkeypatch): +def pgvecto_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_pgvecto_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -126,7 +126,7 @@ def test_collection_base_has_expected_annotations(pgvecto_module): assert {"id", "text", "meta", "vector"} <= set(annotations) -def test_init_get_type_and_create_delegate(pgvecto_module, monkeypatch): +def test_init_get_type_and_create_delegate(pgvecto_module, monkeypatch: pytest.MonkeyPatch): module, _ = pgvecto_module session_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) @@ -145,7 +145,7 @@ def test_init_get_type_and_create_delegate(pgvecto_module, monkeypatch): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_create_collection_cache_and_sql_execution(pgvecto_module, monkeypatch): +def test_create_collection_cache_and_sql_execution(pgvecto_module, monkeypatch: pytest.MonkeyPatch): module, _ = pgvecto_module session_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) @@ -169,7 +169,7 @@ def test_create_collection_cache_and_sql_execution(pgvecto_module, monkeypatch): module.redis_client.set.assert_called() -def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): +def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch: pytest.MonkeyPatch): module, _ = pgvecto_module init_calls = [] runtime_calls = [] @@ -241,7 +241,7 @@ def test_add_texts_get_ids_and_delete_methods(pgvecto_module, monkeypatch): assert any("DROP TABLE IF EXISTS collection_1" in str(args[0]) for args, _ in runtime_calls) -def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch): +def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch: pytest.MonkeyPatch): module, _ = pgvecto_module init_calls = [] monkeypatch.setattr(module, "create_engine", MagicMock(return_value="engine")) @@ -313,7 +313,7 @@ def test_text_exists_search_and_full_text(pgvecto_module, monkeypatch): assert vector.search_by_full_text("hello") == [] -def test_factory_uses_existing_or_generated_collection(pgvecto_module, monkeypatch): +def test_factory_uses_existing_or_generated_collection(pgvecto_module, monkeypatch: pytest.MonkeyPatch): module, _ = pgvecto_module factory = module.PGVectoRSFactory() dataset_with_index = SimpleNamespace( diff --git a/api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py b/api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py index 99a6e00c16..38e472df63 100644 --- a/api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py +++ b/api/providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py @@ -336,7 +336,7 @@ def test_create_delegates_collection_creation_and_insert(): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_add_texts_uses_execute_values_and_returns_ids(monkeypatch): +def test_add_texts_uses_execute_values_and_returns_ids(monkeypatch: pytest.MonkeyPatch): vector = PGVector.__new__(PGVector) vector.table_name = "embedding_collection_1" @@ -387,7 +387,7 @@ def test_text_get_and_delete_methods(): assert any("DROP TABLE IF EXISTS embedding_collection_1" in sql for sql in executed_sql) -def test_delete_by_ids_handles_empty_undefined_table_and_generic_exception(monkeypatch): +def test_delete_by_ids_handles_empty_undefined_table_and_generic_exception(monkeypatch: pytest.MonkeyPatch): vector = PGVector.__new__(PGVector) vector.table_name = "embedding_collection_1" cursor = MagicMock() @@ -464,7 +464,7 @@ def test_search_by_full_text_branches_for_bigm_and_standard(): assert "bigm_similarity" in cursor.execute.call_args_list[1].args[0] -def test_pgvector_factory_initializes_expected_collection_name(monkeypatch): +def test_pgvector_factory_initializes_expected_collection_name(monkeypatch: pytest.MonkeyPatch): factory = pgvector_module.PGVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py b/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py index b5ff87fc5d..6b0216441b 100644 --- a/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py +++ b/api/providers/vdb/vdb-qdrant/src/dify_vdb_qdrant/qdrant_vector.py @@ -471,7 +471,7 @@ class QdrantVector(BaseVector): def _reload_if_needed(self): if isinstance(self._client, QdrantLocal): - self._client._load() # pyright: ignore[reportPrivateUsage] + self._client._load() @classmethod def _document_from_scored_point( diff --git a/api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py b/api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py index 0ed5491fbe..89ee0a47f1 100644 --- a/api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py +++ b/api/providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py @@ -121,7 +121,7 @@ def _build_fake_qdrant_modules(): @pytest.fixture -def qdrant_module(monkeypatch): +def qdrant_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_qdrant_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -170,7 +170,7 @@ def test_init_and_basic_behaviour(qdrant_module): vector.add_texts.assert_called_once() -def test_create_collection_and_add_texts(qdrant_module, monkeypatch): +def test_create_collection_and_add_texts(qdrant_module, monkeypatch: pytest.MonkeyPatch): vector = qdrant_module.QdrantVector("collection_1", "group-1", _config(qdrant_module)) lock = MagicMock() lock.__enter__.return_value = None @@ -288,7 +288,7 @@ def test_search_and_helper_methods(qdrant_module): assert doc.page_content == "doc" -def test_qdrant_factory_paths(qdrant_module, monkeypatch): +def test_qdrant_factory_paths(qdrant_module, monkeypatch: pytest.MonkeyPatch): factory = qdrant_module.QdrantVectorFactory() dataset = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py b/api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py index f97ad1400a..c5f3a9f847 100644 --- a/api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py +++ b/api/providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py @@ -59,7 +59,7 @@ def _patch_both(monkeypatch, module, session): @pytest.fixture -def relyt_module(monkeypatch): +def relyt_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_relyt_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -97,7 +97,7 @@ def test_relyt_config_validation(relyt_module, field, value, message): relyt_module.RelytConfig.model_validate(values) -def test_init_get_type_and_create_delegate(relyt_module, monkeypatch): +def test_init_get_type_and_create_delegate(relyt_module, monkeypatch: pytest.MonkeyPatch): engine = MagicMock() monkeypatch.setattr(relyt_module, "create_engine", MagicMock(return_value=engine)) vector = relyt_module.RelytVector("collection_1", _config(relyt_module), group_id="group-1") @@ -114,7 +114,7 @@ def test_init_get_type_and_create_delegate(relyt_module, monkeypatch): vector.add_texts.assert_called_once_with(docs, [[0.1, 0.2]]) -def test_create_collection_cache_and_sql_execution(relyt_module, monkeypatch): +def test_create_collection_cache_and_sql_execution(relyt_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -142,7 +142,7 @@ def test_create_collection_cache_and_sql_execution(relyt_module, monkeypatch): relyt_module.redis_client.set.assert_called_once() -def test_add_texts_and_metadata_queries(relyt_module, monkeypatch): +def test_add_texts_and_metadata_queries(relyt_module, monkeypatch: pytest.MonkeyPatch): vector = relyt_module.RelytVector.__new__(relyt_module.RelytVector) vector._collection_name = "collection_1" vector._group_id = "group-1" @@ -212,7 +212,7 @@ def test_delete_by_metadata_field_calls_delete_by_uuids(relyt_module): # 3. delete_by_ids translates to uuids -def test_delete_by_ids_translates_to_uuids(relyt_module, monkeypatch): +def test_delete_by_ids_translates_to_uuids(relyt_module, monkeypatch: pytest.MonkeyPatch): vector = relyt_module.RelytVector.__new__(relyt_module.RelytVector) vector._collection_name = "collection_1" vector.client = MagicMock() @@ -225,7 +225,7 @@ def test_delete_by_ids_translates_to_uuids(relyt_module, monkeypatch): # 4. text_exists True -def test_text_exists_true(relyt_module, monkeypatch): +def test_text_exists_true(relyt_module, monkeypatch: pytest.MonkeyPatch): vector = relyt_module.RelytVector.__new__(relyt_module.RelytVector) vector._collection_name = "collection_1" vector.client = MagicMock() @@ -236,7 +236,7 @@ def test_text_exists_true(relyt_module, monkeypatch): # 5. text_exists False -def test_text_exists_false(relyt_module, monkeypatch): +def test_text_exists_false(relyt_module, monkeypatch: pytest.MonkeyPatch): vector = relyt_module.RelytVector.__new__(relyt_module.RelytVector) vector._collection_name = "collection_1" vector.client = MagicMock() @@ -284,7 +284,7 @@ def test_search_by_vector_filters_by_score_and_ids(relyt_module): # 8. delete commits session -def test_delete_drops_table(relyt_module, monkeypatch): +def test_delete_drops_table(relyt_module, monkeypatch: pytest.MonkeyPatch): vector = relyt_module.RelytVector.__new__(relyt_module.RelytVector) vector._collection_name = "collection_1" vector.client = MagicMock() @@ -295,7 +295,7 @@ def test_delete_drops_table(relyt_module, monkeypatch): session.execute.assert_called_once() -def test_relyt_factory_existing_and_generated_collection(relyt_module, monkeypatch): +def test_relyt_factory_existing_and_generated_collection(relyt_module, monkeypatch: pytest.MonkeyPatch): factory = relyt_module.RelytVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py b/api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py index 62a11e0445..49d4b160cf 100644 --- a/api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py +++ b/api/providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py @@ -77,7 +77,7 @@ def _build_fake_tablestore_module(): @pytest.fixture -def tablestore_module(monkeypatch): +def tablestore_module(monkeypatch: pytest.MonkeyPatch): fake_module = _build_fake_tablestore_module() monkeypatch.setitem(sys.modules, "tablestore", fake_module) @@ -177,7 +177,7 @@ def test_get_by_ids_text_exists_delete_and_wrappers(tablestore_module): vector._delete_table_if_exist.assert_called_once() -def test_create_collection_and_table_index_lifecycle(tablestore_module, monkeypatch): +def test_create_collection_and_table_index_lifecycle(tablestore_module, monkeypatch: pytest.MonkeyPatch): vector = tablestore_module.TableStoreVector("collection_1", _config(tablestore_module)) lock = MagicMock() lock.__enter__.return_value = None @@ -289,7 +289,7 @@ def test_write_row_and_search_helpers(tablestore_module): assert "score" not in docs[0].metadata -def test_tablestore_factory_uses_existing_or_generated_collection(tablestore_module, monkeypatch): +def test_tablestore_factory_uses_existing_or_generated_collection(tablestore_module, monkeypatch: pytest.MonkeyPatch): factory = tablestore_module.TableStoreVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py b/api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py index 299e40ee1e..e1fe227a29 100644 --- a/api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py +++ b/api/providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py @@ -136,7 +136,7 @@ def _build_fake_tencent_modules(): @pytest.fixture -def tencent_module(monkeypatch): +def tencent_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_tencent_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -187,7 +187,7 @@ def test_config_and_init_paths(tencent_module): assert vector._enable_hybrid_search is False -def test_create_collection_branches(tencent_module, monkeypatch): +def test_create_collection_branches(tencent_module, monkeypatch: pytest.MonkeyPatch): vector = tencent_module.TencentVector("collection_1", _config(tencent_module)) lock = MagicMock() @@ -279,7 +279,7 @@ def test_create_add_delete_and_search_behaviour(tencent_module): vector._client.drop_collection.assert_called_once() -def test_tencent_factory_existing_and_generated_collection(tencent_module, monkeypatch): +def test_tencent_factory_existing_and_generated_collection(tencent_module, monkeypatch: pytest.MonkeyPatch): factory = tencent_module.TencentVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py index ece061db67..6283dbb986 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py @@ -246,8 +246,18 @@ class TidbService: userPrefix = item["userPrefix"] if state == "ACTIVE" and len(userPrefix) > 0: cluster_info = tidb_serverless_list_map[item["clusterId"]] - cluster_info.status = TidbAuthBindingStatus.ACTIVE cluster_info.account = f"{userPrefix}.root" + if not cluster_info.qdrant_endpoint: + cluster_info.qdrant_endpoint = TidbService.extract_qdrant_endpoint( + item + ) or TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, item["clusterId"]) + if cluster_info.qdrant_endpoint: + cluster_info.status = TidbAuthBindingStatus.ACTIVE + else: + logger.warning( + "Cluster %s is ACTIVE but qdrant endpoint is not ready; will retry later", + item["clusterId"], + ) db.session.add(cluster_info) db.session.commit() else: diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py index c1ffbacbbc..20a42f6cc3 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py @@ -1,8 +1,11 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from dify_vdb_tidb_on_qdrant.tidb_service import TidbService +from models.enums import TidbAuthBindingStatus + class TestExtractQdrantEndpoint: """Unit tests for TidbService.extract_qdrant_endpoint.""" @@ -216,3 +219,86 @@ class TestBatchCreateEdgeCases: private_key="priv", region="us-east-1", ) + + +class TestBatchUpdateTidbServerlessClusterStatus: + """Verify that status updates only expose clusters after qdrant endpoint is ready.""" + + @patch("dify_vdb_tidb_on_qdrant.tidb_service.db") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + def test_sets_active_when_batch_response_contains_endpoint(self, mock_http, mock_db): + binding = SimpleNamespace( + cluster_id="c-1", + status=TidbAuthBindingStatus.CREATING, + account="root", + qdrant_endpoint=None, + ) + mock_http.get.return_value = MagicMock( + status_code=200, + json=lambda: { + "clusters": [ + { + "clusterId": "c-1", + "state": "ACTIVE", + "userPrefix": "pfx", + "endpoints": {"public": {"host": "gw.tidbcloud.com"}}, + } + ] + }, + ) + + TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv") + + assert binding.account == "pfx.root" + assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com" + assert binding.status == TidbAuthBindingStatus.ACTIVE + mock_db.session.add.assert_called_once_with(binding) + mock_db.session.commit.assert_called_once() + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.db") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + def test_fetches_endpoint_when_batch_response_omits_it(self, mock_http, mock_db, mock_fetch_endpoint): + binding = SimpleNamespace( + cluster_id="c-1", + status=TidbAuthBindingStatus.CREATING, + account="root", + qdrant_endpoint=None, + ) + mock_http.get.return_value = MagicMock( + status_code=200, + json=lambda: {"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]}, + ) + + TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv") + + assert binding.account == "pfx.root" + assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com" + assert binding.status == TidbAuthBindingStatus.ACTIVE + mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1") + mock_db.session.add.assert_called_once_with(binding) + mock_db.session.commit.assert_called_once() + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None) + @patch("dify_vdb_tidb_on_qdrant.tidb_service.db") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + def test_keeps_creating_when_endpoint_is_not_ready(self, mock_http, mock_db, mock_fetch_endpoint): + binding = SimpleNamespace( + cluster_id="c-1", + status=TidbAuthBindingStatus.CREATING, + account="root", + qdrant_endpoint=None, + ) + mock_http.get.return_value = MagicMock( + status_code=200, + json=lambda: {"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]}, + ) + + TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv") + + assert binding.account == "pfx.root" + assert binding.qdrant_endpoint is None + assert binding.status == TidbAuthBindingStatus.CREATING + mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1") + mock_db.session.add.assert_called_once_with(binding) + mock_db.session.commit.assert_called_once() diff --git a/api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py b/api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py index bdbed2f740..ed03cbee88 100644 --- a/api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py +++ b/api/providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py @@ -46,7 +46,7 @@ def test_tidb_config_validation(tidb_module, field, value, message): tidb_module.TiDBVectorConfig.model_validate(values) -def test_init_get_type_and_distance_func(tidb_module, monkeypatch): +def test_init_get_type_and_distance_func(tidb_module, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(tidb_module, "create_engine", MagicMock(return_value="engine")) vector = tidb_module.TiDBVector("collection_1", _config(tidb_module), distance_func="L2") @@ -63,7 +63,7 @@ def test_init_get_type_and_distance_func(tidb_module, monkeypatch): assert vector._get_distance_func() == "VEC_COSINE_DISTANCE" -def test_table_builds_columns_with_tidb_vector_type(tidb_module, monkeypatch): +def test_table_builds_columns_with_tidb_vector_type(tidb_module, monkeypatch: pytest.MonkeyPatch): fake_tidb_vector = types.ModuleType("tidb_vector") fake_tidb_sqlalchemy = types.ModuleType("tidb_vector.sqlalchemy") @@ -107,7 +107,7 @@ def test_create_calls_collection_and_add_texts(tidb_module): assert vector._dimension == 2 -def test_create_collection_skips_when_cache_hit(tidb_module, monkeypatch): +def test_create_collection_skips_when_cache_hit(tidb_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -127,7 +127,7 @@ def test_create_collection_skips_when_cache_hit(tidb_module, monkeypatch): tidb_module.redis_client.set.assert_not_called() -def test_create_collection_executes_create_sql_and_sets_cache(tidb_module, monkeypatch): +def test_create_collection_executes_create_sql_and_sets_cache(tidb_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -160,7 +160,7 @@ def test_create_collection_executes_create_sql_and_sets_cache(tidb_module, monke tidb_module.redis_client.set.assert_called_once() -def test_add_texts_batches_inserts_and_returns_ids(tidb_module, monkeypatch): +def test_add_texts_batches_inserts_and_returns_ids(tidb_module, monkeypatch: pytest.MonkeyPatch): class _InsertStmt: def __init__(self, table): self.table = table @@ -198,7 +198,7 @@ def test_add_texts_batches_inserts_and_returns_ids(tidb_module, monkeypatch): @pytest.fixture -def tidb_vector_with_session(tidb_module, monkeypatch): +def tidb_vector_with_session(tidb_module, monkeypatch: pytest.MonkeyPatch): vector = tidb_module.TiDBVector.__new__(tidb_module.TiDBVector) vector._collection_name = "collection_1" vector._engine = MagicMock() @@ -354,7 +354,7 @@ def test_delete_by_metadata_field_does_nothing_when_no_ids(tidb_module): # Test search_by_vector filters and scores -def test_search_by_vector_filters_and_scores(tidb_module, monkeypatch): +def test_search_by_vector_filters_and_scores(tidb_module, monkeypatch: pytest.MonkeyPatch): session = MagicMock() session.execute.return_value = [ ('{"doc_id":"id-1","document_id":"d-1"}', "text-1", 0.2), @@ -392,7 +392,7 @@ def test_search_by_vector_filters_and_scores(tidb_module, monkeypatch): # Test delete drops table -def test_delete_drops_table(tidb_module, monkeypatch): +def test_delete_drops_table(tidb_module, monkeypatch: pytest.MonkeyPatch): session = MagicMock() session.execute.return_value = None @@ -413,7 +413,7 @@ def test_delete_drops_table(tidb_module, monkeypatch): assert "DROP TABLE IF EXISTS collection_1" in drop_sql -def test_tidb_factory_uses_existing_or_generated_collection(tidb_module, monkeypatch): +def test_tidb_factory_uses_existing_or_generated_collection(tidb_module, monkeypatch: pytest.MonkeyPatch): factory = tidb_module.TiDBVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py b/api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py index a884275c89..55d27ad264 100644 --- a/api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py +++ b/api/providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py @@ -36,7 +36,7 @@ def _build_fake_upstash_module(): @pytest.fixture -def upstash_module(monkeypatch): +def upstash_module(monkeypatch: pytest.MonkeyPatch): # Remove patched modules if present for modname in ["upstash_vector", "dify_vdb_upstash.upstash_vector"]: if modname in sys.modules: @@ -65,7 +65,7 @@ def test_upstash_config_validation(upstash_module, field, value, message): upstash_module.UpstashVectorConfig.model_validate(values) -def test_init_get_type_and_dimension(upstash_module, monkeypatch): +def test_init_get_type_and_dimension(upstash_module, monkeypatch: pytest.MonkeyPatch): vector = upstash_module.UpstashVector("collection_1", _config(upstash_module)) assert vector.get_type() == upstash_module.VectorType.UPSTASH @@ -162,7 +162,7 @@ def test_search_by_vector_filter_threshold_and_delete(upstash_module): vector.index.reset.assert_called_once() -def test_upstash_factory_uses_existing_or_generated_collection(upstash_module, monkeypatch): +def test_upstash_factory_uses_existing_or_generated_collection(upstash_module, monkeypatch: pytest.MonkeyPatch): factory = upstash_module.UpstashVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py b/api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py index 4dfb956c00..32f47c67ed 100644 --- a/api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py +++ b/api/providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py @@ -37,7 +37,7 @@ def _build_fake_psycopg2_modules(): @pytest.fixture -def vastbase_module(monkeypatch): +def vastbase_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_psycopg2_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -93,7 +93,7 @@ def test_vastbase_config_rejects_invalid_connection_window(vastbase_module): ) -def test_init_and_get_cursor_context_manager(vastbase_module, monkeypatch): +def test_init_and_get_cursor_context_manager(vastbase_module, monkeypatch: pytest.MonkeyPatch): pool = MagicMock() monkeypatch.setattr(vastbase_module.psycopg2.pool, "SimpleConnectionPool", MagicMock(return_value=pool)) @@ -114,7 +114,7 @@ def test_init_and_get_cursor_context_manager(vastbase_module, monkeypatch): pool.putconn.assert_called_once_with(conn) -def test_create_and_add_texts(vastbase_module, monkeypatch): +def test_create_and_add_texts(vastbase_module, monkeypatch: pytest.MonkeyPatch): vector = vastbase_module.VastbaseVector.__new__(vastbase_module.VastbaseVector) vector.table_name = "embedding_collection_1" vector._create_collection = MagicMock() @@ -205,7 +205,7 @@ def test_search_by_vector_and_full_text(vastbase_module): assert full_docs[0].page_content == "full-text" -def test_create_collection_cache_and_dimension_branches(vastbase_module, monkeypatch): +def test_create_collection_cache_and_dimension_branches(vastbase_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -240,7 +240,7 @@ def test_create_collection_cache_and_dimension_branches(vastbase_module, monkeyp vastbase_module.redis_client.set.assert_called() -def test_vastbase_factory_uses_existing_or_generated_collection(vastbase_module, monkeypatch): +def test_vastbase_factory_uses_existing_or_generated_collection(vastbase_module, monkeypatch: pytest.MonkeyPatch): factory = vastbase_module.VastbaseVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", diff --git a/api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py b/api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py index 544b8163be..6559ad97d2 100644 --- a/api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py +++ b/api/providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py @@ -79,7 +79,7 @@ def _build_fake_vikingdb_modules(): @pytest.fixture -def vikingdb_module(monkeypatch): +def vikingdb_module(monkeypatch: pytest.MonkeyPatch): for name, module in _build_fake_vikingdb_modules().items(): monkeypatch.setitem(sys.modules, name, module) @@ -117,7 +117,7 @@ def test_init_get_type_and_has_checks(vikingdb_module): assert vector._has_index() is False -def test_create_collection_cache_and_creation_paths(vikingdb_module, monkeypatch): +def test_create_collection_cache_and_creation_paths(vikingdb_module, monkeypatch: pytest.MonkeyPatch): lock = MagicMock() lock.__enter__.return_value = None lock.__exit__.return_value = None @@ -253,7 +253,7 @@ def test_delete_drops_index_and_collection_when_present(vikingdb_module): vector._client.drop_collection.assert_not_called() -def test_vikingdb_factory_validates_config_and_builds_vector(vikingdb_module, monkeypatch): +def test_vikingdb_factory_validates_config_and_builds_vector(vikingdb_module, monkeypatch: pytest.MonkeyPatch): factory = vikingdb_module.VikingDBVectorFactory() dataset_with_index = SimpleNamespace( id="dataset-1", @@ -293,7 +293,9 @@ def test_vikingdb_factory_validates_config_and_builds_vector(vikingdb_module, mo ("VIKINGDB_SCHEME", "VIKINGDB_SCHEME should not be None"), ], ) -def test_vikingdb_factory_raises_when_required_config_missing(vikingdb_module, monkeypatch, field, message): +def test_vikingdb_factory_raises_when_required_config_missing( + vikingdb_module, monkeypatch: pytest.MonkeyPatch, field, message +): factory = vikingdb_module.VikingDBVectorFactory() dataset = SimpleNamespace( id="dataset-1", index_struct_dict={"vector_store": {"class_prefix": "existing"}}, index_struct=None diff --git a/api/pyproject.toml b/api/pyproject.toml index bcbde0842b..95fc38e2c8 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "dify-api" -version = "1.14.0" +version = "1.14.2" requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.43.3", + "boto3>=1.43.6", "celery>=5.6.3", "croniter>=6.2.2", "flask>=3.1.3,<4.0.0", @@ -14,15 +14,14 @@ dependencies = [ "gevent>=26.4.0", "gevent-websocket>=0.10.1", "gmpy2>=2.3.0", - "google-api-python-client>=2.195.0", - "gunicorn>=25.3.0", + "google-api-python-client>=2.196.0", + "gunicorn>=26.0.0", "psycogreen>=1.0.2", "psycopg2-binary>=2.9.12", "python-socketio>=5.13.0", "redis[hiredis]>=7.4.0", "sendgrid>=6.12.5", "sseclient-py>=1.8.0", - # Stable: production-proven, cap below the next major "aliyun-log-python-sdk>=0.9.44,<1.0.0", "azure-identity>=1.25.3,<2.0.0", @@ -31,7 +30,7 @@ dependencies = [ "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", - "google-cloud-aiplatform>=1.149.0,<2.0.0", + "google-cloud-aiplatform>=1.151.0,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", "opentelemetry-distro>=0.62b1,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", @@ -42,10 +41,9 @@ dependencies = [ "opentelemetry-propagator-b3>=1.41.1,<2.0.0", "readabilipy>=0.3.0,<1.0.0", "resend>=2.27.0,<3.0.0", - # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]~=0.7.0", - "graphon~=0.2.2", + "graphon~=0.4.0", "httpx-sse~=0.4.0", "json-repair~=0.59.4", ] @@ -60,6 +58,7 @@ members = ["providers/vdb/*", "providers/trace/*"] exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"] [tool.uv.sources] +dify-agent = { path = "../dify-agent" } dify-vdb-alibabacloud-mysql = { workspace = true } dify-vdb-analyticdb = { workspace = true } dify-vdb-baidu = { workspace = true } @@ -103,6 +102,7 @@ dify-trace-weave = { workspace = true } default-groups = ["storage", "tools", "vdb-all", "trace-all"] package = false override-dependencies = [ + "litellm>=1.83.7", "pyarrow>=18.0.0", ] @@ -114,10 +114,10 @@ override-dependencies = [ ############################################################ dev = [ "coverage>=7.13.4", + "dify-agent", "dotenv-linter>=0.7.0", "faker>=40.15.0", "lxml-stubs>=0.5.1", - "basedpyright>=1.39.3", "ruff>=0.15.12", "pytest>=9.0.3", "pytest-benchmark>=5.2.3", @@ -174,7 +174,7 @@ dev = [ # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "pyrefly>=0.62.0", + "pyrefly>=1.0.0", "xinference-client>=2.7.0", ] @@ -190,7 +190,7 @@ storage = [ "google-cloud-storage>=3.10.1", "opendal>=0.46.0", "oss2>=2.19.1", - "supabase>=2.29.0", + "supabase>=2.30.0", "tos>=2.9.0", ] diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json deleted file mode 100644 index ac0e2a3a53..0000000000 --- a/api/pyrightconfig.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "include": ["."], - "exclude": [ - "tests/", - ".venv", - "migrations/", - "core/rag", - "providers/vdb/", - "providers/trace/*/tests", - ], - "typeCheckingMode": "strict", - "allowedUntypedLibraries": [ - "fastopenapi", - "flask_restx", - "flask_login", - "opentelemetry.instrumentation.celery", - "opentelemetry.instrumentation.flask", - "opentelemetry.instrumentation.httpx", - "opentelemetry.instrumentation.requests", - "opentelemetry.instrumentation.sqlalchemy", - "opentelemetry.instrumentation.redis", - "langfuse", - "cloudscraper", - "readabilipy", - "pypandoc", - "pypdfium2", - "webvtt", - "flask_compress", - "oss2", - "baidubce.auth.bce_credentials", - "baidubce.bce_client_configuration", - "baidubce.services.bos.bos_client", - "clickzetta", - "google.cloud", - "obs", - "qcloud_cos", - "tos", - "gmpy2", - "sendgrid", - "sendgrid.helpers.mail", - "holo_search_sdk.types", - "dify_vdb_qdrant", - "dify_vdb_tidb_on_qdrant" - ], - "reportUnknownMemberType": "hint", - "reportUnknownParameterType": "hint", - "reportUnknownArgumentType": "hint", - "reportUnknownVariableType": "hint", - "reportUnknownLambdaType": "hint", - "reportMissingParameterType": "hint", - "reportMissingTypeArgument": "hint", - "reportUnnecessaryComparison": "hint", - "reportUnnecessaryIsInstance": "hint", - "reportUnnecessaryTypeIgnoreComment": "hint", - "reportAttributeAccessIssue": "hint", - "pythonVersion": "3.12", - "pythonPlatform": "All" -} diff --git a/api/schedule/trigger_provider_refresh_task.py b/api/schedule/trigger_provider_refresh_task.py index df5058d70a..1fefdb9e5a 100644 --- a/api/schedule/trigger_provider_refresh_task.py +++ b/api/schedule/trigger_provider_refresh_task.py @@ -1,6 +1,5 @@ import logging import math -import time from collections.abc import Iterable, Sequence from celery import group @@ -13,16 +12,13 @@ from configs import dify_config from core.trigger.utils.locks import build_trigger_refresh_lock_keys from extensions.ext_database import db from extensions.ext_redis import redis_client +from libs.helper import current_timestamp from models.trigger import TriggerSubscription from tasks.trigger_subscription_refresh_tasks import trigger_subscription_refresh logger = logging.getLogger(__name__) -def _now_ts() -> int: - return int(time.time()) - - def _build_due_filter(now_ts: int): """Build SQLAlchemy filter for due credential or subscription refresh.""" credential_due: ColumnElement[bool] = and_( @@ -54,7 +50,7 @@ def trigger_provider_refresh() -> None: """ Scan due trigger subscriptions and enqueue refresh tasks with in-flight locks. """ - now: int = _now_ts() + now: int = current_timestamp() batch_size: int = int(dify_config.TRIGGER_PROVIDER_REFRESH_BATCH_SIZE) lock_ttl: int = max(300, int(dify_config.TRIGGER_PROVIDER_SUBSCRIPTION_THRESHOLD_SECONDS)) diff --git a/api/services/account_service.py b/api/services/account_service.py index b6554a3de7..6533526b60 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -29,6 +29,7 @@ from extensions.ext_database import db from extensions.ext_redis import redis_client, redis_fallback from libs.datetime_utils import naive_utc_now from libs.helper import RateLimiter, TokenManager +from libs.helper import timezone as validate_timezone from libs.passport import PassportService from libs.password import compare_password, hash_password, valid_password from libs.rsa import generate_key_pair @@ -271,8 +272,9 @@ class AccountService: password: str | None = None, interface_theme: str = "light", is_setup: bool | None = False, + timezone: str | None = None, ) -> Account: - """create account""" + """Create an account, preferring explicit user timezone over language-derived defaults.""" if not FeatureService.get_system_features().is_allow_register and not is_setup: from controllers.console.error import AccountNotFound @@ -302,6 +304,10 @@ class AccountService: password_to_set = base64_password_hashed salt_to_set = base64_salt + resolved_timezone = language_timezone_mapping.get(interface_language, "UTC") + if timezone is not None: + resolved_timezone = validate_timezone(timezone) + account = Account( name=name, email=email, @@ -309,7 +315,7 @@ class AccountService: password_salt=salt_to_set, interface_language=interface_language, interface_theme=interface_theme, - timezone=language_timezone_mapping.get(interface_language, "UTC"), + timezone=resolved_timezone, ) db.session.add(account) @@ -318,11 +324,15 @@ class AccountService: @staticmethod def create_account_and_tenant( - email: str, name: str, interface_language: str, password: str | None = None + email: str, name: str, interface_language: str, password: str | None = None, timezone: str | None = None ) -> Account: - """create account""" + """Create an account and owner workspace.""" account = AccountService.create_account( - email=email, name=name, interface_language=interface_language, password=password + email=email, + name=name, + interface_language=interface_language, + password=password, + timezone=timezone, ) try: @@ -1280,8 +1290,8 @@ class TenantService: """Check member permission""" perms = { "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "remove": [TenantAccountRole.OWNER], - "update": [TenantAccountRole.OWNER], + "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], } if action not in {"add", "remove", "update"}: raise InvalidActionError("Invalid action.") @@ -1299,6 +1309,15 @@ class TenantService: if not ta_operator or ta_operator.role not in perms[action]: raise NoPermissionError(f"No permission to {action} member.") + if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member: + ta_member = db.session.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id) + .limit(1) + ) + if ta_member and ta_member.role == TenantAccountRole.OWNER: + raise NoPermissionError(f"No permission to {action} member.") + @staticmethod def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account): """Remove member from tenant. @@ -1370,6 +1389,7 @@ class TenantService: def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account): """Update member role""" TenantService.check_member_permission(tenant, operator, member, "update") + new_tenant_role = TenantAccountRole(new_role) target_member_join = db.session.scalar( select(TenantAccountJoin) @@ -1380,6 +1400,11 @@ class TenantService: if not target_member_join: raise MemberNotInTenantError("Member not in tenant.") + operator_role = TenantService.get_user_role(operator, tenant) + target_role = TenantAccountRole(target_member_join.role) + if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}): + raise NoPermissionError("No permission to update member.") + if target_member_join.role == new_role: raise RoleAlreadyAssignedError("The provided role is already assigned to the member.") @@ -1394,7 +1419,7 @@ class TenantService: current_owner_join.role = TenantAccountRole.ADMIN # Update the role of the target member - target_member_join.role = TenantAccountRole(new_role) + target_member_join.role = new_tenant_role db.session.commit() @staticmethod @@ -1459,8 +1484,8 @@ class RegisterService: @classmethod def register( cls, - email, - name, + email: str, + name: str, password: str | None = None, open_id: str | None = None, provider: str | None = None, @@ -1468,16 +1493,19 @@ class RegisterService: status: AccountStatus | None = None, is_setup: bool | None = False, create_workspace_required: bool | None = True, + timezone: str | None = None, ) -> Account: - db.session.begin_nested() """Register account""" + db.session.begin_nested() try: + interface_language = get_valid_language(language) account = AccountService.create_account( email=email, name=name, - interface_language=get_valid_language(language), + interface_language=interface_language, password=password, is_setup=is_setup, + timezone=timezone, ) account.status = status or AccountStatus.ACTIVE account.initialized_at = naive_utc_now() diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index 0229a1f43a..aa6b8ffc6e 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -425,7 +425,7 @@ class AppAnnotationService: return {"deleted_count": deleted_count} @classmethod - def batch_import_app_annotations(cls, app_id, file: FileStorage): + def batch_import_app_annotations(cls, app_id: str, file: FileStorage): """ Batch import annotations from CSV file with enhanced security checks. diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 97aaea3395..7ba2b64c74 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -10,7 +10,6 @@ from uuid import uuid4 import yaml from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad -from packaging import version from packaging.version import parse as parse_version from pydantic import BaseModel from sqlalchemy import select @@ -40,6 +39,7 @@ from libs.datetime_utils import naive_utc_now from models import Account, App, AppMode from models.model import AppModelConfig, AppModelConfigDict, IconType from models.workflow import Workflow +from services.dsl_version import check_version_compatibility from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.workflow_draft_variable_service import WorkflowDraftVariableService @@ -64,30 +64,6 @@ class Import(BaseModel): error: str = "" -def _check_version_compatibility(imported_version: str) -> ImportStatus: - """Determine import status based on version comparison""" - try: - current_ver = version.parse(CURRENT_DSL_VERSION) - imported_ver = version.parse(imported_version) - except version.InvalidVersion: - return ImportStatus.FAILED - - # If imported version is newer than current, always return PENDING - if imported_ver > current_ver: - return ImportStatus.PENDING - - # If imported version is older than current's major, return PENDING - if imported_ver.major < current_ver.major: - return ImportStatus.PENDING - - # If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS - if imported_ver.minor < current_ver.minor: - return ImportStatus.COMPLETED_WITH_WARNINGS - - # If imported version equals or is older than current's micro, return COMPLETED - return ImportStatus.COMPLETED - - class PendingData(BaseModel): import_mode: str yaml_content: str @@ -203,7 +179,7 @@ class AppDslService: # check if imported_version is a float-like string if not isinstance(imported_version, str): raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}") - status = _check_version_compatibility(imported_version) + status = check_version_compatibility(imported_version, CURRENT_DSL_VERSION) # Extract app data app_data = data.get("app") diff --git a/api/services/app_service.py b/api/services/app_service.py index a046b909b3..6716833f6c 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -1,9 +1,10 @@ import json import logging -from typing import Any, TypedDict, cast +from typing import Any, Literal, TypedDict, cast import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination +from pydantic import BaseModel, Field from sqlalchemy import select from configs import dify_config @@ -31,39 +32,59 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t logger = logging.getLogger(__name__) +class AppListParams(BaseModel): + page: int = Field(default=1, ge=1) + limit: int = Field(default=20, ge=1, le=100) + mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = "all" + name: str | None = None + tag_ids: list[str] | None = None + is_created_by_me: bool | None = None + + +class CreateAppParams(BaseModel): + name: str = Field(min_length=1) + description: str | None = None + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + api_rph: int = 0 + api_rpm: int = 0 + max_active_requests: int | None = None + + class AppService: - def get_paginate_apps(self, user_id: str, tenant_id: str, args: dict[str, Any]) -> Pagination | None: + def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: """ Get app list with pagination :param user_id: user id :param tenant_id: tenant id - :param args: request args + :param params: query parameters :return: """ filters = [App.tenant_id == tenant_id, App.is_universal == False] - if args["mode"] == "workflow": + if params.mode == "workflow": filters.append(App.mode == AppMode.WORKFLOW) - elif args["mode"] == "completion": + elif params.mode == "completion": filters.append(App.mode == AppMode.COMPLETION) - elif args["mode"] == "chat": + elif params.mode == "chat": filters.append(App.mode == AppMode.CHAT) - elif args["mode"] == "advanced-chat": + elif params.mode == "advanced-chat": filters.append(App.mode == AppMode.ADVANCED_CHAT) - elif args["mode"] == "agent-chat": + elif params.mode == "agent-chat": filters.append(App.mode == AppMode.AGENT_CHAT) - if args.get("is_created_by_me", False): + if params.is_created_by_me: filters.append(App.created_by == user_id) - if args.get("name"): + if params.name: from libs.helper import escape_like_pattern - name = args["name"][:30] + name = params.name[:30] escaped_name = escape_like_pattern(name) filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) - # Check if tag_ids is not empty to avoid WHERE false condition - if args.get("tag_ids") and len(args["tag_ids"]) > 0: - target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, args["tag_ids"]) + if params.tag_ids and len(params.tag_ids) > 0: + target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids) if target_ids and len(target_ids) > 0: filters.append(App.id.in_(target_ids)) else: @@ -71,21 +92,21 @@ class AppService: app_models = db.paginate( sa.select(App).where(*filters).order_by(App.created_at.desc()), - page=args["page"], - per_page=args["limit"], + page=params.page, + per_page=params.limit, error_out=False, ) return app_models - def create_app(self, tenant_id: str, args: dict[str, Any], account: Account) -> App: + def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App: """ Create app :param tenant_id: tenant id - :param args: request args + :param params: app creation parameters :param account: Account instance """ - app_mode = AppMode.value_of(args["mode"]) + app_mode = AppMode.value_of(params.mode) app_template = default_app_templates[app_mode] # get model config @@ -143,15 +164,16 @@ class AppService: default_model_config["model"] = json.dumps(default_model_dict) app = App(**app_template["app"]) - app.name = args["name"] - app.description = args.get("description", "") - app.mode = args["mode"] - app.icon_type = args.get("icon_type", "emoji") - app.icon = args["icon"] - app.icon_background = args["icon_background"] + app.name = params.name + app.description = params.description or "" + app.mode = app_mode + app.icon_type = IconType(params.icon_type) if params.icon_type else IconType.EMOJI + app.icon = params.icon + app.icon_background = params.icon_background app.tenant_id = tenant_id - app.api_rph = args.get("api_rph", 0) - app.api_rpm = args.get("api_rpm", 0) + app.api_rph = params.api_rph + app.api_rpm = params.api_rpm + app.max_active_requests = params.max_active_requests app.created_by = account.id app.updated_by = account.id diff --git a/api/services/attachment_service.py b/api/services/attachment_service.py index 54e664e944..dad7163739 100644 --- a/api/services/attachment_service.py +++ b/api/services/attachment_service.py @@ -22,10 +22,11 @@ class AttachmentService: raise AssertionError("must be a sessionmaker or an Engine.") def get_file_base64(self, file_id: str) -> str: - upload_file = self._session_maker(expire_on_commit=False).scalar( - select(UploadFile).where(UploadFile.id == file_id).limit(1) - ) - if not upload_file: - raise NotFound("File not found") - blob = storage.load_once(upload_file.key) + with self._session_maker(expire_on_commit=False) as session: + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) + if not upload_file: + raise NotFound("File not found") + upload_file_key = upload_file.key + + blob = storage.load_once(upload_file_key) return base64.b64encode(blob).decode() diff --git a/api/services/audio_service.py b/api/services/audio_service.py index 60948e652b..c80b2f43fd 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -54,7 +54,7 @@ class AudioService: if extension not in [f"audio/{ext}" for ext in AUDIO_EXTENSIONS]: raise UnsupportedAudioTypeServiceError() - file_content = file.read() + file_content = file.stream.read() file_size = len(file_content) if file_size > FILE_SIZE_LIMIT: diff --git a/api/services/clear_free_plan_tenant_expired_logs.py b/api/services/clear_free_plan_tenant_expired_logs.py index dcc93b4b0f..3fb340d3a7 100644 --- a/api/services/clear_free_plan_tenant_expired_logs.py +++ b/api/services/clear_free_plan_tenant_expired_logs.py @@ -35,6 +35,21 @@ logger = logging.getLogger(__name__) class ClearFreePlanTenantExpiredLogs: + @staticmethod + def _serialize_record(record: object) -> dict[str, object]: + if hasattr(record, "to_dict"): + return record.to_dict() # type: ignore[no-any-return] + + table = getattr(record, "__table__", None) + columns = getattr(table, "columns", None) + if columns is None: + raise TypeError(f"Unsupported record type for serialization: {type(record)!r}") + + record_dict: dict[str, object] = {} + for column in columns: + record_dict[column.name] = getattr(record, column.name) + return record_dict + @classmethod def _clear_message_related_tables(cls, session: Session, tenant_id: str, batch_message_ids: list[str]): """ @@ -77,14 +92,7 @@ class ClearFreePlanTenantExpiredLogs: record_data = [] for record in records: try: - if hasattr(record, "to_dict"): - record_data.append(record.to_dict()) - else: - # if record doesn't have to_dict method, we need to transform it to dict manually - record_dict = {} - for column in record.__table__.columns: - record_dict[column.name] = getattr(record, column.name) - record_data.append(record_dict) + record_data.append(cls._serialize_record(record)) except Exception: logger.exception("Failed to transform %s record: %s", table_name, record.id) continue @@ -222,7 +230,12 @@ class ClearFreePlanTenantExpiredLogs: f"{tenant_id}/workflow_node_executions/{datetime.datetime.now().strftime('%Y-%m-%d')}" f"-{time.time()}.json", json.dumps( - jsonable_encoder(workflow_node_executions), + jsonable_encoder( + [ + cls._serialize_record(workflow_node_execution) + for workflow_node_execution in workflow_node_executions + ] + ), ).encode("utf-8"), ) diff --git a/api/services/credit_pool_service.py b/api/services/credit_pool_service.py index 2d210db121..2e103dec15 100644 --- a/api/services/credit_pool_service.py +++ b/api/services/credit_pool_service.py @@ -1,9 +1,10 @@ import logging -from sqlalchemy import select, update -from sqlalchemy.orm import sessionmaker +from sqlalchemy import select +from sqlalchemy.orm import Session from configs import dify_config +from core.db.session_factory import session_factory from core.errors.error import QuotaExceededError from extensions.ext_database import db from models import TenantCreditPool @@ -13,6 +14,18 @@ logger = logging.getLogger(__name__) class CreditPoolService: + @staticmethod + def _get_locked_pool(session: Session, tenant_id: str, pool_type: str) -> TenantCreditPool | None: + return session.scalar( + select(TenantCreditPool) + .where( + TenantCreditPool.tenant_id == tenant_id, + TenantCreditPool.pool_type == pool_type, + ) + .limit(1) + .with_for_update() + ) + @classmethod def create_default_pool(cls, tenant_id: str) -> TenantCreditPool: """create default credit pool for new tenant""" @@ -29,7 +42,7 @@ class CreditPoolService: @classmethod def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None: """get tenant credit pool""" - with sessionmaker(db.engine, expire_on_commit=False).begin() as session: + with session_factory.get_session_maker().begin() as session: return session.scalar( select(TenantCreditPool) .where( @@ -59,31 +72,57 @@ class CreditPoolService: credits_required: int, pool_type: str = "trial", ) -> int: - """check and deduct credits, returns actual credits deducted""" - - pool = cls.get_pool(tenant_id, pool_type) - if not pool: - raise QuotaExceededError("Credit pool not found") - - if pool.remaining_credits <= 0: - raise QuotaExceededError("No credits remaining") - - # deduct all remaining credits if less than required - actual_credits = min(credits_required, pool.remaining_credits) + """Deduct exactly the requested credits or raise without mutating the pool.""" + if credits_required <= 0: + return 0 try: - with sessionmaker(db.engine).begin() as session: - stmt = ( - update(TenantCreditPool) - .where( - TenantCreditPool.tenant_id == tenant_id, - TenantCreditPool.pool_type == pool_type, - ) - .values(quota_used=TenantCreditPool.quota_used + actual_credits) - ) - session.execute(stmt) + with session_factory.get_session_maker().begin() as session: + pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type) + if not pool: + raise QuotaExceededError("Credit pool not found") + + remaining_credits = pool.remaining_credits + if remaining_credits <= 0: + raise QuotaExceededError("No credits remaining") + if remaining_credits < credits_required: + raise QuotaExceededError("Insufficient credits remaining") + + pool.quota_used += credits_required + except QuotaExceededError: + raise except Exception: logger.exception("Failed to deduct credits for tenant %s", tenant_id) raise QuotaExceededError("Failed to deduct credits") - return actual_credits + return credits_required + + @classmethod + def deduct_credits_capped( + cls, + tenant_id: str, + credits_required: int, + pool_type: str = "trial", + ) -> int: + """Deduct up to the available balance and return the actual deducted credits.""" + if credits_required <= 0: + return 0 + + try: + with session_factory.get_session_maker().begin() as session: + pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=pool_type) + if not pool: + logger.warning("Credit pool not found, tenant_id=%s, pool_type=%s", tenant_id, pool_type) + return 0 + + deducted_credits = min(credits_required, pool.remaining_credits) + if deducted_credits <= 0: + return 0 + + pool.quota_used += deducted_credits + return deducted_credits + except QuotaExceededError: + raise + except Exception: + logger.exception("Failed to deduct capped credits for tenant %s", tenant_id) + raise QuotaExceededError("Failed to deduct credits") diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index eef38f1ce2..ea8896a5aa 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -7,9 +7,10 @@ import time import uuid from collections import Counter from collections.abc import Sequence -from typing import Any, Literal, TypedDict, cast +from typing import Annotated, Any, Literal, TypedDict, cast import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from redis.exceptions import LockNotOwnedError from sqlalchemy import delete, exists, func, select, update from sqlalchemy.orm import Session, sessionmaker @@ -108,7 +109,7 @@ logger = logging.getLogger(__name__) class ProcessRulesDict(TypedDict): - mode: str + mode: ProcessRuleMode rules: dict[str, Any] @@ -117,6 +118,86 @@ class AutoDisableLogsDict(TypedDict): count: int +class _EstimatePreProcessingRule(BaseModel): + id: str = Field(min_length=1) + enabled: bool + + @field_validator("id") + @classmethod + def _validate_id(cls, v: str) -> str: + if v not in DatasetProcessRule.PRE_PROCESSING_RULES: + raise ValueError("Process rule pre_processing_rules id is invalid") + return v + + +class _EstimateSegmentation(BaseModel): + separator: str = Field(min_length=1) + max_tokens: int = Field(gt=0) + + +class _EstimateRules(BaseModel): + pre_processing_rules: list[_EstimatePreProcessingRule] + segmentation: _EstimateSegmentation + + @field_validator("pre_processing_rules") + @classmethod + def _deduplicate(cls, v: list[_EstimatePreProcessingRule]) -> list[_EstimatePreProcessingRule]: + seen: dict[str, _EstimatePreProcessingRule] = {} + for rule in v: + seen[rule.id] = rule + return list(seen.values()) + + +class _SummaryIndexSettingDisabled(BaseModel): + enable: Literal[False] = False + + +class _SummaryIndexSettingEnabled(BaseModel): + enable: Literal[True] + model_name: str = Field(min_length=1) + model_provider_name: str = Field(min_length=1) + + +_SummaryIndexSetting = Annotated[ + _SummaryIndexSettingDisabled | _SummaryIndexSettingEnabled, + Field(discriminator="enable"), +] + + +class _AutomaticProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.AUTOMATIC] + summary_index_setting: _SummaryIndexSetting | None = None + + +class _CustomProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.CUSTOM] + rules: _EstimateRules + summary_index_setting: _SummaryIndexSetting | None = None + + +class _HierarchicalProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.HIERARCHICAL] + rules: _EstimateRules + summary_index_setting: _SummaryIndexSetting | None = None + + +_EstimateProcessRule = Annotated[ + _AutomaticProcessRule | _CustomProcessRule | _HierarchicalProcessRule, + Field(discriminator="mode"), +] + + +class _EstimateArgs(BaseModel): + info_list: dict[str, Any] + process_rule: _EstimateProcessRule + + class DatasetService: @staticmethod def get_datasets(page, per_page, tenant_id=None, user=None, search=None, tag_ids=None, include_all=False): @@ -204,7 +285,7 @@ class DatasetService: mode = dataset_process_rule.mode rules = dataset_process_rule.rules_dict or {} else: - mode = str(DocumentService.DEFAULT_RULES["mode"]) + mode = ProcessRuleMode(DocumentService.DEFAULT_RULES["mode"]) rules = dict(DocumentService.DEFAULT_RULES.get("rules") or {}) return {"mode": mode, "rules": rules} @@ -669,7 +750,7 @@ class DatasetService: knowledge_index_node_data["embedding_model_provider"] = dataset.embedding_model_provider knowledge_index_node_data["retrieval_model"] = dataset.retrieval_model knowledge_index_node_data["chunk_structure"] = dataset.chunk_structure - knowledge_index_node_data["indexing_technique"] = dataset.indexing_technique # pyright: ignore[reportAttributeAccessIssue] + knowledge_index_node_data["indexing_technique"] = dataset.indexing_technique knowledge_index_node_data["keyword_number"] = dataset.keyword_number knowledge_index_node_data["summary_index_setting"] = dataset.summary_index_setting node["data"] = knowledge_index_node_data @@ -1984,7 +2065,7 @@ class DocumentService: if process_rule.rules: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode(process_rule.mode), rules=process_rule.rules.model_dump_json() if process_rule.rules else None, created_by=account.id, ) @@ -1995,7 +2076,7 @@ class DocumentService: elif process_rule.mode == ProcessRuleMode.AUTOMATIC: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode.AUTOMATIC, rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), created_by=account.id, ) @@ -2572,14 +2653,14 @@ class DocumentService: if process_rule.mode in {ProcessRuleMode.CUSTOM, ProcessRuleMode.HIERARCHICAL}: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode(process_rule.mode), rules=process_rule.rules.model_dump_json() if process_rule.rules else None, created_by=account.id, ) elif process_rule.mode == ProcessRuleMode.AUTOMATIC: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode.AUTOMATIC, rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), created_by=account.id, ) @@ -2851,94 +2932,16 @@ class DocumentService: @classmethod def estimate_args_validate(cls, args: dict[str, Any]): - if "info_list" not in args or not args["info_list"]: - raise ValueError("Data source info is required") - - if not isinstance(args["info_list"], dict): - raise ValueError("Data info is invalid") - - if "process_rule" not in args or not args["process_rule"]: - raise ValueError("Process rule is required") - - if not isinstance(args["process_rule"], dict): - raise ValueError("Process rule is invalid") - - if "mode" not in args["process_rule"] or not args["process_rule"]["mode"]: - raise ValueError("Process rule mode is required") - - if args["process_rule"]["mode"] not in DatasetProcessRule.MODES: - raise ValueError("Process rule mode is invalid") - - if args["process_rule"]["mode"] == ProcessRuleMode.AUTOMATIC: - args["process_rule"]["rules"] = {} - else: - if "rules" not in args["process_rule"] or not args["process_rule"]["rules"]: - raise ValueError("Process rule rules is required") - - if not isinstance(args["process_rule"]["rules"], dict): - raise ValueError("Process rule rules is invalid") - - if ( - "pre_processing_rules" not in args["process_rule"]["rules"] - or args["process_rule"]["rules"]["pre_processing_rules"] is None - ): - raise ValueError("Process rule pre_processing_rules is required") - - if not isinstance(args["process_rule"]["rules"]["pre_processing_rules"], list): - raise ValueError("Process rule pre_processing_rules is invalid") - - unique_pre_processing_rule_dicts = {} - for pre_processing_rule in args["process_rule"]["rules"]["pre_processing_rules"]: - if "id" not in pre_processing_rule or not pre_processing_rule["id"]: - raise ValueError("Process rule pre_processing_rules id is required") - - if pre_processing_rule["id"] not in DatasetProcessRule.PRE_PROCESSING_RULES: - raise ValueError("Process rule pre_processing_rules id is invalid") - - if "enabled" not in pre_processing_rule or pre_processing_rule["enabled"] is None: - raise ValueError("Process rule pre_processing_rules enabled is required") - - if not isinstance(pre_processing_rule["enabled"], bool): - raise ValueError("Process rule pre_processing_rules enabled is invalid") - - unique_pre_processing_rule_dicts[pre_processing_rule["id"]] = pre_processing_rule - - args["process_rule"]["rules"]["pre_processing_rules"] = list(unique_pre_processing_rule_dicts.values()) - - if ( - "segmentation" not in args["process_rule"]["rules"] - or args["process_rule"]["rules"]["segmentation"] is None - ): - raise ValueError("Process rule segmentation is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"], dict): - raise ValueError("Process rule segmentation is invalid") - - if ( - "separator" not in args["process_rule"]["rules"]["segmentation"] - or not args["process_rule"]["rules"]["segmentation"]["separator"] - ): - raise ValueError("Process rule segmentation separator is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"]["separator"], str): - raise ValueError("Process rule segmentation separator is invalid") - - if ( - "max_tokens" not in args["process_rule"]["rules"]["segmentation"] - or not args["process_rule"]["rules"]["segmentation"]["max_tokens"] - ): - raise ValueError("Process rule segmentation max_tokens is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"]["max_tokens"], int): - raise ValueError("Process rule segmentation max_tokens is invalid") - - # valid summary index setting - summary_index_setting = args["process_rule"].get("summary_index_setting") - if summary_index_setting and summary_index_setting.get("enable"): - if "model_name" not in summary_index_setting or not summary_index_setting["model_name"]: - raise ValueError("Summary index model name is required") - if "model_provider_name" not in summary_index_setting or not summary_index_setting["model_provider_name"]: - raise ValueError("Summary index model provider name is required") + try: + validated = _EstimateArgs.model_validate(args) + except ValidationError as e: + first = e.errors()[0] + original = first.get("ctx", {}).get("error") + raise ValueError(str(original) if isinstance(original, ValueError) else first["msg"]) from e + process_rule_dict = validated.process_rule.model_dump(exclude_none=True) + if validated.process_rule.mode == ProcessRuleMode.AUTOMATIC: + process_rule_dict["rules"] = {} + args["process_rule"] = process_rule_dict @staticmethod def batch_update_document_status( diff --git a/api/services/datasource_provider_service.py b/api/services/datasource_provider_service.py index 416bc8cef9..2245adb681 100644 --- a/api/services/datasource_provider_service.py +++ b/api/services/datasource_provider_service.py @@ -49,6 +49,94 @@ class DatasourceProviderService: def __init__(self) -> None: self.provider_manager = PluginDatasourceManager() + @staticmethod + def _should_refresh_credentials(datasource_provider: DatasourceProvider, now: int | None = None) -> bool: + current_time = int(time.time()) if now is None else now + if datasource_provider.expires_at == -1: + return False + return (datasource_provider.expires_at - 60) < current_time + + def _refresh_datasource_credentials( + self, + tenant_id: str, + provider: str, + plugin_id: str, + datasource_provider: DatasourceProvider, + current_user: Any, + ) -> tuple[dict[str, Any], int]: + datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}") + provider_name = datasource_provider_id.provider_name + credential_id = getattr(datasource_provider, "id", None) + credential_name = getattr(datasource_provider, "name", None) + logger.info( + "Refreshing datasource credentials for provider %s", + provider_name, + extra={ + "tenant_id": tenant_id, + "plugin_id": datasource_provider_id.plugin_id, + "provider": provider_name, + "credential_id": credential_id, + "credential_name": credential_name, + "expires_at": datasource_provider.expires_at, + }, + ) + decrypted_credentials = self.decrypt_datasource_provider_credentials( + tenant_id=tenant_id, + datasource_provider=datasource_provider, + plugin_id=plugin_id, + provider=provider, + ) + redirect_uri = ( + f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{datasource_provider_id}/datasource/callback" + ) + system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id) + try: + refreshed_credentials = OAuthHandler().refresh_credentials( + tenant_id=tenant_id, + user_id=current_user.id, + plugin_id=datasource_provider_id.plugin_id, + provider=provider_name, + redirect_uri=redirect_uri, + system_credentials=system_credentials or {}, + credentials=decrypted_credentials, + ) + except Exception as exc: + message = ( + f"Failed to refresh datasource credentials for provider {provider_name}" + f" (credential: {credential_name or credential_id or 'unknown'})" + ) + logger.exception( + message, + extra={ + "tenant_id": tenant_id, + "plugin_id": datasource_provider_id.plugin_id, + "provider": provider_name, + "credential_id": credential_id, + "credential_name": credential_name, + }, + ) + raise ValueError(f"{message}: {exc}") from exc + encrypted_credentials = self.encrypt_datasource_provider_credentials( + tenant_id=tenant_id, + raw_credentials=refreshed_credentials.credentials, + provider=provider, + plugin_id=plugin_id, + datasource_provider=datasource_provider, + ) + logger.info( + "Refreshed datasource credentials for provider %s", + provider_name, + extra={ + "tenant_id": tenant_id, + "plugin_id": datasource_provider_id.plugin_id, + "provider": provider_name, + "credential_id": credential_id, + "credential_name": credential_name, + "expires_at": refreshed_credentials.expires_at, + }, + ) + return encrypted_credentials, refreshed_credentials.expires_at + def remove_oauth_custom_client_params(self, tenant_id: str, datasource_provider_id: DatasourceProviderID): """ remove oauth custom client params @@ -108,7 +196,10 @@ class DatasourceProviderService: credential_id: str | None = None, ) -> dict[str, Any]: """ - get credential by id + Return decrypted datasource credentials. + + If the stored credential is expired or about to expire, this method refreshes + it through plugin-daemon and persists the refreshed credential before returning. """ with sessionmaker(bind=db.engine).begin() as session: if credential_id: @@ -130,39 +221,17 @@ class DatasourceProviderService: ) if not datasource_provider: return {} - # refresh the credentials - if datasource_provider.expires_at != -1 and (datasource_provider.expires_at - 60) < int(time.time()): + if self._should_refresh_credentials(datasource_provider): current_user = get_current_user() - decrypted_credentials = self.decrypt_datasource_provider_credentials( + encrypted_credentials, expires_at = self._refresh_datasource_credentials( tenant_id=tenant_id, - datasource_provider=datasource_provider, - plugin_id=plugin_id, - provider=provider, - ) - datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}") - provider_name = datasource_provider_id.provider_name - redirect_uri = ( - f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/" - f"{datasource_provider_id}/datasource/callback" - ) - system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id) - refreshed_credentials = OAuthHandler().refresh_credentials( - tenant_id=tenant_id, - user_id=current_user.id, - plugin_id=datasource_provider_id.plugin_id, - provider=provider_name, - redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, - credentials=decrypted_credentials, - ) - datasource_provider.encrypted_credentials = self.encrypt_datasource_provider_credentials( - tenant_id=tenant_id, - raw_credentials=refreshed_credentials.credentials, provider=provider, plugin_id=plugin_id, datasource_provider=datasource_provider, + current_user=current_user, ) - datasource_provider.expires_at = refreshed_credentials.expires_at + datasource_provider.encrypted_credentials = encrypted_credentials + datasource_provider.expires_at = expires_at return self.decrypt_datasource_provider_credentials( tenant_id=tenant_id, @@ -178,7 +247,10 @@ class DatasourceProviderService: plugin_id: str, ) -> list[dict[str, Any]]: """ - get all datasource credentials by provider + Return all decrypted datasource credentials for a provider. + + Expired credentials are refreshed independently. A failed credential refresh is + logged and skipped so one broken authorization does not block other credentials. """ with sessionmaker(bind=db.engine).begin() as session: datasource_providers = session.scalars( @@ -193,46 +265,39 @@ class DatasourceProviderService: if not datasource_providers: return [] current_user = get_current_user() - # refresh the credentials real_credentials_list = [] for datasource_provider in datasource_providers: - decrypted_credentials = self.decrypt_datasource_provider_credentials( - tenant_id=tenant_id, - datasource_provider=datasource_provider, - plugin_id=plugin_id, - provider=provider, - ) - datasource_provider_id = DatasourceProviderID(f"{plugin_id}/{provider}") - provider_name = datasource_provider_id.provider_name - redirect_uri = ( - f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/" - f"{datasource_provider_id}/datasource/callback" - ) - system_credentials = self.get_oauth_client(tenant_id, datasource_provider_id) - refreshed_credentials = OAuthHandler().refresh_credentials( - tenant_id=tenant_id, - user_id=current_user.id, - plugin_id=datasource_provider_id.plugin_id, - provider=provider_name, - redirect_uri=redirect_uri, - system_credentials=system_credentials or {}, - credentials=decrypted_credentials, - ) - datasource_provider.encrypted_credentials = self.encrypt_datasource_provider_credentials( - tenant_id=tenant_id, - raw_credentials=refreshed_credentials.credentials, - provider=provider, - plugin_id=plugin_id, - datasource_provider=datasource_provider, - ) - datasource_provider.expires_at = refreshed_credentials.expires_at - real_credentials = self.decrypt_datasource_provider_credentials( - tenant_id=tenant_id, - datasource_provider=datasource_provider, - plugin_id=plugin_id, - provider=provider, - ) - real_credentials_list.append(real_credentials) + try: + if self._should_refresh_credentials(datasource_provider): + encrypted_credentials, expires_at = self._refresh_datasource_credentials( + tenant_id=tenant_id, + provider=provider, + plugin_id=plugin_id, + datasource_provider=datasource_provider, + current_user=current_user, + ) + datasource_provider.encrypted_credentials = encrypted_credentials + datasource_provider.expires_at = expires_at + real_credentials = self.decrypt_datasource_provider_credentials( + tenant_id=tenant_id, + datasource_provider=datasource_provider, + plugin_id=plugin_id, + provider=provider, + ) + real_credentials_list.append(real_credentials) + except Exception: + logger.exception( + "Skipping datasource credentials for provider %s after refresh or decrypt failure", + provider, + extra={ + "tenant_id": tenant_id, + "plugin_id": plugin_id, + "provider": provider, + "credential_id": getattr(datasource_provider, "id", None), + "credential_name": getattr(datasource_provider, "name", None), + "expires_at": getattr(datasource_provider, "expires_at", None), + }, + ) return real_credentials_list diff --git a/api/services/dsl_version.py b/api/services/dsl_version.py new file mode 100644 index 0000000000..cb7384df70 --- /dev/null +++ b/api/services/dsl_version.py @@ -0,0 +1,20 @@ +from packaging import version + +from services.entities.dsl_entities import ImportStatus + + +def check_version_compatibility(imported_version: str, current_version: str) -> ImportStatus: + """Determine DSL import status based on imported and current versions.""" + try: + current_ver = version.parse(current_version) + imported_ver = version.parse(imported_version) + except version.InvalidVersion: + return ImportStatus.FAILED + + if imported_ver > current_ver: + return ImportStatus.PENDING + if imported_ver.major < current_ver.major: + return ImportStatus.PENDING + if imported_ver.minor < current_ver.minor: + return ImportStatus.COMPLETED_WITH_WARNINGS + return ImportStatus.COMPLETED diff --git a/api/services/errors/base.py b/api/services/errors/base.py index 0f9631190f..12eb6f51d0 100644 --- a/api/services/errors/base.py +++ b/api/services/errors/base.py @@ -1,3 +1,3 @@ class BaseServiceError(ValueError): - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): self.description = description diff --git a/api/services/errors/enterprise.py b/api/services/errors/enterprise.py index c9126199fd..60f518dd3c 100644 --- a/api/services/errors/enterprise.py +++ b/api/services/errors/enterprise.py @@ -6,7 +6,7 @@ from services.errors.base import BaseServiceError class EnterpriseServiceError(BaseServiceError): """Base exception for enterprise service errors.""" - def __init__(self, description: str | None = None, status_code: int | None = None): + def __init__(self, description: str = "", status_code: int | None = None): super().__init__(description) self.status_code = status_code @@ -20,26 +20,26 @@ class EnterpriseAPIError(EnterpriseServiceError): class EnterpriseAPINotFoundError(EnterpriseServiceError): """Enterprise API returned 404 Not Found.""" - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): super().__init__(description, status_code=404) class EnterpriseAPIForbiddenError(EnterpriseServiceError): """Enterprise API returned 403 Forbidden.""" - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): super().__init__(description, status_code=403) class EnterpriseAPIUnauthorizedError(EnterpriseServiceError): """Enterprise API returned 401 Unauthorized.""" - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): super().__init__(description, status_code=401) class EnterpriseAPIBadRequestError(EnterpriseServiceError): """Enterprise API returned 400 Bad Request.""" - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): super().__init__(description, status_code=400) diff --git a/api/services/errors/llm.py b/api/services/errors/llm.py index 5bf34f3aa6..407779d795 100644 --- a/api/services/errors/llm.py +++ b/api/services/errors/llm.py @@ -1,9 +1,9 @@ class InvokeError(Exception): """Base class for all LLM exceptions.""" - description: str | None = None + description: str = "" - def __init__(self, description: str | None = None): + def __init__(self, description: str = ""): self.description = description def __str__(self): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 9477c28bf3..257c4bea9a 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -166,7 +166,7 @@ class SystemFeatureModel(BaseModel): enable_email_code_login: bool = False enable_email_password_login: bool = True enable_social_oauth_login: bool = False - enable_collaboration_mode: bool = False + enable_collaboration_mode: bool = True is_allow_register: bool = False is_allow_create_workspace: bool = False is_email_setup: bool = False diff --git a/api/services/file_service.py b/api/services/file_service.py index f60afe2f19..4d3afcc9ad 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -107,15 +107,14 @@ class FileService: hash=hashlib.sha3_256(content).hexdigest(), source_url=source_url, ) - # The `UploadFile` ID is generated within its constructor, so flushing to retrieve the ID is unnecessary. - # We can directly generate the `source_url` here before committing. - if not upload_file.source_url: - upload_file.source_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id) with self._session_maker(expire_on_commit=False) as session: session.add(upload_file) session.commit() + if not upload_file.source_url: + upload_file.source_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id) + return upload_file @staticmethod @@ -132,12 +131,13 @@ class FileService: return file_size <= file_size_limit def get_file_base64(self, file_id: str) -> str: - upload_file = self._session_maker(expire_on_commit=False).scalar( - select(UploadFile).where(UploadFile.id == file_id).limit(1) - ) - if not upload_file: - raise NotFound("File not found") - blob = storage.load_once(upload_file.key) + with self._session_maker(expire_on_commit=False) as session: + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) + if not upload_file: + raise NotFound("File not found") + upload_file_key = upload_file.key + + blob = storage.load_once(upload_file_key) return base64.b64encode(blob).decode() def upload_text(self, text: str, text_name: str, user_id: str, tenant_id: str) -> UploadFile: @@ -173,12 +173,14 @@ class FileService: return upload_file - def get_file_preview(self, file_id: str): + def get_file_preview(self, file_id: str, tenant_id: str): """ Return a short text preview extracted from a document file. """ with self._session_maker(expire_on_commit=False) as session: - upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) + upload_file = session.scalar( + select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id).limit(1) + ) if not upload_file: raise NotFound("File not found") diff --git a/api/services/hit_testing_service.py b/api/services/hit_testing_service.py index 2e5987dd28..42c531ae48 100644 --- a/api/services/hit_testing_service.py +++ b/api/services/hit_testing_service.py @@ -3,6 +3,8 @@ import logging import time from typing import Any, TypedDict, cast +from sqlalchemy import select + from core.app.app_config.entities import ModelConfig from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.index_processor.constant.query_type import QueryType @@ -13,6 +15,7 @@ from extensions.ext_database import db from graphon.model_runtime.entities import LLMMode from models import Account from models.dataset import Dataset, DatasetQuery +from models.dataset import Document as DatasetDocument from models.enums import CreatorUserRole, DatasetQuerySource logger = logging.getLogger(__name__) @@ -41,6 +44,59 @@ class HitTestingRetrievalModelDict(DefaultRetrievalModelDict, total=False): class HitTestingService: + @staticmethod + def _dump_dataset_document(document: DatasetDocument) -> dict[str, Any]: + return { + "id": document.id, + "data_source_type": document.data_source_type, + "name": document.name, + "doc_type": document.doc_type, + "doc_metadata": document.doc_metadata, + } + + @classmethod + def _dump_retrieval_records(cls, records: list[Any]) -> list[dict[str, Any]]: + dumped_records = [record.model_dump() for record in records] + document_ids = { + segment.get("document_id") + for record in dumped_records + if isinstance(record, dict) + for segment in [record.get("segment")] + if isinstance(segment, dict) and segment.get("document_id") + } + if not document_ids: + return dumped_records + + documents = { + document.id: cls._dump_dataset_document(document) + for document in db.session.scalars( + select(DatasetDocument).where(DatasetDocument.id.in_(document_ids)) + ).all() + } + + records_with_documents: list[dict[str, Any]] = [] + missing_document_ids: set[str] = set() + for record in dumped_records: + segment = record.get("segment") + if not isinstance(segment, dict): + records_with_documents.append(record) + continue + + document_id = segment.get("document_id") + if document_id in documents: + segment["document"] = documents[document_id] + records_with_documents.append(record) + elif document_id: + missing_document_ids.add(document_id) + + if missing_document_ids: + logger.warning( + "Skipping hit-testing records with missing documents, document_ids=%s", + sorted(missing_document_ids), + ) + + return records_with_documents + @classmethod def retrieve( cls, @@ -174,7 +230,7 @@ class HitTestingService: "query": { "content": query, }, - "records": [record.model_dump() for record in records], + "records": cls._dump_retrieval_records(records), } @classmethod diff --git a/api/services/model_load_balancing_service.py b/api/services/model_load_balancing_service.py index c269346f5f..15be7d5af3 100644 --- a/api/services/model_load_balancing_service.py +++ b/api/services/model_load_balancing_service.py @@ -1,6 +1,6 @@ import json import logging -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from sqlalchemy import or_, select @@ -620,7 +620,7 @@ class ModelLoadBalancingService: for key, value in credentials.items(): if key in provider_credential_secret_variables: - credentials[key] = encrypter.encrypt_token(tenant_id, value) + credentials[key] = encrypter.encrypt_token(tenant_id, cast(str, value)) return credentials diff --git a/api/services/plugin/plugin_migration.py b/api/services/plugin/plugin_migration.py index 43a726b100..8fa3c3d4ef 100644 --- a/api/services/plugin/plugin_migration.py +++ b/api/services/plugin/plugin_migration.py @@ -16,6 +16,7 @@ from pydantic import TypeAdapter from sqlalchemy import func, select from sqlalchemy.orm import Session +from configs import dify_config from core.agent.entities import AgentToolEntity from core.helper import marketplace from core.plugin.entities.plugin import PluginInstallationSource @@ -310,6 +311,8 @@ class PluginMigration: """ Fetch plugin unique identifier using plugin id. """ + if not dify_config.MARKETPLACE_ENABLED: + return None plugin_manifest = marketplace.batch_fetch_plugin_manifests([plugin_id]) if not plugin_manifest: return None @@ -542,6 +545,11 @@ class PluginMigration: """ Install plugins for a tenant. """ + if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED: + raise ValueError( + "Marketplace disabled in offline mode; cannot bulk-install plugins. " + "Pre-upload plugin packages via Console first." + ) manager = PluginInstaller() # download all the plugins and upload diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index ca83742d65..72271c55d8 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -73,35 +73,43 @@ class PluginService: cache_not_exists.append(plugin_id) if cache_not_exists: - manifests = { - manifest.plugin_id: manifest - for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists) - } - - for plugin_id, manifest in manifests.items(): - latest_plugin = PluginService.LatestPluginCache( - plugin_id=plugin_id, - version=manifest.latest_version, - unique_identifier=manifest.latest_package_identifier, - status=manifest.status, - deprecated_reason=manifest.deprecated_reason, - alternative_plugin_id=manifest.alternative_plugin_id, + if not dify_config.MARKETPLACE_ENABLED: + logger.info( + "Marketplace disabled; skipping latest-plugins metadata fetch for %d ids", + len(cache_not_exists), ) + for plugin_id in cache_not_exists: + result[plugin_id] = None + else: + manifests = { + manifest.plugin_id: manifest + for manifest in marketplace.batch_fetch_plugin_manifests(cache_not_exists) + } - # Store in Redis - redis_client.setex( - f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}", - PluginService.REDIS_TTL, - latest_plugin.model_dump_json(), - ) + for plugin_id, manifest in manifests.items(): + latest_plugin = PluginService.LatestPluginCache( + plugin_id=plugin_id, + version=manifest.latest_version, + unique_identifier=manifest.latest_package_identifier, + status=manifest.status, + deprecated_reason=manifest.deprecated_reason, + alternative_plugin_id=manifest.alternative_plugin_id, + ) - result[plugin_id] = latest_plugin + # Store in Redis + redis_client.setex( + f"{PluginService.REDIS_KEY_PREFIX}{plugin_id}", + PluginService.REDIS_TTL, + latest_plugin.model_dump_json(), + ) - # pop plugin_id from cache_not_exists - cache_not_exists.remove(plugin_id) + result[plugin_id] = latest_plugin - for plugin_id in cache_not_exists: - result[plugin_id] = None + # pop plugin_id from cache_not_exists + cache_not_exists.remove(plugin_id) + + for plugin_id in cache_not_exists: + result[plugin_id] = None return result except Exception: diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index 9db6682e10..91d917b0b1 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -1350,6 +1350,12 @@ class RagPipelineService: ) return workflow_node_execution_db_model + def _fetch_recommended_plugin_manifests(self, plugin_ids: list[str]) -> list[Any]: + if not dify_config.MARKETPLACE_ENABLED: + logger.info("Marketplace disabled; recommended-plugins list empty") + return [] + return marketplace.batch_fetch_plugin_by_ids(plugin_ids) + def get_recommended_plugins(self, type: str) -> dict[str, Any]: # Query active recommended plugins stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True) @@ -1372,7 +1378,7 @@ class RagPipelineService: ) providers_map = {provider.plugin_id: provider.to_dict() for provider in providers} - plugin_manifests = marketplace.batch_fetch_plugin_by_ids(plugin_ids) + plugin_manifests = self._fetch_recommended_plugin_manifests(plugin_ids) plugin_manifests_map = {manifest["plugin_id"]: manifest for manifest in plugin_manifests} installed_plugin_list = [] diff --git a/api/services/rag_pipeline/rag_pipeline_dsl_service.py b/api/services/rag_pipeline/rag_pipeline_dsl_service.py index f315d053cb..37ebffbeb4 100644 --- a/api/services/rag_pipeline/rag_pipeline_dsl_service.py +++ b/api/services/rag_pipeline/rag_pipeline_dsl_service.py @@ -13,7 +13,6 @@ import yaml # type: ignore from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from flask_login import current_user -from packaging import version from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session @@ -37,6 +36,7 @@ from models import Account from models.dataset import Dataset, DatasetCollectionBinding, Pipeline from models.enums import CollectionBindingType, DatasetRuntimeMode from models.workflow import Workflow, WorkflowType +from services.dsl_version import check_version_compatibility from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus from services.entities.knowledge_entities.rag_pipeline_entities import ( IconInfo, @@ -64,30 +64,6 @@ class RagPipelineImportInfo(BaseModel): dataset_id: str | None = None -def _check_version_compatibility(imported_version: str) -> ImportStatus: - """Determine import status based on version comparison""" - try: - current_ver = version.parse(CURRENT_DSL_VERSION) - imported_ver = version.parse(imported_version) - except version.InvalidVersion: - return ImportStatus.FAILED - - # If imported version is newer than current, always return PENDING - if imported_ver > current_ver: - return ImportStatus.PENDING - - # If imported version is older than current's major, return PENDING - if imported_ver.major < current_ver.major: - return ImportStatus.PENDING - - # If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS - if imported_ver.minor < current_ver.minor: - return ImportStatus.COMPLETED_WITH_WARNINGS - - # If imported version equals or is older than current's micro, return COMPLETED - return ImportStatus.COMPLETED - - class RagPipelinePendingData(BaseModel): import_mode: str yaml_content: str @@ -100,6 +76,13 @@ class CheckDependenciesPendingData(BaseModel): class RagPipelineDslService: + """Import, export, and inspect RAG pipeline DSL using the caller-owned session. + + Controllers wrap this service in a SQLAlchemy transaction context, so methods must only flush interim changes when + generated IDs are needed. Committing inside the service would close the caller's transaction and break later work in + the same context manager. + """ + def __init__(self, session: Session): self._session = session @@ -195,7 +178,7 @@ class RagPipelineDslService: # check if imported_version is a float-like string if not isinstance(imported_version, str): raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}") - status = _check_version_compatibility(imported_version) + status = check_version_compatibility(imported_version, CURRENT_DSL_VERSION) # Extract app data pipeline_data = data.get("rag_pipeline") @@ -325,7 +308,7 @@ class RagPipelineDslService: type=CollectionBindingType.DATASET, ) self._session.add(dataset_collection_binding) - self._session.commit() + self._session.flush() dataset_collection_binding_id = dataset_collection_binding.id dataset.collection_binding_id = dataset_collection_binding_id dataset.embedding_model = knowledge_configuration.embedding_model @@ -337,7 +320,7 @@ class RagPipelineDslService: dataset.summary_index_setting = knowledge_configuration.summary_index_setting dataset.pipeline_id = pipeline.id self._session.add(dataset) - self._session.commit() + self._session.flush() dataset_id = dataset.id if not dataset_id: raise ValueError("DSL is not valid, please check the Knowledge Index node.") @@ -462,7 +445,7 @@ class RagPipelineDslService: type=CollectionBindingType.DATASET, ) self._session.add(dataset_collection_binding) - self._session.commit() + self._session.flush() dataset_collection_binding_id = dataset_collection_binding.id dataset.collection_binding_id = dataset_collection_binding_id dataset.embedding_model = knowledge_configuration.embedding_model @@ -474,7 +457,7 @@ class RagPipelineDslService: dataset.summary_index_setting = knowledge_configuration.summary_index_setting dataset.pipeline_id = pipeline.id self._session.add(dataset) - self._session.commit() + self._session.flush() dataset_id = dataset.id if not dataset_id: raise ValueError("DSL is not valid, please check the Knowledge Index node.") @@ -585,7 +568,7 @@ class RagPipelineDslService: pipeline.id = str(uuid4()) self._session.add(pipeline) - self._session.commit() + self._session.flush() # save dependencies if dependencies: redis_client.setex( @@ -627,8 +610,8 @@ class RagPipelineDslService: workflow.environment_variables = environment_variables workflow.conversation_variables = conversation_variables workflow.rag_pipeline_variables = rag_pipeline_variables_list - # commit db session changes - self._session.commit() + # Keep transaction ownership with the caller while materializing IDs and constraint checks before returning. + self._session.flush() return pipeline diff --git a/api/services/rag_pipeline/rag_pipeline_manage_service.py b/api/services/rag_pipeline/rag_pipeline_manage_service.py index 0908d30c12..eaf797760f 100644 --- a/api/services/rag_pipeline/rag_pipeline_manage_service.py +++ b/api/services/rag_pipeline/rag_pipeline_manage_service.py @@ -1,7 +1,11 @@ +import logging + from core.plugin.entities.plugin_daemon import PluginDatasourceProviderEntity from core.plugin.impl.datasource import PluginDatasourceManager from services.datasource_provider_service import DatasourceProviderService +logger = logging.getLogger(__name__) + class RagPipelineManageService: @staticmethod @@ -15,9 +19,21 @@ class RagPipelineManageService: datasources = manager.fetch_datasource_providers(tenant_id) for datasource in datasources: datasource_provider_service = DatasourceProviderService() - credentials = datasource_provider_service.get_datasource_credentials( - tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id - ) - if credentials: - datasource.is_authorized = True + try: + credentials = datasource_provider_service.get_datasource_credentials( + tenant_id=tenant_id, provider=datasource.provider, plugin_id=datasource.plugin_id + ) + if credentials: + datasource.is_authorized = True + except Exception: + logger.exception( + "Skipping datasource credentials for provider %s after refresh or decrypt failure", + datasource.provider, + extra={ + "tenant_id": tenant_id, + "plugin_id": datasource.plugin_id, + "provider": datasource.provider, + }, + ) + return datasources diff --git a/api/services/rag_pipeline/rag_pipeline_transform_service.py b/api/services/rag_pipeline/rag_pipeline_transform_service.py index f08ec7474b..f95519fc9e 100644 --- a/api/services/rag_pipeline/rag_pipeline_transform_service.py +++ b/api/services/rag_pipeline/rag_pipeline_transform_service.py @@ -9,6 +9,7 @@ import yaml from flask_login import current_user from sqlalchemy import select +from configs import dify_config from constants import DOCUMENT_EXTENSIONS from core.plugin.impl.plugin import PluginInstaller from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType @@ -273,6 +274,13 @@ class RagPipelineTransformService: plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier") plugin_id = plugin_unique_identifier.split(":")[0] if plugin_id not in installed_plugins_ids: + if not dify_config.MARKETPLACE_ENABLED: + logger.warning( + "Marketplace disabled; skipping auto-install of %s. " + "Pre-install via Console if pipeline requires it.", + plugin_id, + ) + continue plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore if plugin_unique_identifier: need_install_plugin_unique_identifiers.append(plugin_unique_identifier) diff --git a/api/services/recommend_app/category_order.py b/api/services/recommend_app/category_order.py new file mode 100644 index 0000000000..be6b112aa4 --- /dev/null +++ b/api/services/recommend_app/category_order.py @@ -0,0 +1,49 @@ +"""Apply Redis-backed category ordering for DB-backed Explore apps.""" + +import json +import logging +from collections.abc import Collection +from typing import Any + +from extensions.ext_redis import redis_client + +logger = logging.getLogger(__name__) + +EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order" + + +def _category_order_key(language: str) -> str: + return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}" + + +def get_explore_app_category_order(language: str) -> list[str]: + try: + raw_categories = redis_client.get(_category_order_key(language)) + except Exception: + logger.exception("Failed to read explore app category order from Redis.") + return [] + + if not raw_categories: + return [] + + if isinstance(raw_categories, bytes): + raw_categories = raw_categories.decode("utf-8") + + try: + categories: Any = json.loads(raw_categories) + except (TypeError, json.JSONDecodeError): + logger.warning("Invalid explore app category order payload for language %s.", language) + return [] + + if not isinstance(categories, list): + return [] + + return [category for category in categories if isinstance(category, str)] + + +def order_categories(categories: Collection[str], language: str) -> list[str]: + configured_order = get_explore_app_category_order(language) + if configured_order: + return configured_order + + return sorted(categories) diff --git a/api/services/recommend_app/database/database_retrieval.py b/api/services/recommend_app/database/database_retrieval.py index 1df5fd13b6..ac870f0700 100644 --- a/api/services/recommend_app/database/database_retrieval.py +++ b/api/services/recommend_app/database/database_retrieval.py @@ -6,6 +6,7 @@ from constants.languages import languages from extensions.ext_database import db from models.model import App, RecommendedApp from services.app_dsl_service import AppDslService +from services.recommend_app.category_order import order_categories from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase from services.recommend_app.recommend_app_type import RecommendAppType @@ -18,7 +19,7 @@ class RecommendedAppItemDict(TypedDict): copyright: Any privacy_policy: Any custom_disclaimer: str - category: str + categories: list[str] position: int is_listed: bool @@ -80,6 +81,7 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): if not site: continue + app_categories = recommended_app.categories or [] recommended_app_result: RecommendedAppItemDict = { "id": recommended_app.id, "app": recommended_app.app, @@ -88,15 +90,18 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase): "copyright": site.copyright, "privacy_policy": site.privacy_policy, "custom_disclaimer": site.custom_disclaimer, - "category": recommended_app.category, + "categories": app_categories, "position": recommended_app.position, "is_listed": recommended_app.is_listed, } recommended_apps_result.append(recommended_app_result) - categories.add(recommended_app.category) + categories.update(app_categories) - return RecommendedAppsResultDict(recommended_apps=recommended_apps_result, categories=sorted(categories)) + return RecommendedAppsResultDict( + recommended_apps=recommended_apps_result, + categories=order_categories(categories, language), + ) @classmethod def fetch_recommended_app_detail_from_db(cls, app_id: str) -> RecommendedAppDetailDict | None: diff --git a/api/services/recommend_app/remote/remote_retrieval.py b/api/services/recommend_app/remote/remote_retrieval.py index 5818be0480..0603e4c482 100644 --- a/api/services/recommend_app/remote/remote_retrieval.py +++ b/api/services/recommend_app/remote/remote_retrieval.py @@ -13,7 +13,10 @@ logger = logging.getLogger(__name__) class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): """ - Retrieval recommended app from dify official + Retrieval recommended app from dify official. + + The remote `/apps` payload is already curated for display, including category order. + Keep the response order intact so Explore matches the template service. """ def get_recommend_app_detail(self, app_id: str): @@ -64,8 +67,4 @@ class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase): raise ValueError(f"fetch recommended apps failed, status code: {response.status_code}") result: dict[str, Any] = response.json() - - if "categories" in result: - result["categories"] = sorted(result["categories"]) - return result diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 134dd37a3e..4e189e6e7c 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -47,7 +47,9 @@ class RecommendedAppService: """ mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)() - result: dict[str, Any] = retrieval_instance.get_recommend_app_detail(app_id) + result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id) + if result is None: + return None if FeatureService.get_system_features().enable_trial_app: app_id = result["id"] trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) diff --git a/api/services/retention/conversation/message_export_service.py b/api/services/retention/conversation/message_export_service.py index fbe0d2795d..8d7fedf2c1 100644 --- a/api/services/retention/conversation/message_export_service.py +++ b/api/services/retention/conversation/message_export_service.py @@ -228,7 +228,7 @@ class AppMessageExportService: Message.conversation_id, Message.query, Message.answer, - Message._inputs, # pyright: ignore[reportPrivateUsage] + Message._inputs, Message.message_metadata, Message.created_at, ) diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 1882c855ea..09d49d8b3e 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -1,9 +1,11 @@ import uuid +from typing import cast import sqlalchemy as sa from flask_login import current_user from pydantic import BaseModel, Field -from sqlalchemy import func, select +from sqlalchemy import delete, func, select +from sqlalchemy.engine import CursorResult from werkzeug.exceptions import NotFound from extensions.ext_database import db @@ -19,7 +21,6 @@ class SaveTagPayload(BaseModel): class UpdateTagPayload(BaseModel): name: str = Field(min_length=1, max_length=50) - type: TagType class TagBindingCreatePayload(BaseModel): @@ -29,7 +30,7 @@ class TagBindingCreatePayload(BaseModel): class TagBindingDeletePayload(BaseModel): - tag_id: str + tag_ids: list[str] = Field(min_length=1) target_id: str type: TagType @@ -178,13 +179,18 @@ class TagService: @staticmethod def delete_tag_binding(payload: TagBindingDeletePayload): TagService.check_target_exists(payload.type, payload.target_id) - tag_binding = db.session.scalar( - select(TagBinding) - .where(TagBinding.target_id == payload.target_id, TagBinding.tag_id == payload.tag_id) - .limit(1) + result = cast( + CursorResult, + db.session.execute( + delete(TagBinding).where( + TagBinding.target_id == payload.target_id, + TagBinding.tag_id.in_(payload.tag_ids), + TagBinding.tenant_id == current_user.current_tenant_id, + ) + ), ) - if tag_binding: - db.session.delete(tag_binding) + + if result.rowcount: db.session.commit() @staticmethod diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index b8242ab3a5..20de1f4058 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -408,7 +408,7 @@ class BuiltinToolManageService: return {"result": "success"} @staticmethod - def set_default_provider(tenant_id: str, user_id: str, provider: str, id: str): + def set_default_provider(tenant_id: str, provider: str, id: str): """ set default provider """ @@ -422,12 +422,11 @@ class BuiltinToolManageService: if target_provider is None: raise ValueError("provider not found") - # clear default provider + # clear default provider (tenant-scoped: only one default per provider per workspace) session.execute( update(BuiltinToolProvider) .where( BuiltinToolProvider.tenant_id == tenant_id, - BuiltinToolProvider.user_id == user_id, BuiltinToolProvider.provider == provider, BuiltinToolProvider.is_default.is_(True), ) diff --git a/api/services/trigger/trigger_subscription_builder_service.py b/api/services/trigger/trigger_subscription_builder_service.py index 889717df72..cff735b39d 100644 --- a/api/services/trigger/trigger_subscription_builder_service.py +++ b/api/services/trigger/trigger_subscription_builder_service.py @@ -121,9 +121,7 @@ class TriggerSubscriptionBuilderService: if not subscription_builder.name: raise ValueError("Subscription builder name is required") - credential_type = CredentialType.of( - subscription_builder.credential_type or CredentialType.UNAUTHORIZED.value - ) + credential_type = CredentialType.of(subscription_builder.credential_type or CredentialType.UNAUTHORIZED) if credential_type == CredentialType.UNAUTHORIZED: # manually create TriggerProviderService.add_trigger_subscription( @@ -321,9 +319,7 @@ class TriggerSubscriptionBuilderService: raise ValueError("Subscription builder name is required") # Build - credential_type = CredentialType.of( - subscription_builder.credential_type or CredentialType.UNAUTHORIZED.value - ) + credential_type = CredentialType.of(subscription_builder.credential_type or CredentialType.UNAUTHORIZED) if credential_type == CredentialType.UNAUTHORIZED: # manually create TriggerProviderService.add_trigger_subscription( diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 5d99900a04..592f678421 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -402,7 +402,7 @@ class WebhookService: for name, file in files.items(): if file and file.filename: try: - file_content = file.read() + file_content = file.stream.read() mimetype = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream" file_obj = cls._create_file_from_binary(file_content, mimetype, webhook_trigger) processed_files[name] = file_obj.to_dict() diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index 1529c2b98f..5dd5f6873f 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -194,14 +194,15 @@ class VariableTruncator(BaseTruncator): result: _PartResult[Any] # Apply type-specific truncation with target size - if isinstance(segment, ArraySegment): - result = self._truncate_array(segment.value, target_size) - elif isinstance(segment, StringSegment): - result = self._truncate_string(segment.value, target_size) - elif isinstance(segment, ObjectSegment): - result = self._truncate_object(segment.value, target_size) - else: - raise AssertionError("this should be unreachable.") + match segment: + case ArraySegment(): + result = self._truncate_array(segment.value, target_size) + case StringSegment(): + result = self._truncate_string(segment.value, target_size) + case ObjectSegment(): + result = self._truncate_object(segment.value, target_size) + case _: + raise AssertionError("this should be unreachable.") return _PartResult( value=segment.model_copy(update={"value": result.value}), @@ -219,40 +220,41 @@ class VariableTruncator(BaseTruncator): return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) if depth > _MAX_DEPTH: raise MaxDepthExceededError() - if isinstance(value, str): - # Ideally, the size of strings should be calculated based on their utf-8 encoded length. - # However, this adds complexity as we would need to compute encoded sizes consistently - # throughout the code. Therefore, we approximate the size using the string's length. - # Rough estimate: number of characters, plus 2 for quotes - return len(value) + 2 - elif isinstance(value, (int, float)): - return len(str(value)) - elif isinstance(value, bool): - return 4 if value else 5 # "true" or "false" - elif value is None: - return 4 # "null" - elif isinstance(value, list): - # Size = sum of elements + separators + brackets - total = 2 # "[]" - for i, item in enumerate(value): - if i > 0: - total += 1 # "," - total += VariableTruncator.calculate_json_size(item, depth=depth + 1) - return total - elif isinstance(value, dict): - # Size = sum of keys + values + separators + brackets - total = 2 # "{}" - for index, key in enumerate(value.keys()): - if index > 0: - total += 1 # "," - total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string - total += 1 # ":" - total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1) - return total - elif isinstance(value, File): - return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) - else: - raise UnknownTypeError(f"got unknown type {type(value)}") + match value: + case str(): + # Ideally, the size of strings should be calculated based on their utf-8 encoded length. + # However, this adds complexity as we would need to compute encoded sizes consistently + # throughout the code. Therefore, we approximate the size using the string's length. + # Rough estimate: number of characters, plus 2 for quotes + return len(value) + 2 + case bool(): + return 4 if value else 5 # "true" or "false" + case int() | float(): + return len(str(value)) + case None: + return 4 # "null" + case list(): + # Size = sum of elements + separators + brackets + total = 2 # "[]" + for i, item in enumerate(value): + if i > 0: + total += 1 # "," + total += VariableTruncator.calculate_json_size(item, depth=depth + 1) + return total + case dict(): + # Size = sum of keys + values + separators + brackets + total = 2 # "{}" + for index, key in enumerate(value.keys()): + if index > 0: + total += 1 # "," + total += VariableTruncator.calculate_json_size(str(key), depth=depth + 1) # Key as string + total += 1 # ":" + total += VariableTruncator.calculate_json_size(value[key], depth=depth + 1) + return total + case File(): + return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1) + case _: + raise UnknownTypeError(f"got unknown type {type(value)}") def _truncate_string(self, value: str, target_size: int) -> _PartResult[str]: if (size := self.calculate_json_size(value)) < target_size: @@ -419,22 +421,23 @@ class VariableTruncator(BaseTruncator): target_size: int, ) -> _PartResult[Any]: """Truncate a value within an object to fit within budget.""" - if isinstance(val, UpdatedVariable): - # TODO(Workflow): push UpdatedVariable normalization closer to its producer. - return self._truncate_object(val.model_dump(), target_size) - elif isinstance(val, str): - return self._truncate_string(val, target_size) - elif isinstance(val, list): - return self._truncate_array(val, target_size) - elif isinstance(val, dict): - return self._truncate_object(val, target_size) - elif isinstance(val, File): - # File objects should not be truncated, return as-is - return _PartResult(val, self.calculate_json_size(val), False) - elif val is None or isinstance(val, (bool, int, float)): - return _PartResult(val, self.calculate_json_size(val), False) - else: - raise AssertionError("this statement should be unreachable.") + match val: + case UpdatedVariable(): + # TODO(Workflow): push UpdatedVariable normalization closer to its producer. + return self._truncate_object(val.model_dump(), target_size) + case str(): + return self._truncate_string(val, target_size) + case list(): + return self._truncate_array(val, target_size) + case dict(): + return self._truncate_object(val, target_size) + case File(): + # File objects should not be truncated, return as-is + return _PartResult(val, self.calculate_json_size(val), False) + case None | bool() | int() | float(): + return _PartResult(val, self.calculate_json_size(val), False) + case _: + raise AssertionError("this statement should be unreachable.") class DummyVariableTruncator(BaseTruncator): diff --git a/api/services/vector_service.py b/api/services/vector_service.py index 7e689af35d..49c3b85831 100644 --- a/api/services/vector_service.py +++ b/api/services/vector_service.py @@ -111,6 +111,7 @@ class VectorService: "dataset_id": segment.dataset_id, }, ) + assert segment.index_node_id if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY: # update vector index vector = Vector(dataset=dataset) @@ -138,6 +139,7 @@ class VectorService: regenerate: bool = False, ): index_processor = IndexProcessorFactory(dataset.doc_form).init_index_processor() + assert segment.index_node_id if regenerate: # delete child chunks index_processor.clean(dataset, [segment.index_node_id], with_keywords=True, delete_child_chunks=True) diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index a55448e352..59db147576 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -157,8 +157,8 @@ class DraftVarLoader(VariableLoader): # This approach reduces loading time by querying external systems concurrently. with ThreadPoolExecutor(max_workers=10) as executor: offloaded_variables = executor.map(self._load_offloaded_variable, offloaded_draft_vars) - for selector, variable in offloaded_variables: - variable_by_selector[selector] = variable + for selector, offloaded_variable in offloaded_variables: + variable_by_selector[selector] = offloaded_variable return list(variable_by_selector.values()) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index f97b85dc2b..1b0e10d784 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -29,7 +29,11 @@ from core.workflow.node_factory import ( get_node_type_classes_mapping, is_start_node_type, ) -from core.workflow.node_runtime import DifyHumanInputNodeRuntime, apply_dify_debug_email_recipient +from core.workflow.node_runtime import ( + DifyFileReferenceFactory, + DifyHumanInputNodeRuntime, + apply_dify_debug_email_recipient, +) from core.workflow.system_variables import build_bootstrap_variables, build_system_variables, default_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry @@ -1066,8 +1070,13 @@ class WorkflowService: ) rendered_content = node.render_form_content_before_submission() + selected_action = next( + (user_action for user_action in node_data.user_actions if user_action.id == action), + None, + ) outputs: dict[str, Any] = dict(form_inputs) outputs["__action_id"] = action + outputs["__action_value"] = selected_action.title if selected_action else "" outputs["__rendered_content"] = node.render_form_content_with_outputs( rendered_content, outputs, node_data.outputs_field_names() ) @@ -1251,9 +1260,10 @@ class WorkflowService: node_data = HumanInputNode.validate_node_data(adapt_human_input_node_data_for_graph(node_config["data"])) node = HumanInputNode( node_id=node_config["id"], - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, + file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), runtime=DifyHumanInputNodeRuntime(run_context), ) return node diff --git a/api/tasks/batch_clean_document_task.py b/api/tasks/batch_clean_document_task.py index 56c371fcc1..5794726716 100644 --- a/api/tasks/batch_clean_document_task.py +++ b/api/tasks/batch_clean_document_task.py @@ -50,7 +50,7 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] segment_ids = [segment.id for segment in segments] # Collect image file IDs from segment content diff --git a/api/tasks/batch_create_segment_to_index_task.py b/api/tasks/batch_create_segment_to_index_task.py index beb23d8354..9f19b03544 100644 --- a/api/tasks/batch_create_segment_to_index_task.py +++ b/api/tasks/batch_create_segment_to_index_task.py @@ -19,6 +19,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from libs import helper from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, Document, DocumentSegment +from models.enums import SegmentStatus from models.model import UploadFile from services.vector_service import VectorService @@ -156,7 +157,7 @@ def batch_create_segment_to_index_task( tokens=tokens, created_by=user_id, indexing_at=naive_utc_now(), - status="completed", + status=SegmentStatus.COMPLETED, completed_at=naive_utc_now(), ) if document_config["doc_form"] == IndexStructureType.QA_INDEX: diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index c8d0e31c06..869e2b3028 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -53,7 +53,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i binding_ids = [binding.id for binding, _ in attachments_with_bindings] total_attachment_files.extend([attachment_file.key for _, attachment_file in attachments_with_bindings]) - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] segment_contents = [segment.content for segment in segments] except Exception: logger.exception("Cleaned document when document deleted failed") diff --git a/api/tasks/clean_notion_document_task.py b/api/tasks/clean_notion_document_task.py index 017d60efac..782d7d0226 100644 --- a/api/tasks/clean_notion_document_task.py +++ b/api/tasks/clean_notion_document_task.py @@ -38,7 +38,7 @@ def clean_notion_document_task(document_ids: list[str], dataset_id: str): for document_id in document_ids: segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() - total_index_node_ids.extend([segment.index_node_id for segment in segments]) + total_index_node_ids.extend([segment.index_node_id for segment in segments if segment.index_node_id]) # Wrap vector / keyword index cleanup in try/except so that a transient # failure here (e.g. billing API hiccup propagated via FeatureService when diff --git a/api/tasks/disable_segment_from_index_task.py b/api/tasks/disable_segment_from_index_task.py index dd1a40844b..d00e143093 100644 --- a/api/tasks/disable_segment_from_index_task.py +++ b/api/tasks/disable_segment_from_index_task.py @@ -9,6 +9,7 @@ from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory from extensions.ext_redis import redis_client from models.dataset import DocumentSegment +from models.enums import SegmentStatus logger = logging.getLogger(__name__) @@ -30,7 +31,7 @@ def disable_segment_from_index_task(segment_id: str): logger.info(click.style(f"Segment not found: {segment_id}", fg="red")) return - if segment.status != "completed": + if segment.status != SegmentStatus.COMPLETED: logger.info(click.style(f"Segment is not completed, disable is not allowed: {segment_id}", fg="red")) return @@ -59,6 +60,7 @@ def disable_segment_from_index_task(segment_id: str): index_type = dataset_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() + assert segment.index_node_id index_processor.clean(dataset, [segment.index_node_id]) # Disable summary index for this segment diff --git a/api/tasks/disable_segments_from_index_task.py b/api/tasks/disable_segments_from_index_task.py index 86e96ea3f0..cd91ddd074 100644 --- a/api/tasks/disable_segments_from_index_task.py +++ b/api/tasks/disable_segments_from_index_task.py @@ -55,7 +55,7 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen return try: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] if dataset.is_multimodal: segment_ids = [segment.id for segment in segments] segment_attachment_bindings = session.scalars( diff --git a/api/tasks/document_indexing_sync_task.py b/api/tasks/document_indexing_sync_task.py index 90c80be3a1..842e7dcdb2 100644 --- a/api/tasks/document_indexing_sync_task.py +++ b/api/tasks/document_indexing_sync_task.py @@ -69,7 +69,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): index_type = document.doc_form segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # Get credentials from datasource provider datasource_provider_service = DatasourceProviderService() diff --git a/api/tasks/document_indexing_update_task.py b/api/tasks/document_indexing_update_task.py index 15f0e0162b..ba26c20331 100644 --- a/api/tasks/document_indexing_update_task.py +++ b/api/tasks/document_indexing_update_task.py @@ -7,10 +7,12 @@ from sqlalchemy import delete, select from core.db.session_factory import session_factory from core.indexing_runner import DocumentIsPausedError, IndexingRunner +from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.index_processor.index_processor_factory import IndexProcessorFactory from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, Document, DocumentSegment from models.enums import IndexingStatus +from tasks.generate_summary_index_task import generate_summary_index_task logger = logging.getLogger(__name__) @@ -45,7 +47,7 @@ def document_indexing_update_task(dataset_id: str, document_id: str): index_type = document.doc_form segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] clean_success = False try: @@ -70,6 +72,7 @@ def document_indexing_update_task(dataset_id: str, document_id: str): segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id == document_id) session.execute(segment_delete_stmt) + has_error = False try: indexing_runner = IndexingRunner() indexing_runner.run([document]) @@ -77,5 +80,45 @@ def document_indexing_update_task(dataset_id: str, document_id: str): logger.info(click.style(f"update document: {document.id} latency: {end_at - start_at}", fg="green")) except DocumentIsPausedError as ex: logger.info(click.style(str(ex), fg="yellow")) + has_error = True except Exception: logger.exception("document_indexing_update_task failed, document_id: %s", document_id) + has_error = True + + if has_error: + return + + # Trigger summary index generation for the updated document if enabled. + # Only generate for high_quality indexing technique and when summary_index_setting is enabled. + with session_factory.create_session() as session: + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) + if not dataset: + logger.warning("Dataset %s not found after update indexing", dataset_id) + return + + if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY: + summary_index_setting = dataset.summary_index_setting + if summary_index_setting and summary_index_setting.get("enable"): + session.expire_all() + document = session.scalar( + select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1) + ) + if ( + document + and document.indexing_status == IndexingStatus.COMPLETED + and document.doc_form != IndexStructureType.QA_INDEX + and document.need_summary is True + ): + try: + generate_summary_index_task.delay(dataset.id, document.id, None) + logger.info( + "Queued summary index generation task for document %s in dataset %s " + "after update indexing completed", + document.id, + dataset.id, + ) + except Exception: + logger.exception( + "Failed to queue summary index generation task for document %s after update", + document.id, + ) diff --git a/api/tasks/duplicate_document_indexing_task.py b/api/tasks/duplicate_document_indexing_task.py index 6bc58bdf9c..71f367c5e7 100644 --- a/api/tasks/duplicate_document_indexing_task.py +++ b/api/tasks/duplicate_document_indexing_task.py @@ -137,7 +137,7 @@ def _duplicate_document_indexing_task(dataset_id: str, document_ids: Sequence[st select(DocumentSegment).where(DocumentSegment.document_id == document.id) ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # delete from vector index index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) diff --git a/api/tasks/ops_trace_task.py b/api/tasks/ops_trace_task.py index c95b8db078..49fe68ad7e 100644 --- a/api/tasks/ops_trace_task.py +++ b/api/tasks/ops_trace_task.py @@ -1,11 +1,31 @@ +""" +Celery task for asynchronous ops trace dispatch. + +Trace providers may report explicitly retryable dispatch failures through the +core retryable exception contract. The task preserves the payload file only +when Celery accepts the retry request; successful dispatches and terminal +failures clean up the stored payload. + +One concrete producer today is Phoenix nested workflow tracing. The outer +workflow tool span publishes a restorable parent span context asynchronously, +while the nested workflow trace may be picked up by Celery first. In that +ordering window, the provider raises a retryable core exception instead of +dropping the trace or emitting it under the wrong parent. The task intentionally +does not know that the provider is Phoenix; it only honors the core retryable +dispatch contract. +""" + import json import logging from celery import shared_task +from celery.exceptions import Retry from flask import current_app +from configs import dify_config from core.ops.entities.config_entity import OPS_FILE_PATH, OPS_TRACE_FAILED_KEY from core.ops.entities.trace_entity import trace_info_info_map +from core.ops.exceptions import RetryableTraceDispatchError from core.rag.models.document import Document from extensions.ext_redis import redis_client from extensions.ext_storage import storage @@ -14,9 +34,17 @@ from models.workflow import WorkflowRun logger = logging.getLogger(__name__) +_RETRYABLE_TRACE_DISPATCH_LIMIT = dify_config.OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES +_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS = dify_config.OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS -@shared_task(queue="ops_trace") -def process_trace_tasks(file_info): + +@shared_task( + queue="ops_trace", + bind=True, + max_retries=_RETRYABLE_TRACE_DISPATCH_LIMIT, + default_retry_delay=_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS, +) +def process_trace_tasks(self, file_info): """ Async process trace tasks Usage: process_trace_tasks.delay(tasks_data) @@ -29,6 +57,7 @@ def process_trace_tasks(file_info): file_data = json.loads(storage.load(file_path)) trace_info = file_data.get("trace_info") trace_info_type = file_data.get("trace_info_type") + enterprise_trace_dispatched = bool(file_data.get("_enterprise_trace_dispatched")) trace_instance = OpsTraceManager.get_ops_trace_instance(app_id) if trace_info.get("message_data"): @@ -38,6 +67,8 @@ def process_trace_tasks(file_info): if trace_info.get("documents"): trace_info["documents"] = [Document.model_validate(doc) for doc in trace_info["documents"]] + should_delete_file = True + try: trace_type = trace_info_info_map.get(trace_info_type) if trace_type: @@ -45,30 +76,66 @@ def process_trace_tasks(file_info): from extensions.ext_enterprise_telemetry import is_enabled as is_ee_telemetry_enabled - if is_ee_telemetry_enabled(): + if is_ee_telemetry_enabled() and not enterprise_trace_dispatched: from enterprise.telemetry.enterprise_trace import EnterpriseOtelTrace try: EnterpriseOtelTrace().trace(trace_info) except Exception: logger.exception("Enterprise trace failed for app_id: %s", app_id) + else: + file_data["_enterprise_trace_dispatched"] = True + enterprise_trace_dispatched = True if trace_instance: with current_app.app_context(): trace_instance.trace(trace_info) logger.info("Processing trace tasks success, app_id: %s", app_id) + except RetryableTraceDispatchError as e: + # Retryable dispatch failures represent a transient provider-side + # ordering gap, not corrupt payload data. Keep the payload only after + # Celery accepts the retry request; otherwise this attempt becomes a + # terminal failure and the stored file is cleaned up in `finally`. + # + # Enterprise telemetry runs before provider dispatch. If it already ran + # and provider dispatch asks for a retry, persist that private flag so + # the next attempt does not emit the same enterprise trace twice. + if self.request.retries >= _RETRYABLE_TRACE_DISPATCH_LIMIT: + logger.exception("Retryable trace dispatch budget exhausted, app_id: %s", app_id) + failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" + redis_client.incr(failed_key) + else: + logger.warning( + "Retryable trace dispatch failure, scheduling retry %s/%s for app_id %s: %s", + self.request.retries + 1, + _RETRYABLE_TRACE_DISPATCH_LIMIT, + app_id, + e, + ) + try: + if enterprise_trace_dispatched: + storage.save(file_path, json.dumps(file_data).encode("utf-8")) + raise self.retry(exc=e, countdown=_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS) + except Retry: + should_delete_file = False + raise + except Exception: + logger.exception("Failed to schedule trace dispatch retry, app_id: %s", app_id) + failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" + redis_client.incr(failed_key) except Exception as e: logger.exception("Processing trace tasks failed, app_id: %s", app_id) failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" redis_client.incr(failed_key) finally: - try: - storage.delete(file_path) - except Exception as e: - logger.warning( - "Failed to delete trace file %s for app_id %s: %s", - file_path, - app_id, - e, - ) + if should_delete_file: + try: + storage.delete(file_path) + except Exception as e: + logger.warning( + "Failed to delete trace file %s for app_id %s: %s", + file_path, + app_id, + e, + ) diff --git a/api/tasks/remove_document_from_index_task.py b/api/tasks/remove_document_from_index_task.py index 74e8a012cf..2314d32232 100644 --- a/api/tasks/remove_document_from_index_task.py +++ b/api/tasks/remove_document_from_index_task.py @@ -61,7 +61,7 @@ def remove_document_from_index_task(document_id: str): except Exception as e: logger.warning("Failed to disable summaries for document %s: %s", document.id, str(e)) - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] if index_node_ids: try: index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=False) diff --git a/api/tasks/retry_document_indexing_task.py b/api/tasks/retry_document_indexing_task.py index 7cc28d5226..0df5896ce3 100644 --- a/api/tasks/retry_document_indexing_task.py +++ b/api/tasks/retry_document_indexing_task.py @@ -85,7 +85,7 @@ def retry_document_indexing_task(dataset_id: str, document_ids: list[str], user_ select(DocumentSegment).where(DocumentSegment.document_id == document_id) ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # delete from vector index index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) diff --git a/api/tasks/sync_website_document_indexing_task.py b/api/tasks/sync_website_document_indexing_task.py index ab21f63f7e..06eb460311 100644 --- a/api/tasks/sync_website_document_indexing_task.py +++ b/api/tasks/sync_website_document_indexing_task.py @@ -70,7 +70,7 @@ def sync_website_document_indexing_task(dataset_id: str, document_id: str): segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # delete from vector index index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) diff --git a/api/tasks/trigger_subscription_refresh_tasks.py b/api/tasks/trigger_subscription_refresh_tasks.py index 1daf8f302c..f6552fb294 100644 --- a/api/tasks/trigger_subscription_refresh_tasks.py +++ b/api/tasks/trigger_subscription_refresh_tasks.py @@ -1,5 +1,4 @@ import logging -import time from collections.abc import Mapping from typing import Any @@ -12,16 +11,13 @@ from core.db.session_factory import session_factory from core.plugin.entities.plugin_daemon import CredentialType from core.trigger.utils.locks import build_trigger_refresh_lock_key from extensions.ext_redis import redis_client +from libs.helper import current_timestamp from models.trigger import TriggerSubscription from services.trigger.trigger_provider_service import TriggerProviderService logger = logging.getLogger(__name__) -def _now_ts() -> int: - return int(time.time()) - - def _load_subscription(session: Session, tenant_id: str, subscription_id: str) -> TriggerSubscription | None: return session.scalar( select(TriggerSubscription) @@ -96,7 +92,7 @@ def trigger_subscription_refresh(tenant_id: str, subscription_id: str) -> None: logger.info("Begin subscription refresh: tenant=%s id=%s", tenant_id, subscription_id) try: - now: int = _now_ts() + now: int = current_timestamp() with session_factory.create_session() as session: subscription: TriggerSubscription | None = _load_subscription(session, tenant_id, subscription_id) diff --git a/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py b/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py index 3b5e822b90..90131fe98d 100644 --- a/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py +++ b/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py @@ -13,7 +13,7 @@ from controllers.console.app import wraps from libs.datetime_utils import naive_utc_now from models import App, Tenant from models.account import Account, TenantAccountJoin, TenantAccountRole -from models.enums import ConversationFromSource +from models.enums import AppStatus, ConversationFromSource from models.model import AppMode from services.app_generate_service import AppGenerateService @@ -28,7 +28,7 @@ class TestChatMessageApiPermissions: app.id = str(uuid.uuid4()) app.mode = AppMode.CHAT app.tenant_id = str(uuid.uuid4()) - app.status = "normal" + app.status = AppStatus.NORMAL return app @pytest.fixture @@ -78,7 +78,7 @@ class TestChatMessageApiPermissions: self, test_client: FlaskClient, auth_header, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account, role: TenantAccountRole, @@ -130,7 +130,7 @@ class TestChatMessageApiPermissions: self, test_client: FlaskClient, auth_header, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account, role: TenantAccountRole, diff --git a/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py b/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py index 309a0b015a..c4db0d5111 100644 --- a/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py +++ b/api/tests/integration_tests/controllers/console/app/test_feedback_export_api.py @@ -14,7 +14,7 @@ from controllers.console.app import wraps from libs.datetime_utils import naive_utc_now from models import App, Tenant from models.account import Account, TenantAccountJoin, TenantAccountRole -from models.enums import FeedbackFromSource, FeedbackRating +from models.enums import AppStatus, FeedbackFromSource, FeedbackRating from models.model import AppMode, MessageFeedback from services.feedback_service import FeedbackService @@ -29,7 +29,7 @@ class TestFeedbackExportApi: app.id = str(uuid.uuid4()) app.mode = AppMode.CHAT app.tenant_id = str(uuid.uuid4()) - app.status = "normal" + app.status = AppStatus.NORMAL app.name = "Test App" return app @@ -135,7 +135,7 @@ class TestFeedbackExportApi: self, test_client: FlaskClient, auth_header, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account, role: TenantAccountRole, @@ -167,7 +167,13 @@ class TestFeedbackExportApi: mock_export_feedbacks.assert_called_once() def test_feedback_export_csv_format( - self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account, sample_feedback_data + self, + test_client: FlaskClient, + auth_header, + monkeypatch: pytest.MonkeyPatch, + mock_app_model, + mock_account, + sample_feedback_data, ): """Test feedback export in CSV format.""" @@ -202,7 +208,13 @@ class TestFeedbackExportApi: assert "text/csv" in response.content_type def test_feedback_export_json_format( - self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account, sample_feedback_data + self, + test_client: FlaskClient, + auth_header, + monkeypatch: pytest.MonkeyPatch, + mock_app_model, + mock_account, + sample_feedback_data, ): """Test feedback export in JSON format.""" @@ -246,7 +258,7 @@ class TestFeedbackExportApi: assert "application/json" in response.content_type def test_feedback_export_with_filters( - self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account + self, test_client: FlaskClient, auth_header, monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account ): """Test feedback export with various filters.""" @@ -287,7 +299,7 @@ class TestFeedbackExportApi: ) def test_feedback_export_invalid_date_format( - self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account + self, test_client: FlaskClient, auth_header, monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account ): """Test feedback export with invalid date format.""" @@ -312,7 +324,7 @@ class TestFeedbackExportApi: assert "Parameter validation error" in response_json["error"] def test_feedback_export_server_error( - self, test_client: FlaskClient, auth_header, monkeypatch, mock_app_model, mock_account + self, test_client: FlaskClient, auth_header, monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account ): """Test feedback export with server error.""" diff --git a/api/tests/integration_tests/controllers/console/app/test_model_config_permissions.py b/api/tests/integration_tests/controllers/console/app/test_model_config_permissions.py index 04945e57a0..ab08c7a6d8 100644 --- a/api/tests/integration_tests/controllers/console/app/test_model_config_permissions.py +++ b/api/tests/integration_tests/controllers/console/app/test_model_config_permissions.py @@ -11,6 +11,7 @@ from controllers.console.app import wraps from libs.datetime_utils import naive_utc_now from models import App, Tenant from models.account import Account, TenantAccountJoin, TenantAccountRole +from models.enums import AppStatus from models.model import AppMode from services.app_model_config_service import AppModelConfigService @@ -25,7 +26,7 @@ class TestModelConfigResourcePermissions: app.id = str(uuid.uuid4()) app.mode = AppMode.CHAT app.tenant_id = str(uuid.uuid4()) - app.status = "normal" + app.status = AppStatus.NORMAL app.app_model_config_id = str(uuid.uuid4()) return app @@ -73,7 +74,7 @@ class TestModelConfigResourcePermissions: self, test_client: FlaskClient, auth_header, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, mock_app_model, mock_account, role: TenantAccountRole, diff --git a/api/tests/integration_tests/core/datasource/test_datasource_manager_integration.py b/api/tests/integration_tests/core/datasource/test_datasource_manager_integration.py index a876b0c4aa..7d0b575262 100644 --- a/api/tests/integration_tests/core/datasource/test_datasource_manager_integration.py +++ b/api/tests/integration_tests/core/datasource/test_datasource_manager_integration.py @@ -1,5 +1,7 @@ from collections.abc import Generator +from pytest_mock import MockerFixture + from core.datasource.datasource_manager import DatasourceManager from core.datasource.entities.datasource_entities import DatasourceMessage from graphon.node_events import StreamCompletedEvent @@ -19,7 +21,7 @@ def _gen_var_stream() -> Generator[DatasourceMessage, None, None]: ) -def test_stream_node_events_accumulates_variables(mocker): +def test_stream_node_events_accumulates_variables(mocker: MockerFixture): mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_var_stream()) events = list( DatasourceManager.stream_node_events( diff --git a/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py b/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py index 2392084c36..b9f09ccadd 100644 --- a/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py +++ b/api/tests/integration_tests/core/workflow/nodes/datasource/test_datasource_node_integration.py @@ -1,3 +1,5 @@ +from pytest_mock import MockerFixture + from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from core.workflow.nodes.datasource.datasource_node import DatasourceNode from core.workflow.nodes.datasource.entities import DatasourceNodeData @@ -44,7 +46,7 @@ class _GP: call_depth = 0 -def test_node_integration_minimal_stream(mocker): +def test_node_integration_minimal_stream(mocker: MockerFixture): sys_d = { "sys": { "datasource_type": "online_document", @@ -71,7 +73,7 @@ def test_node_integration_minimal_stream(mocker): node = DatasourceNode( node_id="n", - config=DatasourceNodeData( + data=DatasourceNodeData( type="datasource", version="1", title="Datasource", diff --git a/api/tests/integration_tests/workflow/nodes/__mock/model.py b/api/tests/integration_tests/workflow/nodes/__mock/model.py index a9a2617bae..a77fe5970a 100644 --- a/api/tests/integration_tests/workflow/nodes/__mock/model.py +++ b/api/tests/integration_tests/workflow/nodes/__mock/model.py @@ -4,7 +4,7 @@ from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEnti from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle from core.entities.provider_entities import CustomConfiguration, CustomProviderConfiguration, SystemConfiguration from core.model_manager import ModelInstance -from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory +from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from graphon.model_runtime.entities.model_entities import ModelType from models.provider import ProviderType @@ -15,8 +15,9 @@ def get_mocked_fetch_model_config( mode: str, credentials: dict, ): - model_provider_factory = create_plugin_model_provider_factory(tenant_id="9d2074fc-6f86-45a9-b09d-6ecc63b9056b") - model_type_instance = model_provider_factory.get_model_type_instance(provider, ModelType.LLM) + model_assembly = create_plugin_model_assembly(tenant_id="9d2074fc-6f86-45a9-b09d-6ecc63b9056b") + model_provider_factory = model_assembly.model_provider_factory + model_type_instance = model_assembly.create_model_type_instance(provider=provider, model_type=ModelType.LLM) provider_model_bundle = ProviderModelBundle( configuration=ProviderConfiguration( tenant_id="1", diff --git a/api/tests/integration_tests/workflow/nodes/test_code.py b/api/tests/integration_tests/workflow/nodes/test_code.py index aaa6092993..9345113aa3 100644 --- a/api/tests/integration_tests/workflow/nodes/test_code.py +++ b/api/tests/integration_tests/workflow/nodes/test_code.py @@ -45,7 +45,7 @@ def init_code_node(code_config: dict): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -66,7 +66,7 @@ def init_code_node(code_config: dict): node = CodeNode( node_id=str(uuid.uuid4()), - config=CodeNodeData.model_validate(code_config["data"]), + data=CodeNodeData.model_validate(code_config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, code_executor=node_factory._code_executor, diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index b9f7b9575b..7cd7f50b77 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -55,7 +55,7 @@ def init_http_node(config: dict): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -76,7 +76,7 @@ def init_http_node(config: dict): node = HttpRequestNode( node_id=str(uuid.uuid4()), - config=HttpRequestNodeData.model_validate(config["data"]), + data=HttpRequestNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, @@ -204,7 +204,7 @@ def test_custom_auth_with_empty_api_key_raises_error(setup_http_mock): from graphon.runtime import VariablePool # Create variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="test", files=[]), user_inputs={}, environment_variables=[], @@ -702,7 +702,7 @@ def test_nested_object_variable_selector(setup_http_mock): ) # Create independent variable pool for this test only - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -724,7 +724,7 @@ def test_nested_object_variable_selector(setup_http_mock): node = HttpRequestNode( node_id=str(uuid.uuid4()), - config=HttpRequestNodeData.model_validate(graph_config["nodes"][1]["data"]), + data=HttpRequestNodeData.model_validate(graph_config["nodes"][1]["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index 3eead70163..5b7790f6f4 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -53,7 +53,7 @@ def init_llm_node(config: dict) -> LLMNode: ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="aaa", app_id=app_id, @@ -77,7 +77,7 @@ def init_llm_node(config: dict) -> LLMNode: node = LLMNode( node_id=str(uuid.uuid4()), - config=LLMNodeData.model_validate(config["data"]), + data=LLMNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, credentials_provider=MagicMock(spec=CredentialsProvider), @@ -91,7 +91,11 @@ def init_llm_node(config: dict) -> LLMNode: return node -def test_execute_llm(): +def _mock_db_session_close(monkeypatch) -> None: + monkeypatch.setattr(db.session, "close", MagicMock()) + + +def test_execute_llm(monkeypatch): node = init_llm_node( config={ "id": "llm", @@ -118,7 +122,7 @@ def test_execute_llm(): }, ) - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) def build_mock_model_instance() -> MagicMock: from decimal import Decimal @@ -195,7 +199,7 @@ def test_execute_llm(): assert item.node_run_result.outputs.get("usage", {})["total_tokens"] > 0 -def test_execute_llm_with_jinja2(): +def test_execute_llm_with_jinja2(monkeypatch): """ Test execute LLM node with jinja2 """ @@ -233,8 +237,7 @@ def test_execute_llm_with_jinja2(): }, ) - # Mock db.session.close() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) def build_mock_model_instance() -> MagicMock: from decimal import Decimal diff --git a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py index f2eabb86c3..fc230a2a68 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -56,7 +56,7 @@ def init_parameter_extractor_node(config: dict, memory=None): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="aaa", files=[], query="what's the weather in SF", conversation_id="abababa" ), @@ -71,7 +71,7 @@ def init_parameter_extractor_node(config: dict, memory=None): node = ParameterExtractorNode( node_id=str(uuid.uuid4()), - config=ParameterExtractorNodeData.model_validate(config["data"]), + data=ParameterExtractorNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, credentials_provider=MagicMock(spec=CredentialsProvider), @@ -83,7 +83,11 @@ def init_parameter_extractor_node(config: dict, memory=None): return node -def test_function_calling_parameter_extractor(setup_model_mock): +def _mock_db_session_close(monkeypatch) -> None: + monkeypatch.setattr(db.session, "close", MagicMock()) + + +def test_function_calling_parameter_extractor(setup_model_mock, monkeypatch): """ Test function calling for parameter extractor. """ @@ -114,7 +118,7 @@ def test_function_calling_parameter_extractor(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -124,7 +128,7 @@ def test_function_calling_parameter_extractor(setup_model_mock): assert result.outputs.get("__reason") == None -def test_instructions(setup_model_mock): +def test_instructions(setup_model_mock, monkeypatch): """ Test chat parameter extractor. """ @@ -155,7 +159,7 @@ def test_instructions(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -174,7 +178,7 @@ def test_instructions(setup_model_mock): assert "what's the weather in SF" in prompt.get("text") -def test_chat_parameter_extractor(setup_model_mock): +def test_chat_parameter_extractor(setup_model_mock, monkeypatch): """ Test chat parameter extractor. """ @@ -205,7 +209,7 @@ def test_chat_parameter_extractor(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -225,7 +229,7 @@ def test_chat_parameter_extractor(setup_model_mock): assert '\n{"type": "object"' in prompt.get("text") -def test_completion_parameter_extractor(setup_model_mock): +def test_completion_parameter_extractor(setup_model_mock, monkeypatch): """ Test completion parameter extractor. """ @@ -256,7 +260,7 @@ def test_completion_parameter_extractor(setup_model_mock): mode="completion", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -350,7 +354,7 @@ def test_extract_json_from_tool_call(): assert result["location"] == "kawaii" -def test_chat_parameter_extractor_with_memory(setup_model_mock): +def test_chat_parameter_extractor_with_memory(setup_model_mock, monkeypatch): """ Test chat parameter extractor with memory. """ @@ -382,7 +386,7 @@ def test_chat_parameter_extractor_with_memory(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() diff --git a/api/tests/integration_tests/workflow/nodes/test_template_transform.py b/api/tests/integration_tests/workflow/nodes/test_template_transform.py index e2e0723fb8..80489e6809 100644 --- a/api/tests/integration_tests/workflow/nodes/test_template_transform.py +++ b/api/tests/integration_tests/workflow/nodes/test_template_transform.py @@ -66,7 +66,7 @@ def test_execute_template_transform(): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -88,7 +88,7 @@ def test_execute_template_transform(): node = TemplateTransformNode( node_id=str(uuid.uuid4()), - config=TemplateTransformNodeData.model_validate(config["data"]), + data=TemplateTransformNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, jinja2_template_renderer=_SimpleJinja2Renderer(), diff --git a/api/tests/integration_tests/workflow/nodes/test_tool.py b/api/tests/integration_tests/workflow/nodes/test_tool.py index a8e9422c1e..c109be9fae 100644 --- a/api/tests/integration_tests/workflow/nodes/test_tool.py +++ b/api/tests/integration_tests/workflow/nodes/test_tool.py @@ -2,6 +2,8 @@ import time import uuid from unittest.mock import MagicMock, patch +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.tools.utils.configuration import ToolParameterConfigurationManager from core.workflow.node_factory import DifyNodeFactory @@ -41,7 +43,7 @@ def init_tool_node(config: dict): ) # construct variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -58,20 +60,20 @@ def init_tool_node(config: dict): graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") - tool_file_manager_factory = MagicMock(spec=ToolFileManagerProtocol) + tool_file_manager = MagicMock(spec=ToolFileManagerProtocol) node = ToolNode( node_id=str(uuid.uuid4()), - config=ToolNodeData.model_validate(config["data"]), + data=ToolNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - tool_file_manager_factory=tool_file_manager_factory, + tool_file_manager=tool_file_manager, runtime=DifyToolNodeRuntime(init_params.run_context), ) return node -def test_tool_variable_invoke(monkeypatch): +def test_tool_variable_invoke(monkeypatch: pytest.MonkeyPatch): node = init_tool_node( config={ "id": "1", @@ -106,7 +108,7 @@ def test_tool_variable_invoke(monkeypatch): assert item.node_run_result.outputs.get("text") is not None -def test_tool_mixed_invoke(monkeypatch): +def test_tool_mixed_invoke(monkeypatch: pytest.MonkeyPatch): node = init_tool_node( config={ "id": "1", diff --git a/api/tests/test_containers_integration_tests/conftest.py b/api/tests/test_containers_integration_tests/conftest.py index 66a25e5daf..ee953fcbfe 100644 --- a/api/tests/test_containers_integration_tests/conftest.py +++ b/api/tests/test_containers_integration_tests/conftest.py @@ -22,7 +22,7 @@ from sqlalchemy import Engine, text from sqlalchemy.orm import Session from testcontainers.core.container import DockerContainer from testcontainers.core.network import Network -from testcontainers.core.waiting_utils import wait_for_logs +from testcontainers.core.wait_strategies import LogMessageWaitStrategy from testcontainers.postgres import PostgresContainer from testcontainers.redis import RedisContainer @@ -54,6 +54,10 @@ def _auto_close[T: _CloserProtocol](closer: T) -> Generator[T, None, None]: closer.close() +def _wait_for_log_message(message: str, timeout: int) -> LogMessageWaitStrategy: + return LogMessageWaitStrategy(message).with_startup_timeout(timeout) + + class DifyTestContainers: """ Manages all test containers required for Dify integration tests. @@ -99,6 +103,7 @@ class DifyTestContainers: self.postgres = PostgresContainer( image="postgres:14-alpine", ).with_network(self.network) + self.postgres.waiting_for(_wait_for_log_message("is ready to accept connections", 30)) self.postgres.start() db_host = self.postgres.get_container_host_ip() db_port = self.postgres.get_exposed_port(5432) @@ -115,9 +120,6 @@ class DifyTestContainers: self.postgres.dbname, ) - # Wait for PostgreSQL to be ready - logger.info("Waiting for PostgreSQL to be ready to accept connections...") - wait_for_logs(self.postgres, "is ready to accept connections", timeout=30) logger.info("PostgreSQL container is ready and accepting connections") conn = psycopg2.connect( @@ -152,6 +154,7 @@ class DifyTestContainers: # Redis is used for storing session data, cache entries, and temporary data logger.info("Initializing Redis container...") self.redis = RedisContainer(image="redis:6-alpine", port=6379).with_network(self.network) + self.redis.waiting_for(_wait_for_log_message("Ready to accept connections", 30)) self.redis.start() redis_host = self.redis.get_container_host_ip() redis_port = self.redis.get_exposed_port(6379) @@ -159,9 +162,6 @@ class DifyTestContainers: os.environ["REDIS_PORT"] = str(redis_port) logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port) - # Wait for Redis to be ready - logger.info("Waiting for Redis to be ready to accept connections...") - wait_for_logs(self.redis, "Ready to accept connections", timeout=30) logger.info("Redis container is ready and accepting connections") # Start Dify Sandbox container for code execution environment. @@ -170,6 +170,7 @@ class DifyTestContainers: sandbox_image = os.getenv(SANDBOX_TEST_IMAGE_ENV, DEFAULT_SANDBOX_TEST_IMAGE) self.dify_sandbox = DockerContainer(image=sandbox_image).with_network(self.network) self.dify_sandbox.with_exposed_ports(8194) + self.dify_sandbox.waiting_for(_wait_for_log_message("config init success", 60)) self.dify_sandbox.env = { "API_KEY": "test_api_key", } @@ -185,9 +186,6 @@ class DifyTestContainers: sandbox_port, ) - # Wait for Dify Sandbox to be ready - logger.info("Waiting for Dify Sandbox to be ready to accept connections...") - wait_for_logs(self.dify_sandbox, "config init success", timeout=60) logger.info("Dify Sandbox container is ready and accepting connections") # Start Dify Plugin Daemon container for plugin management @@ -197,6 +195,7 @@ class DifyTestContainers: self.network ) self.dify_plugin_daemon.with_exposed_ports(5002) + self.dify_plugin_daemon.waiting_for(_wait_for_log_message("start plugin manager daemon", 60)) # Get container internal network addresses postgres_container_name = self.postgres.get_wrapped_container().name redis_container_name = self.redis.get_wrapped_container().name @@ -243,9 +242,6 @@ class DifyTestContainers: plugin_daemon_port, ) - # Wait for Dify Plugin Daemon to be ready - logger.info("Waiting for Dify Plugin Daemon to be ready to accept connections...") - wait_for_logs(self.dify_plugin_daemon, "start plugin manager daemon", timeout=60) logger.info("Dify Plugin Daemon container is ready and accepting connections") except Exception as e: logger.warning("Failed to start Dify Plugin Daemon container: %s", e) @@ -433,7 +429,7 @@ def flask_app_with_containers(set_up_containers_and_env) -> Flask: @pytest.fixture -def flask_req_ctx_with_containers(flask_app_with_containers) -> Generator[None, None, None]: +def flask_req_ctx_with_containers(flask_app_with_containers: Flask) -> Generator[None, None, None]: """ Request context fixture for containerized Flask application. @@ -454,7 +450,7 @@ def flask_req_ctx_with_containers(flask_app_with_containers) -> Generator[None, @pytest.fixture -def test_client_with_containers(flask_app_with_containers) -> Generator[FlaskClient, None, None]: +def test_client_with_containers(flask_app_with_containers: Flask) -> Generator[FlaskClient, None, None]: """ Test client fixture for containerized Flask application. @@ -475,7 +471,7 @@ def test_client_with_containers(flask_app_with_containers) -> Generator[FlaskCli @pytest.fixture -def db_session_with_containers(flask_app_with_containers) -> Generator[Session, None, None]: +def db_session_with_containers(flask_app_with_containers: Flask) -> Generator[Session, None, None]: """ Database session fixture for containerized testing. @@ -500,6 +496,51 @@ def db_session_with_containers(flask_app_with_containers) -> Generator[Session, logger.debug("Database session closed") +def _truncate_container_database(app: Flask) -> None: + """ + Reset application tables after a container integration test. + + Tests in this package share one PostgreSQL container for performance, while + application code may commit through db.session, Session(db.engine), or + session_factory-created sessions. Truncating after each test gives the suite + a central DB isolation contract that does not depend on which session a test used. + This only covers SQLAlchemy application tables in db.metadata for now; + Redis, object storage, and custom ad hoc metadata still need their own cleanup. + """ + with app.app_context(): + db.session.remove() + + tables = db.metadata.sorted_tables + if not tables: + return + + preparer = db.engine.dialect.identifier_preparer + table_names = ", ".join(preparer.format_table(table) for table in tables) + + with db.engine.begin() as conn: + conn.execute(text("SET LOCAL lock_timeout = '5s'")) + conn.execute(text(f"TRUNCATE TABLE {table_names} RESTART IDENTITY CASCADE")) + + db.session.remove() + + +@pytest.fixture(autouse=True) +def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None, None, None]: + """ + Clean DB state after tests that use the containerized Flask app. + + This fixture intentionally does not depend on flask_app_with_containers so + non-DB tests under this package do not start the full app/container stack. + """ + yield + + if "flask_app_with_containers" not in request.fixturenames: + return + + app = request.getfixturevalue("flask_app_with_containers") + _truncate_container_database(app) + + @pytest.fixture(scope="package", autouse=True) def mock_ssrf_proxy_requests(): """ diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py index 18755ef012..b13bdba2bc 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py @@ -7,7 +7,10 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from flask import Flask +from flask.testing import FlaskClient from pydantic import ValidationError +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound from controllers.console import console_ns @@ -56,6 +59,12 @@ from controllers.console.app.workflow_app_log import WorkflowAppLogQuery from controllers.console.app.workflow_draft_variable import WorkflowDraftVariableUpdatePayload from controllers.console.app.workflow_statistic import WorkflowStatisticQuery from controllers.console.app.workflow_trigger import Parser, ParserEnable +from models.model import AppMode +from tests.test_containers_integration_tests.controllers.console.helpers import ( + authenticate_console_client, + create_console_account_and_tenant, + create_console_app, +) def _unwrap(func): @@ -69,7 +78,7 @@ def _unwrap(func): class TestCompletionEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def test_completion_create_payload(self): @@ -86,7 +95,7 @@ class TestCompletionEndpoints: ) assert payload.query == "hi" - def test_completion_api_success(self, app, monkeypatch): + def test_completion_api_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = completion_module.CompletionMessageApi() method = _unwrap(api.post) @@ -116,7 +125,7 @@ class TestCompletionEndpoints: assert resp == {"result": {"text": "ok"}} - def test_completion_api_conversation_not_exists(self, app, monkeypatch): + def test_completion_api_conversation_not_exists(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = completion_module.CompletionMessageApi() method = _unwrap(api.post) @@ -142,7 +151,7 @@ class TestCompletionEndpoints: with pytest.raises(NotFound): method(app_model=MagicMock(id="app-1")) - def test_completion_api_provider_not_initialized(self, app, monkeypatch): + def test_completion_api_provider_not_initialized(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = completion_module.CompletionMessageApi() method = _unwrap(api.post) @@ -166,7 +175,7 @@ class TestCompletionEndpoints: with pytest.raises(completion_module.ProviderNotInitializeError): method(app_model=MagicMock(id="app-1")) - def test_completion_api_quota_exceeded(self, app, monkeypatch): + def test_completion_api_quota_exceeded(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = completion_module.CompletionMessageApi() method = _unwrap(api.post) @@ -193,10 +202,10 @@ class TestCompletionEndpoints: class TestAppEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_app_put_should_preserve_icon_type_when_payload_omits_it(self, app, monkeypatch): + def test_app_put_should_preserve_icon_type_when_payload_omits_it(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = app_module.AppApi() method = _unwrap(api.put) payload = { @@ -234,7 +243,7 @@ class TestAppEndpoints: } ) - def test_app_icon_post_should_forward_icon_type(self, app, monkeypatch): + def test_app_icon_post_should_forward_icon_type(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = app_module.AppIconApi() method = _unwrap(api.post) payload = { @@ -266,9 +275,38 @@ class TestAppEndpoints: class TestOpsTraceEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers + @pytest.mark.parametrize( + "path_template", + [ + "/console/api/apps/{app_id}/trace-config?tracing_provider=langfuse", + "/console/api/apps/{app_id}/trace", + ], + ) + def test_trace_endpoints_hide_apps_from_other_tenants( + self, + db_session_with_containers: Session, + test_client_with_containers: FlaskClient, + path_template: str, + ): + account, _tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers) + foreign_app = create_console_app( + db_session_with_containers, + tenant_id=foreign_tenant.id, + account_id=foreign_account.id, + mode=AppMode.CHAT, + ) + + response = test_client_with_containers.get( + path_template.format(app_id=foreign_app.id), + headers=authenticate_console_client(test_client_with_containers, account), + ) + + assert response.status_code == 404 + def test_ops_trace_query_basic(self): query = TraceProviderQuery(tracing_provider="langfuse") assert query.tracing_provider == "langfuse" @@ -277,7 +315,7 @@ class TestOpsTraceEndpoints: payload = TraceConfigPayload(tracing_provider="langfuse", tracing_config={"api_key": "k"}) assert payload.tracing_config["api_key"] == "k" - def test_trace_app_config_get_empty(self, app, monkeypatch): + def test_trace_app_config_get_empty(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = ops_trace_module.TraceAppConfigApi() method = _unwrap(api.get) @@ -288,11 +326,11 @@ class TestOpsTraceEndpoints: ) with app.test_request_context("/?tracing_provider=langfuse"): - result = method(app_id="app-1") + result = method(app_model=MagicMock(id="app-1")) assert result == {"has_not_configured": True} - def test_trace_app_config_post_invalid(self, app, monkeypatch): + def test_trace_app_config_post_invalid(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = ops_trace_module.TraceAppConfigApi() method = _unwrap(api.post) @@ -307,9 +345,9 @@ class TestOpsTraceEndpoints: json={"tracing_provider": "langfuse", "tracing_config": {"api_key": "k"}}, ): with pytest.raises(BadRequest): - method(app_id="app-1") + method(app_model=MagicMock(id="app-1")) - def test_trace_app_config_delete_not_found(self, app, monkeypatch): + def test_trace_app_config_delete_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = ops_trace_module.TraceAppConfigApi() method = _unwrap(api.delete) @@ -321,12 +359,12 @@ class TestOpsTraceEndpoints: with app.test_request_context("/?tracing_provider=langfuse"): with pytest.raises(BadRequest): - method(app_id="app-1") + method(app_model=MagicMock(id="app-1")) class TestSiteEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def test_site_response_structure(self): @@ -337,7 +375,7 @@ class TestSiteEndpoints: payload = AppSiteUpdatePayload(default_language="en-US") assert payload.default_language == "en-US" - def test_app_site_update_post(self, app, monkeypatch): + def test_app_site_update_post(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = site_module.AppSite() method = _unwrap(api.post) @@ -375,7 +413,7 @@ class TestSiteEndpoints: assert isinstance(result, dict) assert result["title"] == "My Site" - def test_app_site_access_token_reset(self, app, monkeypatch): + def test_app_site_access_token_reset(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = site_module.AppSiteAccessTokenReset() method = _unwrap(api.post) @@ -427,7 +465,7 @@ class TestWorkflowEndpoints: class TestWorkflowAppLogEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def test_workflow_app_log_query(self): @@ -438,7 +476,7 @@ class TestWorkflowAppLogEndpoints: query = WorkflowAppLogQuery(detail="true") assert query.detail is True - def test_workflow_app_log_api_get(self, app, monkeypatch): + def test_workflow_app_log_api_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = workflow_app_log_module.WorkflowAppLogApi() method = _unwrap(api.get) @@ -477,14 +515,14 @@ class TestWorkflowAppLogEndpoints: class TestWorkflowDraftVariableEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def test_workflow_variable_creation(self): payload = WorkflowDraftVariableUpdatePayload(name="var1", value="test") assert payload.name == "var1" - def test_workflow_variable_collection_get(self, app, monkeypatch): + def test_workflow_variable_collection_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = workflow_draft_variable_module.WorkflowVariableCollectionApi() method = _unwrap(api.get) @@ -529,7 +567,7 @@ class TestWorkflowDraftVariableEndpoints: class TestWorkflowStatisticEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def test_workflow_statistic_time_range(self): @@ -541,7 +579,7 @@ class TestWorkflowStatisticEndpoints: assert query.start is None assert query.end is None - def test_workflow_daily_runs_statistic(self, app, monkeypatch): + def test_workflow_daily_runs_statistic(self, app: Flask, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(workflow_statistic_module, "db", SimpleNamespace(engine=MagicMock())) monkeypatch.setattr( workflow_statistic_module.DifyAPIRepositoryFactory, @@ -567,7 +605,7 @@ class TestWorkflowStatisticEndpoints: assert response.get_json() == {"data": [{"date": "2024-01-01"}]} - def test_workflow_daily_terminals_statistic(self, app, monkeypatch): + def test_workflow_daily_terminals_statistic(self, app: Flask, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(workflow_statistic_module, "db", SimpleNamespace(engine=MagicMock())) monkeypatch.setattr( workflow_statistic_module.DifyAPIRepositoryFactory, @@ -598,7 +636,7 @@ class TestWorkflowStatisticEndpoints: class TestWorkflowTriggerEndpoints: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def test_webhook_trigger_payload(self): @@ -608,7 +646,7 @@ class TestWorkflowTriggerEndpoints: enable_payload = ParserEnable(trigger_id="trigger-1", enable_trigger=True) assert enable_payload.enable_trigger is True - def test_webhook_trigger_api_get(self, app, monkeypatch): + def test_webhook_trigger_api_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch): api = workflow_trigger_module.WebhookTriggerApi() method = _unwrap(api.get) diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_import_api.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_import_api.py index 25d19cf35a..bcb6e41ef7 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_app_import_api.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from flask import Flask from controllers.console.app import app_import as app_import_module from services.app_dsl_service import ImportStatus @@ -36,10 +37,10 @@ def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: class TestAppImportApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_import_post_returns_failed_status(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_returns_failed_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportApi() method = _unwrap(api.post) @@ -57,7 +58,7 @@ class TestAppImportApi: assert status == 400 assert response["status"] == ImportStatus.FAILED - def test_import_post_returns_pending_status(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_returns_pending_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportApi() method = _unwrap(api.post) @@ -75,7 +76,7 @@ class TestAppImportApi: assert status == 202 assert response["status"] == ImportStatus.PENDING - def test_import_post_updates_webapp_auth_when_enabled(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_updates_webapp_auth_when_enabled(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportApi() method = _unwrap(api.post) @@ -96,7 +97,7 @@ class TestAppImportApi: assert status == 200 assert response["status"] == ImportStatus.COMPLETED - def test_import_post_commits_session_on_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_commits_session_on_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportApi() method = _unwrap(api.post) @@ -121,7 +122,7 @@ class TestAppImportApi: assert status == 200 assert response["status"] == ImportStatus.COMPLETED - def test_import_post_rolls_back_session_on_failure(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_rolls_back_session_on_failure(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportApi() method = _unwrap(api.post) @@ -149,10 +150,10 @@ class TestAppImportApi: class TestAppImportConfirmApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_import_confirm_returns_failed_status(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_confirm_returns_failed_status(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportConfirmApi() method = _unwrap(api.post) @@ -172,10 +173,10 @@ class TestAppImportConfirmApi: class TestAppImportCheckDependenciesApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_import_check_dependencies_returns_result(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_check_dependencies_returns_result(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = app_import_module.AppImportCheckDependenciesApi() method = _unwrap(api.get) diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py index 5a22f81a69..0efd77934e 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py @@ -6,17 +6,17 @@ import uuid from flask.testing import FlaskClient from sqlalchemy.orm import Session -from configs import dify_config from constants import HEADER_NAME_CSRF_TOKEN from graphon.enums import WorkflowExecutionStatus from libs.datetime_utils import naive_utc_now from libs.token import _real_cookie_name, generate_csrf_token -from models import Account, DifySetup, Tenant, TenantAccountJoin +from models import Account, Tenant, TenantAccountJoin from models.account import AccountStatus, TenantAccountRole, TenantStatus from models.enums import ConversationFromSource, CreatorUserRole from models.model import App, AppMode, Conversation, Message from models.workflow import WorkflowRun from services.account_service import AccountService +from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]: @@ -47,9 +47,7 @@ def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]: account.timezone = "UTC" db_session.commit() - dify_setup = DifySetup(version=dify_config.project.version) - db_session.add(dify_setup) - db_session.commit() + ensure_dify_setup(db_session) return account, tenant diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py index 290be87697..a071d22ee9 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py @@ -168,6 +168,7 @@ def test_node_variable_collection_get_success( account, tenant = create_console_account_and_tenant(db_session_with_containers) app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW) node_variable = _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_123") + node_variable_id = node_variable.id _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_456", name="other") response = test_client_with_containers.get( @@ -178,7 +179,7 @@ def test_node_variable_collection_get_success( assert response.status_code == 200 payload = response.get_json() assert payload is not None - assert [item["id"] for item in payload["items"]] == [node_variable.id] + assert [item["id"] for item in payload["items"]] == [node_variable_id] def test_node_variable_collection_get_invalid_node_id( @@ -377,6 +378,7 @@ def test_system_variable_collection_get( account, tenant = create_console_account_and_tenant(db_session_with_containers) app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW) variable = _create_system_variable(db_session_with_containers, app.id, account.id) + variable_id = variable.id response = test_client_with_containers.get( f"/console/api/apps/{app.id}/workflows/draft/system-variables", @@ -386,7 +388,7 @@ def test_system_variable_collection_get( assert response.status_code == 200 payload = response.get_json() assert payload is not None - assert [item["id"] for item in payload["items"]] == [variable.id] + assert [item["id"] for item in payload["items"]] == [variable_id] def test_environment_variable_collection_get( diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py index 81b5423261..f2c45f76da 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py @@ -17,6 +17,8 @@ def test_get_oauth_url_successful( test_client_with_containers: FlaskClient, ) -> None: account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id + current_tenant_id = account.current_tenant_id provider = MagicMock() provider.get_authorization_url.return_value = "http://oauth.provider/auth" @@ -29,7 +31,7 @@ def test_get_oauth_url_successful( headers=authenticate_console_client(test_client_with_containers, account), ) - assert tenant.id == account.current_tenant_id + assert tenant_id == current_tenant_id assert response.status_code == 200 assert response.get_json() == {"data": "http://oauth.provider/auth"} provider.get_authorization_url.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py index 320da85b60..bb7921a5f4 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_email_register.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.console.auth.email_register import ( EmailRegisterCheckApi, @@ -16,7 +17,7 @@ from services.account_service import AccountService @pytest.fixture -def app(flask_app_with_containers): +def app(flask_app_with_containers: Flask): return flask_app_with_containers @@ -33,7 +34,7 @@ class TestEmailRegisterSendEmailApi: mock_is_freeze, mock_send_mail, mock_get_account, - app, + app: Flask, ): mock_send_mail.return_value = "token-123" mock_is_freeze.return_value = False @@ -75,7 +76,7 @@ class TestEmailRegisterCheckApi: mock_revoke, mock_generate_token, mock_reset_rate, - app, + app: Flask, ): mock_rate_limit_check.return_value = False mock_get_data.return_value = {"email": "User@Example.com", "code": "4321"} @@ -120,7 +121,7 @@ class TestEmailRegisterResetApi: mock_create_account, mock_login, mock_reset_login_rate, - app, + app: Flask, ): mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} mock_create_account.return_value = MagicMock() @@ -142,7 +143,118 @@ class TestEmailRegisterResetApi: response = EmailRegisterResetApi().post() assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} - mock_create_account.assert_called_once_with("invitee@example.com", "ValidPass123!") + mock_create_account.assert_called_once_with( + email="invitee@example.com", + password="ValidPass123!", + timezone=None, + language=None, + ) + mock_reset_login_rate.assert_called_once_with("invitee@example.com") + mock_revoke_token.assert_called_once_with("token-123") + mock_extract_ip.assert_called_once() + + @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") + @patch("controllers.console.auth.email_register.AccountService.login") + @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") + @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") + @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") + @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") + @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") + def test_reset_passes_timezone_to_new_account( + self, + mock_extract_ip, + mock_get_data, + mock_revoke_token, + mock_get_account, + mock_create_account, + mock_login, + mock_reset_login_rate, + app: Flask, + ): + mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} + mock_create_account.return_value = MagicMock() + token_pair = MagicMock() + token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} + mock_login.return_value = token_pair + mock_get_account.return_value = None + + feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True) + with ( + patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")), + patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), + ): + with app.test_request_context( + "/email-register", + method="POST", + json={ + "token": "token-123", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "timezone": "Asia/Shanghai", + }, + ): + response = EmailRegisterResetApi().post() + + assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} + mock_create_account.assert_called_once_with( + email="invitee@example.com", + password="ValidPass123!", + timezone="Asia/Shanghai", + language=None, + ) + mock_reset_login_rate.assert_called_once_with("invitee@example.com") + mock_revoke_token.assert_called_once_with("token-123") + mock_extract_ip.assert_called_once() + + @patch("controllers.console.auth.email_register.AccountService.reset_login_error_rate_limit") + @patch("controllers.console.auth.email_register.AccountService.login") + @patch("controllers.console.auth.email_register.EmailRegisterResetApi._create_new_account") + @patch("controllers.console.auth.email_register.AccountService.get_account_by_email_with_case_fallback") + @patch("controllers.console.auth.email_register.AccountService.revoke_email_register_token") + @patch("controllers.console.auth.email_register.AccountService.get_email_register_data") + @patch("controllers.console.auth.email_register.extract_remote_ip", return_value="127.0.0.1") + def test_reset_passes_language_to_new_account( + self, + mock_extract_ip, + mock_get_data, + mock_revoke_token, + mock_get_account, + mock_create_account, + mock_login, + mock_reset_login_rate, + app: Flask, + ): + mock_get_data.return_value = {"phase": "register", "email": "Invitee@Example.com"} + mock_create_account.return_value = MagicMock() + token_pair = MagicMock() + token_pair.model_dump.return_value = {"access_token": "a", "refresh_token": "r"} + mock_login.return_value = token_pair + mock_get_account.return_value = None + + feature_flags = SimpleNamespace(enable_email_password_login=True, is_allow_register=True) + with ( + patch("controllers.console.wraps.dify_config", SimpleNamespace(EDITION="CLOUD")), + patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), + ): + with app.test_request_context( + "/email-register", + method="POST", + json={ + "token": "token-123", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "language": "zh-Hans", + }, + ): + response = EmailRegisterResetApi().post() + + assert response == {"result": "success", "data": {"access_token": "a", "refresh_token": "r"}} + mock_create_account.assert_called_once_with( + email="invitee@example.com", + password="ValidPass123!", + timezone=None, + language="zh-Hans", + ) mock_reset_login_rate.assert_called_once_with("invitee@example.com") mock_revoke_token.assert_called_once_with("token-123") mock_extract_ip.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py index d2703ed5cc..014c1588fe 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_forgot_password.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.console.auth.forgot_password import ( ForgotPasswordCheckApi, @@ -16,7 +17,7 @@ from services.account_service import AccountService @pytest.fixture -def app(flask_app_with_containers): +def app(flask_app_with_containers: Flask): return flask_app_with_containers @@ -31,7 +32,7 @@ class TestForgotPasswordSendEmailApi: mock_is_ip_limit, mock_send_email, mock_get_account, - app, + app: Flask, ): mock_account = MagicMock() mock_get_account.return_value = mock_account @@ -80,7 +81,7 @@ class TestForgotPasswordCheckApi: mock_revoke_token, mock_generate_token, mock_reset_rate, - app, + app: Flask, ): mock_rate_limit_check.return_value = False mock_get_data.return_value = {"email": "Admin@Example.com", "code": "4321"} @@ -123,7 +124,7 @@ class TestForgotPasswordResetApi: mock_db, mock_get_account, mock_update_account, - app, + app: Flask, ): mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com"} mock_account = MagicMock() diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py index 1eabb45422..a5ae83739c 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_oauth.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.console.auth.oauth import ( OAuthCallback, @@ -13,7 +14,7 @@ from controllers.console.auth.oauth import ( _get_account_by_openid_or_email, get_oauth_providers, ) -from libs.oauth import OAuthUserInfo +from libs.oauth import OAuthUserInfo, encode_oauth_state from models.account import AccountStatus from services.account_service import AccountService from services.errors.account import AccountRegisterError @@ -21,7 +22,7 @@ from services.errors.account import AccountRegisterError class TestGetOAuthProviders: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @pytest.mark.parametrize( @@ -65,7 +66,7 @@ class TestOAuthLogin: return OAuthLogin() @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @pytest.fixture @@ -89,7 +90,7 @@ class TestOAuthLogin: mock_redirect, mock_get_providers, resource, - app, + app: Flask, mock_oauth_provider, invite_token, expected_token, @@ -100,7 +101,55 @@ class TestOAuthLogin: with app.test_request_context(f"/auth/oauth/github?{query_string}"): resource.get("github") - mock_oauth_provider.get_authorization_url.assert_called_once_with(invite_token=expected_token) + mock_oauth_provider.get_authorization_url.assert_called_once_with( + invite_token=expected_token, + timezone=None, + language=None, + ) + mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...") + + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth.redirect") + def test_should_pass_timezone_to_oauth_state( + self, + mock_redirect, + mock_get_providers, + resource, + app: Flask, + mock_oauth_provider, + ): + mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None} + + with app.test_request_context("/auth/oauth/github?timezone=Asia/Shanghai"): + resource.get("github") + + mock_oauth_provider.get_authorization_url.assert_called_once_with( + invite_token=None, + timezone="Asia/Shanghai", + language=None, + ) + mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...") + + @patch("controllers.console.auth.oauth.get_oauth_providers") + @patch("controllers.console.auth.oauth.redirect") + def test_should_pass_language_to_oauth_state( + self, + mock_redirect, + mock_get_providers, + resource, + app: Flask, + mock_oauth_provider, + ): + mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None} + + with app.test_request_context("/auth/oauth/github?language=zh-Hans"): + resource.get("github") + + mock_oauth_provider.get_authorization_url.assert_called_once_with( + invite_token=None, + timezone=None, + language="zh-Hans", + ) mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...") @pytest.mark.parametrize( @@ -130,7 +179,7 @@ class TestOAuthCallback: return OAuthCallback() @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @pytest.fixture @@ -164,7 +213,7 @@ class TestOAuthCallback: mock_get_providers, mock_config, resource, - app, + app: Flask, oauth_setup, ): mock_config.CONSOLE_WEB_URL = "http://localhost:3000" @@ -217,7 +266,7 @@ class TestOAuthCallback: mock_get_providers, mock_config, resource, - app, + app: Flask, oauth_setup, ): mock_config.CONSOLE_WEB_URL = "http://localhost:3000" @@ -228,7 +277,8 @@ class TestOAuthCallback: mock_register_service.is_valid_invite_token.return_value = True mock_register_service.get_invitation_by_token.return_value = {"email": "user@example.com"} - with app.test_request_context("/auth/oauth/github/callback?code=test_code&state=invite123"): + state = encode_oauth_state(invite_token="invite123", timezone="Asia/Shanghai") + with app.test_request_context(f"/auth/oauth/github/callback?code=test_code&state={state}"): resource.get("github") mock_register_service.get_invitation_by_token.assert_called_once_with(token="invite123") @@ -261,7 +311,7 @@ class TestOAuthCallback: mock_tenant_service, mock_account_service, resource, - app, + app: Flask, oauth_setup, account_status, expected_redirect, @@ -300,7 +350,7 @@ class TestOAuthCallback: mock_get_providers, mock_config, resource, - app, + app: Flask, oauth_setup, ): mock_get_providers.return_value = {"github": oauth_setup["provider"]} @@ -336,7 +386,7 @@ class TestOAuthCallback: mock_get_providers, mock_config, resource, - app, + app: Flask, oauth_setup, ): """Defensive test for CLOSED account status handling in OAuth callback. @@ -394,7 +444,7 @@ class TestOAuthCallback: class TestAccountGeneration: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @pytest.fixture @@ -465,7 +515,7 @@ class TestAccountGeneration: mock_register_service, mock_feature_service, mock_get_account, - app, + app: Flask, user_info, mock_account, allow_register, @@ -487,7 +537,13 @@ class TestAccountGeneration: if should_create: mock_register_service.register.assert_called_once_with( - email="test@example.com", name="Test User", password=None, open_id="123", provider="github" + email="test@example.com", + name="Test User", + password=None, + open_id="123", + provider="github", + language="en-US", + timezone=None, ) else: mock_register_service.register.assert_not_called() @@ -504,7 +560,7 @@ class TestAccountGeneration: mock_register_service, mock_feature_service, mock_get_account, - app, + app: Flask, ): user_info = OAuthUserInfo(id="123", name="Test User", email="Upper@Example.com") mock_feature_service.get_system_features.return_value.is_allow_register = True @@ -514,7 +570,75 @@ class TestAccountGeneration: _generate_account("github", user_info) mock_register_service.register.assert_called_once_with( - email="upper@example.com", name="Test User", password=None, open_id="123", provider="github" + email="upper@example.com", + name="Test User", + password=None, + open_id="123", + provider="github", + language="en-US", + timezone=None, + ) + + @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) + @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.RegisterService") + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.TenantService") + def test_should_register_with_browser_timezone( + self, + mock_tenant_service, + mock_account_service, + mock_register_service, + mock_feature_service, + mock_get_account, + app: Flask, + user_info, + ): + mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_register_service.register.return_value = MagicMock() + + with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}): + _generate_account("github", user_info, timezone="Asia/Shanghai") + + mock_register_service.register.assert_called_once_with( + email="test@example.com", + name="Test User", + password=None, + open_id="123", + provider="github", + language="zh-Hans", + timezone="Asia/Shanghai", + ) + + @patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) + @patch("controllers.console.auth.oauth.FeatureService") + @patch("controllers.console.auth.oauth.RegisterService") + @patch("controllers.console.auth.oauth.AccountService") + @patch("controllers.console.auth.oauth.TenantService") + def test_should_register_with_state_language( + self, + mock_tenant_service, + mock_account_service, + mock_register_service, + mock_feature_service, + mock_get_account, + app: Flask, + user_info, + ): + mock_feature_service.get_system_features.return_value.is_allow_register = True + mock_register_service.register.return_value = MagicMock() + + with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): + _generate_account("github", user_info, language="zh-Hans") + + mock_register_service.register.assert_called_once_with( + email="test@example.com", + name="Test User", + password=None, + open_id="123", + provider="github", + language="zh-Hans", + timezone=None, ) @patch("controllers.console.auth.oauth._get_account_by_openid_or_email") @@ -529,7 +653,7 @@ class TestAccountGeneration: mock_feature_service, mock_tenant_service, mock_get_account, - app, + app: Flask, user_info, mock_account, ): diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py index 50249bcd74..5fc3b3084a 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py @@ -5,6 +5,8 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask +from sqlalchemy.orm import Session from controllers.console.auth.error import ( EmailCodeError, @@ -19,13 +21,15 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError +from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup class TestForgotPasswordSendEmailApi: """Test cases for sending password reset emails.""" @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @pytest.fixture @@ -46,7 +50,7 @@ class TestForgotPasswordSendEmailApi: mock_send_email, mock_get_account, mock_is_ip_limit, - app, + app: Flask, mock_account, ): # Arrange @@ -68,7 +72,7 @@ class TestForgotPasswordSendEmailApi: mock_send_email.assert_called_once() @patch("controllers.console.auth.forgot_password.AccountService.is_email_send_ip_limit") - def test_send_reset_email_ip_rate_limited(self, mock_is_ip_limit, app): + def test_send_reset_email_ip_rate_limited(self, mock_is_ip_limit, app: Flask): """ Test password reset email blocked by IP rate limit. @@ -104,7 +108,7 @@ class TestForgotPasswordSendEmailApi: mock_send_email, mock_get_account, mock_is_ip_limit, - app, + app: Flask, mock_account, language_input, expected_language, @@ -138,7 +142,8 @@ class TestForgotPasswordCheckApi: """Test cases for verifying password reset codes.""" @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @@ -153,7 +158,7 @@ class TestForgotPasswordCheckApi: mock_revoke_token, mock_get_data, mock_is_rate_limit, - app, + app: Flask, ): """ Test successful verification code validation. @@ -200,7 +205,7 @@ class TestForgotPasswordCheckApi: mock_revoke_token, mock_get_data, mock_is_rate_limit, - app, + app: Flask, ): mock_is_rate_limit.return_value = False mock_get_data.return_value = {"email": "User@Example.com", "code": "999888"} @@ -221,7 +226,7 @@ class TestForgotPasswordCheckApi: mock_reset_rate_limit.assert_called_once_with("user@example.com") @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") - def test_verify_code_rate_limited(self, mock_is_rate_limit, app): + def test_verify_code_rate_limited(self, mock_is_rate_limit, app: Flask): """ Test code verification blocked by rate limit. @@ -244,7 +249,7 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_verify_code_invalid_token(self, mock_get_data, mock_is_rate_limit, app): + def test_verify_code_invalid_token(self, mock_get_data, mock_is_rate_limit, app: Flask): """ Test code verification with invalid token. @@ -267,7 +272,7 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_verify_code_email_mismatch(self, mock_get_data, mock_is_rate_limit, app): + def test_verify_code_email_mismatch(self, mock_get_data, mock_is_rate_limit, app: Flask): """ Test code verification with mismatched email. @@ -292,7 +297,7 @@ class TestForgotPasswordCheckApi: @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.add_forgot_password_error_rate_limit") - def test_verify_code_wrong_code(self, mock_add_rate_limit, mock_get_data, mock_is_rate_limit, app): + def test_verify_code_wrong_code(self, mock_add_rate_limit, mock_get_data, mock_is_rate_limit, app: Flask): """ Test code verification with incorrect code. @@ -321,7 +326,8 @@ class TestForgotPasswordResetApi: """Test cases for resetting password with verified token.""" @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @pytest.fixture @@ -344,7 +350,7 @@ class TestForgotPasswordResetApi: mock_get_account, mock_revoke_token, mock_get_data, - app, + app: Flask, mock_account, ): """ @@ -375,7 +381,7 @@ class TestForgotPasswordResetApi: mock_revoke_token.assert_called_once_with("valid_token") @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_reset_password_mismatch(self, mock_get_data, app): + def test_reset_password_mismatch(self, mock_get_data, app: Flask): """ Test password reset with mismatched passwords. @@ -397,7 +403,7 @@ class TestForgotPasswordResetApi: api.post() @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_reset_password_invalid_token(self, mock_get_data, app): + def test_reset_password_invalid_token(self, mock_get_data, app: Flask): """ Test password reset with invalid token. @@ -418,7 +424,7 @@ class TestForgotPasswordResetApi: api.post() @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") - def test_reset_password_wrong_phase(self, mock_get_data, app): + def test_reset_password_wrong_phase(self, mock_get_data, app: Flask): """ Test password reset with token not in reset phase. @@ -442,7 +448,7 @@ class TestForgotPasswordResetApi: @patch("controllers.console.auth.forgot_password.AccountService.get_reset_password_data") @patch("controllers.console.auth.forgot_password.AccountService.revoke_reset_password_token") @patch("controllers.console.auth.forgot_password.AccountService.get_account_by_email_with_case_fallback") - def test_reset_password_account_not_found(self, mock_get_account, mock_revoke_token, mock_get_data, app): + def test_reset_password_account_not_found(self, mock_get_account, mock_revoke_token, mock_get_data, app: Flask): """ Test password reset for non-existent account. diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py index d5ae95dfb7..7aa4aff1cc 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.orm import Session from controllers.console import console_ns @@ -26,10 +27,10 @@ def unwrap(func): class TestPipelineTemplateListApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = PipelineTemplateListApi() method = unwrap(api.get) @@ -50,10 +51,10 @@ class TestPipelineTemplateListApi: class TestPipelineTemplateDetailApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = PipelineTemplateDetailApi() method = unwrap(api.get) @@ -74,7 +75,7 @@ class TestPipelineTemplateDetailApi: assert status == 200 assert response == template - def test_get_returns_404_when_template_not_found(self, app): + def test_get_returns_404_when_template_not_found(self, app: Flask): api = PipelineTemplateDetailApi() method = unwrap(api.get) @@ -93,7 +94,7 @@ class TestPipelineTemplateDetailApi: assert status == 404 assert "error" in response - def test_get_returns_404_for_customized_type_not_found(self, app): + def test_get_returns_404_for_customized_type_not_found(self, app: Flask): api = PipelineTemplateDetailApi() method = unwrap(api.get) @@ -115,10 +116,10 @@ class TestPipelineTemplateDetailApi: class TestCustomizedPipelineTemplateApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_patch_success(self, app): + def test_patch_success(self, app: Flask): api = CustomizedPipelineTemplateApi() method = unwrap(api.patch) @@ -140,7 +141,7 @@ class TestCustomizedPipelineTemplateApi: update_mock.assert_called_once() assert response == 200 - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = CustomizedPipelineTemplateApi() method = unwrap(api.delete) @@ -155,7 +156,7 @@ class TestCustomizedPipelineTemplateApi: delete_mock.assert_called_once_with("tpl-1") assert response == 200 - def test_post_success(self, app, db_session_with_containers: Session): + def test_post_success(self, app: Flask, db_session_with_containers: Session): api = CustomizedPipelineTemplateApi() method = unwrap(api.post) @@ -182,7 +183,7 @@ class TestCustomizedPipelineTemplateApi: assert status == 200 assert response == {"data": "yaml-data"} - def test_post_template_not_found(self, app): + def test_post_template_not_found(self, app: Flask): api = CustomizedPipelineTemplateApi() method = unwrap(api.post) @@ -193,10 +194,10 @@ class TestCustomizedPipelineTemplateApi: class TestPublishCustomizedPipelineTemplateApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = PublishCustomizedPipelineTemplateApi() method = unwrap(api.post) diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py index 64e3de2ca3..7624c1150f 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden import services @@ -24,13 +25,13 @@ def unwrap(func): class TestCreateRagPipelineDatasetApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def _valid_payload(self): return {"yaml_content": "name: test"} - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = CreateRagPipelineDatasetApi() method = unwrap(api.post) @@ -58,7 +59,7 @@ class TestCreateRagPipelineDatasetApi: assert status == 201 assert response == import_info - def test_post_forbidden_non_editor(self, app): + def test_post_forbidden_non_editor(self, app: Flask): api = CreateRagPipelineDatasetApi() method = unwrap(api.post) @@ -76,7 +77,7 @@ class TestCreateRagPipelineDatasetApi: with pytest.raises(Forbidden): method(api) - def test_post_dataset_name_duplicate(self, app): + def test_post_dataset_name_duplicate(self, app: Flask): api = CreateRagPipelineDatasetApi() method = unwrap(api.post) @@ -101,7 +102,7 @@ class TestCreateRagPipelineDatasetApi: with pytest.raises(DatasetNameDuplicateError): method(api) - def test_post_invalid_payload(self, app): + def test_post_invalid_payload(self, app: Flask): api = CreateRagPipelineDatasetApi() method = unwrap(api.post) @@ -122,10 +123,10 @@ class TestCreateRagPipelineDatasetApi: class TestCreateEmptyRagPipelineDatasetApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = CreateEmptyRagPipelineDatasetApi() method = unwrap(api.post) @@ -152,7 +153,7 @@ class TestCreateEmptyRagPipelineDatasetApi: assert status == 201 assert response == {"id": "ds-1"} - def test_post_forbidden_non_editor(self, app): + def test_post_forbidden_non_editor(self, app: Flask): api = CreateEmptyRagPipelineDatasetApi() method = unwrap(api.post) diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py index cb67892878..44eb5c336c 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.console import console_ns from controllers.console.datasets.rag_pipeline.rag_pipeline_import import ( @@ -25,7 +26,7 @@ def unwrap(func): class TestRagPipelineImportApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def _payload(self, mode="create"): @@ -35,7 +36,7 @@ class TestRagPipelineImportApi: "name": "Test", } - def test_post_success_200(self, app): + def test_post_success_200(self, app: Flask): api = RagPipelineImportApi() method = unwrap(api.post) @@ -65,7 +66,7 @@ class TestRagPipelineImportApi: assert status == 200 assert response == {"status": "success"} - def test_post_failed_400(self, app): + def test_post_failed_400(self, app: Flask): api = RagPipelineImportApi() method = unwrap(api.post) @@ -95,7 +96,7 @@ class TestRagPipelineImportApi: assert status == 400 assert response == {"status": "failed"} - def test_post_pending_202(self, app): + def test_post_pending_202(self, app: Flask): api = RagPipelineImportApi() method = unwrap(api.post) @@ -128,10 +129,10 @@ class TestRagPipelineImportApi: class TestRagPipelineImportConfirmApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_confirm_success(self, app): + def test_confirm_success(self, app: Flask): api = RagPipelineImportConfirmApi() method = unwrap(api.post) @@ -159,7 +160,7 @@ class TestRagPipelineImportConfirmApi: assert status == 200 assert response == {"ok": True} - def test_confirm_failed(self, app): + def test_confirm_failed(self, app: Flask): api = RagPipelineImportConfirmApi() method = unwrap(api.post) @@ -190,10 +191,10 @@ class TestRagPipelineImportConfirmApi: class TestRagPipelineImportCheckDependenciesApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = RagPipelineImportCheckDependenciesApi() method = unwrap(api.get) @@ -219,10 +220,10 @@ class TestRagPipelineImportCheckDependenciesApi: class TestRagPipelineExportApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_with_include_secret(self, app): + def test_get_with_include_secret(self, app: Flask): api = RagPipelineExportApi() method = unwrap(api.get) diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index c1f3122c2b..77b3c72e5e 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -2,11 +2,15 @@ from __future__ import annotations +import json from datetime import datetime +from types import SimpleNamespace +from typing import TypedDict, Unpack from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, Forbidden, HTTPException, NotFound @@ -33,9 +37,54 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import ( ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from libs.datetime_utils import naive_utc_now +from models.account import Account +from models.workflow import Workflow from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError +DEFAULT_WORKFLOW_TENANT_ID = "00000000-0000-0000-0000-000000000001" +DEFAULT_WORKFLOW_APP_ID = "00000000-0000-0000-0000-000000000002" +DEFAULT_WORKFLOW_CREATED_BY = "00000000-0000-0000-0000-000000000003" +type WorkflowVariablePayload = dict[str, object] + + +class WorkflowFactoryPayload(TypedDict): + id: str + tenant_id: str + app_id: str + type: str + version: str + marked_name: str + marked_comment: str + graph: str + features: str + created_by: str + created_at: datetime + updated_by: str | None + updated_at: datetime + environment_variables: list[WorkflowVariablePayload] + conversation_variables: list[WorkflowVariablePayload] + rag_pipeline_variables: list[WorkflowVariablePayload] + + +class WorkflowFactoryOverrides(TypedDict, total=False): + id: str + tenant_id: str + app_id: str + type: str + version: str + marked_name: str + marked_comment: str + graph: str + features: str + created_by: str + created_at: datetime + updated_by: str | None + updated_at: datetime + environment_variables: list[WorkflowVariablePayload] + conversation_variables: list[WorkflowVariablePayload] + rag_pipeline_variables: list[WorkflowVariablePayload] + def unwrap(func): while hasattr(func, "__wrapped__"): @@ -43,17 +92,81 @@ def unwrap(func): return func +def make_node_execution(**overrides): + payload = { + "id": "node-exec-1", + "index": 1, + "predecessor_node_id": None, + "node_id": "node1", + "node_type": "start", + "title": "Start", + "inputs_dict": {"query": "hello"}, + "process_data_dict": {}, + "outputs_dict": {"answer": "world"}, + "status": "succeeded", + "error": None, + "elapsed_time": 1.0, + "execution_metadata_dict": {}, + "extras": {}, + "created_at": datetime(2026, 1, 1, 0, 0, 0), + "created_by_role": "account", + "created_by_account": None, + "created_by_end_user": None, + "finished_at": datetime(2026, 1, 1, 0, 0, 1), + "inputs_truncated": False, + "outputs_truncated": False, + "process_data_truncated": False, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + +def default_workflow_payload() -> WorkflowFactoryPayload: + return { + "id": "workflow-1", + "tenant_id": DEFAULT_WORKFLOW_TENANT_ID, + "app_id": DEFAULT_WORKFLOW_APP_ID, + "type": "workflow", + "version": "1", + "marked_name": "Release 1", + "marked_comment": "Initial release", + "graph": json.dumps({"nodes": [], "edges": []}), + "features": json.dumps({"file_upload": {"enabled": False}}), + "created_by": DEFAULT_WORKFLOW_CREATED_BY, + "created_at": datetime(2024, 1, 1, 12, 0, 0), + "updated_by": None, + "updated_at": datetime(2024, 1, 1, 12, 1, 0), + "environment_variables": [], + "conversation_variables": [], + "rag_pipeline_variables": [], + } + + +def make_workflow(**overrides: Unpack[WorkflowFactoryOverrides]) -> Workflow: + payload = default_workflow_payload() + payload.update(overrides) + return Workflow(**payload) + + +@pytest.fixture +def workflow_author(db_session_with_containers: Session) -> Account: + account = Account(name="Alice", email=f"alice-{uuid4()}@example.com") + db_session_with_containers.add(account) + db_session_with_containers.commit() + return account + + class TestDraftWorkflowApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_draft_success(self, app): + def test_get_draft_success(self, app: Flask, workflow_author: Account): api = DraftRagPipelineApi() method = unwrap(api.get) pipeline = MagicMock() - workflow = MagicMock() + workflow = make_workflow(created_by=workflow_author.id) service = MagicMock() service.get_draft_workflow.return_value = workflow @@ -66,9 +179,19 @@ class TestDraftWorkflowApi: ), ): result = method(api, pipeline) - assert result == workflow - def test_get_draft_not_exist(self, app): + assert result["id"] == "workflow-1" + assert result["graph"] == {"nodes": [], "edges": []} + assert result["features"] == {"file_upload": {"enabled": False}} + assert result["hash"] == workflow.unique_hash + assert result["created_by"] == { + "id": workflow_author.id, + "name": workflow_author.name, + "email": workflow_author.email, + } + assert result["updated_by"] is None + + def test_get_draft_not_exist(self, app: Flask): api = DraftRagPipelineApi() method = unwrap(api.get) @@ -86,7 +209,7 @@ class TestDraftWorkflowApi: with pytest.raises(DraftWorkflowNotExist): method(api, pipeline) - def test_sync_hash_not_match(self, app): + def test_sync_hash_not_match(self, app: Flask): api = DraftRagPipelineApi() method = unwrap(api.post) @@ -111,7 +234,7 @@ class TestDraftWorkflowApi: with pytest.raises(DraftWorkflowNotSync): method(api, pipeline) - def test_sync_invalid_text_plain(self, app): + def test_sync_invalid_text_plain(self, app: Flask): api = DraftRagPipelineApi() method = unwrap(api.post) @@ -128,7 +251,7 @@ class TestDraftWorkflowApi: response, status = method(api, pipeline) assert status == 400 - def test_restore_published_workflow_to_draft_success(self, app): + def test_restore_published_workflow_to_draft_success(self, app: Flask): api = RagPipelineDraftWorkflowRestoreApi() method = unwrap(api.post) @@ -155,7 +278,7 @@ class TestDraftWorkflowApi: assert result["result"] == "success" assert result["hash"] == "restored-hash" - def test_restore_published_workflow_to_draft_not_found(self, app): + def test_restore_published_workflow_to_draft_not_found(self, app: Flask): api = RagPipelineDraftWorkflowRestoreApi() method = unwrap(api.post) @@ -179,7 +302,7 @@ class TestDraftWorkflowApi: with pytest.raises(NotFound): method(api, pipeline, "published-workflow") - def test_restore_published_workflow_to_draft_returns_400_for_draft_source(self, app): + def test_restore_published_workflow_to_draft_returns_400_for_draft_source(self, app: Flask): api = RagPipelineDraftWorkflowRestoreApi() method = unwrap(api.post) @@ -211,10 +334,10 @@ class TestDraftWorkflowApi: class TestDraftRunNodes: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_iteration_node_success(self, app): + def test_iteration_node_success(self, app: Flask): api = RagPipelineDraftRunIterationNodeApi() method = unwrap(api.post) @@ -240,7 +363,7 @@ class TestDraftRunNodes: result = method(api, pipeline, "node") assert result == {"ok": True} - def test_iteration_node_conversation_not_exists(self, app): + def test_iteration_node_conversation_not_exists(self, app: Flask): api = RagPipelineDraftRunIterationNodeApi() method = unwrap(api.post) @@ -262,7 +385,7 @@ class TestDraftRunNodes: with pytest.raises(NotFound): method(api, pipeline, "node") - def test_loop_node_success(self, app): + def test_loop_node_success(self, app: Flask): api = RagPipelineDraftRunLoopNodeApi() method = unwrap(api.post) @@ -290,10 +413,10 @@ class TestDraftRunNodes: class TestPipelineRunApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_draft_run_success(self, app): + def test_draft_run_success(self, app: Flask): api = DraftRagPipelineRunApi() method = unwrap(api.post) @@ -325,7 +448,7 @@ class TestPipelineRunApis: ): assert method(api, pipeline) == {"ok": True} - def test_draft_run_rate_limit(self, app): + def test_draft_run_rate_limit(self, app: Flask): api = DraftRagPipelineRunApi() method = unwrap(api.post) @@ -356,10 +479,10 @@ class TestPipelineRunApis: class TestDraftNodeRun: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_execution_not_found(self, app): + def test_execution_not_found(self, app: Flask): api = RagPipelineDraftNodeRunApi() method = unwrap(api.post) @@ -387,10 +510,10 @@ class TestDraftNodeRun: class TestPublishedPipelineApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_publish_success(self, app, db_session_with_containers: Session): + def test_publish_success(self, app: Flask, db_session_with_containers: Session): from models.dataset import Pipeline api = PublishedRagPipelineApi() @@ -436,10 +559,10 @@ class TestPublishedPipelineApis: class TestMiscApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_task_stop(self, app): + def test_task_stop(self, app: Flask): api = RagPipelineTaskStopApi() method = unwrap(api.post) @@ -460,7 +583,7 @@ class TestMiscApis: stop_mock.assert_called_once() assert result["result"] == "success" - def test_transform_forbidden(self, app): + def test_transform_forbidden(self, app: Flask): api = RagPipelineTransformApi() method = unwrap(api.post) @@ -476,7 +599,7 @@ class TestMiscApis: with pytest.raises(Forbidden): method(api, "ds1") - def test_recommended_plugins(self, app): + def test_recommended_plugins(self, app: Flask): api = RagPipelineRecommendedPluginApi() method = unwrap(api.get) @@ -496,10 +619,10 @@ class TestMiscApis: class TestPublishedRagPipelineRunApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_published_run_success(self, app): + def test_published_run_success(self, app: Flask): api = PublishedRagPipelineRunApi() method = unwrap(api.post) @@ -533,7 +656,7 @@ class TestPublishedRagPipelineRunApi: result = method(api, pipeline) assert result == {"ok": True} - def test_published_run_rate_limit(self, app): + def test_published_run_rate_limit(self, app: Flask): api = PublishedRagPipelineRunApi() method = unwrap(api.post) @@ -565,10 +688,10 @@ class TestPublishedRagPipelineRunApi: class TestDefaultBlockConfigApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_block_config_success(self, app): + def test_get_block_config_success(self, app: Flask): api = DefaultRagPipelineBlockConfigApi() method = unwrap(api.get) @@ -587,7 +710,7 @@ class TestDefaultBlockConfigApi: result = method(api, pipeline, "llm") assert result == {"k": "v"} - def test_get_block_config_invalid_json(self, app): + def test_get_block_config_invalid_json(self, app: Flask): api = DefaultRagPipelineBlockConfigApi() method = unwrap(api.get) @@ -600,10 +723,10 @@ class TestDefaultBlockConfigApi: class TestPublishedAllRagPipelineApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_published_workflows_success(self, app): + def test_get_published_workflows_success(self, app: Flask): api = PublishedAllRagPipelineApi() method = unwrap(api.get) @@ -611,7 +734,7 @@ class TestPublishedAllRagPipelineApi: user = MagicMock(id="u1") service = MagicMock() - service.get_all_published_workflow.return_value = ([{"id": "w1"}], False) + service.get_all_published_workflow.return_value = ([make_workflow(id="w1")], False) with ( app.test_request_context("/"), @@ -626,10 +749,11 @@ class TestPublishedAllRagPipelineApi: ): result = method(api, pipeline) - assert result["items"] == [{"id": "w1"}] + assert result["items"][0]["id"] == "w1" + assert result["items"][0]["graph"] == {"nodes": [], "edges": []} assert result["has_more"] is False - def test_get_published_workflows_forbidden(self, app): + def test_get_published_workflows_forbidden(self, app: Flask): api = PublishedAllRagPipelineApi() method = unwrap(api.get) @@ -649,17 +773,17 @@ class TestPublishedAllRagPipelineApi: class TestRagPipelineByIdApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_patch_success(self, app): + def test_patch_success(self, app: Flask): api = RagPipelineByIdApi() method = unwrap(api.patch) pipeline = MagicMock(tenant_id="t1") user = MagicMock(id="u1") - workflow = MagicMock() + workflow = make_workflow(id="w1", marked_name="test") service = MagicMock() service.update_workflow.return_value = workflow @@ -680,9 +804,11 @@ class TestRagPipelineByIdApi: ): result = method(api, pipeline, "w1") - assert result == workflow + assert result["id"] == "w1" + assert result["marked_name"] == "test" + assert result["hash"] == workflow.unique_hash - def test_patch_no_fields(self, app): + def test_patch_no_fields(self, app: Flask): api = RagPipelineByIdApi() method = unwrap(api.patch) @@ -700,7 +826,7 @@ class TestRagPipelineByIdApi: result, status = method(api, pipeline, "w1") assert status == 400 - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = RagPipelineByIdApi() method = unwrap(api.delete) @@ -720,7 +846,7 @@ class TestRagPipelineByIdApi: workflow_service.delete_workflow.assert_called_once() assert result == (None, 204) - def test_delete_active_workflow_rejected(self, app): + def test_delete_active_workflow_rejected(self, app: Flask): api = RagPipelineByIdApi() method = unwrap(api.delete) @@ -733,16 +859,16 @@ class TestRagPipelineByIdApi: class TestRagPipelineWorkflowLastRunApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_last_run_success(self, app): + def test_last_run_success(self, app: Flask): api = RagPipelineWorkflowLastRunApi() method = unwrap(api.get) pipeline = MagicMock() workflow = MagicMock() - node_exec = MagicMock() + node_exec = make_node_execution() service = MagicMock() service.get_draft_workflow.return_value = workflow @@ -756,9 +882,11 @@ class TestRagPipelineWorkflowLastRunApi: ), ): result = method(api, pipeline, "node1") - assert result == node_exec + assert result["id"] == "node-exec-1" + assert result["inputs"] == {"query": "hello"} + assert result["outputs"] == {"answer": "world"} - def test_last_run_not_found(self, app): + def test_last_run_not_found(self, app: Flask): api = RagPipelineWorkflowLastRunApi() method = unwrap(api.get) @@ -780,10 +908,10 @@ class TestRagPipelineWorkflowLastRunApi: class TestRagPipelineDatasourceVariableApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_set_datasource_variables_success(self, app): + def test_set_datasource_variables_success(self, app: Flask): api = RagPipelineDatasourceVariableApi() method = unwrap(api.post) @@ -798,7 +926,7 @@ class TestRagPipelineDatasourceVariableApi: } service = MagicMock() - service.set_datasource_variables.return_value = MagicMock() + service.set_datasource_variables.return_value = make_node_execution(node_id="n1") with ( app.test_request_context("/", json=payload), @@ -813,4 +941,5 @@ class TestRagPipelineDatasourceVariableApi: ), ): result = method(api, pipeline) - assert result is not None + assert result["node_id"] == "n1" + assert result["process_data"] == {} diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py index 1c4c6a899f..b59009f7c4 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, PropertyMock, patch import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.console.datasets import data_source @@ -51,10 +52,10 @@ def mock_engine(): class TestDataSourceApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app, patch_tenant): + def test_get_success(self, app: Flask, patch_tenant): api = DataSourceApi() method = unwrap(api.get) @@ -78,7 +79,7 @@ class TestDataSourceApi: assert status == 200 assert response["data"][0]["is_bound"] is True - def test_get_no_bindings(self, app, patch_tenant): + def test_get_no_bindings(self, app: Flask, patch_tenant): api = DataSourceApi() method = unwrap(api.get) @@ -94,7 +95,7 @@ class TestDataSourceApi: assert status == 200 assert response["data"] == [] - def test_patch_enable_binding(self, app, patch_tenant, mock_engine): + def test_patch_enable_binding(self, app: Flask, patch_tenant, mock_engine): api = DataSourceApi() method = unwrap(api.patch) @@ -115,7 +116,7 @@ class TestDataSourceApi: assert status == 200 assert binding.disabled is False - def test_patch_disable_binding(self, app, patch_tenant, mock_engine): + def test_patch_disable_binding(self, app: Flask, patch_tenant, mock_engine): api = DataSourceApi() method = unwrap(api.patch) @@ -136,7 +137,7 @@ class TestDataSourceApi: assert status == 200 assert binding.disabled is True - def test_patch_binding_not_found(self, app, patch_tenant, mock_engine): + def test_patch_binding_not_found(self, app: Flask, patch_tenant, mock_engine): api = DataSourceApi() method = unwrap(api.patch) @@ -151,7 +152,7 @@ class TestDataSourceApi: with pytest.raises(NotFound): method(api, "b1", "enable") - def test_patch_enable_already_enabled(self, app, patch_tenant, mock_engine): + def test_patch_enable_already_enabled(self, app: Flask, patch_tenant, mock_engine): api = DataSourceApi() method = unwrap(api.patch) @@ -168,7 +169,7 @@ class TestDataSourceApi: with pytest.raises(ValueError): method(api, "b1", "enable") - def test_patch_disable_already_disabled(self, app, patch_tenant, mock_engine): + def test_patch_disable_already_disabled(self, app: Flask, patch_tenant, mock_engine): api = DataSourceApi() method = unwrap(api.patch) @@ -188,10 +189,10 @@ class TestDataSourceApi: class TestDataSourceNotionListApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_credential_not_found(self, app, patch_tenant): + def test_get_credential_not_found(self, app: Flask, patch_tenant): api = DataSourceNotionListApi() method = unwrap(api.get) @@ -205,7 +206,7 @@ class TestDataSourceNotionListApi: with pytest.raises(NotFound): method(api) - def test_get_success_no_dataset_id(self, app, patch_tenant, mock_engine): + def test_get_success_no_dataset_id(self, app: Flask, patch_tenant, mock_engine): api = DataSourceNotionListApi() method = unwrap(api.get) @@ -246,7 +247,7 @@ class TestDataSourceNotionListApi: assert status == 200 - def test_get_success_with_dataset_id(self, app, patch_tenant, mock_engine): + def test_get_success_with_dataset_id(self, app: Flask, patch_tenant, mock_engine): api = DataSourceNotionListApi() method = unwrap(api.get) @@ -299,7 +300,7 @@ class TestDataSourceNotionListApi: assert status == 200 - def test_get_invalid_dataset_type(self, app, patch_tenant, mock_engine): + def test_get_invalid_dataset_type(self, app: Flask, patch_tenant, mock_engine): api = DataSourceNotionListApi() method = unwrap(api.get) @@ -323,10 +324,10 @@ class TestDataSourceNotionListApi: class TestDataSourceNotionApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_preview_success(self, app, patch_tenant): + def test_get_preview_success(self, app: Flask, patch_tenant): api = DataSourceNotionApi() method = unwrap(api.get) @@ -347,7 +348,7 @@ class TestDataSourceNotionApi: assert status == 200 - def test_post_indexing_estimate_success(self, app, patch_tenant): + def test_post_indexing_estimate_success(self, app: Flask, patch_tenant): api = DataSourceNotionApi() method = unwrap(api.post) @@ -381,10 +382,10 @@ class TestDataSourceNotionApi: class TestDataSourceNotionDatasetSyncApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app, patch_tenant): + def test_get_success(self, app: Flask, patch_tenant): api = DataSourceNotionDatasetSyncApi() method = unwrap(api.get) @@ -407,7 +408,7 @@ class TestDataSourceNotionDatasetSyncApi: assert status == 200 - def test_get_dataset_not_found(self, app, patch_tenant): + def test_get_dataset_not_found(self, app: Flask, patch_tenant): api = DataSourceNotionDatasetSyncApi() method = unwrap(api.get) @@ -424,10 +425,10 @@ class TestDataSourceNotionDatasetSyncApi: class TestDataSourceNotionDocumentSyncApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app, patch_tenant): + def test_get_success(self, app: Flask, patch_tenant): api = DataSourceNotionDocumentSyncApi() method = unwrap(api.get) @@ -450,7 +451,7 @@ class TestDataSourceNotionDocumentSyncApi: assert status == 200 - def test_get_document_not_found(self, app, patch_tenant): + def test_get_document_not_found(self, app: Flask, patch_tenant): api = DataSourceNotionDocumentSyncApi() method = unwrap(api.get) diff --git a/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py b/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py index 83492048ef..917aa35fe6 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py +++ b/api/tests/test_containers_integration_tests/controllers/console/explore/test_conversation.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import NotFound import controllers.console.explore.conversation as conversation_module @@ -53,10 +54,10 @@ def user(): class TestConversationListApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app, chat_app, user): + def test_get_success(self, app: Flask, chat_app, user): api = conversation_module.ConversationListApi() method = unwrap(api.get) @@ -81,7 +82,7 @@ class TestConversationListApi: assert result["has_more"] is False assert len(result["data"]) == 2 - def test_last_conversation_not_exists(self, app, chat_app, user): + def test_last_conversation_not_exists(self, app: Flask, chat_app, user): api = conversation_module.ConversationListApi() method = unwrap(api.get) @@ -97,7 +98,7 @@ class TestConversationListApi: with pytest.raises(NotFound): method(chat_app) - def test_wrong_app_mode(self, app, non_chat_app): + def test_wrong_app_mode(self, app: Flask, non_chat_app): api = conversation_module.ConversationListApi() method = unwrap(api.get) @@ -108,10 +109,10 @@ class TestConversationListApi: class TestConversationApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_delete_success(self, app, chat_app, user): + def test_delete_success(self, app: Flask, chat_app, user): api = conversation_module.ConversationApi() method = unwrap(api.delete) @@ -129,7 +130,7 @@ class TestConversationApi: assert status == 204 assert body["result"] == "success" - def test_delete_not_found(self, app, chat_app, user): + def test_delete_not_found(self, app: Flask, chat_app, user): api = conversation_module.ConversationApi() method = unwrap(api.delete) @@ -145,7 +146,7 @@ class TestConversationApi: with pytest.raises(NotFound): method(chat_app, "cid") - def test_delete_wrong_app_mode(self, app, non_chat_app): + def test_delete_wrong_app_mode(self, app: Flask, non_chat_app): api = conversation_module.ConversationApi() method = unwrap(api.delete) @@ -156,10 +157,10 @@ class TestConversationApi: class TestConversationRenameApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_rename_success(self, app, chat_app, user): + def test_rename_success(self, app: Flask, chat_app, user): api = conversation_module.ConversationRenameApi() method = unwrap(api.post) @@ -178,7 +179,7 @@ class TestConversationRenameApi: assert result["id"] == "cid" - def test_rename_not_found(self, app, chat_app, user): + def test_rename_not_found(self, app: Flask, chat_app, user): api = conversation_module.ConversationRenameApi() method = unwrap(api.post) @@ -197,10 +198,10 @@ class TestConversationRenameApi: class TestConversationPinApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_pin_success(self, app, chat_app, user): + def test_pin_success(self, app: Flask, chat_app, user): api = conversation_module.ConversationPinApi() method = unwrap(api.patch) @@ -219,10 +220,10 @@ class TestConversationPinApi: class TestConversationUnPinApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_unpin_success(self, app, chat_app, user): + def test_unpin_success(self, app: Flask, chat_app, user): api = conversation_module.ConversationUnPinApi() method = unwrap(api.patch) diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py b/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py index 7df63aae1a..b55eaa1d58 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_apikey.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask from flask.testing import FlaskClient from sqlalchemy import delete from sqlalchemy.orm import Session @@ -126,7 +127,7 @@ class TestAppApiKeyResource: def test_delete_forbidden_for_non_admin( self, - flask_app_with_containers, + flask_app_with_containers: Flask, ) -> None: """A non-admin member cannot delete API keys via the controller permission check.""" from werkzeug.exceptions import Forbidden diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py index f2e7104b18..d944613886 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_tool_provider.py @@ -6,6 +6,7 @@ import json from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden from controllers.console.workspace.tool_providers import ( @@ -60,7 +61,7 @@ def _mock_user_tenant(): @pytest.fixture -def client(flask_app_with_containers): +def client(flask_app_with_containers: Flask): return flask_app_with_containers.test_client() @@ -147,10 +148,10 @@ class TestUtils: class TestToolProviderListApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = ToolProviderListApi() method = unwrap(api.get) @@ -170,10 +171,10 @@ class TestToolProviderListApi: class TestBuiltinProviderApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_list_tools(self, app): + def test_list_tools(self, app: Flask): api = ToolBuiltinProviderListToolsApi() method = unwrap(api.get) @@ -190,7 +191,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider") == [{"a": 1}] - def test_info(self, app): + def test_info(self, app: Flask): api = ToolBuiltinProviderInfoApi() method = unwrap(api.get) @@ -207,7 +208,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider") == {"x": 1} - def test_delete(self, app): + def test_delete(self, app: Flask): api = ToolBuiltinProviderDeleteApi() method = unwrap(api.post) @@ -224,7 +225,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider")["result"] == "success" - def test_add_invalid_type(self, app): + def test_add_invalid_type(self, app: Flask): api = ToolBuiltinProviderAddApi() method = unwrap(api.post) @@ -238,7 +239,7 @@ class TestBuiltinProviderApis: with pytest.raises(ValueError): method(api, "provider") - def test_add_success(self, app): + def test_add_success(self, app: Flask): api = ToolBuiltinProviderAddApi() method = unwrap(api.post) @@ -257,7 +258,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider")["id"] == 1 - def test_update(self, app): + def test_update(self, app: Flask): api = ToolBuiltinProviderUpdateApi() method = unwrap(api.post) @@ -276,7 +277,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider")["ok"] - def test_get_credentials(self, app): + def test_get_credentials(self, app: Flask): api = ToolBuiltinProviderGetCredentialsApi() method = unwrap(api.get) @@ -293,7 +294,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider") == {"k": "v"} - def test_icon(self, app): + def test_icon(self, app: Flask): api = ToolBuiltinProviderIconApi() method = unwrap(api.get) @@ -307,7 +308,7 @@ class TestBuiltinProviderApis: response = method(api, "provider") assert response.mimetype == "image/png" - def test_credentials_schema(self, app): + def test_credentials_schema(self, app: Flask): api = ToolBuiltinProviderCredentialsSchemaApi() method = unwrap(api.get) @@ -324,7 +325,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider", "oauth2") == {"schema": {}} - def test_set_default_credential(self, app): + def test_set_default_credential(self, app: Flask): api = ToolBuiltinProviderSetDefaultApi() method = unwrap(api.post) @@ -341,7 +342,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider")["ok"] - def test_get_credential_info(self, app): + def test_get_credential_info(self, app: Flask): api = ToolBuiltinProviderGetCredentialInfoApi() method = unwrap(api.get) @@ -358,7 +359,7 @@ class TestBuiltinProviderApis: ): assert method(api, "provider") == {"info": "x"} - def test_get_oauth_client_schema(self, app): + def test_get_oauth_client_schema(self, app: Flask): api = ToolBuiltinProviderGetOauthClientSchemaApi() method = unwrap(api.get) @@ -378,10 +379,10 @@ class TestBuiltinProviderApis: class TestApiProviderApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_add(self, app): + def test_add(self, app: Flask): api = ToolApiProviderAddApi() method = unwrap(api.post) @@ -406,7 +407,7 @@ class TestApiProviderApis: ): assert method(api)["id"] == 1 - def test_remote_schema(self, app): + def test_remote_schema(self, app: Flask): api = ToolApiProviderGetRemoteSchemaApi() method = unwrap(api.get) @@ -423,7 +424,7 @@ class TestApiProviderApis: ): assert method(api)["schema"] == "x" - def test_list_tools(self, app): + def test_list_tools(self, app: Flask): api = ToolApiProviderListToolsApi() method = unwrap(api.get) @@ -440,7 +441,7 @@ class TestApiProviderApis: ): assert method(api) == [{"tool": 1}] - def test_update(self, app): + def test_update(self, app: Flask): api = ToolApiProviderUpdateApi() method = unwrap(api.post) @@ -468,7 +469,7 @@ class TestApiProviderApis: ): assert method(api)["ok"] - def test_delete(self, app): + def test_delete(self, app: Flask): api = ToolApiProviderDeleteApi() method = unwrap(api.post) @@ -485,7 +486,7 @@ class TestApiProviderApis: ): assert method(api)["result"] == "success" - def test_get(self, app): + def test_get(self, app: Flask): api = ToolApiProviderGetApi() method = unwrap(api.get) @@ -505,10 +506,10 @@ class TestApiProviderApis: class TestWorkflowApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_create(self, app): + def test_create(self, app: Flask): api = ToolWorkflowProviderCreateApi() method = unwrap(api.post) @@ -534,7 +535,7 @@ class TestWorkflowApis: ): assert method(api)["id"] == 1 - def test_update_invalid(self, app): + def test_update_invalid(self, app: Flask): api = ToolWorkflowProviderUpdateApi() method = unwrap(api.post) @@ -560,7 +561,7 @@ class TestWorkflowApis: result = method(api) assert result["ok"] - def test_delete(self, app): + def test_delete(self, app: Flask): api = ToolWorkflowProviderDeleteApi() method = unwrap(api.post) @@ -577,7 +578,7 @@ class TestWorkflowApis: ): assert method(api)["ok"] - def test_get_error(self, app): + def test_get_error(self, app: Flask): api = ToolWorkflowProviderGetApi() method = unwrap(api.get) @@ -594,10 +595,10 @@ class TestWorkflowApis: class TestLists: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_builtin_list(self, app): + def test_builtin_list(self, app: Flask): api = ToolBuiltinListApi() method = unwrap(api.get) @@ -617,7 +618,7 @@ class TestLists: ): assert method(api) == [{"x": 1}] - def test_api_list(self, app): + def test_api_list(self, app: Flask): api = ToolApiListApi() method = unwrap(api.get) @@ -637,7 +638,7 @@ class TestLists: ): assert method(api) == [{"x": 1}] - def test_workflow_list(self, app): + def test_workflow_list(self, app: Flask): api = ToolWorkflowListApi() method = unwrap(api.get) @@ -660,10 +661,10 @@ class TestLists: class TestLabels: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_labels(self, app): + def test_labels(self, app: Flask): api = ToolLabelsApi() method = unwrap(api.get) @@ -679,10 +680,10 @@ class TestLabels: class TestOAuth: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_oauth_no_client(self, app): + def test_oauth_no_client(self, app: Flask): api = ToolPluginOAuthApi() method = unwrap(api.get) @@ -700,7 +701,7 @@ class TestOAuth: with pytest.raises(Forbidden): method(api, "provider") - def test_oauth_callback_no_cookie(self, app): + def test_oauth_callback_no_cookie(self, app: Flask): api = ToolOAuthCallback() method = unwrap(api.get) @@ -711,10 +712,10 @@ class TestOAuth: class TestOAuthCustomClient: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_save_custom_client(self, app): + def test_save_custom_client(self, app: Flask): api = ToolOAuthCustomClient() method = unwrap(api.post) @@ -731,7 +732,7 @@ class TestOAuthCustomClient: ): assert method(api, "provider")["ok"] - def test_get_custom_client(self, app): + def test_get_custom_client(self, app: Flask): api = ToolOAuthCustomClient() method = unwrap(api.get) @@ -748,7 +749,7 @@ class TestOAuthCustomClient: ): assert method(api, "provider") == {"client_id": "x"} - def test_delete_custom_client(self, app): + def test_delete_custom_client(self, app: Flask): api = ToolOAuthCustomClient() method = unwrap(api.delete) diff --git a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py index ca8195af53..e155b711a7 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py +++ b/api/tests/test_containers_integration_tests/controllers/console/workspace/test_trigger_providers.py @@ -5,6 +5,7 @@ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import BadRequest, Forbidden from controllers.console.workspace.trigger_providers import ( @@ -45,10 +46,10 @@ def mock_user(): class TestTriggerProviderApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_icon_success(self, app): + def test_icon_success(self, app: Flask): api = TriggerProviderIconApi() method = unwrap(api.get) @@ -62,7 +63,7 @@ class TestTriggerProviderApis: ): assert method(api, "github") == "icon" - def test_list_providers(self, app): + def test_list_providers(self, app: Flask): api = TriggerProviderListApi() method = unwrap(api.get) @@ -76,7 +77,7 @@ class TestTriggerProviderApis: ): assert method(api) == [] - def test_provider_info(self, app): + def test_provider_info(self, app: Flask): api = TriggerProviderInfoApi() method = unwrap(api.get) @@ -93,10 +94,10 @@ class TestTriggerProviderApis: class TestTriggerSubscriptionListApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_list_success(self, app): + def test_list_success(self, app: Flask): api = TriggerSubscriptionListApi() method = unwrap(api.get) @@ -110,7 +111,7 @@ class TestTriggerSubscriptionListApi: ): assert method(api, "github") == [] - def test_list_invalid_provider(self, app): + def test_list_invalid_provider(self, app: Flask): api = TriggerSubscriptionListApi() method = unwrap(api.get) @@ -128,10 +129,10 @@ class TestTriggerSubscriptionListApi: class TestTriggerSubscriptionBuilderApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_create_builder(self, app): + def test_create_builder(self, app: Flask): api = TriggerSubscriptionBuilderCreateApi() method = unwrap(api.post) @@ -146,7 +147,7 @@ class TestTriggerSubscriptionBuilderApis: result = method(api, "github") assert "subscription_builder" in result - def test_get_builder(self, app): + def test_get_builder(self, app: Flask): api = TriggerSubscriptionBuilderGetApi() method = unwrap(api.get) @@ -159,7 +160,7 @@ class TestTriggerSubscriptionBuilderApis: ): assert method(api, "github", "b1") == {"id": "b1"} - def test_verify_builder(self, app): + def test_verify_builder(self, app: Flask): api = TriggerSubscriptionBuilderVerifyApi() method = unwrap(api.post) @@ -173,7 +174,7 @@ class TestTriggerSubscriptionBuilderApis: ): assert method(api, "github", "b1") == {"ok": True} - def test_verify_builder_error(self, app): + def test_verify_builder_error(self, app: Flask): api = TriggerSubscriptionBuilderVerifyApi() method = unwrap(api.post) @@ -188,7 +189,7 @@ class TestTriggerSubscriptionBuilderApis: with pytest.raises(ValueError): method(api, "github", "b1") - def test_update_builder(self, app): + def test_update_builder(self, app: Flask): api = TriggerSubscriptionBuilderUpdateApi() method = unwrap(api.post) @@ -202,7 +203,7 @@ class TestTriggerSubscriptionBuilderApis: ): assert method(api, "github", "b1") == {"id": "b1"} - def test_logs(self, app): + def test_logs(self, app: Flask): api = TriggerSubscriptionBuilderLogsApi() method = unwrap(api.get) @@ -219,7 +220,7 @@ class TestTriggerSubscriptionBuilderApis: ): assert "logs" in method(api, "github", "b1") - def test_build(self, app): + def test_build(self, app: Flask): api = TriggerSubscriptionBuilderBuildApi() method = unwrap(api.post) @@ -236,10 +237,10 @@ class TestTriggerSubscriptionBuilderApis: class TestTriggerSubscriptionCrud: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_update_rename_only(self, app): + def test_update_rename_only(self, app: Flask): api = TriggerSubscriptionUpdateApi() method = unwrap(api.post) @@ -258,7 +259,7 @@ class TestTriggerSubscriptionCrud: ): assert method(api, "s1") == 200 - def test_update_not_found(self, app): + def test_update_not_found(self, app: Flask): api = TriggerSubscriptionUpdateApi() method = unwrap(api.post) @@ -273,7 +274,7 @@ class TestTriggerSubscriptionCrud: with pytest.raises(NotFoundError): method(api, "x") - def test_update_rebuild(self, app): + def test_update_rebuild(self, app: Flask): api = TriggerSubscriptionUpdateApi() method = unwrap(api.post) @@ -296,7 +297,7 @@ class TestTriggerSubscriptionCrud: ): assert method(api, "s1") == 200 - def test_delete_subscription(self, app): + def test_delete_subscription(self, app: Flask): api = TriggerSubscriptionDeleteApi() method = unwrap(api.post) @@ -319,7 +320,7 @@ class TestTriggerSubscriptionCrud: assert result["result"] == "success" - def test_delete_subscription_value_error(self, app): + def test_delete_subscription_value_error(self, app: Flask): api = TriggerSubscriptionDeleteApi() method = unwrap(api.post) @@ -342,10 +343,10 @@ class TestTriggerSubscriptionCrud: class TestTriggerOAuthApis: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_oauth_authorize_success(self, app): + def test_oauth_authorize_success(self, app: Flask): api = TriggerOAuthAuthorizeApi() method = unwrap(api.get) @@ -372,7 +373,7 @@ class TestTriggerOAuthApis: resp = method(api, "github") assert resp.status_code == 200 - def test_oauth_authorize_no_client(self, app): + def test_oauth_authorize_no_client(self, app: Flask): api = TriggerOAuthAuthorizeApi() method = unwrap(api.get) @@ -387,7 +388,7 @@ class TestTriggerOAuthApis: with pytest.raises(NotFoundError): method(api, "github") - def test_oauth_callback_forbidden(self, app): + def test_oauth_callback_forbidden(self, app: Flask): api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -395,7 +396,7 @@ class TestTriggerOAuthApis: with pytest.raises(Forbidden): method(api, "github") - def test_oauth_callback_success(self, app): + def test_oauth_callback_success(self, app: Flask): api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -425,7 +426,7 @@ class TestTriggerOAuthApis: resp = method(api, "github") assert resp.status_code == 302 - def test_oauth_callback_no_oauth_client(self, app): + def test_oauth_callback_no_oauth_client(self, app: Flask): api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -449,7 +450,7 @@ class TestTriggerOAuthApis: with pytest.raises(Forbidden): method(api, "github") - def test_oauth_callback_empty_credentials(self, app): + def test_oauth_callback_empty_credentials(self, app: Flask): api = TriggerOAuthCallbackApi() method = unwrap(api.get) @@ -480,10 +481,10 @@ class TestTriggerOAuthApis: class TestTriggerOAuthClientManageApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_get_client(self, app): + def test_get_client(self, app: Flask): api = TriggerOAuthClientManageApi() method = unwrap(api.get) @@ -510,7 +511,7 @@ class TestTriggerOAuthClientManageApi: result = method(api, "github") assert "configured" in result - def test_post_client(self, app): + def test_post_client(self, app: Flask): api = TriggerOAuthClientManageApi() method = unwrap(api.post) @@ -524,7 +525,7 @@ class TestTriggerOAuthClientManageApi: ): assert method(api, "github") == {"ok": True} - def test_delete_client(self, app): + def test_delete_client(self, app: Flask): api = TriggerOAuthClientManageApi() method = unwrap(api.delete) @@ -538,7 +539,7 @@ class TestTriggerOAuthClientManageApi: ): assert method(api, "github") == {"ok": True} - def test_oauth_client_post_value_error(self, app): + def test_oauth_client_post_value_error(self, app: Flask): api = TriggerOAuthClientManageApi() method = unwrap(api.post) @@ -556,10 +557,10 @@ class TestTriggerOAuthClientManageApi: class TestTriggerSubscriptionVerifyApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_verify_success(self, app): + def test_verify_success(self, app: Flask): api = TriggerSubscriptionVerifyApi() method = unwrap(api.post) @@ -574,7 +575,7 @@ class TestTriggerSubscriptionVerifyApi: assert method(api, "github", "s1") == {"ok": True} @pytest.mark.parametrize("raised_exception", [ValueError("bad"), Exception("boom")]) - def test_verify_errors(self, app, raised_exception): + def test_verify_errors(self, app: Flask, raised_exception): api = TriggerSubscriptionVerifyApi() method = unwrap(api.post) diff --git a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py index 9b913d6d3d..b73d28e4c4 100644 --- a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py +++ b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py @@ -18,6 +18,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound @@ -217,10 +218,20 @@ class TestTagUnbindingPayload: """Test suite for TagUnbindingPayload Pydantic model.""" def test_payload_with_valid_data(self): - payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456") - assert payload.tag_id == "tag_123" + payload = TagUnbindingPayload(tag_ids=["tag_123"], target_id="dataset_456") + assert payload.tag_ids == ["tag_123"] assert payload.target_id == "dataset_456" + def test_payload_normalizes_legacy_tag_id(self): + payload = TagUnbindingPayload(tag_id="tag_123", target_id="dataset_456") + assert payload.tag_ids == ["tag_123"] + assert payload.target_id == "dataset_456" + + def test_payload_rejects_empty_tag_ids(self): + with pytest.raises(ValueError) as exc_info: + TagUnbindingPayload(tag_ids=[], target_id="dataset_456") + assert "Tag IDs is required" in str(exc_info.value) + # --------------------------------------------------------------------------- # Helpers @@ -236,7 +247,7 @@ def _unwrap(method): @pytest.fixture -def app(flask_app_with_containers): +def app(flask_app_with_containers: Flask): # Uses the full containerised app so that Flask config, extensions, and # blueprint registrations match production. Most tests mock the service # layer to isolate controller logic; a few (e.g. test_list_tags_from_db) @@ -280,7 +291,7 @@ class TestDatasetListApiGet: mock_current_user, mock_provider_mgr, mock_marshal, - app, + app: Flask, mock_tenant, ): from controllers.service_api.dataset.dataset import DatasetListApi @@ -315,7 +326,7 @@ class TestDatasetListApiPost: mock_dataset_svc, mock_current_user, mock_marshal, - app, + app: Flask, mock_tenant, ): from controllers.service_api.dataset.dataset import DatasetListApi @@ -341,7 +352,7 @@ class TestDatasetListApiPost: self, mock_dataset_svc, mock_current_user, - app, + app: Flask, mock_tenant, ): from controllers.service_api.dataset.dataset import DatasetListApi @@ -379,7 +390,7 @@ class TestDatasetApiGet: mock_provider_mgr, mock_marshal, mock_perm_svc, - app, + app: Flask, mock_dataset, ): from controllers.service_api.dataset.dataset import DatasetApi @@ -429,7 +440,7 @@ class TestDatasetApiGet: self, mock_dataset_svc, mock_current_user, - app, + app: Flask, mock_dataset, ): from controllers.service_api.dataset.dataset import DatasetApi @@ -457,7 +468,7 @@ class TestDatasetApiDelete: mock_dataset_svc, mock_current_user, mock_perm_svc, - app, + app: Flask, mock_dataset, ): from controllers.service_api.dataset.dataset import DatasetApi @@ -479,7 +490,7 @@ class TestDatasetApiDelete: self, mock_dataset_svc, mock_current_user, - app, + app: Flask, mock_dataset, ): from controllers.service_api.dataset.dataset import DatasetApi @@ -500,7 +511,7 @@ class TestDatasetApiDelete: self, mock_dataset_svc, mock_current_user, - app, + app: Flask, mock_dataset, ): from controllers.service_api.dataset.dataset import DatasetApi @@ -532,7 +543,7 @@ class TestDocumentStatusApiPatch: mock_dataset_svc, mock_current_user, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -563,7 +574,7 @@ class TestDocumentStatusApiPatch: def test_batch_update_status_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -592,7 +603,7 @@ class TestDocumentStatusApiPatch: mock_dataset_svc, mock_current_user, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -625,7 +636,7 @@ class TestDocumentStatusApiPatch: mock_dataset_svc, mock_current_user, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -658,7 +669,7 @@ class TestDocumentStatusApiPatch: mock_dataset_svc, mock_current_user, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -698,7 +709,7 @@ class TestDatasetTagsApiGet: self, mock_current_user, mock_tag_svc, - app, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagsApi @@ -720,7 +731,7 @@ class TestDatasetTagsApiGet: def test_list_tags_from_db( self, mock_current_user, - app, + app: Flask, db_session_with_containers: Session, ): """Integration test: creates real Tag rows and retrieves them @@ -763,7 +774,7 @@ class TestDatasetTagsApiPost: self, mock_current_user, mock_tag_svc, - app, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagsApi @@ -786,7 +797,7 @@ class TestDatasetTagsApiPost: mock_tag_svc.save_tags.assert_called_once() @patch("controllers.service_api.dataset.dataset.current_user") - def test_create_tag_forbidden(self, mock_current_user, app): + def test_create_tag_forbidden(self, mock_current_user, app: Flask): from controllers.service_api.dataset.dataset import DatasetTagsApi mock_current_user.__class__ = Account @@ -815,7 +826,7 @@ class TestDatasetTagsApiPatch: mock_current_user, mock_service_api_ns, mock_tag_svc, - app, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagsApi @@ -841,7 +852,7 @@ class TestDatasetTagsApiPatch: mock_tag_svc.update_tags.assert_called_once_with({"name": "Updated Tag", "type": "knowledge"}, "tag-1") @patch("controllers.service_api.dataset.dataset.current_user") - def test_update_tag_forbidden(self, mock_current_user, app): + def test_update_tag_forbidden(self, mock_current_user, app: Flask): from controllers.service_api.dataset.dataset import DatasetTagsApi mock_current_user.__class__ = Account @@ -869,7 +880,7 @@ class TestDatasetTagsApiDelete: mock_current_user, mock_service_api_ns, mock_tag_svc, - app, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagsApi @@ -894,7 +905,7 @@ class TestDatasetTagsApiDelete: mock_tag_svc.delete_tag.assert_called_once_with("tag-1") @patch("libs.login.current_user") - def test_delete_tag_forbidden(self, mock_current_user, app): + def test_delete_tag_forbidden(self, mock_current_user, app: Flask): from controllers.service_api.dataset.dataset import DatasetTagsApi user_obj = Mock(spec=Account) @@ -922,7 +933,7 @@ class TestDatasetTagsBindingStatusApi: self, mock_current_user, mock_tag_svc, - app, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagsBindingStatusApi @@ -952,7 +963,7 @@ class TestDatasetTagBindingApiPost: self, mock_current_user, mock_tag_svc, - app, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagBindingApi @@ -977,7 +988,7 @@ class TestDatasetTagBindingApiPost: ) @patch("controllers.service_api.dataset.dataset.current_user") - def test_bind_tags_forbidden(self, mock_current_user, app): + def test_bind_tags_forbidden(self, mock_current_user, app: Flask): from controllers.service_api.dataset.dataset import DatasetTagBindingApi mock_current_user.__class__ = Account @@ -1003,7 +1014,37 @@ class TestDatasetTagUnbindingApiPost: self, mock_current_user, mock_tag_svc, - app, + app: Flask, + ): + from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi + + mock_current_user.__class__ = Account + mock_current_user.has_edit_permission = True + mock_current_user.is_dataset_editor = True + mock_tag_svc.delete_tag_binding.return_value = None + + with app.test_request_context( + "/datasets/tags/unbinding", + method="POST", + json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, + ): + api = DatasetTagUnbindingApi() + result = api.post(_=None) + + assert result == ("", 204) + from services.tag_service import TagBindingDeletePayload + + mock_tag_svc.delete_tag_binding.assert_called_once_with( + TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type="knowledge") + ) + + @patch("controllers.service_api.dataset.dataset.TagService") + @patch("controllers.service_api.dataset.dataset.current_user") + def test_unbind_legacy_tag_id_success( + self, + mock_current_user, + mock_tag_svc, + app: Flask, ): from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi @@ -1024,11 +1065,11 @@ class TestDatasetTagUnbindingApiPost: from services.tag_service import TagBindingDeletePayload mock_tag_svc.delete_tag_binding.assert_called_once_with( - TagBindingDeletePayload(tag_id="tag-1", target_id="ds-1", type="knowledge") + TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type="knowledge") ) @patch("controllers.service_api.dataset.dataset.current_user") - def test_unbind_tag_forbidden(self, mock_current_user, app): + def test_unbind_tag_forbidden(self, mock_current_user, app: Flask): from controllers.service_api.dataset.dataset import DatasetTagUnbindingApi mock_current_user.__class__ = Account @@ -1038,7 +1079,7 @@ class TestDatasetTagUnbindingApiPost: with app.test_request_context( "/datasets/tags/unbinding", method="POST", - json={"tag_id": "tag-1", "target_id": "ds-1"}, + json={"tag_ids": ["tag-1"], "target_id": "ds-1"}, ): api = DatasetTagUnbindingApi() with pytest.raises(Forbidden): diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py b/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py index e1e6741014..c34da27ebe 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_conversation.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.web.conversation import ( @@ -34,16 +35,16 @@ def _end_user() -> SimpleNamespace: class TestConversationListApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_non_chat_mode_raises(self, app) -> None: + def test_non_chat_mode_raises(self, app: Flask) -> None: with app.test_request_context("/conversations"): with pytest.raises(NotChatAppError): ConversationListApi().get(_completion_app(), _end_user()) @patch("controllers.web.conversation.WebConversationService.pagination_by_last_id") - def test_happy_path(self, mock_paginate: MagicMock, app) -> None: + def test_happy_path(self, mock_paginate: MagicMock, app: Flask) -> None: conv_id = str(uuid4()) conv = SimpleNamespace( id=conv_id, @@ -65,16 +66,16 @@ class TestConversationListApi: class TestConversationApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_non_chat_mode_raises(self, app) -> None: + def test_non_chat_mode_raises(self, app: Flask) -> None: with app.test_request_context(f"/conversations/{uuid4()}"): with pytest.raises(NotChatAppError): ConversationApi().delete(_completion_app(), _end_user(), uuid4()) @patch("controllers.web.conversation.ConversationService.delete") - def test_delete_success(self, mock_delete: MagicMock, app) -> None: + def test_delete_success(self, mock_delete: MagicMock, app: Flask) -> None: c_id = uuid4() with app.test_request_context(f"/conversations/{c_id}"): result, status = ConversationApi().delete(_chat_app(), _end_user(), c_id) @@ -83,7 +84,7 @@ class TestConversationApi: assert result["result"] == "success" @patch("controllers.web.conversation.ConversationService.delete", side_effect=ConversationNotExistsError()) - def test_delete_not_found(self, mock_delete: MagicMock, app) -> None: + def test_delete_not_found(self, mock_delete: MagicMock, app: Flask) -> None: c_id = uuid4() with app.test_request_context(f"/conversations/{c_id}"): with pytest.raises(NotFound, match="Conversation Not Exists"): @@ -92,17 +93,17 @@ class TestConversationApi: class TestConversationRenameApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_non_chat_mode_raises(self, app) -> None: + def test_non_chat_mode_raises(self, app: Flask) -> None: with app.test_request_context(f"/conversations/{uuid4()}/name", method="POST", json={"name": "x"}): with pytest.raises(NotChatAppError): ConversationRenameApi().post(_completion_app(), _end_user(), uuid4()) @patch("controllers.web.conversation.ConversationService.rename") @patch("controllers.web.conversation.web_ns") - def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app) -> None: + def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None: c_id = uuid4() mock_ns.payload = {"name": "New Name", "auto_generate": False} conv = SimpleNamespace( @@ -126,7 +127,7 @@ class TestConversationRenameApi: side_effect=ConversationNotExistsError(), ) @patch("controllers.web.conversation.web_ns") - def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app) -> None: + def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None: c_id = uuid4() mock_ns.payload = {"name": "X", "auto_generate": False} @@ -137,16 +138,16 @@ class TestConversationRenameApi: class TestConversationPinApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_non_chat_mode_raises(self, app) -> None: + def test_non_chat_mode_raises(self, app: Flask) -> None: with app.test_request_context(f"/conversations/{uuid4()}/pin", method="PATCH"): with pytest.raises(NotChatAppError): ConversationPinApi().patch(_completion_app(), _end_user(), uuid4()) @patch("controllers.web.conversation.WebConversationService.pin") - def test_pin_success(self, mock_pin: MagicMock, app) -> None: + def test_pin_success(self, mock_pin: MagicMock, app: Flask) -> None: c_id = uuid4() with app.test_request_context(f"/conversations/{c_id}/pin", method="PATCH"): result = ConversationPinApi().patch(_chat_app(), _end_user(), c_id) @@ -154,7 +155,7 @@ class TestConversationPinApi: assert result["result"] == "success" @patch("controllers.web.conversation.WebConversationService.pin", side_effect=ConversationNotExistsError()) - def test_pin_not_found(self, mock_pin: MagicMock, app) -> None: + def test_pin_not_found(self, mock_pin: MagicMock, app: Flask) -> None: c_id = uuid4() with app.test_request_context(f"/conversations/{c_id}/pin", method="PATCH"): with pytest.raises(NotFound): @@ -163,16 +164,16 @@ class TestConversationPinApi: class TestConversationUnPinApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers - def test_non_chat_mode_raises(self, app) -> None: + def test_non_chat_mode_raises(self, app: Flask) -> None: with app.test_request_context(f"/conversations/{uuid4()}/unpin", method="PATCH"): with pytest.raises(NotChatAppError): ConversationUnPinApi().patch(_completion_app(), _end_user(), uuid4()) @patch("controllers.web.conversation.WebConversationService.unpin") - def test_unpin_success(self, mock_unpin: MagicMock, app) -> None: + def test_unpin_success(self, mock_unpin: MagicMock, app: Flask) -> None: c_id = uuid4() with app.test_request_context(f"/conversations/{c_id}/unpin", method="PATCH"): result = ConversationUnPinApi().patch(_chat_app(), _end_user(), c_id) diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py index 635cfee2da..2c6a990240 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py @@ -7,6 +7,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.web.forgot_password import ( ForgotPasswordCheckApi, @@ -29,7 +30,7 @@ def _patch_wraps(): class TestForgotPasswordSendEmailApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @patch("controllers.web.forgot_password.AccountService.send_reset_password_email") @@ -42,7 +43,7 @@ class TestForgotPasswordSendEmailApi: mock_rate_limit, mock_get_account, mock_send_mail, - app, + app: Flask, ): mock_account = MagicMock() mock_get_account.return_value = mock_account @@ -64,7 +65,7 @@ class TestForgotPasswordSendEmailApi: class TestForgotPasswordCheckApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @patch("controllers.web.forgot_password.AccountService.reset_forgot_password_error_rate_limit") @@ -81,7 +82,7 @@ class TestForgotPasswordCheckApi: mock_revoke_token, mock_generate_token, mock_reset_rate, - app, + app: Flask, ): mock_is_rate_limit.return_value = False mock_get_data.return_value = {"email": "User@Example.com", "code": "1234"} @@ -117,7 +118,7 @@ class TestForgotPasswordCheckApi: mock_revoke_token, mock_generate_token, mock_reset_rate, - app, + app: Flask, ): mock_is_rate_limit.return_value = False mock_get_data.return_value = {"email": "MixedCase@Example.com", "code": "5678"} @@ -142,7 +143,7 @@ class TestForgotPasswordCheckApi: class TestForgotPasswordResetApi: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @patch("controllers.web.forgot_password.ForgotPasswordResetApi._update_existing_account") @@ -157,7 +158,7 @@ class TestForgotPasswordResetApi: mock_db, mock_get_account, mock_update_account, - app, + app: Flask, ): mock_get_reset_data.return_value = {"phase": "reset", "email": "User@Example.com", "code": "1234"} mock_account = MagicMock() @@ -194,7 +195,7 @@ class TestForgotPasswordResetApi: mock_db, mock_token_bytes, mock_hash_password, - app, + app: Flask, ): mock_get_reset_data.return_value = {"phase": "reset", "email": "user@example.com"} account = MagicMock() diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py index 19833cc772..1bc4253cb9 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, NotFound, Unauthorized @@ -182,7 +183,7 @@ class TestValidateUserAccessibility: class TestDecodeJwtToken: @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers def _create_app_site_enduser(self, db_session: Session, *, enable_site: bool = True): @@ -239,7 +240,7 @@ class TestDecodeJwtToken: mock_access_mode: MagicMock, mock_validate_token: MagicMock, mock_validate_user: MagicMock, - app, + app: Flask, db_session_with_containers: Session, ) -> None: app_model, site, end_user = self._create_app_site_enduser(db_session_with_containers) @@ -276,7 +277,7 @@ class TestDecodeJwtToken: mock_extract: MagicMock, mock_passport_cls: MagicMock, mock_features: MagicMock, - app, + app: Flask, ) -> None: non_existent_id = str(uuid4()) mock_extract.return_value = "jwt-token" @@ -299,7 +300,7 @@ class TestDecodeJwtToken: mock_extract: MagicMock, mock_passport_cls: MagicMock, mock_features: MagicMock, - app, + app: Flask, db_session_with_containers: Session, ) -> None: app_model, site, end_user = self._create_app_site_enduser(db_session_with_containers, enable_site=False) @@ -324,7 +325,7 @@ class TestDecodeJwtToken: mock_extract: MagicMock, mock_passport_cls: MagicMock, mock_features: MagicMock, - app, + app: Flask, db_session_with_containers: Session, ) -> None: app_model, site, _ = self._create_app_site_enduser(db_session_with_containers) @@ -350,7 +351,7 @@ class TestDecodeJwtToken: mock_extract: MagicMock, mock_passport_cls: MagicMock, mock_features: MagicMock, - app, + app: Flask, db_session_with_containers: Session, ) -> None: app_model, site, end_user = self._create_app_site_enduser(db_session_with_containers) diff --git a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py index c342e8994b..66b3392a4b 100644 --- a/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py +++ b/api/tests/test_containers_integration_tests/core/app/layers/test_pause_state_persist_layer.py @@ -85,7 +85,7 @@ class TestPauseStatePersistenceLayerTestContainers: return WorkflowRunService(engine) @pytest.fixture(autouse=True) - def setup_test_data(self, db_session_with_containers, file_service, workflow_run_service): + def setup_test_data(self, db_session_with_containers: Session, file_service, workflow_run_service): """Set up test data for each test method using TestContainers.""" # Create test tenant and account from models.account import AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus @@ -210,7 +210,9 @@ class TestPauseStatePersistenceLayerTestContainers: execution_id = workflow_run_id or getattr(self, "test_workflow_run_id", None) or str(uuid.uuid4()) # Create variable pool - variable_pool = VariablePool(system_variables=build_system_variables(workflow_execution_id=execution_id)) + variable_pool = VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id=execution_id) + ) if variables: for (node_id, var_key), value in variables.items(): variable_pool.add([node_id, var_key], value) @@ -295,7 +297,7 @@ class TestPauseStatePersistenceLayerTestContainers: generate_entity=entity, ) - def test_complete_pause_flow_with_real_dependencies(self, db_session_with_containers): + def test_complete_pause_flow_with_real_dependencies(self, db_session_with_containers: Session): """Test complete pause flow: event -> state serialization -> database save -> storage save.""" # Arrange layer = self._create_pause_state_persistence_layer() @@ -352,7 +354,7 @@ class TestPauseStatePersistenceLayerTestContainers: assert isinstance(persisted_entity, WorkflowAppGenerateEntity) assert persisted_entity.workflow_execution_id == self.test_workflow_run_id - def test_state_persistence_and_retrieval(self, db_session_with_containers): + def test_state_persistence_and_retrieval(self, db_session_with_containers: Session): """Test that pause state can be persisted and retrieved correctly.""" # Arrange layer = self._create_pause_state_persistence_layer() @@ -402,7 +404,7 @@ class TestPauseStatePersistenceLayerTestContainers: assert retrieved_state["node_run_steps"] == 10 assert resumption_context.get_generate_entity().workflow_execution_id == self.test_workflow_run_id - def test_database_transaction_handling(self, db_session_with_containers): + def test_database_transaction_handling(self, db_session_with_containers: Session): """Test that database transactions are handled correctly.""" # Arrange layer = self._create_pause_state_persistence_layer() @@ -433,7 +435,7 @@ class TestPauseStatePersistenceLayerTestContainers: assert pause_model.resumed_at is None assert pause_model.state_object_key != "" - def test_file_storage_integration(self, db_session_with_containers): + def test_file_storage_integration(self, db_session_with_containers: Session): """Test integration with file storage system.""" # Arrange layer = self._create_pause_state_persistence_layer() @@ -467,7 +469,7 @@ class TestPauseStatePersistenceLayerTestContainers: assert resumption_context.serialized_graph_runtime_state == graph_runtime_state.dumps() assert resumption_context.get_generate_entity().workflow_execution_id == self.test_workflow_run_id - def test_workflow_with_different_creators(self, db_session_with_containers): + def test_workflow_with_different_creators(self, db_session_with_containers: Session): """Test pause state with workflows created by different users.""" # Arrange - Create workflow with different creator different_user_id = str(uuid.uuid4()) @@ -532,7 +534,7 @@ class TestPauseStatePersistenceLayerTestContainers: resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode()) assert resumption_context.get_generate_entity().workflow_execution_id == different_workflow_run.id - def test_layer_ignores_non_pause_events(self, db_session_with_containers): + def test_layer_ignores_non_pause_events(self, db_session_with_containers: Session): """Test that layer ignores non-pause events.""" # Arrange layer = self._create_pause_state_persistence_layer() @@ -562,7 +564,7 @@ class TestPauseStatePersistenceLayerTestContainers: ).all() assert len(pause_states) == 0 - def test_layer_requires_initialization(self, db_session_with_containers): + def test_layer_requires_initialization(self, db_session_with_containers: Session): """Test that layer requires proper initialization before handling events.""" # Arrange layer = self._create_pause_state_persistence_layer() diff --git a/api/tests/test_containers_integration_tests/core/rag/pipeline/test_queue_integration.py b/api/tests/test_containers_integration_tests/core/rag/pipeline/test_queue_integration.py index a60159c66a..d1af0a56ef 100644 --- a/api/tests/test_containers_integration_tests/core/rag/pipeline/test_queue_integration.py +++ b/api/tests/test_containers_integration_tests/core/rag/pipeline/test_queue_integration.py @@ -15,11 +15,14 @@ from uuid import uuid4 import pytest from faker import Faker +from sqlalchemy.orm import Session from core.rag.pipeline.queue import TaskWrapper, TenantIsolatedTaskQueue from extensions.ext_redis import redis_client from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus +TenantAndAccount = tuple[Tenant, Account] + @dataclass class TestTask: @@ -40,7 +43,7 @@ class TestTenantIsolatedTaskQueueIntegration: return Faker() @pytest.fixture - def test_tenant_and_account(self, db_session_with_containers, fake): + def test_tenant_and_account(self, db_session_with_containers: Session, fake: Faker): """Create test tenant and account for testing.""" # Create account account = Account( @@ -73,18 +76,18 @@ class TestTenantIsolatedTaskQueueIntegration: return tenant, account @pytest.fixture - def test_queue(self, test_tenant_and_account): + def test_queue(self, test_tenant_and_account: TenantAndAccount): """Create a generic test queue for testing.""" tenant, _ = test_tenant_and_account return TenantIsolatedTaskQueue(tenant.id, "test_queue") @pytest.fixture - def secondary_queue(self, test_tenant_and_account): + def secondary_queue(self, test_tenant_and_account: TenantAndAccount): """Create a secondary test queue for testing isolation.""" tenant, _ = test_tenant_and_account return TenantIsolatedTaskQueue(tenant.id, "secondary_queue") - def test_queue_initialization(self, test_tenant_and_account): + def test_queue_initialization(self, test_tenant_and_account: TenantAndAccount): """Test queue initialization with correct key generation.""" tenant, _ = test_tenant_and_account queue = TenantIsolatedTaskQueue(tenant.id, "test-key") @@ -94,7 +97,9 @@ class TestTenantIsolatedTaskQueueIntegration: assert queue._queue == f"tenant_self_test-key_task_queue:{tenant.id}" assert queue._task_key == f"tenant_test-key_task:{tenant.id}" - def test_tenant_isolation(self, test_tenant_and_account, db_session_with_containers, fake): + def test_tenant_isolation( + self, test_tenant_and_account: TenantAndAccount, db_session_with_containers: Session, fake: Faker + ): """Test that different tenants have isolated queues.""" tenant1, _ = test_tenant_and_account @@ -114,7 +119,7 @@ class TestTenantIsolatedTaskQueueIntegration: assert queue1._queue == f"tenant_self_same-key_task_queue:{tenant1.id}" assert queue2._queue == f"tenant_self_same-key_task_queue:{tenant2.id}" - def test_key_isolation(self, test_tenant_and_account): + def test_key_isolation(self, test_tenant_and_account: TenantAndAccount): """Test that different keys have isolated queues.""" tenant, _ = test_tenant_and_account queue1 = TenantIsolatedTaskQueue(tenant.id, "key1") @@ -176,7 +181,7 @@ class TestTenantIsolatedTaskQueueIntegration: assert len(remaining_tasks) == 2 assert remaining_tasks == ["task4", "task5"] - def test_push_and_pull_complex_objects(self, test_queue, fake): + def test_push_and_pull_complex_objects(self, test_queue, fake: Faker): """Test pushing and pulling complex object tasks.""" # Create complex task objects as dictionaries (not dataclass instances) tasks = [ @@ -218,7 +223,7 @@ class TestTenantIsolatedTaskQueueIntegration: assert pulled_task["data"] == original_task["data"] assert pulled_task["metadata"] == original_task["metadata"] - def test_mixed_task_types(self, test_queue, fake): + def test_mixed_task_types(self, test_queue, fake: Faker): """Test pushing and pulling mixed string and object tasks.""" string_task = "simple_string_task" object_task = { @@ -267,7 +272,7 @@ class TestTenantIsolatedTaskQueueIntegration: # Verify task key has expired assert test_queue.get_task_key() is None - def test_large_task_batch(self, test_queue, fake): + def test_large_task_batch(self, test_queue, fake: Faker): """Test handling large batches of tasks.""" # Create large batch of tasks large_batch = [] @@ -292,7 +297,7 @@ class TestTenantIsolatedTaskQueueIntegration: assert isinstance(task, dict) assert task["index"] == i # FIFO order - def test_queue_operations_isolation(self, test_tenant_and_account, fake): + def test_queue_operations_isolation(self, test_tenant_and_account: TenantAndAccount, fake: Faker): """Test concurrent operations on different queues.""" tenant, _ = test_tenant_and_account @@ -312,7 +317,7 @@ class TestTenantIsolatedTaskQueueIntegration: assert tasks2 == ["task1_queue2", "task2_queue2"] assert tasks1 != tasks2 - def test_task_wrapper_serialization_roundtrip(self, test_queue, fake): + def test_task_wrapper_serialization_roundtrip(self, test_queue, fake: Faker): """Test TaskWrapper serialization and deserialization roundtrip.""" # Create complex nested data complex_data = { @@ -346,7 +351,7 @@ class TestTenantIsolatedTaskQueueIntegration: task = test_queue.pull_tasks(1) assert task[0] == invalid_json_task - def test_real_world_batch_processing_scenario(self, test_queue, fake): + def test_real_world_batch_processing_scenario(self, test_queue, fake: Faker): """Test realistic batch processing scenario.""" # Simulate batch processing tasks batch_tasks = [] @@ -403,7 +408,7 @@ class TestTenantIsolatedTaskQueueCompatibility: return Faker() @pytest.fixture - def test_tenant_and_account(self, db_session_with_containers, fake): + def test_tenant_and_account(self, db_session_with_containers: Session, fake: Faker): """Create test tenant and account for testing.""" # Create account account = Account( @@ -435,7 +440,7 @@ class TestTenantIsolatedTaskQueueCompatibility: return tenant, account - def test_legacy_string_queue_compatibility(self, test_tenant_and_account, fake): + def test_legacy_string_queue_compatibility(self, test_tenant_and_account: TenantAndAccount, fake: Faker): """ Test compatibility with legacy queues containing only string data. @@ -465,7 +470,7 @@ class TestTenantIsolatedTaskQueueCompatibility: expected_order = ["legacy_task_1", "legacy_task_2", "legacy_task_3", "legacy_task_4", "legacy_task_5"] assert pulled_tasks == expected_order - def test_legacy_queue_migration_scenario(self, test_tenant_and_account, fake): + def test_legacy_queue_migration_scenario(self, test_tenant_and_account: TenantAndAccount, fake: Faker): """ Test complete migration scenario from legacy to new system. @@ -546,7 +551,7 @@ class TestTenantIsolatedTaskQueueCompatibility: assert task["tenant_id"] == tenant.id assert task["processing_type"] == "new_system" - def test_legacy_queue_error_recovery(self, test_tenant_and_account, fake): + def test_legacy_queue_error_recovery(self, test_tenant_and_account: TenantAndAccount, fake: Faker): """ Test error recovery when legacy queue contains malformed data. diff --git a/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py b/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py index 00d7496a40..9da6b04a2c 100644 --- a/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py +++ b/api/tests/test_containers_integration_tests/core/rag/retrieval/test_dataset_retrieval_integration.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest from faker import Faker +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.retrieval.dataset_retrieval import DatasetRetrieval @@ -15,7 +16,7 @@ from tests.test_containers_integration_tests.helpers import generate_valid_passw class TestGetAvailableDatasetsIntegration: def test_returns_datasets_with_available_documents( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -77,7 +78,7 @@ class TestGetAvailableDatasetsIntegration: assert result[0].name == dataset.name def test_filters_out_datasets_with_only_archived_documents( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -130,7 +131,7 @@ class TestGetAvailableDatasetsIntegration: assert len(result) == 0 def test_filters_out_datasets_with_only_disabled_documents( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -183,7 +184,7 @@ class TestGetAvailableDatasetsIntegration: assert len(result) == 0 def test_filters_out_datasets_with_non_completed_documents( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -236,7 +237,7 @@ class TestGetAvailableDatasetsIntegration: assert len(result) == 0 def test_includes_external_datasets_without_documents( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test that external datasets are returned even with no available documents. @@ -280,7 +281,7 @@ class TestGetAvailableDatasetsIntegration: assert result[0].id == dataset.id assert result[0].provider == "external" - def test_filters_by_tenant_id(self, db_session_with_containers, mock_external_service_dependencies): + def test_filters_by_tenant_id(self, db_session_with_containers: Session, mock_external_service_dependencies): # Arrange fake = Faker() @@ -356,7 +357,7 @@ class TestGetAvailableDatasetsIntegration: assert result[0].tenant_id == tenant1.id def test_returns_empty_list_when_no_datasets_found( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -379,7 +380,9 @@ class TestGetAvailableDatasetsIntegration: # Assert assert result == [] - def test_returns_only_requested_dataset_ids(self, db_session_with_containers, mock_external_service_dependencies): + def test_returns_only_requested_dataset_ids( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): # Arrange fake = Faker() @@ -439,7 +442,7 @@ class TestGetAvailableDatasetsIntegration: class TestKnowledgeRetrievalIntegration: def test_knowledge_retrieval_with_available_datasets( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -507,7 +510,7 @@ class TestKnowledgeRetrievalIntegration: assert isinstance(result, list) def test_knowledge_retrieval_no_available_datasets( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() @@ -555,7 +558,7 @@ class TestKnowledgeRetrievalIntegration: assert result == [] def test_knowledge_retrieval_rate_limit_exceeded( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): # Arrange fake = Faker() diff --git a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py index 6524d6ce61..676833ce98 100644 --- a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py @@ -17,7 +17,7 @@ from core.workflow.human_input_adapter import ( MemberRecipient, WebAppDeliveryMethod, ) -from graphon.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserAction +from graphon.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig from models.account import ( Account, AccountStatus, @@ -69,7 +69,7 @@ def _build_form_params(delivery_methods: list[DeliveryChannelConfig]) -> FormCre title="Human Approval", delivery_methods=delivery_methods, form_content="

Approve?

", - user_actions=[UserAction(id="approve", title="Approve")], + user_actions=[UserActionConfig(id="approve", title="Approve")], ) return FormCreateParams( workflow_execution_id=str(uuid4()), @@ -185,7 +185,7 @@ class TestHumanInputFormRepositoryImplWithContainers: title="Human Approval", form_content="

Approve?

", inputs=[], - user_actions=[UserAction(id="approve", title="Approve")], + user_actions=[UserActionConfig(id="approve", title="Approve")], ), rendered_content="

Approve?

", delivery_methods=[], @@ -220,7 +220,7 @@ class TestHumanInputFormRepositoryImplWithContainers: title="Human Approval", form_content="

Approve?

", inputs=[], - user_actions=[UserAction(id="approve", title="Approve")], + user_actions=[UserActionConfig(id="approve", title="Approve")], delivery_methods=[WebAppDeliveryMethod()], ), rendered_content="

Approve?

", diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py index 5aed230cd4..103fe88df7 100644 --- a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py +++ b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py @@ -13,7 +13,7 @@ from core.app.workflow.layers import PersistenceWorkflowInfo, WorkflowPersistenc from core.repositories.human_input_repository import HumanInputFormEntity, HumanInputFormRepository from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository -from core.workflow.node_runtime import DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowType from graphon.graph import Graph @@ -21,7 +21,7 @@ from graphon.graph_engine import GraphEngine from graphon.graph_engine.command_channels import InMemoryChannel from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData -from graphon.nodes.human_input.entities import HumanInputNodeData, UserAction +from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig from graphon.nodes.human_input.enums import HumanInputFormStatus from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData @@ -66,7 +66,7 @@ def _mock_form_repository_with_submission(action_id: str) -> HumanInputFormRepos def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: str, user_id: str) -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( workflow_execution_id=workflow_execution_id, app_id=app_id, @@ -102,7 +102,7 @@ def _build_graph( start_data = StartNodeData(title="start", variables=[]) start_node = StartNode( node_id="start", - config=start_data, + data=start_data, graph_init_params=params, graph_runtime_state=runtime_state, ) @@ -112,15 +112,16 @@ def _build_graph( form_content="Awaiting human input", inputs=[], user_actions=[ - UserAction(id="continue", title="Continue"), + UserActionConfig(id="continue", title="Continue"), ], ) human_node = HumanInputNode( node_id="human", - config=human_data, + data=human_data, graph_init_params=params, graph_runtime_state=runtime_state, form_repository=form_repository, + file_reference_factory=DifyFileReferenceFactory(params.run_context), runtime=DifyHumanInputNodeRuntime(params.run_context), ) @@ -131,7 +132,7 @@ def _build_graph( ) end_node = EndNode( node_id="end", - config=end_data, + data=end_data, graph_init_params=params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py index 2fd289dfbc..2a1638d126 100644 --- a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py @@ -5,7 +5,7 @@ from datetime import timedelta from decimal import Decimal from uuid import uuid4 -from graphon.nodes.human_input.entities import FormDefinition, UserAction +from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin from models.enums import ConversationFromSource, InvokeFrom @@ -116,7 +116,7 @@ def create_human_input_message_fixture(db_session) -> HumanInputMessageFixture: form_definition = FormDefinition( form_content="content", inputs=[], - user_actions=[UserAction(id=action_id, title=action_text)], + user_actions=[UserActionConfig(id=action_id, title=action_text)], rendered_content="Rendered block", expiration_time=naive_utc_now() + timedelta(days=1), node_title=node_title, diff --git a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py index 43915a204d..84c1d0ca41 100644 --- a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py +++ b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py @@ -8,6 +8,7 @@ Covers real Redis 7+ sharded pub/sub interactions including: - Resource cleanup accounting via PUBSUB SHARDNUMSUB """ +import socket import threading import time import uuid @@ -356,10 +357,17 @@ class TestShardedRedisBroadcastChannelClusterIntegration: def _get_test_topic_name(cls) -> str: return f"test_sharded_cluster_topic_{uuid.uuid4()}" + @staticmethod + def _resolve_announced_ip(host: str) -> str: + """Resolve the container host name to a literal IP accepted by Redis cluster config.""" + return socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)[0][4][0] + @staticmethod def _ensure_single_node_cluster(host: str, port: int) -> None: + """Bootstrap a single-node cluster using a literal IP for Redis node advertisement.""" client = redis.Redis(host=host, port=port, decode_responses=False) - client.config_set("cluster-announce-ip", host) + announced_ip = TestShardedRedisBroadcastChannelClusterIntegration._resolve_announced_ip(host) + client.config_set("cluster-announce-ip", announced_ip) client.config_set("cluster-announce-port", port) slots = client.execute_command("CLUSTER", "SLOTS") if not slots: diff --git a/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py b/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py index 178fc2e4fb..390795486b 100644 --- a/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py +++ b/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py @@ -11,7 +11,7 @@ from libs import helper as helper_module @pytest.mark.usefixtures("flask_app_with_containers") -def test_rate_limiter_counts_multiple_attempts_in_same_second(monkeypatch): +def test_rate_limiter_counts_multiple_attempts_in_same_second(monkeypatch: pytest.MonkeyPatch): prefix = f"test_rate_limit:{uuid.uuid4().hex}" limiter = helper_module.RateLimiter(prefix=prefix, max_attempts=2, time_window=60) key = limiter._get_key("203.0.113.10") diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index d9828e19c5..1ffc84c167 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -17,7 +17,7 @@ from extensions.ext_storage import storage from graphon.entities import WorkflowExecution from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import FormDefinition, FormInput, UserAction +from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig from graphon.nodes.human_input.enums import FormInputType, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom @@ -642,8 +642,8 @@ class TestBuildHumanInputRequiredReason: expiration_time = naive_utc_now() form_definition = FormDefinition( form_content="content", - inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], - user_actions=[UserAction(id="approve", title="Approve")], + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], rendered_content="rendered", expiration_time=expiration_time, default_values={"name": "Alice"}, @@ -754,8 +754,8 @@ class TestBuildHumanInputRequiredReason: expiration_time = naive_utc_now() form_definition = FormDefinition( form_content="content", - inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], - user_actions=[UserAction(id="approve", title="Approve")], + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], rendered_content="rendered", expiration_time=expiration_time, default_values={"name": "Alice"}, diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py index 54b7afc018..7da6f4a32d 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py @@ -15,7 +15,7 @@ import pytest from sqlalchemy import Engine, delete, select from sqlalchemy.orm import Session, sessionmaker -from graphon.nodes.human_input.entities import FormDefinition, UserAction +from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig from graphon.nodes.human_input.enums import HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -179,7 +179,7 @@ def _create_submitted_form( form_definition = FormDefinition( form_content="content", inputs=[], - user_actions=[UserAction(id=action_id, title=action_title)], + user_actions=[UserActionConfig(id=action_id, title=action_title)], rendered_content="rendered", expiration_time=expiration_time, node_title=node_title, @@ -212,7 +212,7 @@ def _create_waiting_form( form_definition = FormDefinition( form_content="content", inputs=[], - user_actions=[UserAction(id="approve", title="Approve")], + user_actions=[UserActionConfig(id="approve", title="Approve")], rendered_content="rendered", expiration_time=expiration_time, default_values=default_values or {"name": "John"}, diff --git a/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py b/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py index 177fb95ff3..9d588d4c73 100644 --- a/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py +++ b/api/tests/test_containers_integration_tests/services/auth/test_api_key_auth_service.py @@ -5,6 +5,8 @@ from unittest.mock import Mock, patch from uuid import uuid4 import pytest +from flask import Flask +from sqlalchemy.orm import Session from models.source import DataSourceApiKeyAuthBinding from services.auth.api_key_auth_service import ApiKeyAuthService @@ -31,7 +33,7 @@ class TestApiKeyAuthService: def mock_args(self, category, provider, mock_credentials) -> dict: return {"category": category, "provider": provider, "credentials": mock_credentials} - def _create_binding(self, db_session, *, tenant_id, category, provider, credentials=None, disabled=False): + def _create_binding(self, db_session: Session, *, tenant_id, category, provider, credentials=None, disabled=False): binding = DataSourceApiKeyAuthBinding( tenant_id=tenant_id, category=category, @@ -44,7 +46,7 @@ class TestApiKeyAuthService: return binding def test_get_provider_auth_list_success( - self, flask_app_with_containers, db_session_with_containers, tenant_id, category, provider + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider ): self._create_binding(db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider) db_session_with_containers.expire_all() @@ -56,14 +58,16 @@ class TestApiKeyAuthService: assert len(tenant_results) == 1 assert tenant_results[0].provider == provider - def test_get_provider_auth_list_empty(self, flask_app_with_containers, db_session_with_containers, tenant_id): + def test_get_provider_auth_list_empty( + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id + ): result = ApiKeyAuthService.get_provider_auth_list(tenant_id) tenant_results = [r for r in result if r.tenant_id == tenant_id] assert tenant_results == [] def test_get_provider_auth_list_filters_disabled( - self, flask_app_with_containers, db_session_with_containers, tenant_id, category, provider + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider ): self._create_binding( db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider, disabled=True @@ -78,7 +82,13 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") @patch("services.auth.api_key_auth_service.encrypter") def test_create_provider_auth_success( - self, mock_encrypter, mock_factory, flask_app_with_containers, db_session_with_containers, tenant_id, mock_args + self, + mock_encrypter, + mock_factory, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id, + mock_args, ): mock_auth_instance = Mock() mock_auth_instance.validate_credentials.return_value = True @@ -97,7 +107,7 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") def test_create_provider_auth_validation_failed( - self, mock_factory, flask_app_with_containers, db_session_with_containers, tenant_id, mock_args + self, mock_factory, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, mock_args ): mock_auth_instance = Mock() mock_auth_instance.validate_credentials.return_value = False @@ -112,7 +122,13 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") @patch("services.auth.api_key_auth_service.encrypter") def test_create_provider_auth_encrypts_api_key( - self, mock_encrypter, mock_factory, flask_app_with_containers, db_session_with_containers, tenant_id, mock_args + self, + mock_encrypter, + mock_factory, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id, + mock_args, ): mock_auth_instance = Mock() mock_auth_instance.validate_credentials.return_value = True @@ -128,7 +144,13 @@ class TestApiKeyAuthService: mock_encrypter.encrypt_token.assert_called_once_with(tenant_id, original_key) def test_get_auth_credentials_success( - self, flask_app_with_containers, db_session_with_containers, tenant_id, category, provider, mock_credentials + self, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id, + category, + provider, + mock_credentials, ): self._create_binding( db_session_with_containers, @@ -144,14 +166,14 @@ class TestApiKeyAuthService: assert result == mock_credentials def test_get_auth_credentials_not_found( - self, flask_app_with_containers, db_session_with_containers, tenant_id, category, provider + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider ): result = ApiKeyAuthService.get_auth_credentials(tenant_id, category, provider) assert result is None def test_get_auth_credentials_json_parsing( - self, flask_app_with_containers, db_session_with_containers, tenant_id, category, provider + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider ): special_credentials = {"auth_type": "api_key", "config": {"api_key": "key_with_中文_and_special_chars_!@#$%"}} self._create_binding( @@ -169,7 +191,7 @@ class TestApiKeyAuthService: assert result["config"]["api_key"] == "key_with_中文_and_special_chars_!@#$%" def test_delete_provider_auth_success( - self, flask_app_with_containers, db_session_with_containers, tenant_id, category, provider + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, category, provider ): binding = self._create_binding( db_session_with_containers, tenant_id=tenant_id, category=category, provider=provider @@ -183,7 +205,9 @@ class TestApiKeyAuthService: remaining = db_session_with_containers.query(DataSourceApiKeyAuthBinding).filter_by(id=binding_id).first() assert remaining is None - def test_delete_provider_auth_not_found(self, flask_app_with_containers, db_session_with_containers, tenant_id): + def test_delete_provider_auth_not_found( + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id + ): # Should not raise when binding not found ApiKeyAuthService.delete_provider_auth(tenant_id, str(uuid4())) @@ -252,7 +276,7 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") @patch("services.auth.api_key_auth_service.encrypter") def test_create_provider_auth_database_error_handling( - self, mock_encrypter, mock_factory, flask_app_with_containers, tenant_id, mock_args + self, mock_encrypter, mock_factory, flask_app_with_containers: Flask, tenant_id, mock_args ): mock_auth_instance = Mock() mock_auth_instance.validate_credentials.return_value = True diff --git a/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py b/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py index f48c6da690..1de9ce38a0 100644 --- a/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py +++ b/api/tests/test_containers_integration_tests/services/auth/test_auth_integration.py @@ -10,6 +10,8 @@ from uuid import uuid4 import httpx import pytest +from flask import Flask +from sqlalchemy.orm import Session from models.source import DataSourceApiKeyAuthBinding from services.auth.api_key_auth_factory import ApiKeyAuthFactory @@ -44,8 +46,8 @@ class TestAuthIntegration: self, mock_encrypt, mock_http, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, tenant_id_1, category, firecrawl_credentials, @@ -85,8 +87,8 @@ class TestAuthIntegration: mock_jina_http, mock_fc_http, mock_encrypt, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, tenant_id_1, tenant_id_2, category, @@ -114,7 +116,7 @@ class TestAuthIntegration: assert result2[0].tenant_id == tenant_id_2 def test_cross_tenant_access_prevention( - self, flask_app_with_containers, db_session_with_containers, tenant_id_2, category + self, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id_2, category ): result = ApiKeyAuthService.get_auth_credentials(tenant_id_2, category, AuthType.FIRECRAWL) @@ -138,8 +140,8 @@ class TestAuthIntegration: self, mock_encrypt, mock_http, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, tenant_id_1, category, firecrawl_credentials, @@ -200,8 +202,8 @@ class TestAuthIntegration: def test_network_failure_recovery( self, mock_http, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, tenant_id_1, category, firecrawl_credentials, @@ -238,8 +240,8 @@ class TestAuthIntegration: self, mock_http, mock_encrypt, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, tenant_id_1, category, firecrawl_credentials, diff --git a/api/tests/test_containers_integration_tests/services/dataset_service_update_delete.py b/api/tests/test_containers_integration_tests/services/dataset_service_update_delete.py index 02c3d1a80e..1cffc43658 100644 --- a/api/tests/test_containers_integration_tests/services/dataset_service_update_delete.py +++ b/api/tests/test_containers_integration_tests/services/dataset_service_update_delete.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound from core.rag.index_processor.constant.index_type import IndexTechniqueType -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import AppDatasetJoin, Dataset, DatasetPermissionEnum from models.enums import DataSourceType from models.model import App @@ -38,13 +38,13 @@ class DatasetUpdateDeleteTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() if tenant is None: - tenant = Tenant(name=f"tenant-{uuid4()}", status="normal") + tenant = Tenant(name=f"tenant-{uuid4()}", status=TenantStatus.NORMAL) db_session_with_containers.add(tenant) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/services/document_service_status.py b/api/tests/test_containers_integration_tests/services/document_service_status.py index 42d587b7f7..327f14ddfe 100644 --- a/api/tests/test_containers_integration_tests/services/document_service_status.py +++ b/api/tests/test_containers_integration_tests/services/document_service_status.py @@ -12,6 +12,7 @@ from unittest.mock import create_autospec, patch from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType from extensions.storage.storage_type import StorageType @@ -273,7 +274,9 @@ class TestDocumentServicePauseDocument: "user_id": user_id, } - def test_pause_document_waiting_state_success(self, db_session_with_containers, mock_document_service_dependencies): + def test_pause_document_waiting_state_success( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test successful pause of document in waiting state. @@ -310,7 +313,7 @@ class TestDocumentServicePauseDocument: mock_document_service_dependencies["redis_client"].setnx.assert_called_once_with(expected_cache_key, "True") def test_pause_document_indexing_state_success( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test successful pause of document in indexing state. @@ -340,7 +343,9 @@ class TestDocumentServicePauseDocument: assert document.is_paused is True assert document.paused_by == mock_document_service_dependencies["user_id"] - def test_pause_document_parsing_state_success(self, db_session_with_containers, mock_document_service_dependencies): + def test_pause_document_parsing_state_success( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test successful pause of document in parsing state. @@ -367,7 +372,9 @@ class TestDocumentServicePauseDocument: db_session_with_containers.refresh(document) assert document.is_paused is True - def test_pause_document_completed_state_error(self, db_session_with_containers, mock_document_service_dependencies): + def test_pause_document_completed_state_error( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test error when trying to pause completed document. @@ -396,7 +403,9 @@ class TestDocumentServicePauseDocument: db_session_with_containers.refresh(document) assert document.is_paused is False - def test_pause_document_error_state_error(self, db_session_with_containers, mock_document_service_dependencies): + def test_pause_document_error_state_error( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test error when trying to pause document in error state. @@ -467,7 +476,9 @@ class TestDocumentServiceRecoverDocument: "recover_task": mock_task, } - def test_recover_document_paused_success(self, db_session_with_containers, mock_document_service_dependencies): + def test_recover_document_paused_success( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test successful recovery of paused document. @@ -510,7 +521,9 @@ class TestDocumentServiceRecoverDocument: document.dataset_id, document.id ) - def test_recover_document_not_paused_error(self, db_session_with_containers, mock_document_service_dependencies): + def test_recover_document_not_paused_error( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test error when trying to recover non-paused document. @@ -590,7 +603,9 @@ class TestDocumentServiceRetryDocument: "user_id": user_id, } - def test_retry_document_single_success(self, db_session_with_containers, mock_document_service_dependencies): + def test_retry_document_single_success( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test successful retry of single document. @@ -629,7 +644,9 @@ class TestDocumentServiceRetryDocument: dataset.id, [document.id], mock_document_service_dependencies["user_id"] ) - def test_retry_document_multiple_success(self, db_session_with_containers, mock_document_service_dependencies): + def test_retry_document_multiple_success( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test successful retry of multiple documents. @@ -675,7 +692,7 @@ class TestDocumentServiceRetryDocument: ) def test_retry_document_concurrent_retry_error( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test error when document is already being retried. @@ -708,7 +725,7 @@ class TestDocumentServiceRetryDocument: assert document.indexing_status == IndexingStatus.ERROR def test_retry_document_missing_current_user_error( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test error when current_user is missing. @@ -794,7 +811,7 @@ class TestDocumentServiceBatchUpdateDocumentStatus: } def test_batch_update_document_status_enable_success( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test successful batch enabling of documents. @@ -844,7 +861,7 @@ class TestDocumentServiceBatchUpdateDocumentStatus: assert mock_document_service_dependencies["add_task"].delay.call_count == 2 def test_batch_update_document_status_disable_success( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test successful batch disabling of documents. @@ -886,7 +903,7 @@ class TestDocumentServiceBatchUpdateDocumentStatus: mock_document_service_dependencies["remove_task"].delay.assert_called_once_with(document.id) def test_batch_update_document_status_archive_success( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test successful batch archiving of documents. @@ -928,7 +945,7 @@ class TestDocumentServiceBatchUpdateDocumentStatus: mock_document_service_dependencies["remove_task"].delay.assert_called_once_with(document.id) def test_batch_update_document_status_unarchive_success( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test successful batch unarchiving of documents. @@ -970,7 +987,7 @@ class TestDocumentServiceBatchUpdateDocumentStatus: mock_document_service_dependencies["add_task"].delay.assert_called_once_with(document.id) def test_batch_update_document_status_empty_list( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test handling of empty document list. @@ -996,7 +1013,7 @@ class TestDocumentServiceBatchUpdateDocumentStatus: mock_document_service_dependencies["remove_task"].delay.assert_not_called() def test_batch_update_document_status_document_indexing_error( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test error when document is being indexed. @@ -1073,7 +1090,7 @@ class TestDocumentServiceRenameDocument: "current_user": mock_current_user, } - def test_rename_document_success(self, db_session_with_containers, mock_document_service_dependencies): + def test_rename_document_success(self, db_session_with_containers: Session, mock_document_service_dependencies): """ Test successful document renaming. @@ -1111,7 +1128,9 @@ class TestDocumentServiceRenameDocument: assert result == document assert document.name == new_name - def test_rename_document_with_built_in_fields(self, db_session_with_containers, mock_document_service_dependencies): + def test_rename_document_with_built_in_fields( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test document renaming with built-in fields enabled. @@ -1154,7 +1173,9 @@ class TestDocumentServiceRenameDocument: assert document.doc_metadata["document_name"] == new_name assert document.doc_metadata["existing_key"] == "existing_value" - def test_rename_document_with_upload_file(self, db_session_with_containers, mock_document_service_dependencies): + def test_rename_document_with_upload_file( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test document renaming with associated upload file. @@ -1202,7 +1223,7 @@ class TestDocumentServiceRenameDocument: assert upload_file.name == new_name def test_rename_document_dataset_not_found_error( - self, db_session_with_containers, mock_document_service_dependencies + self, db_session_with_containers: Session, mock_document_service_dependencies ): """ Test error when dataset is not found. @@ -1224,7 +1245,9 @@ class TestDocumentServiceRenameDocument: with pytest.raises(ValueError, match="Dataset not found"): DocumentService.rename_document(dataset_id, document_id, new_name) - def test_rename_document_not_found_error(self, db_session_with_containers, mock_document_service_dependencies): + def test_rename_document_not_found_error( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test error when document is not found. @@ -1251,7 +1274,9 @@ class TestDocumentServiceRenameDocument: with pytest.raises(ValueError, match="Document not found"): DocumentService.rename_document(dataset.id, document_id, new_name) - def test_rename_document_permission_error(self, db_session_with_containers, mock_document_service_dependencies): + def test_rename_document_permission_error( + self, db_session_with_containers: Session, mock_document_service_dependencies + ): """ Test error when user lacks permission. diff --git a/api/tests/test_containers_integration_tests/services/enterprise/test_account_deletion_sync.py b/api/tests/test_containers_integration_tests/services/enterprise/test_account_deletion_sync.py index 4e8255d8ed..075f43d63a 100644 --- a/api/tests/test_containers_integration_tests/services/enterprise/test_account_deletion_sync.py +++ b/api/tests/test_containers_integration_tests/services/enterprise/test_account_deletion_sync.py @@ -10,7 +10,9 @@ from unittest.mock import patch from uuid import uuid4 import pytest +from flask import Flask from redis import RedisError +from sqlalchemy.orm import Session from extensions.ext_redis import redis_client from models.account import TenantAccountJoin @@ -122,7 +124,7 @@ class TestSyncAccountDeletion: mock_queue_task.assert_not_called() def test_sync_account_deletion_multiple_workspaces( - self, flask_app_with_containers, db_session_with_containers, mock_queue_task + self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task ): account_id = str(uuid4()) tenant_ids = [str(uuid4()) for _ in range(3)] @@ -144,7 +146,7 @@ class TestSyncAccountDeletion: assert queued_workspace_ids == set(tenant_ids) def test_sync_account_deletion_no_workspaces( - self, flask_app_with_containers, db_session_with_containers, mock_queue_task + self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task ): with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: mock_config.ENTERPRISE_ENABLED = True @@ -155,7 +157,7 @@ class TestSyncAccountDeletion: mock_queue_task.assert_not_called() def test_sync_account_deletion_partial_failure( - self, flask_app_with_containers, db_session_with_containers, mock_queue_task + self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task ): account_id = str(uuid4()) tenant_ids = [str(uuid4()) for _ in range(3)] @@ -180,7 +182,7 @@ class TestSyncAccountDeletion: assert mock_queue_task.call_count == 3 def test_sync_account_deletion_all_failures( - self, flask_app_with_containers, db_session_with_containers, mock_queue_task + self, flask_app_with_containers: Flask, db_session_with_containers: Session, mock_queue_task ): account_id = str(uuid4()) tenant_id = str(uuid4()) diff --git a/api/tests/test_containers_integration_tests/services/plugin/test_plugin_parameter_service.py b/api/tests/test_containers_integration_tests/services/plugin/test_plugin_parameter_service.py index ce9f10e207..48830c0f43 100644 --- a/api/tests/test_containers_integration_tests/services/plugin/test_plugin_parameter_service.py +++ b/api/tests/test_containers_integration_tests/services/plugin/test_plugin_parameter_service.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from core.plugin.entities.plugin_daemon import CredentialType from models.tools import BuiltinToolProvider @@ -49,8 +50,8 @@ class TestGetDynamicSelectOptionsTool: mock_tool_mgr, mock_encrypter_fn, mock_client_cls, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, ): tenant_id = str(uuid4()) provider_ctrl = MagicMock() @@ -91,8 +92,8 @@ class TestGetDynamicSelectOptionsTool: self, mock_tool_mgr, mock_encrypter_fn, - flask_app_with_containers, - db_session_with_containers, + flask_app_with_containers: Flask, + db_session_with_containers: Session, ): provider_ctrl = MagicMock() provider_ctrl.need_credentials = True diff --git a/api/tests/test_containers_integration_tests/services/plugin/test_plugin_permission_service.py b/api/tests/test_containers_integration_tests/services/plugin/test_plugin_permission_service.py new file mode 100644 index 0000000000..49d06986fd --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/plugin/test_plugin_permission_service.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from uuid import uuid4 + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from models.account import TenantPluginPermission +from services.plugin.plugin_permission_service import PluginPermissionService + + +def _tenant_id() -> str: + return str(uuid4()) + + +def _get_permission(session: Session, tenant_id: str) -> TenantPluginPermission | None: + session.expire_all() + stmt = select(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id) + return session.scalars(stmt).one_or_none() + + +def _count_permissions(session: Session, tenant_id: str) -> int: + stmt = select(func.count()).select_from(TenantPluginPermission).where(TenantPluginPermission.tenant_id == tenant_id) + return session.scalar(stmt) or 0 + + +class TestGetPermission: + """Integration tests for PluginPermissionService.get_permission using testcontainers.""" + + def test_returns_permission_when_found(self, db_session_with_containers: Session): + tenant_id = _tenant_id() + permission = TenantPluginPermission( + tenant_id=tenant_id, + install_permission=TenantPluginPermission.InstallPermission.ADMINS, + debug_permission=TenantPluginPermission.DebugPermission.EVERYONE, + ) + db_session_with_containers.add(permission) + db_session_with_containers.commit() + + result = PluginPermissionService.get_permission(tenant_id) + + assert result is not None + assert result.id == permission.id + assert result.tenant_id == tenant_id + assert result.install_permission == TenantPluginPermission.InstallPermission.ADMINS + assert result.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE + + def test_returns_none_when_not_found(self, db_session_with_containers: Session): + result = PluginPermissionService.get_permission(_tenant_id()) + + assert result is None + + +class TestChangePermission: + """Integration tests for PluginPermissionService.change_permission using testcontainers.""" + + def test_creates_new_permission_when_not_exists(self, db_session_with_containers: Session): + tenant_id = _tenant_id() + + result = PluginPermissionService.change_permission( + tenant_id, + TenantPluginPermission.InstallPermission.EVERYONE, + TenantPluginPermission.DebugPermission.EVERYONE, + ) + + permission = _get_permission(db_session_with_containers, tenant_id) + assert result is True + assert permission is not None + assert permission.install_permission == TenantPluginPermission.InstallPermission.EVERYONE + assert permission.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE + + def test_updates_existing_permission(self, db_session_with_containers: Session): + tenant_id = _tenant_id() + existing = TenantPluginPermission( + tenant_id=tenant_id, + install_permission=TenantPluginPermission.InstallPermission.EVERYONE, + debug_permission=TenantPluginPermission.DebugPermission.EVERYONE, + ) + db_session_with_containers.add(existing) + db_session_with_containers.commit() + + result = PluginPermissionService.change_permission( + tenant_id, + TenantPluginPermission.InstallPermission.ADMINS, + TenantPluginPermission.DebugPermission.ADMINS, + ) + + permission = _get_permission(db_session_with_containers, tenant_id) + assert result is True + assert permission is not None + assert permission.id == existing.id + assert permission.install_permission == TenantPluginPermission.InstallPermission.ADMINS + assert permission.debug_permission == TenantPluginPermission.DebugPermission.ADMINS + assert _count_permissions(db_session_with_containers, tenant_id) == 1 diff --git a/api/tests/test_containers_integration_tests/services/plugin/test_plugin_service.py b/api/tests/test_containers_integration_tests/services/plugin/test_plugin_service.py index 0cdae572fb..0fa4d9043b 100644 --- a/api/tests/test_containers_integration_tests/services/plugin/test_plugin_service.py +++ b/api/tests/test_containers_integration_tests/services/plugin/test_plugin_service.py @@ -11,10 +11,13 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy import select +from sqlalchemy.orm import Session from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginVerification +from models import ProviderType from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider from services.errors.plugin import PluginInstallationForbiddenError from services.feature_service import PluginInstallationScope @@ -346,7 +349,7 @@ class TestUninstall: @patch("services.plugin.plugin_service.PluginInstaller") def test_cleans_credentials_when_plugin_found( - self, mock_installer_cls, flask_app_with_containers, db_session_with_containers + self, mock_installer_cls, flask_app_with_containers: Flask, db_session_with_containers: Session ): tenant_id = str(uuid4()) plugin_id = "org/myplugin" @@ -374,7 +377,7 @@ class TestUninstall: pref = TenantPreferredModelProvider( tenant_id=tenant_id, provider_name=provider_name, - preferred_provider_type="custom", + preferred_provider_type=ProviderType.CUSTOM, ) db_session_with_containers.add(pref) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py index 2b842629a7..e4a106694b 100644 --- a/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/recommend_app/test_database_retrieval.py @@ -3,12 +3,15 @@ from __future__ import annotations from unittest.mock import patch from uuid import uuid4 +from flask import Flask +from sqlalchemy.orm import Session + from models.model import App, RecommendedApp, Site from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval from services.recommend_app.recommend_app_type import RecommendAppType -def _create_app(db_session, *, tenant_id: str, is_public: bool = True) -> App: +def _create_app(db_session: Session, *, tenant_id: str, is_public: bool = True) -> App: app = App( tenant_id=tenant_id, name=f"app-{uuid4()}", @@ -23,7 +26,7 @@ def _create_app(db_session, *, tenant_id: str, is_public: bool = True) -> App: return app -def _create_site(db_session, *, app_id: str) -> Site: +def _create_site(db_session: Session, *, app_id: str) -> Site: site = Site( app_id=app_id, title=f"site-{uuid4()}", @@ -45,6 +48,7 @@ def _create_recommended_app( *, app_id: str, category: str = "chat", + categories: list[str] | None = None, language: str = "en-US", is_listed: bool = True, position: int = 1, @@ -55,6 +59,7 @@ def _create_recommended_app( copyright="copy", privacy_policy="pp", category=category, + categories=[category] if categories is None else categories, language=language, is_listed=is_listed, position=position, @@ -91,7 +96,9 @@ class TestDatabaseRecommendAppRetrieval: class TestFetchRecommendedAppsFromDb: - def test_returns_apps_and_sorted_categories(self, flask_app_with_containers, db_session_with_containers): + def test_returns_apps_and_sorted_categories( + self, flask_app_with_containers: Flask, db_session_with_containers: Session + ): tenant_id = str(uuid4()) app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) _create_site(db_session_with_containers, app_id=app1.id) @@ -111,7 +118,56 @@ class TestFetchRecommendedAppsFromDb: assert "assistant" in result["categories"] assert "writing" in result["categories"] - def test_falls_back_to_default_language_when_empty(self, flask_app_with_containers, db_session_with_containers): + def test_returns_multiple_categories_for_one_app( + self, flask_app_with_containers: Flask, db_session_with_containers: Session + ): + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category="writing", + categories=["writing", "assistant"], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert recommended_app["categories"] == ["writing", "assistant"] + assert "writing" in result["categories"] + assert "assistant" in result["categories"] + + def test_ignores_legacy_category_when_categories_are_empty( + self, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + ): + legacy_category = f"legacy-empty-{uuid4()}" + tenant_id = str(uuid4()) + created_app = _create_app(db_session_with_containers, tenant_id=tenant_id) + _create_site(db_session_with_containers, app_id=created_app.id) + _create_recommended_app( + db_session_with_containers, + app_id=created_app.id, + category=legacy_category, + categories=[], + ) + + db_session_with_containers.expire_all() + + result = DatabaseRecommendAppRetrieval.fetch_recommended_apps_from_db("en-US") + + recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == created_app.id) + assert "category" not in recommended_app + assert recommended_app["categories"] == [] + assert legacy_category not in result["categories"] + + def test_falls_back_to_default_language_when_empty( + self, flask_app_with_containers: Flask, db_session_with_containers: Session + ): tenant_id = str(uuid4()) app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) _create_site(db_session_with_containers, app_id=app1.id) @@ -124,7 +180,7 @@ class TestFetchRecommendedAppsFromDb: app_ids = {r["app_id"] for r in result["recommended_apps"]} assert app1.id in app_ids - def test_skips_non_public_apps(self, flask_app_with_containers, db_session_with_containers): + def test_skips_non_public_apps(self, flask_app_with_containers: Flask, db_session_with_containers: Session): tenant_id = str(uuid4()) app1 = _create_app(db_session_with_containers, tenant_id=tenant_id, is_public=False) _create_site(db_session_with_containers, app_id=app1.id) @@ -137,7 +193,7 @@ class TestFetchRecommendedAppsFromDb: app_ids = {r["app_id"] for r in result["recommended_apps"]} assert app1.id not in app_ids - def test_skips_apps_without_site(self, flask_app_with_containers, db_session_with_containers): + def test_skips_apps_without_site(self, flask_app_with_containers: Flask, db_session_with_containers: Session): tenant_id = str(uuid4()) app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) _create_recommended_app(db_session_with_containers, app_id=app1.id) @@ -151,12 +207,14 @@ class TestFetchRecommendedAppsFromDb: class TestFetchRecommendedAppDetailFromDb: - def test_returns_none_when_not_listed(self, flask_app_with_containers, db_session_with_containers): + def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session): result = DatabaseRecommendAppRetrieval.fetch_recommended_app_detail_from_db(str(uuid4())) assert result is None - def test_returns_none_when_app_not_public(self, flask_app_with_containers, db_session_with_containers): + def test_returns_none_when_app_not_public( + self, flask_app_with_containers: Flask, db_session_with_containers: Session + ): tenant_id = str(uuid4()) app1 = _create_app(db_session_with_containers, tenant_id=tenant_id, is_public=False) _create_recommended_app(db_session_with_containers, app_id=app1.id) @@ -168,7 +226,9 @@ class TestFetchRecommendedAppDetailFromDb: assert result is None @patch("services.recommend_app.database.database_retrieval.AppDslService") - def test_returns_detail_on_success(self, mock_dsl, flask_app_with_containers, db_session_with_containers): + def test_returns_detail_on_success( + self, mock_dsl, flask_app_with_containers: Flask, db_session_with_containers: Session + ): tenant_id = str(uuid4()) app1 = _create_app(db_session_with_containers, tenant_id=tenant_id) _create_site(db_session_with_containers, app_id=app1.id) diff --git a/api/tests/test_containers_integration_tests/services/test_advanced_prompt_template_service.py b/api/tests/test_containers_integration_tests/services/test_advanced_prompt_template_service.py index 3ec265d009..f78037e503 100644 --- a/api/tests/test_containers_integration_tests/services/test_advanced_prompt_template_service.py +++ b/api/tests/test_containers_integration_tests/services/test_advanced_prompt_template_service.py @@ -2,6 +2,7 @@ import copy import pytest from faker import Faker +from sqlalchemy.orm import Session from core.prompt.prompt_templates.advanced_prompt_templates import ( BAICHUAN_CHAT_APP_CHAT_PROMPT_CONFIG, @@ -29,7 +30,9 @@ class TestAdvancedPromptTemplateService: # for consistency with other test files return {} - def test_get_prompt_baichuan_model_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_prompt_baichuan_model_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test successful prompt generation for Baichuan model. @@ -64,7 +67,9 @@ class TestAdvancedPromptTemplateService: assert "{{#histories#}}" in prompt_text assert "{{#query#}}" in prompt_text - def test_get_prompt_common_model_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_prompt_common_model_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test successful prompt generation for common models. @@ -100,7 +105,7 @@ class TestAdvancedPromptTemplateService: assert "{{#query#}}" in prompt_text def test_get_prompt_case_insensitive_baichuan_detection( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan model detection is case insensitive. @@ -131,7 +136,7 @@ class TestAdvancedPromptTemplateService: assert BAICHUAN_CONTEXT in prompt_text def test_get_common_prompt_chat_app_completion_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test common prompt generation for chat app with completion mode. @@ -161,7 +166,9 @@ class TestAdvancedPromptTemplateService: assert "{{#histories#}}" in prompt_text assert "{{#query#}}" in prompt_text - def test_get_common_prompt_chat_app_chat_mode(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_common_prompt_chat_app_chat_mode( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test common prompt generation for chat app with chat mode. @@ -189,7 +196,7 @@ class TestAdvancedPromptTemplateService: assert "{{#pre_prompt#}}" in prompt_text def test_get_common_prompt_completion_app_completion_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test common prompt generation for completion app with completion mode. @@ -217,7 +224,7 @@ class TestAdvancedPromptTemplateService: assert "{{#pre_prompt#}}" in prompt_text def test_get_common_prompt_completion_app_chat_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test common prompt generation for completion app with chat mode. @@ -245,7 +252,9 @@ class TestAdvancedPromptTemplateService: assert CONTEXT in prompt_text assert "{{#pre_prompt#}}" in prompt_text - def test_get_common_prompt_no_context(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_common_prompt_no_context( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test common prompt generation without context. @@ -273,7 +282,7 @@ class TestAdvancedPromptTemplateService: assert "{{#query#}}" in prompt_text def test_get_common_prompt_unsupported_app_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test common prompt generation with unsupported app mode. @@ -291,7 +300,7 @@ class TestAdvancedPromptTemplateService: assert result == {} def test_get_common_prompt_unsupported_model_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test common prompt generation with unsupported model mode. @@ -308,7 +317,9 @@ class TestAdvancedPromptTemplateService: # Assert: Verify empty dict is returned assert result == {} - def test_get_completion_prompt_with_context(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_completion_prompt_with_context( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test completion prompt generation with context. @@ -339,7 +350,7 @@ class TestAdvancedPromptTemplateService: assert result_text == CONTEXT + original_text def test_get_completion_prompt_without_context( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test completion prompt generation without context. @@ -368,7 +379,9 @@ class TestAdvancedPromptTemplateService: assert result_text == original_text assert CONTEXT not in result_text - def test_get_chat_prompt_with_context(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_chat_prompt_with_context( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test chat prompt generation with context. @@ -399,7 +412,9 @@ class TestAdvancedPromptTemplateService: assert original_text in result_text assert result_text == CONTEXT + original_text - def test_get_chat_prompt_without_context(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_chat_prompt_without_context( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test chat prompt generation without context. @@ -429,7 +444,7 @@ class TestAdvancedPromptTemplateService: assert CONTEXT not in result_text def test_get_baichuan_prompt_chat_app_completion_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan prompt generation for chat app with completion mode. @@ -460,7 +475,7 @@ class TestAdvancedPromptTemplateService: assert "{{#query#}}" in prompt_text def test_get_baichuan_prompt_chat_app_chat_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan prompt generation for chat app with chat mode. @@ -489,7 +504,7 @@ class TestAdvancedPromptTemplateService: assert "{{#pre_prompt#}}" in prompt_text def test_get_baichuan_prompt_completion_app_completion_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan prompt generation for completion app with completion mode. @@ -517,7 +532,7 @@ class TestAdvancedPromptTemplateService: assert "{{#pre_prompt#}}" in prompt_text def test_get_baichuan_prompt_completion_app_chat_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan prompt generation for completion app with chat mode. @@ -545,7 +560,9 @@ class TestAdvancedPromptTemplateService: assert BAICHUAN_CONTEXT in prompt_text assert "{{#pre_prompt#}}" in prompt_text - def test_get_baichuan_prompt_no_context(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_baichuan_prompt_no_context( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test Baichuan prompt generation without context. @@ -573,7 +590,7 @@ class TestAdvancedPromptTemplateService: assert "{{#query#}}" in prompt_text def test_get_baichuan_prompt_unsupported_app_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan prompt generation with unsupported app mode. @@ -591,7 +608,7 @@ class TestAdvancedPromptTemplateService: assert result == {} def test_get_baichuan_prompt_unsupported_model_mode( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test Baichuan prompt generation with unsupported model mode. @@ -609,7 +626,7 @@ class TestAdvancedPromptTemplateService: assert result == {} def test_get_prompt_all_app_modes_common_model( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test prompt generation for all app modes with common model. @@ -641,7 +658,7 @@ class TestAdvancedPromptTemplateService: assert result != {} def test_get_prompt_all_app_modes_baichuan_model( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test prompt generation for all app modes with Baichuan model. @@ -672,7 +689,7 @@ class TestAdvancedPromptTemplateService: assert result is not None assert result != {} - def test_get_prompt_edge_cases(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_prompt_edge_cases(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test prompt generation with edge cases. @@ -704,7 +721,7 @@ class TestAdvancedPromptTemplateService: # Should either return a valid result or empty dict, but not crash assert result is not None - def test_template_immutability(self, db_session_with_containers, mock_external_service_dependencies): + def test_template_immutability(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test that original templates are not modified. @@ -738,7 +755,9 @@ class TestAdvancedPromptTemplateService: assert original_completion_completion == COMPLETION_APP_COMPLETION_PROMPT_CONFIG assert original_completion_chat == COMPLETION_APP_CHAT_PROMPT_CONFIG - def test_baichuan_template_immutability(self, db_session_with_containers, mock_external_service_dependencies): + def test_baichuan_template_immutability( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test that original Baichuan templates are not modified. @@ -772,7 +791,9 @@ class TestAdvancedPromptTemplateService: assert original_baichuan_completion_completion == BAICHUAN_COMPLETION_APP_COMPLETION_PROMPT_CONFIG assert original_baichuan_completion_chat == BAICHUAN_COMPLETION_APP_CHAT_PROMPT_CONFIG - def test_context_integration_consistency(self, db_session_with_containers, mock_external_service_dependencies): + def test_context_integration_consistency( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test consistency of context integration across different scenarios. @@ -828,7 +849,7 @@ class TestAdvancedPromptTemplateService: assert prompt_text.startswith(CONTEXT) def test_baichuan_context_integration_consistency( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test consistency of Baichuan context integration across different scenarios. diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index 00a2f9a59f..27e793915a 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -6,12 +6,12 @@ from faker import Faker from sqlalchemy.orm import Session from core.plugin.impl.exc import PluginDaemonClientSideError -from models import Account +from models import Account, AppMode, CreatorUserRole from models.enums import ConversationFromSource, MessageFileBelongsTo from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought from services.account_service import AccountService, TenantService from services.agent_service import AgentService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -119,22 +119,22 @@ class TestAgentService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "agent-chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="agent-chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) # Update the app model config to set agent_mode for agent-chat mode - if app.mode == "agent-chat" and app.app_model_config: + if app.mode == AppMode.AGENT_CHAT and app.app_model_config: app.app_model_config.agent_mode = json.dumps({"enabled": True, "strategy": "react", "tools": []}) db_session_with_containers.commit() @@ -246,7 +246,7 @@ class TestAgentService: tool_input=json.dumps({"test_tool": {"input": "test_input"}}), observation=json.dumps({"test_tool": {"output": "test_output"}}), tokens=50, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(thought1) @@ -272,7 +272,7 @@ class TestAgentService: tool_input=json.dumps({"dataset_tool": {"query": "test_query"}}), observation=json.dumps({"dataset_tool": {"results": "test_results"}}), tokens=30, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(thought2) @@ -294,7 +294,7 @@ class TestAgentService: agent_thoughts = self._create_test_agent_thoughts(db_session_with_containers, message) # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result structure assert result is not None @@ -370,7 +370,7 @@ class TestAgentService: # Execute the method under test with non-existent message with pytest.raises(ValueError, match="Message not found"): - AgentService.get_agent_logs(app, str(conversation.id), fake.uuid4()) + AgentService.get_agent_logs(app, conversation.id, fake.uuid4()) def test_get_agent_logs_with_end_user( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -451,7 +451,7 @@ class TestAgentService: db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -523,7 +523,7 @@ class TestAgentService: db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -561,14 +561,14 @@ class TestAgentService: tool_input=json.dumps({"error_tool": {"input": "test_input"}}), observation=json.dumps({"error_tool": {"output": "error_output"}}), tokens=50, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(thought_with_error) db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -592,7 +592,7 @@ class TestAgentService: conversation, message = self._create_test_conversation_and_message(db_session_with_containers, app, account) # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -654,7 +654,7 @@ class TestAgentService: # Execute the method under test with pytest.raises(ValueError, match="App model config not found"): - AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + AgentService.get_agent_logs(app, conversation.id, message.id) def test_get_agent_logs_agent_config_not_found( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -673,7 +673,7 @@ class TestAgentService: # Execute the method under test with pytest.raises(ValueError, match="Agent config not found"): - AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + AgentService.get_agent_logs(app, conversation.id, message.id) def test_list_agent_providers_success( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -687,7 +687,7 @@ class TestAgentService: app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) # Execute the method under test - result = AgentService.list_agent_providers(str(account.id), str(app.tenant_id)) + result = AgentService.list_agent_providers(account.id, app.tenant_id) # Verify the result assert result is not None @@ -696,7 +696,7 @@ class TestAgentService: # Verify the mock was called correctly mock_plugin_client = mock_external_service_dependencies["plugin_agent_client"].return_value - mock_plugin_client.fetch_agent_strategy_providers.assert_called_once_with(str(app.tenant_id)) + mock_plugin_client.fetch_agent_strategy_providers.assert_called_once_with(app.tenant_id) def test_get_agent_provider_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ @@ -710,7 +710,7 @@ class TestAgentService: provider_name = "test_provider" # Execute the method under test - result = AgentService.get_agent_provider(str(account.id), str(app.tenant_id), provider_name) + result = AgentService.get_agent_provider(account.id, app.tenant_id, provider_name) # Verify the result assert result is not None @@ -718,7 +718,7 @@ class TestAgentService: # Verify the mock was called correctly mock_plugin_client = mock_external_service_dependencies["plugin_agent_client"].return_value - mock_plugin_client.fetch_agent_strategy_provider.assert_called_once_with(str(app.tenant_id), provider_name) + mock_plugin_client.fetch_agent_strategy_provider.assert_called_once_with(app.tenant_id, provider_name) def test_get_agent_provider_plugin_error( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -740,7 +740,7 @@ class TestAgentService: # Execute the method under test with pytest.raises(ValueError, match=error_message): - AgentService.get_agent_provider(str(account.id), str(app.tenant_id), provider_name) + AgentService.get_agent_provider(account.id, app.tenant_id, provider_name) def test_get_agent_logs_with_complex_tool_data( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -796,14 +796,14 @@ class TestAgentService: {"tool1": {"output1": "result1"}, "tool2": {"output2": "result2"}, "tool3": {"output3": "result3"}} ), tokens=100, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(complex_thought) db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -891,14 +891,14 @@ class TestAgentService: observation=json.dumps({"file_tool": {"output": "test_output"}}), message_files=json.dumps(["file1", "file2"]), tokens=50, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(thought_with_files) db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -926,7 +926,7 @@ class TestAgentService: mock_external_service_dependencies["current_user"].timezone = "Asia/Shanghai" # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -960,14 +960,14 @@ class TestAgentService: tool_input="", # Empty input observation="", # Empty observation tokens=50, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(empty_thought) db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result assert result is not None @@ -1001,14 +1001,14 @@ class TestAgentService: tool_input="invalid json", # Malformed JSON observation="invalid json", # Malformed JSON tokens=50, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=message.from_account_id, ) db_session_with_containers.add(malformed_thought) db_session_with_containers.commit() # Execute the method under test - result = AgentService.get_agent_logs(app, str(conversation.id), str(message.id)) + result = AgentService.get_agent_logs(app, conversation.id, message.id) # Verify the result - should handle malformed JSON gracefully assert result is not None diff --git a/api/tests/test_containers_integration_tests/services/test_annotation_service.py b/api/tests/test_containers_integration_tests/services/test_annotation_service.py index 95fc73f45a..bc75562d15 100644 --- a/api/tests/test_containers_integration_tests/services/test_annotation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_annotation_service.py @@ -9,7 +9,7 @@ from models import Account from models.enums import ConversationFromSource, InvokeFrom from models.model import MessageAnnotation from services.annotation_service import AppAnnotationService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -86,16 +86,16 @@ class TestAnnotationService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app app_service = AppService() diff --git a/api/tests/test_containers_integration_tests/services/test_api_token_service.py b/api/tests/test_containers_integration_tests/services/test_api_token_service.py index a2028d3ed3..9d0082a421 100644 --- a/api/tests/test_containers_integration_tests/services/test_api_token_service.py +++ b/api/tests/test_containers_integration_tests/services/test_api_token_service.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from werkzeug.exceptions import Unauthorized import services.api_token_service as api_token_service_module @@ -14,7 +15,7 @@ from services.api_token_service import ApiTokenCache, CachedApiToken class TestQueryTokenFromDb: def test_should_return_api_token_and_cache_when_token_exists( - self, flask_app_with_containers, db_session_with_containers + self, flask_app_with_containers: Flask, db_session_with_containers ): tenant_id = str(uuid4()) app_id = str(uuid4()) @@ -41,7 +42,7 @@ class TestQueryTokenFromDb: mock_record_usage.assert_called_once_with(token_value, "app") def test_should_cache_null_and_raise_unauthorized_when_token_not_found( - self, flask_app_with_containers, db_session_with_containers + self, flask_app_with_containers: Flask, db_session_with_containers ): with ( patch.object(api_token_service_module.ApiTokenCache, "set") as mock_cache_set, diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 1835650c42..ca3ae6d0cf 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -3,13 +3,15 @@ from __future__ import annotations import base64 import json from types import SimpleNamespace -from typing import Any, cast +from typing import Any from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest import yaml from faker import Faker +from flask import Flask +from sqlalchemy.orm import Session from core.trigger.constants import ( TRIGGER_PLUGIN_NODE_TYPE, @@ -33,9 +35,9 @@ from services.app_dsl_service import ( ImportMode, ImportStatus, PendingData, - _check_version_compatibility, ) -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams +from services.dsl_version import check_version_compatibility from tests.test_containers_integration_tests.helpers import generate_valid_password _DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001" @@ -69,6 +71,7 @@ def _pending_yaml_content(version: str = "99.0.0") -> bytes: def _app_stub(**overrides: Any) -> App: + """Create a stub App object for testing without hitting the database.""" defaults = { "id": str(uuid4()), "tenant_id": _DEFAULT_TENANT_ID, @@ -81,14 +84,17 @@ def _app_stub(**overrides: Any) -> App: "use_icon_as_answer_icon": False, "app_model_config": None, } - return cast(App, SimpleNamespace(**(defaults | overrides))) + app = MagicMock(spec=App) + for key, value in (defaults | overrides).items(): + object.__setattr__(app, key, value) + return app class TestAppDslService: """Integration tests for AppDslService using testcontainers.""" @pytest.fixture - def app(self, flask_app_with_containers): + def app(self, flask_app_with_containers: Flask): return flask_app_with_containers @pytest.fixture @@ -129,7 +135,7 @@ class TestAppDslService: "enterprise_service": mock_enterprise_service, } - def _create_test_app_and_account(self, db_session_with_containers, mock_external_service_dependencies): + def _create_test_app_and_account(self, db_session_with_containers: Session, mock_external_service_dependencies): fake = Faker() with patch("services.account_service.FeatureService") as mock_account_feature_service: mock_account_feature_service.get_system_features.return_value.is_allow_register = True @@ -141,16 +147,16 @@ class TestAppDslService: ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) return app, account @@ -187,26 +193,29 @@ class TestAppDslService: # ── Version Compatibility ───────────────────────────────────────── def test_check_version_compatibility_invalid_version_returns_failed(self): - assert _check_version_compatibility("not-a-version") == ImportStatus.FAILED + assert check_version_compatibility("not-a-version", app_dsl_service.CURRENT_DSL_VERSION) == ImportStatus.FAILED def test_check_version_compatibility_newer_version_returns_pending(self): - assert _check_version_compatibility("99.0.0") == ImportStatus.PENDING + assert check_version_compatibility("99.0.0", app_dsl_service.CURRENT_DSL_VERSION) == ImportStatus.PENDING - def test_check_version_compatibility_major_older_returns_pending(self, monkeypatch): + def test_check_version_compatibility_major_older_returns_pending(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_dsl_service, "CURRENT_DSL_VERSION", "1.0.0") - assert _check_version_compatibility("0.9.9") == ImportStatus.PENDING + assert check_version_compatibility("0.9.9", app_dsl_service.CURRENT_DSL_VERSION) == ImportStatus.PENDING def test_check_version_compatibility_minor_older_returns_completed_with_warnings( self, ): - assert _check_version_compatibility("0.5.0") == ImportStatus.COMPLETED_WITH_WARNINGS + assert ( + check_version_compatibility("0.5.0", app_dsl_service.CURRENT_DSL_VERSION) + == ImportStatus.COMPLETED_WITH_WARNINGS + ) def test_check_version_compatibility_equal_returns_completed(self): - assert _check_version_compatibility(CURRENT_DSL_VERSION) == ImportStatus.COMPLETED + assert check_version_compatibility(CURRENT_DSL_VERSION, CURRENT_DSL_VERSION) == ImportStatus.COMPLETED # ── Import: Validation ──────────────────────────────────────────── - def test_import_app_invalid_import_mode_raises_value_error(self, db_session_with_containers): + def test_import_app_invalid_import_mode_raises_value_error(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Invalid import_mode"): service.import_app( @@ -215,7 +224,7 @@ class TestAppDslService: yaml_content="version: '0.1.0'", ) - def test_import_app_missing_yaml_content(self, db_session_with_containers): + def test_import_app_missing_yaml_content(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.import_app( account=_account_mock(), @@ -225,7 +234,7 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "yaml_content is required" in result.error - def test_import_app_missing_yaml_url(self, db_session_with_containers): + def test_import_app_missing_yaml_url(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.import_app( account=_account_mock(), @@ -235,7 +244,7 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "yaml_url is required" in result.error - def test_import_app_yaml_not_mapping_returns_failed(self, db_session_with_containers): + def test_import_app_yaml_not_mapping_returns_failed(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.import_app( account=_account_mock(), @@ -245,7 +254,7 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "content must be a mapping" in result.error - def test_import_app_version_not_str_returns_failed(self, db_session_with_containers): + def test_import_app_version_not_str_returns_failed(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) yaml_content = _yaml_dump({"version": 1, "kind": "app", "app": {"name": "x", "mode": "workflow"}}) result = service.import_app( @@ -256,7 +265,7 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "Invalid version type" in result.error - def test_import_app_missing_app_data_returns_failed(self, db_session_with_containers): + def test_import_app_missing_app_data_returns_failed(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.import_app( account=_account_mock(), @@ -266,7 +275,9 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "Missing app data" in result.error - def test_import_app_yaml_error_returns_failed(self, db_session_with_containers, monkeypatch): + def test_import_app_yaml_error_returns_failed( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): def bad_safe_load(_content: str): raise yaml.YAMLError("bad") @@ -281,7 +292,9 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert result.error.startswith("Invalid YAML format:") - def test_import_app_unexpected_error_returns_failed(self, db_session_with_containers, monkeypatch): + def test_import_app_unexpected_error_returns_failed( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.setattr( AppDslService, "_create_or_update_app", @@ -299,7 +312,9 @@ class TestAppDslService: # ── Import: YAML URL ────────────────────────────────────────────── - def test_import_app_yaml_url_fetch_error_returns_failed(self, db_session_with_containers, monkeypatch): + def test_import_app_yaml_url_fetch_error_returns_failed( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.setattr( app_dsl_service.ssrf_proxy, "get", @@ -315,7 +330,9 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "Error fetching YAML from URL: boom" in result.error - def test_import_app_yaml_url_empty_content_returns_failed(self, db_session_with_containers, monkeypatch): + def test_import_app_yaml_url_empty_content_returns_failed( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): response = MagicMock() response.content = b"" response.raise_for_status.return_value = None @@ -330,7 +347,9 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "Empty content" in result.error - def test_import_app_yaml_url_file_too_large_returns_failed(self, db_session_with_containers, monkeypatch): + def test_import_app_yaml_url_file_too_large_returns_failed( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): response = MagicMock() response.content = b"x" * (DSL_MAX_SIZE + 1) response.raise_for_status.return_value = None @@ -345,7 +364,9 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "File size exceeds" in result.error - def test_import_app_yaml_url_user_attachments_keeps_original_url(self, db_session_with_containers, monkeypatch): + def test_import_app_yaml_url_user_attachments_keeps_original_url( + self, db_session_with_containers: Session, monkeypatch + ): yaml_url = "https://github.com/user-attachments/files/24290802/loop-test.yml" yaml_bytes = _pending_yaml_content() @@ -371,7 +392,9 @@ class TestAppDslService: assert result.imported_dsl_version == "99.0.0" assert requested_urls == [yaml_url] - def test_import_app_yaml_url_github_blob_rewrites_to_raw(self, db_session_with_containers, monkeypatch): + def test_import_app_yaml_url_github_blob_rewrites_to_raw( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): yaml_url = "https://github.com/acme/repo/blob/main/app.yml" raw_url = "https://raw.githubusercontent.com/acme/repo/main/app.yml" yaml_bytes = _pending_yaml_content() @@ -400,7 +423,7 @@ class TestAppDslService: # ── Import: App ID checks ──────────────────────────────────────── - def test_import_app_app_id_not_found_returns_failed(self, db_session_with_containers): + def test_import_app_app_id_not_found_returns_failed(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.import_app( account=_account_mock(), @@ -412,7 +435,7 @@ class TestAppDslService: assert result.error == "App not found" def test_import_app_overwrite_only_allows_workflow_and_advanced_chat( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) assert app.mode == "chat" @@ -429,7 +452,7 @@ class TestAppDslService: # ── Import: Flow ────────────────────────────────────────────────── - def test_import_app_pending_stores_import_info_in_redis(self, db_session_with_containers): + def test_import_app_pending_stores_import_info_in_redis(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.import_app( account=_account_mock(), @@ -449,7 +472,7 @@ class TestAppDslService: assert stored is not None def test_import_app_completed_uses_declared_dependencies( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): _, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) @@ -483,7 +506,7 @@ class TestAppDslService: @pytest.mark.parametrize("has_workflow", [True, False]) def test_import_app_legacy_versions_extract_dependencies( - self, db_session_with_containers, monkeypatch, has_workflow: bool + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch, has_workflow: bool ): monkeypatch.setattr( AppDslService, @@ -540,13 +563,15 @@ class TestAppDslService: # ── Confirm Import ──────────────────────────────────────────────── - def test_confirm_import_expired_returns_failed(self, db_session_with_containers): + def test_confirm_import_expired_returns_failed(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) result = service.confirm_import(import_id=str(uuid4()), account=_account_mock()) assert result.status == ImportStatus.FAILED assert "expired" in result.error - def test_confirm_import_success_deletes_redis_key(self, db_session_with_containers, monkeypatch): + def test_confirm_import_success_deletes_redis_key( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): import_id = str(uuid4()) redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" @@ -579,7 +604,7 @@ class TestAppDslService: assert result.app_id == created_app.id assert redis_client.get(redis_key) is None - def test_confirm_import_invalid_pending_data_type_returns_failed(self, db_session_with_containers): + def test_confirm_import_invalid_pending_data_type_returns_failed(self, db_session_with_containers: Session): import_id = str(uuid4()) redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" redis_client.setex(redis_key, IMPORT_INFO_REDIS_EXPIRY, "123") @@ -589,7 +614,7 @@ class TestAppDslService: assert result.status == ImportStatus.FAILED assert "validation error" in result.error - def test_confirm_import_exception_returns_failed(self, db_session_with_containers): + def test_confirm_import_exception_returns_failed(self, db_session_with_containers: Session): import_id = str(uuid4()) redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" redis_client.setex(redis_key, IMPORT_INFO_REDIS_EXPIRY, "not-valid-json") @@ -600,13 +625,15 @@ class TestAppDslService: # ── Check Dependencies ──────────────────────────────────────────── - def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers): + def test_check_dependencies_returns_empty_when_no_redis_data(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) app_model = _app_stub() result = service.check_dependencies(app_model=app_model) assert result.leaked_dependencies == [] - def test_check_dependencies_calls_analysis_service(self, db_session_with_containers, monkeypatch): + def test_check_dependencies_calls_analysis_service( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): app_id = str(uuid4()) pending = CheckDependenciesPendingData(dependencies=[], app_id=app_id) redis_client.setex( @@ -634,7 +661,9 @@ class TestAppDslService: result = service.check_dependencies(app_model=_app_stub(id=app_id)) assert len(result.leaked_dependencies) == 1 - def test_check_dependencies_with_real_app(self, db_session_with_containers, mock_external_service_dependencies): + def test_check_dependencies_with_real_app( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) mock_dependencies_json = '{"app_id": "' + app.id + '", "dependencies": []}' @@ -650,12 +679,14 @@ class TestAppDslService: # ── Create/Update App ───────────────────────────────────────────── - def test_create_or_update_app_missing_mode_raises(self, db_session_with_containers): + def test_create_or_update_app_missing_mode_raises(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="loss app mode"): service._create_or_update_app(app=None, data={"app": {}}, account=_account_mock()) - def test_create_or_update_app_existing_app_updates_fields(self, db_session_with_containers, monkeypatch): + def test_create_or_update_app_existing_app_updates_fields( + self, db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch + ): fixed_now = object() monkeypatch.setattr(app_dsl_service, "naive_utc_now", lambda: fixed_now) @@ -707,7 +738,7 @@ class TestAppDslService: assert app.icon_background == "#222222" assert app.updated_at is fixed_now - def test_create_or_update_app_new_app_requires_tenant(self, db_session_with_containers): + def test_create_or_update_app_new_app_requires_tenant(self, db_session_with_containers: Session): account = _account_mock() account.current_tenant_id = None service = AppDslService(db_session_with_containers) @@ -719,7 +750,7 @@ class TestAppDslService: ) def test_create_or_update_app_creates_workflow_app_and_saves_dependencies( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): _, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) @@ -755,7 +786,7 @@ class TestAppDslService: stored = redis_client.get(f"{CHECK_DEPENDENCIES_REDIS_KEY_PREFIX}{app.id}") assert stored is not None - def test_create_or_update_app_workflow_missing_workflow_data_raises(self, db_session_with_containers): + def test_create_or_update_app_workflow_missing_workflow_data_raises(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Missing workflow data"): service._create_or_update_app( @@ -764,17 +795,17 @@ class TestAppDslService: account=_account_mock(), ) - def test_create_or_update_app_chat_requires_model_config(self, db_session_with_containers): + def test_create_or_update_app_chat_requires_model_config(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Missing model_config"): service._create_or_update_app( - app=_app_stub(mode=AppMode.CHAT.value), - data={"app": {"mode": AppMode.CHAT.value}}, + app=_app_stub(mode=AppMode.CHAT), + data={"app": {"mode": AppMode.CHAT}}, account=_account_mock(), ) def test_create_or_update_app_chat_creates_model_config_and_sends_event( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) app.app_model_config_id = None @@ -784,7 +815,7 @@ class TestAppDslService: service._create_or_update_app( app=app, data={ - "app": {"mode": AppMode.CHAT.value}, + "app": {"mode": AppMode.CHAT}, "model_config": {"model": {"provider": "openai"}}, }, account=account, @@ -793,18 +824,18 @@ class TestAppDslService: db_session_with_containers.expire_all() assert app.app_model_config_id is not None - def test_create_or_update_app_invalid_mode_raises(self, db_session_with_containers): + def test_create_or_update_app_invalid_mode_raises(self, db_session_with_containers: Session): service = AppDslService(db_session_with_containers) with pytest.raises(ValueError, match="Invalid app mode"): service._create_or_update_app( - app=_app_stub(mode=AppMode.RAG_PIPELINE.value), - data={"app": {"mode": AppMode.RAG_PIPELINE.value}}, + app=_app_stub(mode=AppMode.RAG_PIPELINE), + data={"app": {"mode": AppMode.RAG_PIPELINE}}, account=_account_mock(), ) # ── Export ───────────────────────────────────────────────────────── - def test_export_dsl_delegates_by_mode(self, monkeypatch): + def test_export_dsl_delegates_by_mode(self, monkeypatch: pytest.MonkeyPatch): workflow_calls: list[bool] = [] model_calls: list[bool] = [] monkeypatch.setattr( @@ -826,14 +857,14 @@ class TestAppDslService: assert workflow_calls == [True] chat_app = _app_stub( - mode=AppMode.CHAT.value, + mode=AppMode.CHAT, icon_type="emoji", app_model_config=SimpleNamespace(to_dict=lambda: {"agent_mode": {"tools": []}}), ) AppDslService.export_dsl(chat_app) assert model_calls == [True] - def test_export_dsl_preserves_icon_and_icon_type(self, monkeypatch): + def test_export_dsl_preserves_icon_and_icon_type(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( AppDslService, "_append_workflow_export_data", @@ -870,7 +901,7 @@ class TestAppDslService: assert data["app"]["icon_type"] == "image" assert data["app"]["icon_background"] == "#FFEAD5" - def test_export_dsl_chat_app_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_export_dsl_chat_app_success(self, db_session_with_containers: Session, mock_external_service_dependencies): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) model_config = AppModelConfig( @@ -908,7 +939,9 @@ class TestAppDslService: assert "model_config" in exported_data assert "dependencies" in exported_data - def test_export_dsl_workflow_app_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_export_dsl_workflow_app_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) app.mode = "workflow" db_session_with_containers.commit() @@ -941,7 +974,9 @@ class TestAppDslService: assert "workflow" in exported_data assert "dependencies" in exported_data - def test_export_dsl_with_workflow_id_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_export_dsl_with_workflow_id_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) app.mode = "workflow" db_session_with_containers.commit() @@ -981,7 +1016,7 @@ class TestAppDslService: assert "workflow" in exported_data def test_export_dsl_with_invalid_workflow_id_raises_error( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) app.mode = "workflow" @@ -997,7 +1032,7 @@ class TestAppDslService: # ── Workflow Export Data ─────────────────────────────────────────── - def test_append_workflow_export_data_filters_and_overrides(self, monkeypatch): + def test_append_workflow_export_data_filters_and_overrides(self, monkeypatch: pytest.MonkeyPatch): workflow_dict = { "graph": { "nodes": [ @@ -1097,7 +1132,7 @@ class TestAppDslService: assert nodes[5]["data"]["subscription_id"] == "" assert export_data["dependencies"] == [{"tenant": _DEFAULT_TENANT_ID, "dep": "dep-1"}] - def test_append_workflow_export_data_missing_workflow_raises(self, monkeypatch): + def test_append_workflow_export_data_missing_workflow_raises(self, monkeypatch: pytest.MonkeyPatch): workflow_service = MagicMock() workflow_service.get_draft_workflow.return_value = None monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service) @@ -1112,7 +1147,7 @@ class TestAppDslService: # ── Model Config Export Data ────────────────────────────────────── - def test_append_model_config_export_data_filters_credential_id(self, monkeypatch): + def test_append_model_config_export_data_filters_credential_id(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( AppDslService, "_extract_dependencies_from_model_config", @@ -1146,7 +1181,7 @@ class TestAppDslService: # ── Dependency Extraction ───────────────────────────────────────── - def test_extract_dependencies_from_workflow_graph_covers_all_node_types(self, monkeypatch): + def test_extract_dependencies_from_workflow_graph_covers_all_node_types(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.DependenciesAnalysisService, "analyze_tool_dependency", @@ -1216,7 +1251,7 @@ class TestAppDslService: "model:m4", ] - def test_extract_dependencies_from_workflow_graph_handles_exceptions(self, monkeypatch): + def test_extract_dependencies_from_workflow_graph_handles_exceptions(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.ToolNodeData, "model_validate", @@ -1227,7 +1262,7 @@ class TestAppDslService: ) assert deps == [] - def test_extract_dependencies_from_model_config_parses_providers(self, monkeypatch): + def test_extract_dependencies_from_model_config_parses_providers(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.DependenciesAnalysisService, "analyze_model_provider_dependency", @@ -1250,7 +1285,7 @@ class TestAppDslService: ) assert deps == ["model:p1", "model:p2", "tool:t1"] - def test_extract_dependencies_from_model_config_handles_exceptions(self, monkeypatch): + def test_extract_dependencies_from_model_config_handles_exceptions(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.DependenciesAnalysisService, "analyze_model_provider_dependency", @@ -1264,7 +1299,7 @@ class TestAppDslService: def test_get_leaked_dependencies_empty_returns_empty(self): assert AppDslService.get_leaked_dependencies(_DEFAULT_TENANT_ID, []) == [] - def test_get_leaked_dependencies_delegates(self, monkeypatch): + def test_get_leaked_dependencies_delegates(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.DependenciesAnalysisService, "get_leaked_dependencies", @@ -1275,7 +1310,7 @@ class TestAppDslService: # ── Encryption/Decryption ───────────────────────────────────────── - def test_encrypt_decrypt_dataset_id_respects_config(self, monkeypatch): + def test_encrypt_decrypt_dataset_id_respects_config(self, monkeypatch: pytest.MonkeyPatch): tenant_id = _DEFAULT_TENANT_ID dataset_uuid = "00000000-0000-0000-0000-000000000000" @@ -1300,7 +1335,7 @@ class TestAppDslService: value = "00000000-0000-0000-0000-000000000000" assert AppDslService.decrypt_dataset_id(encrypted_data=value, tenant_id=_DEFAULT_TENANT_ID) == value - def test_decrypt_dataset_id_returns_none_on_invalid_data(self, monkeypatch): + def test_decrypt_dataset_id_returns_none_on_invalid_data(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", @@ -1308,7 +1343,7 @@ class TestAppDslService: ) assert AppDslService.decrypt_dataset_id(encrypted_data="not-base64", tenant_id=_DEFAULT_TENANT_ID) is None - def test_decrypt_dataset_id_returns_none_when_decrypted_is_not_uuid(self, monkeypatch): + def test_decrypt_dataset_id_returns_none_when_decrypted_is_not_uuid(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( app_dsl_service.dify_config, "DSL_EXPORT_ENCRYPT_DATASET_ID", diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index 3229693fd4..8be4c040b7 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -1,4 +1,5 @@ import uuid +from typing import Literal from unittest.mock import ANY, MagicMock, patch import pytest @@ -7,6 +8,7 @@ from faker import Faker from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom +from models import App from models.model import EndUser from models.workflow import Workflow from services.app_generate_service import AppGenerateService @@ -132,7 +134,10 @@ class TestAppGenerateService: } def _create_test_app_and_account( - self, db_session_with_containers: Session, mock_external_service_dependencies, mode="chat" + self, + db_session_with_containers: Session, + mock_external_service_dependencies, + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = "chat", ): """ Helper method to create a test app and account for testing. @@ -164,27 +169,27 @@ class TestAppGenerateService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": mode, - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - "max_active_requests": 5, - } + from services.app_service import AppService, CreateAppParams - from services.app_service import AppService + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode=mode, + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + max_active_requests=5, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) return app, account - def _create_test_workflow(self, db_session_with_containers: Session, app): + def _create_test_workflow(self, db_session_with_containers: Session, app: App): """ Helper method to create a test workflow for testing. diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index b695ae9fd9..c37fce296f 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -2,16 +2,18 @@ from unittest.mock import create_autospec, patch import pytest from faker import Faker +from pydantic import ValidationError from sqlalchemy.orm import Session from constants.model_template import default_app_templates from models import Account +from models.enums import AppStatus, CustomizeTokenStrategy from models.model import App, IconType, Site from services.account_service import AccountService, TenantService from tests.test_containers_integration_tests.helpers import generate_valid_password # Delay import of AppService to avoid circular dependency -# from services.app_service import AppService +# from services.app_service import AppService, AppListParams, CreateAppParams class TestAppService: @@ -63,34 +65,34 @@ class TestAppService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + # Import here to avoid circular dependency + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app - # Import here to avoid circular dependency - from services.app_service import AppService - app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Verify app was created correctly - assert app.name == app_args["name"] - assert app.description == app_args["description"] - assert app.mode == app_args["mode"] - assert app.icon_type == app_args["icon_type"] - assert app.icon == app_args["icon"] - assert app.icon_background == app_args["icon_background"] + assert app.name == app_params.name + assert app.description == app_params.description + assert app.mode == app_params.mode + assert app.icon_type == app_params.icon_type + assert app.icon == app_params.icon + assert app.icon_background == app_params.icon_background assert app.tenant_id == tenant.id - assert app.api_rph == app_args["api_rph"] - assert app.api_rpm == app_args["api_rpm"] + assert app.api_rph == app_params.api_rph + assert app.api_rpm == app_params.api_rpm assert app.created_by == account.id assert app.updated_by == account.id assert app.status == "normal" @@ -119,7 +121,7 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() @@ -128,20 +130,20 @@ class TestAppService: app_modes = [v.value for v in default_app_templates] for mode in app_modes: - app_args = { - "name": f"{fake.company()} {mode}", - "description": f"Test app for {mode} mode", - "mode": mode, - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_params = CreateAppParams( + name=f"{fake.company()} {mode}", + description=f"Test app for {mode} mode", + mode=mode, + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Verify app mode was set correctly assert app.mode == mode - assert app.name == app_args["name"] + assert app.name == app_params.name assert app.tenant_id == tenant.id assert app.created_by == account.id @@ -162,20 +164,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - created_app = app_service.create_app(tenant.id, app_args, account) + created_app = app_service.create_app(tenant.id, app_params, account) # Get app using the service - needs current_user mock mock_current_user = create_autospec(Account, instance=True) @@ -210,31 +212,27 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create multiple apps app_names = [fake.company() for _ in range(5)] for name in app_names: - app_args = { - "name": name, - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "📱", - "icon_background": "#96CEB4", - } - app_service.create_app(tenant.id, app_args, account) + app_params = CreateAppParams( + name=name, + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="📱", + icon_background="#96CEB4", + ) + app_service.create_app(tenant.id, app_params, account) # Get paginated apps - args = { - "page": 1, - "limit": 10, - "mode": "chat", - } + params = AppListParams(page=1, limit=10, mode="chat") - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Verify pagination results assert paginated_apps is not None @@ -266,60 +264,47 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create apps with different modes - chat_app_args = { - "name": "Chat App", - "description": "A chat application", - "mode": "chat", - "icon_type": "emoji", - "icon": "💬", - "icon_background": "#FF6B6B", - } - completion_app_args = { - "name": "Completion App", - "description": "A completion application", - "mode": "completion", - "icon_type": "emoji", - "icon": "✍️", - "icon_background": "#4ECDC4", - } + chat_app_params = CreateAppParams( + name="Chat App", + description="A chat application", + mode="chat", + icon_type="emoji", + icon="💬", + icon_background="#FF6B6B", + ) + completion_app_params = CreateAppParams( + name="Completion App", + description="A completion application", + mode="completion", + icon_type="emoji", + icon="✍️", + icon_background="#4ECDC4", + ) - chat_app = app_service.create_app(tenant.id, chat_app_args, account) - completion_app = app_service.create_app(tenant.id, completion_app_args, account) + chat_app = app_service.create_app(tenant.id, chat_app_params, account) + completion_app = app_service.create_app(tenant.id, completion_app_params, account) # Test filter by mode - chat_args = { - "page": 1, - "limit": 10, - "mode": "chat", - } - chat_apps = app_service.get_paginate_apps(account.id, tenant.id, chat_args) + chat_apps = app_service.get_paginate_apps(account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")) assert len(chat_apps.items) == 1 assert chat_apps.items[0].mode == "chat" # Test filter by name - name_args = { - "page": 1, - "limit": 10, - "mode": "chat", - "name": "Chat", - } - filtered_apps = app_service.get_paginate_apps(account.id, tenant.id, name_args) + filtered_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", name="Chat") + ) assert len(filtered_apps.items) == 1 assert "Chat" in filtered_apps.items[0].name # Test filter by created_by_me - created_by_me_args = { - "page": 1, - "limit": 10, - "mode": "completion", - "is_created_by_me": True, - } - my_apps = app_service.get_paginate_apps(account.id, tenant.id, created_by_me_args) + my_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="completion", is_created_by_me=True) + ) assert len(my_apps.items) == 1 def test_get_paginate_apps_with_tag_filters( @@ -341,34 +326,29 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create an app - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🏷️", - "icon_background": "#FFEAA7", - } - app = app_service.create_app(tenant.id, app_args, account) + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🏷️", + icon_background="#FFEAA7", + ) + app = app_service.create_app(tenant.id, app_params, account) # Mock TagService to return the app ID for tag filtering with patch("services.app_service.TagService.get_target_ids_by_tag_ids") as mock_tag_service: mock_tag_service.return_value = [app.id] # Test with tag filter - args = { - "page": 1, - "limit": 10, - "mode": "chat", - "tag_ids": ["tag1", "tag2"], - } + params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["tag1", "tag2"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Verify tag service was called mock_tag_service.assert_called_once_with("app", tenant.id, ["tag1", "tag2"]) @@ -382,14 +362,9 @@ class TestAppService: with patch("services.app_service.TagService.get_target_ids_by_tag_ids") as mock_tag_service: mock_tag_service.return_value = [] - args = { - "page": 1, - "limit": 10, - "mode": "chat", - "tag_ids": ["nonexistent_tag"], - } + params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["nonexistent_tag"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Should return None when no apps match tag filter assert paginated_apps is None @@ -411,20 +386,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original values original_name = app.name @@ -480,19 +455,19 @@ class TestAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ), account, ) @@ -532,19 +507,19 @@ class TestAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ), account, ) @@ -583,20 +558,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original name original_name = app.name @@ -636,20 +611,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original values original_icon = app.icon @@ -697,18 +672,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🌐", - "icon_background": "#74B9FF", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🌐", + icon_background="#74B9FF", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -757,18 +731,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔌", - "icon_background": "#A29BFE", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔌", + icon_background="#A29BFE", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -817,18 +790,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔄", - "icon_background": "#FD79A8", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔄", + icon_background="#FD79A8", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -868,18 +840,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🗑️", - "icon_background": "#E17055", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🗑️", + icon_background="#E17055", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -920,18 +891,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🧹", - "icon_background": "#00B894", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🧹", + icon_background="#00B894", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -980,18 +950,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "📊", - "icon_background": "#6C5CE7", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="📊", + icon_background="#6C5CE7", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1019,18 +988,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔗", - "icon_background": "#FDCB6E", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔗", + icon_background="#FDCB6E", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1059,18 +1027,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🆔", - "icon_background": "#E84393", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🆔", + icon_background="#E84393", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1079,9 +1046,9 @@ class TestAppService: site.app_id = app.id site.code = fake.postalcode() site.title = fake.company() - site.status = "normal" + site.status = AppStatus.NORMAL site.default_language = "en-US" - site.customize_token_strategy = "uuid" + site.customize_token_strategy = CustomizeTokenStrategy.UUID db_session_with_containers.add(site) db_session_with_containers.commit() @@ -1106,26 +1073,20 @@ class TestAppService: password=generate_valid_password(fake), ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) - tenant = account.current_tenant - - # Setup app creation arguments with invalid mode - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "invalid_mode", # Invalid mode - "icon_type": "emoji", - "icon": "❌", - "icon_background": "#D63031", - } # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import CreateAppParams - app_service = AppService() - - # Attempt to create app with invalid mode - with pytest.raises(ValueError, match="invalid mode value"): - app_service.create_app(tenant.id, app_args, account) + # Attempt to create app with invalid mode - Pydantic will reject invalid literal + with pytest.raises(ValidationError): + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="invalid_mode", # type: ignore[arg-type] + icon_type="emoji", + icon="❌", + icon_background="#D63031", + ) def test_get_apps_with_special_characters_in_name( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1151,99 +1112,103 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create apps with special characters in names app_with_percent = app_service.create_app( tenant.id, - { - "name": "App with 50% discount", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="App with 50% discount", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) app_with_underscore = app_service.create_app( tenant.id, - { - "name": "test_data_app", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="test_data_app", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) app_with_backslash = app_service.create_app( tenant.id, - { - "name": "path\\to\\app", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="path\\to\\app", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) # Create app that should NOT match app_no_match = app_service.create_app( tenant.id, - { - "name": "100% different", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="100% different", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) # Test 1: Search with % character - args = {"name": "50%", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "App with 50% discount" # Test 2: Search with _ character - args = {"name": "test_data", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="test_data", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "test_data_app" # Test 3: Search with \ character - args = {"name": "path\\to\\app", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="path\\to\\app", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "path\\to\\app" # Test 4: Search with % should NOT match 100% (verifies escaping works) - args = {"name": "50%", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert all("50%" in app.name for app in paginated_apps.items) diff --git a/api/tests/test_containers_integration_tests/services/test_attachment_service.py b/api/tests/test_containers_integration_tests/services/test_attachment_service.py index 768a8baee2..d0c07f0de8 100644 --- a/api/tests/test_containers_integration_tests/services/test_attachment_service.py +++ b/api/tests/test_containers_integration_tests/services/test_attachment_service.py @@ -7,7 +7,7 @@ from uuid import uuid4 import pytest from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import NotFound import services.attachment_service as attachment_service_module @@ -19,7 +19,7 @@ from services.attachment_service import AttachmentService class TestAttachmentService: - def _create_upload_file(self, db_session_with_containers, *, tenant_id: str | None = None) -> UploadFile: + def _create_upload_file(self, db_session_with_containers: Session, *, tenant_id: str | None = None) -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id or str(uuid4()), storage_type=StorageType.OPENDAL, @@ -60,7 +60,7 @@ class TestAttachmentService: with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."): AttachmentService(session_factory=invalid_session_factory) - def test_should_return_base64_when_file_exists(self, db_session_with_containers): + def test_should_return_base64_when_file_exists(self, db_session_with_containers: Session): upload_file = self._create_upload_file(db_session_with_containers) service = AttachmentService(session_factory=sessionmaker(bind=db.engine)) @@ -70,7 +70,7 @@ class TestAttachmentService: assert result == base64.b64encode(b"binary-content").decode() mock_load.assert_called_once_with(upload_file.key) - def test_should_raise_not_found_when_file_missing(self, db_session_with_containers): + def test_should_raise_not_found_when_file_missing(self, db_session_with_containers: Session): service = AttachmentService(session_factory=sessionmaker(bind=db.engine)) with patch.object(attachment_service_module.storage, "load_once") as mock_load: diff --git a/api/tests/test_containers_integration_tests/services/test_billing_service.py b/api/tests/test_containers_integration_tests/services/test_billing_service.py index 8092c7ad75..4893126d7f 100644 --- a/api/tests/test_containers_integration_tests/services/test_billing_service.py +++ b/api/tests/test_containers_integration_tests/services/test_billing_service.py @@ -4,6 +4,7 @@ from unittest.mock import patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.orm import Session from extensions.ext_redis import redis_client @@ -24,7 +25,7 @@ class TestBillingServiceGetPlanBulkWithCache: """ @pytest.fixture(autouse=True) - def setup_redis_cleanup(self, flask_app_with_containers): + def setup_redis_cleanup(self, flask_app_with_containers: Flask): """Clean up Redis cache before and after each test.""" with flask_app_with_containers.app_context(): # Clean up before test @@ -56,7 +57,7 @@ class TestBillingServiceGetPlanBulkWithCache: return value return None - def test_get_plan_bulk_with_cache_all_cache_hit(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_all_cache_hit(self, flask_app_with_containers: Flask): """Test bulk plan retrieval when all tenants are in cache.""" with flask_app_with_containers.app_context(): # Arrange @@ -87,7 +88,7 @@ class TestBillingServiceGetPlanBulkWithCache: # Verify API was not called mock_get_plan_bulk.assert_not_called() - def test_get_plan_bulk_with_cache_all_cache_miss(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_all_cache_miss(self, flask_app_with_containers: Flask): """Test bulk plan retrieval when all tenants are not in cache.""" with flask_app_with_containers.app_context(): # Arrange @@ -127,7 +128,7 @@ class TestBillingServiceGetPlanBulkWithCache: assert ttl_1 > 0 assert ttl_1 <= 600 # Should be <= 600 seconds - def test_get_plan_bulk_with_cache_partial_cache_hit(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_partial_cache_hit(self, flask_app_with_containers: Flask): """Test bulk plan retrieval when some tenants are in cache, some are not.""" with flask_app_with_containers.app_context(): # Arrange @@ -158,7 +159,7 @@ class TestBillingServiceGetPlanBulkWithCache: cached_data_3 = json.loads(cached_3) assert cached_data_3 == missing_plan["tenant-3"] - def test_get_plan_bulk_with_cache_redis_mget_failure(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_redis_mget_failure(self, flask_app_with_containers: Flask): """Test fallback to API when Redis mget fails.""" with flask_app_with_containers.app_context(): # Arrange @@ -189,7 +190,7 @@ class TestBillingServiceGetPlanBulkWithCache: assert cached_1 is not None assert cached_2 is not None - def test_get_plan_bulk_with_cache_invalid_json_in_cache(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_invalid_json_in_cache(self, flask_app_with_containers: Flask): """Test fallback to API when cache contains invalid JSON.""" with flask_app_with_containers.app_context(): # Arrange @@ -241,7 +242,7 @@ class TestBillingServiceGetPlanBulkWithCache: cached_data_3 = json.loads(cached_3) assert cached_data_3 == expected_plans["tenant-3"] - def test_get_plan_bulk_with_cache_invalid_plan_data_in_cache(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_invalid_plan_data_in_cache(self, flask_app_with_containers: Flask): """Test fallback to API when cache data doesn't match SubscriptionPlan schema.""" with flask_app_with_containers.app_context(): # Arrange @@ -274,7 +275,7 @@ class TestBillingServiceGetPlanBulkWithCache: # Verify API was called for tenant-2 and tenant-3 mock_get_plan_bulk.assert_called_once_with(["tenant-2", "tenant-3"]) - def test_get_plan_bulk_with_cache_redis_pipeline_failure(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_redis_pipeline_failure(self, flask_app_with_containers: Flask): """Test that pipeline failure doesn't affect return value.""" with flask_app_with_containers.app_context(): # Arrange @@ -303,7 +304,7 @@ class TestBillingServiceGetPlanBulkWithCache: # Verify pipeline was attempted mock_pipeline.assert_called_once() - def test_get_plan_bulk_with_cache_empty_tenant_ids(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_empty_tenant_ids(self, flask_app_with_containers: Flask): """Test with empty tenant_ids list.""" with flask_app_with_containers.app_context(): # Act @@ -321,7 +322,7 @@ class TestBillingServiceGetPlanBulkWithCache: # But we should check that mget was not called at all # Since we can't easily verify this without more mocking, we just verify the result - def test_get_plan_bulk_with_cache_ttl_expired(self, flask_app_with_containers): + def test_get_plan_bulk_with_cache_ttl_expired(self, flask_app_with_containers: Flask): """Test that expired cache keys are treated as cache misses.""" with flask_app_with_containers.app_context(): # Arrange diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_conversation_service.py index 98c38f2b5f..5f3914eb19 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_service.py @@ -7,8 +7,10 @@ from uuid import uuid4 import pytest from sqlalchemy import select +from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom +from models import TenantAccountRole from models.account import Account, Tenant, TenantAccountJoin from models.enums import ConversationFromSource from models.model import App, Conversation, EndUser, Message, MessageAnnotation @@ -21,7 +23,7 @@ from services.message_service import MessageService class ConversationServiceIntegrationTestDataFactory: @staticmethod - def create_app_and_account(db_session_with_containers): + def create_app_and_account(db_session_with_containers: Session): tenant = Tenant(name=f"Tenant {uuid4()}") db_session_with_containers.add(tenant) db_session_with_containers.flush() @@ -40,7 +42,7 @@ class ConversationServiceIntegrationTestDataFactory: tenant_join = TenantAccountJoin( tenant_id=tenant.id, account_id=account.id, - role="owner", + role=TenantAccountRole.OWNER, current=True, ) db_session_with_containers.add(tenant_join) @@ -154,7 +156,7 @@ class ConversationServiceIntegrationTestDataFactory: total_price=Decimal(0), currency="USD", status="normal", - invoke_from=InvokeFrom.WEB_APP.value, + invoke_from=InvokeFrom.WEB_APP, from_source=ConversationFromSource.API if isinstance(user, EndUser) else ConversationFromSource.CONSOLE, from_end_user_id=user.id if isinstance(user, EndUser) else None, from_account_id=user.id if isinstance(user, Account) else None, @@ -170,7 +172,7 @@ class ConversationServiceIntegrationTestDataFactory: class TestConversationServicePagination: """Test conversation pagination operations.""" - def test_pagination_with_non_empty_include_ids(self, db_session_with_containers): + def test_pagination_with_non_empty_include_ids(self, db_session_with_containers: Session): """ Test that non-empty include_ids filters properly. @@ -204,7 +206,7 @@ class TestConversationServicePagination: returned_ids = {conversation.id for conversation in result.data} assert returned_ids == {conversations[0].id, conversations[1].id} - def test_pagination_with_empty_exclude_ids(self, db_session_with_containers): + def test_pagination_with_empty_exclude_ids(self, db_session_with_containers: Session): """ Test that empty exclude_ids doesn't filter. @@ -237,7 +239,7 @@ class TestConversationServicePagination: # Assert assert len(result.data) == len(conversations) - def test_pagination_with_non_empty_exclude_ids(self, db_session_with_containers): + def test_pagination_with_non_empty_exclude_ids(self, db_session_with_containers: Session): """ Test that non-empty exclude_ids filters properly. @@ -271,7 +273,7 @@ class TestConversationServicePagination: returned_ids = {conversation.id for conversation in result.data} assert returned_ids == {conversations[2].id} - def test_pagination_with_sorting_descending(self, db_session_with_containers): + def test_pagination_with_sorting_descending(self, db_session_with_containers: Session): """ Test pagination with descending sort order. @@ -316,7 +318,7 @@ class TestConversationServiceMessageCreation: within conversations. """ - def test_pagination_by_first_id_without_first_id(self, db_session_with_containers): + def test_pagination_by_first_id_without_first_id(self, db_session_with_containers: Session): """ Test message pagination without specifying first_id. @@ -354,7 +356,7 @@ class TestConversationServiceMessageCreation: assert len(result.data) == 3 # All 3 messages returned assert result.has_more is False # No more messages available (3 < limit of 10) - def test_pagination_by_first_id_with_first_id(self, db_session_with_containers): + def test_pagination_by_first_id_with_first_id(self, db_session_with_containers: Session): """ Test message pagination with first_id specified. @@ -399,7 +401,9 @@ class TestConversationServiceMessageCreation: assert len(result.data) == 2 # Only 2 messages returned after first_id assert result.has_more is False # No more messages available (2 < limit of 10) - def test_pagination_by_first_id_raises_error_when_first_message_not_found(self, db_session_with_containers): + def test_pagination_by_first_id_raises_error_when_first_message_not_found( + self, db_session_with_containers: Session + ): """ Test that FirstMessageNotExistsError is raised when first_id doesn't exist. @@ -424,7 +428,7 @@ class TestConversationServiceMessageCreation: limit=10, ) - def test_pagination_with_has_more_flag(self, db_session_with_containers): + def test_pagination_with_has_more_flag(self, db_session_with_containers: Session): """ Test that has_more flag is correctly set when there are more messages. @@ -463,7 +467,7 @@ class TestConversationServiceMessageCreation: assert len(result.data) == limit # Extra message should be removed assert result.has_more is True # Flag should be set - def test_pagination_with_ascending_order(self, db_session_with_containers): + def test_pagination_with_ascending_order(self, db_session_with_containers: Session): """ Test message pagination with ascending order. @@ -512,7 +516,7 @@ class TestConversationServiceSummarization: """ @patch("services.conversation_service.LLMGenerator.generate_conversation_name") - def test_auto_generate_name_success(self, mock_llm_generator, db_session_with_containers): + def test_auto_generate_name_success(self, mock_llm_generator, db_session_with_containers: Session): """ Test successful auto-generation of conversation name. @@ -552,7 +556,7 @@ class TestConversationServiceSummarization: app_model.tenant_id, first_message.query, conversation.id, app_model.id ) - def test_auto_generate_name_raises_error_when_no_message(self, db_session_with_containers): + def test_auto_generate_name_raises_error_when_no_message(self, db_session_with_containers: Session): """ Test that MessageNotExistsError is raised when conversation has no messages. @@ -571,7 +575,9 @@ class TestConversationServiceSummarization: ConversationService.auto_generate_name(app_model, conversation) @patch("services.conversation_service.LLMGenerator.generate_conversation_name") - def test_auto_generate_name_handles_llm_failure_gracefully(self, mock_llm_generator, db_session_with_containers): + def test_auto_generate_name_handles_llm_failure_gracefully( + self, mock_llm_generator, db_session_with_containers: Session + ): """ Test that LLM generation failures are suppressed and don't crash. @@ -604,7 +610,7 @@ class TestConversationServiceSummarization: assert conversation.name == original_name # Name remains unchanged @patch("services.conversation_service.naive_utc_now") - def test_rename_with_manual_name(self, mock_naive_utc_now, db_session_with_containers): + def test_rename_with_manual_name(self, mock_naive_utc_now, db_session_with_containers: Session): """ Test renaming conversation with manual name. @@ -638,7 +644,7 @@ class TestConversationServiceSummarization: assert conversation.updated_at == mock_time @patch("services.conversation_service.LLMGenerator.generate_conversation_name") - def test_rename_with_auto_generate(self, mock_llm_generator, db_session_with_containers): + def test_rename_with_auto_generate(self, mock_llm_generator, db_session_with_containers: Session): """ Test rename delegates to auto_generate_name when auto_generate is True. @@ -682,7 +688,9 @@ class TestConversationServiceMessageAnnotation: @patch("services.annotation_service.add_annotation_to_index_task") @patch("services.annotation_service.current_account_with_tenant") - def test_create_annotation_from_message(self, mock_current_account, mock_add_task, db_session_with_containers): + def test_create_annotation_from_message( + self, mock_current_account, mock_add_task, db_session_with_containers: Session + ): """ Test creating annotation from existing message. @@ -721,7 +729,9 @@ class TestConversationServiceMessageAnnotation: @patch("services.annotation_service.add_annotation_to_index_task") @patch("services.annotation_service.current_account_with_tenant") - def test_create_annotation_without_message(self, mock_current_account, mock_add_task, db_session_with_containers): + def test_create_annotation_without_message( + self, mock_current_account, mock_add_task, db_session_with_containers: Session + ): """ Test creating standalone annotation without message. @@ -753,7 +763,7 @@ class TestConversationServiceMessageAnnotation: @patch("services.annotation_service.add_annotation_to_index_task") @patch("services.annotation_service.current_account_with_tenant") - def test_update_existing_annotation(self, mock_current_account, mock_add_task, db_session_with_containers): + def test_update_existing_annotation(self, mock_current_account, mock_add_task, db_session_with_containers: Session): """ Test updating an existing annotation. @@ -800,7 +810,7 @@ class TestConversationServiceMessageAnnotation: mock_add_task.delay.assert_not_called() @patch("services.annotation_service.current_account_with_tenant") - def test_get_annotation_list(self, mock_current_account, db_session_with_containers): + def test_get_annotation_list(self, mock_current_account, db_session_with_containers: Session): """ Test retrieving paginated annotation list. @@ -836,7 +846,7 @@ class TestConversationServiceMessageAnnotation: assert result_total == 5 @patch("services.annotation_service.current_account_with_tenant") - def test_get_annotation_list_with_keyword_search(self, mock_current_account, db_session_with_containers): + def test_get_annotation_list_with_keyword_search(self, mock_current_account, db_session_with_containers: Session): """ Test retrieving annotations with keyword filtering. @@ -885,7 +895,7 @@ class TestConversationServiceMessageAnnotation: @patch("services.annotation_service.add_annotation_to_index_task") @patch("services.annotation_service.current_account_with_tenant") - def test_insert_annotation_directly(self, mock_current_account, mock_add_task, db_session_with_containers): + def test_insert_annotation_directly(self, mock_current_account, mock_add_task, db_session_with_containers: Session): """ Test direct annotation insertion without message reference. @@ -919,7 +929,7 @@ class TestConversationServiceExport: Tests retrieving conversation data for export purposes. """ - def test_get_conversation_success(self, db_session_with_containers): + def test_get_conversation_success(self, db_session_with_containers: Session): """Test successful retrieval of conversation.""" # Arrange app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( @@ -937,7 +947,7 @@ class TestConversationServiceExport: # Assert assert result == conversation - def test_get_conversation_not_found(self, db_session_with_containers): + def test_get_conversation_not_found(self, db_session_with_containers: Session): """Test ConversationNotExistsError when conversation doesn't exist.""" # Arrange app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( @@ -949,7 +959,7 @@ class TestConversationServiceExport: ConversationService.get_conversation(app_model=app_model, conversation_id=str(uuid4()), user=user) @patch("services.annotation_service.current_account_with_tenant") - def test_export_annotation_list(self, mock_current_account, db_session_with_containers): + def test_export_annotation_list(self, mock_current_account, db_session_with_containers: Session): """Test exporting all annotations for an app.""" # Arrange app_model, account = ConversationServiceIntegrationTestDataFactory.create_app_and_account( @@ -977,7 +987,7 @@ class TestConversationServiceExport: # Assert assert len(result) == 10 - def test_get_message_success(self, db_session_with_containers): + def test_get_message_success(self, db_session_with_containers: Session): """Test successful retrieval of a message.""" # Arrange app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( @@ -1001,7 +1011,7 @@ class TestConversationServiceExport: # Assert assert result == message - def test_get_message_not_found(self, db_session_with_containers): + def test_get_message_not_found(self, db_session_with_containers: Session): """Test MessageNotExistsError when message doesn't exist.""" # Arrange app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( @@ -1012,7 +1022,7 @@ class TestConversationServiceExport: with pytest.raises(MessageNotExistsError): MessageService.get_message(app_model=app_model, user=user, message_id=str(uuid4())) - def test_get_conversation_for_end_user(self, db_session_with_containers): + def test_get_conversation_for_end_user(self, db_session_with_containers: Session): """ Test retrieving conversation created by end user via API. @@ -1038,7 +1048,7 @@ class TestConversationServiceExport: assert result == conversation @patch("services.conversation_service.delete_conversation_related_data") - def test_delete_conversation(self, mock_delete_task, db_session_with_containers): + def test_delete_conversation(self, mock_delete_task, db_session_with_containers: Session): """ Test conversation deletion with async cleanup. @@ -1071,7 +1081,7 @@ class TestConversationServiceExport: mock_delete_task.delay.assert_called_once_with(conversation_id) @patch("services.conversation_service.delete_conversation_related_data") - def test_delete_conversation_not_owned_by_account(self, mock_delete_task, db_session_with_containers): + def test_delete_conversation_not_owned_by_account(self, mock_delete_task, db_session_with_containers: Session): """ Test deletion is denied when conversation belongs to a different account. """ @@ -1102,7 +1112,7 @@ class TestConversationServiceExport: mock_delete_task.delay.assert_not_called() @patch("services.conversation_service.delete_conversation_related_data") - def test_delete_handles_exception_and_rollback(self, mock_delete_task, db_session_with_containers): + def test_delete_handles_exception_and_rollback(self, mock_delete_task, db_session_with_containers: Session): """ Test that delete propagates exceptions and does not trigger the cleanup task. diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py b/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py index 0b7bd9ca64..853630ad65 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py @@ -5,7 +5,8 @@ from unittest.mock import patch from uuid import uuid4 import pytest -from sqlalchemy.orm import sessionmaker +from flask import Flask +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db @@ -24,7 +25,7 @@ from services.errors.conversation import ( class ConversationServiceVariableIntegrationFactory: @staticmethod - def create_app_and_account(db_session_with_containers): + def create_app_and_account(db_session_with_containers: Session): tenant = Tenant(name=f"Tenant {uuid4()}") db_session_with_containers.add(tenant) db_session_with_containers.flush() @@ -149,7 +150,7 @@ class ConversationServiceVariableIntegrationFactory: @pytest.fixture -def real_conversation_service_session_factory(flask_app_with_containers): +def real_conversation_service_session_factory(flask_app_with_containers: Flask): del flask_app_with_containers real_session_maker = sessionmaker(bind=db.engine, expire_on_commit=False) @@ -162,7 +163,7 @@ def real_conversation_service_session_factory(flask_app_with_containers): class TestConversationServiceVariables: def test_get_conversational_variable_success( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -200,7 +201,7 @@ class TestConversationServiceVariables: assert result.has_more is False def test_get_conversational_variable_with_last_id( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -242,7 +243,7 @@ class TestConversationServiceVariables: assert result.has_more is False def test_get_conversational_variable_last_id_not_found_raises_error( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -259,7 +260,7 @@ class TestConversationServiceVariables: ) def test_get_conversational_variable_sets_has_more( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -287,7 +288,7 @@ class TestConversationServiceVariables: assert result.has_more is True def test_update_conversation_variable_success( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -320,7 +321,7 @@ class TestConversationServiceVariables: assert result["updated_at"] == updated_at def test_update_conversation_variable_not_found_raises_error( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -337,7 +338,7 @@ class TestConversationServiceVariables: ) def test_update_conversation_variable_type_mismatch_raises_error( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -360,7 +361,7 @@ class TestConversationServiceVariables: ) def test_update_conversation_variable_integer_number_compatibility( - self, db_session_with_containers, real_conversation_service_session_factory + self, db_session_with_containers: Session, real_conversation_service_session_factory ): del real_conversation_service_session_factory factory = ConversationServiceVariableIntegrationFactory @@ -390,7 +391,7 @@ class TestConversationServiceVariables: class TestConversationServicePaginationWithContainers: - def test_pagination_by_last_id_raises_error_when_last_id_missing(self, db_session_with_containers): + def test_pagination_by_last_id_raises_error_when_last_id_missing(self, db_session_with_containers: Session): factory = ConversationServiceVariableIntegrationFactory app, account = factory.create_app_and_account(db_session_with_containers) @@ -404,7 +405,7 @@ class TestConversationServicePaginationWithContainers: invoke_from=InvokeFrom.WEB_APP, ) - def test_pagination_by_last_id_with_default_desc_updated_at(self, db_session_with_containers): + def test_pagination_by_last_id_with_default_desc_updated_at(self, db_session_with_containers: Session): factory = ConversationServiceVariableIntegrationFactory app, account = factory.create_app_and_account(db_session_with_containers) base_time = datetime(2024, 1, 1, 8, 0, 0) @@ -442,7 +443,7 @@ class TestConversationServicePaginationWithContainers: assert newest.id != middle.id assert [conversation.id for conversation in result.data] == [oldest.id] - def test_pagination_by_last_id_with_name_sort(self, db_session_with_containers): + def test_pagination_by_last_id_with_name_sort(self, db_session_with_containers: Session): factory = ConversationServiceVariableIntegrationFactory app, account = factory.create_app_and_account(db_session_with_containers) alpha = factory.create_conversation(db_session_with_containers, app, account, name="Alpha") @@ -462,7 +463,7 @@ class TestConversationServicePaginationWithContainers: assert alpha.id != beta.id assert [conversation.id for conversation in result.data] == [gamma.id] - def test_pagination_filters_to_end_user_api_source(self, db_session_with_containers): + def test_pagination_filters_to_end_user_api_source(self, db_session_with_containers: Session): factory = ConversationServiceVariableIntegrationFactory app, account = factory.create_app_and_account(db_session_with_containers) end_user = factory.create_end_user(db_session_with_containers, app) @@ -493,7 +494,7 @@ class TestConversationServicePaginationWithContainers: assert account_conversation.id != end_user_conversation.id assert [conversation.id for conversation in result.data] == [end_user_conversation.id] - def test_pagination_filters_to_account_console_source(self, db_session_with_containers): + def test_pagination_filters_to_account_console_source(self, db_session_with_containers: Session): factory = ConversationServiceVariableIntegrationFactory app, account = factory.create_app_and_account(db_session_with_containers) end_user = factory.create_end_user(db_session_with_containers, app) diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_variable_updater.py b/api/tests/test_containers_integration_tests/services/test_conversation_variable_updater.py index 02ab3f8314..638a962f18 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_variable_updater.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_variable_updater.py @@ -3,7 +3,7 @@ from uuid import uuid4 import pytest -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker from extensions.ext_database import db from graphon.variables import StringVariable @@ -13,7 +13,12 @@ from services.conversation_variable_updater import ConversationVariableNotFoundE class TestConversationVariableUpdater: def _create_conversation_variable( - self, db_session_with_containers, *, conversation_id: str, variable: StringVariable, app_id: str | None = None + self, + db_session_with_containers: Session, + *, + conversation_id: str, + variable: StringVariable, + app_id: str | None = None, ) -> ConversationVariable: row = ConversationVariable( id=variable.id, @@ -25,7 +30,7 @@ class TestConversationVariableUpdater: db_session_with_containers.commit() return row - def test_should_update_conversation_variable_data_and_commit(self, db_session_with_containers): + def test_should_update_conversation_variable_data_and_commit(self, db_session_with_containers: Session): conversation_id = str(uuid4()) variable = StringVariable(id=str(uuid4()), name="topic", value="old value") self._create_conversation_variable( @@ -42,7 +47,7 @@ class TestConversationVariableUpdater: assert row is not None assert row.data == updated_variable.model_dump_json() - def test_should_raise_not_found_when_variable_missing(self, db_session_with_containers): + def test_should_raise_not_found_when_variable_missing(self, db_session_with_containers: Session): conversation_id = str(uuid4()) variable = StringVariable(id=str(uuid4()), name="topic", value="value") updater = ConversationVariableUpdater(sessionmaker(bind=db.engine)) @@ -50,7 +55,7 @@ class TestConversationVariableUpdater: with pytest.raises(ConversationVariableNotFoundError, match="conversation variable not found in the database"): updater.update(conversation_id=conversation_id, variable=variable) - def test_should_do_nothing_when_flush_is_called(self, db_session_with_containers): + def test_should_do_nothing_when_flush_is_called(self, db_session_with_containers: Session): updater = ConversationVariableUpdater(sessionmaker(bind=db.engine)) result = updater.flush() diff --git a/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py b/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py index 0f63d98642..07dc3a4e9e 100644 --- a/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py +++ b/api/tests/test_containers_integration_tests/services/test_credit_pool_service.py @@ -3,6 +3,7 @@ from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from core.errors.error import QuotaExceededError from models import TenantCreditPool @@ -14,7 +15,7 @@ class TestCreditPoolService: def _create_tenant_id(self) -> str: return str(uuid4()) - def test_create_default_pool(self, db_session_with_containers): + def test_create_default_pool(self, db_session_with_containers: Session): tenant_id = self._create_tenant_id() pool = CreditPoolService.create_default_pool(tenant_id) @@ -25,7 +26,7 @@ class TestCreditPoolService: assert pool.quota_used == 0 assert pool.quota_limit > 0 - def test_get_pool_returns_pool_when_exists(self, db_session_with_containers): + def test_get_pool_returns_pool_when_exists(self, db_session_with_containers: Session): tenant_id = self._create_tenant_id() CreditPoolService.create_default_pool(tenant_id) @@ -35,17 +36,17 @@ class TestCreditPoolService: assert result.tenant_id == tenant_id assert result.pool_type == ProviderQuotaType.TRIAL - def test_get_pool_returns_none_when_not_exists(self, db_session_with_containers): + def test_get_pool_returns_none_when_not_exists(self, db_session_with_containers: Session): result = CreditPoolService.get_pool(tenant_id=self._create_tenant_id(), pool_type=ProviderQuotaType.TRIAL) assert result is None - def test_check_credits_available_returns_false_when_no_pool(self, db_session_with_containers): + def test_check_credits_available_returns_false_when_no_pool(self, db_session_with_containers: Session): result = CreditPoolService.check_credits_available(tenant_id=self._create_tenant_id(), credits_required=10) assert result is False - def test_check_credits_available_returns_true_when_sufficient(self, db_session_with_containers): + def test_check_credits_available_returns_true_when_sufficient(self, db_session_with_containers: Session): tenant_id = self._create_tenant_id() CreditPoolService.create_default_pool(tenant_id) @@ -53,7 +54,7 @@ class TestCreditPoolService: assert result is True - def test_check_credits_available_returns_false_when_insufficient(self, db_session_with_containers): + def test_check_credits_available_returns_false_when_insufficient(self, db_session_with_containers: Session): tenant_id = self._create_tenant_id() pool = CreditPoolService.create_default_pool(tenant_id) # Exhaust credits @@ -64,11 +65,11 @@ class TestCreditPoolService: assert result is False - def test_check_and_deduct_credits_raises_when_no_pool(self, db_session_with_containers): + def test_check_and_deduct_credits_raises_when_no_pool(self, db_session_with_containers: Session): with pytest.raises(QuotaExceededError, match="Credit pool not found"): CreditPoolService.check_and_deduct_credits(tenant_id=self._create_tenant_id(), credits_required=10) - def test_check_and_deduct_credits_raises_when_no_remaining(self, db_session_with_containers): + def test_check_and_deduct_credits_raises_when_no_remaining(self, db_session_with_containers: Session): tenant_id = self._create_tenant_id() pool = CreditPoolService.create_default_pool(tenant_id) pool.quota_used = pool.quota_limit @@ -77,7 +78,7 @@ class TestCreditPoolService: with pytest.raises(QuotaExceededError, match="No credits remaining"): CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=10) - def test_check_and_deduct_credits_deducts_required_amount(self, db_session_with_containers): + def test_check_and_deduct_credits_deducts_required_amount(self, db_session_with_containers: Session): tenant_id = self._create_tenant_id() CreditPoolService.create_default_pool(tenant_id) credits_required = 10 @@ -89,16 +90,34 @@ class TestCreditPoolService: pool = CreditPoolService.get_pool(tenant_id=tenant_id) assert pool.quota_used == credits_required - def test_check_and_deduct_credits_caps_at_remaining(self, db_session_with_containers): + def test_check_and_deduct_credits_raises_without_deducting_when_insufficient( + self, db_session_with_containers: Session + ): tenant_id = self._create_tenant_id() pool = CreditPoolService.create_default_pool(tenant_id) remaining = 5 pool.quota_used = pool.quota_limit - remaining + quota_used = pool.quota_used db_session_with_containers.commit() - result = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=200) + with pytest.raises(QuotaExceededError, match="Insufficient credits remaining"): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=200) + + db_session_with_containers.expire_all() + updated_pool = CreditPoolService.get_pool(tenant_id=tenant_id) + assert updated_pool.quota_used == quota_used + + def test_deduct_credits_capped_depletes_available_balance(self, db_session_with_containers: Session): + tenant_id = self._create_tenant_id() + pool = CreditPoolService.create_default_pool(tenant_id) + remaining = 5 + pool.quota_used = pool.quota_limit - remaining + quota_limit = pool.quota_limit + db_session_with_containers.commit() + + result = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=200) assert result == remaining db_session_with_containers.expire_all() updated_pool = CreditPoolService.get_pool(tenant_id=tenant_id) - assert updated_pool.quota_used == pool.quota_limit + assert updated_pool.quota_used == quota_limit diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py b/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py index 71c8874f79..f9898e2cfa 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py @@ -8,6 +8,7 @@ checks with testcontainers-backed infrastructure instead of database-chain mocks from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType from extensions.ext_database import db @@ -107,7 +108,7 @@ class DatasetPermissionTestDataFactory: class TestDatasetPermissionServiceGetPartialMemberList: """Verify partial-member list reads against persisted DatasetPermission rows.""" - def test_get_dataset_partial_member_list_with_members(self, db_session_with_containers): + def test_get_dataset_partial_member_list_with_members(self, db_session_with_containers: Session): """ Test retrieving partial member list with multiple members. """ @@ -138,7 +139,7 @@ class TestDatasetPermissionServiceGetPartialMemberList: assert set(result) == set(expected_account_ids) assert len(result) == 3 - def test_get_dataset_partial_member_list_with_single_member(self, db_session_with_containers): + def test_get_dataset_partial_member_list_with_single_member(self, db_session_with_containers: Session): """ Test retrieving partial member list with single member. """ @@ -160,7 +161,7 @@ class TestDatasetPermissionServiceGetPartialMemberList: assert set(result) == set(expected_account_ids) assert len(result) == 1 - def test_get_dataset_partial_member_list_empty(self, db_session_with_containers): + def test_get_dataset_partial_member_list_empty(self, db_session_with_containers: Session): """ Test retrieving partial member list when no members exist. """ @@ -179,7 +180,7 @@ class TestDatasetPermissionServiceGetPartialMemberList: class TestDatasetPermissionServiceUpdatePartialMemberList: """Verify partial-member list updates against persisted DatasetPermission rows.""" - def test_update_partial_member_list_add_new_members(self, db_session_with_containers): + def test_update_partial_member_list_add_new_members(self, db_session_with_containers: Session): """ Test adding new partial members to a dataset. """ @@ -203,7 +204,7 @@ class TestDatasetPermissionServiceUpdatePartialMemberList: result = DatasetPermissionService.get_dataset_partial_member_list(dataset.id) assert set(result) == {member_1.id, member_2.id} - def test_update_partial_member_list_replace_existing(self, db_session_with_containers): + def test_update_partial_member_list_replace_existing(self, db_session_with_containers: Session): """ Test replacing existing partial members with new ones. """ @@ -239,7 +240,7 @@ class TestDatasetPermissionServiceUpdatePartialMemberList: result = DatasetPermissionService.get_dataset_partial_member_list(dataset.id) assert set(result) == {new_member_1.id, new_member_2.id} - def test_update_partial_member_list_empty_list(self, db_session_with_containers): + def test_update_partial_member_list_empty_list(self, db_session_with_containers: Session): """ Test updating with empty member list (clearing all members). """ @@ -264,7 +265,7 @@ class TestDatasetPermissionServiceUpdatePartialMemberList: result = DatasetPermissionService.get_dataset_partial_member_list(dataset.id) assert result == [] - def test_update_partial_member_list_database_error_rollback(self, db_session_with_containers): + def test_update_partial_member_list_database_error_rollback(self, db_session_with_containers: Session): """ Test error handling and rollback on database error. """ @@ -313,7 +314,7 @@ class TestDatasetPermissionServiceUpdatePartialMemberList: class TestDatasetPermissionServiceClearPartialMemberList: """Verify partial-member clearing against persisted DatasetPermission rows.""" - def test_clear_partial_member_list_success(self, db_session_with_containers): + def test_clear_partial_member_list_success(self, db_session_with_containers: Session): """ Test successful clearing of partial member list. """ @@ -338,7 +339,7 @@ class TestDatasetPermissionServiceClearPartialMemberList: result = DatasetPermissionService.get_dataset_partial_member_list(dataset.id) assert result == [] - def test_clear_partial_member_list_empty_list(self, db_session_with_containers): + def test_clear_partial_member_list_empty_list(self, db_session_with_containers: Session): """ Test clearing partial member list when no members exist. """ @@ -353,7 +354,7 @@ class TestDatasetPermissionServiceClearPartialMemberList: result = DatasetPermissionService.get_dataset_partial_member_list(dataset.id) assert result == [] - def test_clear_partial_member_list_database_error_rollback(self, db_session_with_containers): + def test_clear_partial_member_list_database_error_rollback(self, db_session_with_containers: Session): """ Test error handling and rollback on database error. """ @@ -398,7 +399,7 @@ class TestDatasetPermissionServiceClearPartialMemberList: class TestDatasetServiceCheckDatasetPermission: """Verify dataset access checks against persisted partial-member permissions.""" - def test_check_dataset_permission_different_tenant_should_fail(self, db_session_with_containers): + def test_check_dataset_permission_different_tenant_should_fail(self, db_session_with_containers: Session): """Test that users from different tenants cannot access dataset.""" owner, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER) other_user, _ = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR) @@ -410,7 +411,7 @@ class TestDatasetServiceCheckDatasetPermission: with pytest.raises(NoPermissionError): DatasetService.check_dataset_permission(dataset, other_user) - def test_check_dataset_permission_owner_can_access_any_dataset(self, db_session_with_containers): + def test_check_dataset_permission_owner_can_access_any_dataset(self, db_session_with_containers: Session): """Test that tenant owners can access any dataset regardless of permission level.""" owner, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.OWNER) creator, _ = DatasetPermissionTestDataFactory.create_account_with_tenant( @@ -423,7 +424,7 @@ class TestDatasetServiceCheckDatasetPermission: DatasetService.check_dataset_permission(dataset, owner) - def test_check_dataset_permission_only_me_creator_can_access(self, db_session_with_containers): + def test_check_dataset_permission_only_me_creator_can_access(self, db_session_with_containers: Session): """Test ONLY_ME permission allows only the dataset creator to access.""" creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR) @@ -433,7 +434,7 @@ class TestDatasetServiceCheckDatasetPermission: DatasetService.check_dataset_permission(dataset, creator) - def test_check_dataset_permission_only_me_others_cannot_access(self, db_session_with_containers): + def test_check_dataset_permission_only_me_others_cannot_access(self, db_session_with_containers: Session): """Test ONLY_ME permission denies access to non-creators.""" creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL) other, _ = DatasetPermissionTestDataFactory.create_account_with_tenant( @@ -447,7 +448,7 @@ class TestDatasetServiceCheckDatasetPermission: with pytest.raises(NoPermissionError): DatasetService.check_dataset_permission(dataset, other) - def test_check_dataset_permission_all_team_allows_access(self, db_session_with_containers): + def test_check_dataset_permission_all_team_allows_access(self, db_session_with_containers: Session): """Test ALL_TEAM permission allows any team member to access the dataset.""" creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.NORMAL) member, _ = DatasetPermissionTestDataFactory.create_account_with_tenant( @@ -460,7 +461,9 @@ class TestDatasetServiceCheckDatasetPermission: DatasetService.check_dataset_permission(dataset, member) - def test_check_dataset_permission_partial_members_with_permission_success(self, db_session_with_containers): + def test_check_dataset_permission_partial_members_with_permission_success( + self, db_session_with_containers: Session + ): """ Test that user with explicit permission can access partial_members dataset. """ @@ -485,7 +488,9 @@ class TestDatasetServiceCheckDatasetPermission: permissions = DatasetPermissionService.get_dataset_partial_member_list(dataset.id) assert user.id in permissions - def test_check_dataset_permission_partial_members_without_permission_error(self, db_session_with_containers): + def test_check_dataset_permission_partial_members_without_permission_error( + self, db_session_with_containers: Session + ): """ Test error when user without permission tries to access partial_members dataset. """ @@ -506,7 +511,7 @@ class TestDatasetServiceCheckDatasetPermission: with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset"): DatasetService.check_dataset_permission(dataset, user) - def test_check_dataset_permission_partial_team_creator_can_access(self, db_session_with_containers): + def test_check_dataset_permission_partial_team_creator_can_access(self, db_session_with_containers: Session): """Test PARTIAL_TEAM permission allows creator to access without explicit permission.""" creator, tenant = DatasetPermissionTestDataFactory.create_account_with_tenant(role=TenantAccountRole.EDITOR) diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service.py b/api/tests/test_containers_integration_tests/services/test_dataset_service.py index 0de3c64c4f..e6ee896a52 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service.py @@ -712,7 +712,7 @@ class TestDatasetServiceRetrievalConfiguration: class TestDocumentServicePauseRecoverRetry: """Tests for pause/recover/retry orchestration using real DB and Redis.""" - def _create_indexing_document(self, db_session_with_containers, indexing_status="indexing"): + def _create_indexing_document(self, db_session_with_containers: Session, indexing_status="indexing"): factory = DatasetServiceIntegrationDataFactory account, tenant = factory.create_account_with_tenant(db_session_with_containers) dataset = factory.create_dataset(db_session_with_containers, tenant.id, account.id) @@ -721,7 +721,7 @@ class TestDocumentServicePauseRecoverRetry: db_session_with_containers.commit() return doc, account - def test_pause_document_success(self, db_session_with_containers): + def test_pause_document_success(self, db_session_with_containers: Session): from extensions.ext_redis import redis_client from services.dataset_service import DocumentService @@ -740,7 +740,7 @@ class TestDocumentServicePauseRecoverRetry: assert redis_client.get(cache_key) is not None redis_client.delete(cache_key) - def test_pause_document_invalid_status_error(self, db_session_with_containers): + def test_pause_document_invalid_status_error(self, db_session_with_containers: Session): from services.dataset_service import DocumentService from services.errors.document import DocumentIndexingError @@ -751,7 +751,7 @@ class TestDocumentServicePauseRecoverRetry: with pytest.raises(DocumentIndexingError): DocumentService.pause_document(doc) - def test_recover_document_success(self, db_session_with_containers): + def test_recover_document_success(self, db_session_with_containers: Session): from extensions.ext_redis import redis_client from services.dataset_service import DocumentService @@ -775,7 +775,7 @@ class TestDocumentServicePauseRecoverRetry: assert redis_client.get(cache_key) is None recover_task.delay.assert_called_once_with(doc.dataset_id, doc.id) - def test_retry_document_indexing_success(self, db_session_with_containers): + def test_retry_document_indexing_success(self, db_session_with_containers: Session): from extensions.ext_redis import redis_client from services.dataset_service import DocumentService diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_create_dataset.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_create_dataset.py index c486ff5613..08de79f4b7 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_create_dataset.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_create_dataset.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from models.account import Account, Tenant, TenantAccountJoin from services.dataset_service import DatasetService @@ -48,7 +49,7 @@ class TestDatasetServiceCreateRagPipelineDataset: permission="only_me", ) - def test_create_rag_pipeline_dataset_raises_when_current_user_id_is_none(self, db_session_with_containers): + def test_create_rag_pipeline_dataset_raises_when_current_user_id_is_none(self, db_session_with_containers: Session): tenant, _ = self._create_tenant_and_account(db_session_with_containers) mock_user = Mock(id=None) diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_delete_dataset.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_delete_dataset.py index 3cac964d89..c43a5d5978 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_delete_dataset.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_delete_dataset.py @@ -3,6 +3,8 @@ from unittest.mock import patch from uuid import uuid4 +from sqlalchemy.orm import Session + from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import Dataset, Document @@ -101,7 +103,7 @@ class DatasetDeleteIntegrationDataFactory: class TestDatasetServiceDeleteDataset: """Integration coverage for DatasetService.delete_dataset using testcontainers.""" - def test_delete_dataset_with_documents_success(self, db_session_with_containers): + def test_delete_dataset_with_documents_success(self, db_session_with_containers: Session): """Delete a dataset with documents and dispatch cleanup through the real signal handler.""" # Arrange owner, tenant = DatasetDeleteIntegrationDataFactory.create_account_with_tenant(db_session_with_containers) @@ -144,7 +146,7 @@ class TestDatasetServiceDeleteDataset: dataset.pipeline_id, ) - def test_delete_empty_dataset_success(self, db_session_with_containers): + def test_delete_empty_dataset_success(self, db_session_with_containers: Session): """Delete an empty dataset without scheduling cleanup when both gating fields are absent.""" # Arrange owner, tenant = DatasetDeleteIntegrationDataFactory.create_account_with_tenant(db_session_with_containers) @@ -172,7 +174,7 @@ class TestDatasetServiceDeleteDataset: assert db_session_with_containers.get(Dataset, dataset.id) is None clean_dataset_delay.assert_not_called() - def test_delete_dataset_with_partial_none_values(self, db_session_with_containers): + def test_delete_dataset_with_partial_none_values(self, db_session_with_containers: Session): """Delete a dataset without cleanup when indexing_technique is missing but doc_form resolves.""" # Arrange owner, tenant = DatasetDeleteIntegrationDataFactory.create_account_with_tenant(db_session_with_containers) @@ -200,7 +202,7 @@ class TestDatasetServiceDeleteDataset: assert db_session_with_containers.get(Dataset, dataset.id) is None clean_dataset_delay.assert_not_called() - def test_delete_dataset_with_doc_form_none_indexing_technique_exists(self, db_session_with_containers): + def test_delete_dataset_with_doc_form_none_indexing_technique_exists(self, db_session_with_containers: Session): """Delete a dataset without cleanup when indexing exists but doc_form resolves to None.""" # Arrange owner, tenant = DatasetDeleteIntegrationDataFactory.create_account_with_tenant(db_session_with_containers) @@ -228,7 +230,7 @@ class TestDatasetServiceDeleteDataset: assert db_session_with_containers.get(Dataset, dataset.id) is None clean_dataset_delay.assert_not_called() - def test_delete_dataset_not_found(self, db_session_with_containers): + def test_delete_dataset_not_found(self, db_session_with_containers: Session): """Return False without scheduling cleanup when the target dataset does not exist.""" # Arrange owner, _ = DatasetDeleteIntegrationDataFactory.create_account_with_tenant(db_session_with_containers) diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py index 2bec703f0c..0c089e506b 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py @@ -6,6 +6,7 @@ from unittest.mock import create_autospec, patch from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound from core.rag.index_processor.constant.index_type import IndexStructureType @@ -119,13 +120,13 @@ def current_user_mock(): yield current_user -def test_get_document_returns_none_when_document_id_is_missing(db_session_with_containers): +def test_get_document_returns_none_when_document_id_is_missing(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) assert DocumentService.get_document(dataset.id, None) is None -def test_get_document_queries_by_dataset_and_document_id(db_session_with_containers): +def test_get_document_queries_by_dataset_and_document_id(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) document = DocumentServiceIntegrationFactory.create_document(db_session_with_containers, dataset=dataset) @@ -135,7 +136,7 @@ def test_get_document_queries_by_dataset_and_document_id(db_session_with_contain assert result.id == document.id -def test_get_documents_by_ids_returns_empty_for_empty_input(db_session_with_containers): +def test_get_documents_by_ids_returns_empty_for_empty_input(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) result = DocumentService.get_documents_by_ids(dataset.id, []) @@ -143,7 +144,7 @@ def test_get_documents_by_ids_returns_empty_for_empty_input(db_session_with_cont assert result == [] -def test_get_documents_by_ids_uses_single_batch_query(db_session_with_containers): +def test_get_documents_by_ids_uses_single_batch_query(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) doc_a = DocumentServiceIntegrationFactory.create_document(db_session_with_containers, dataset=dataset, name="a.txt") doc_b = DocumentServiceIntegrationFactory.create_document( @@ -158,13 +159,13 @@ def test_get_documents_by_ids_uses_single_batch_query(db_session_with_containers assert {document.id for document in result} == {doc_a.id, doc_b.id} -def test_update_documents_need_summary_returns_zero_for_empty_input(db_session_with_containers): +def test_update_documents_need_summary_returns_zero_for_empty_input(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) assert DocumentService.update_documents_need_summary(dataset.id, []) == 0 -def test_update_documents_need_summary_updates_matching_non_qa_documents(db_session_with_containers): +def test_update_documents_need_summary_updates_matching_non_qa_documents(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) paragraph_doc = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -195,7 +196,7 @@ def test_update_documents_need_summary_updates_matching_non_qa_documents(db_sess assert refreshed_qa.need_summary is True -def test_get_document_download_url_uses_signed_url_helper(db_session_with_containers): +def test_get_document_download_url_uses_signed_url_helper(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) upload_file = DocumentServiceIntegrationFactory.create_upload_file( db_session_with_containers, @@ -215,7 +216,7 @@ def test_get_document_download_url_uses_signed_url_helper(db_session_with_contai get_url.assert_called_once_with(upload_file_id=upload_file.id, as_attachment=True) -def test_get_upload_file_id_for_upload_file_document_rejects_invalid_source_type(db_session_with_containers): +def test_get_upload_file_id_for_upload_file_document_rejects_invalid_source_type(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -232,7 +233,9 @@ def test_get_upload_file_id_for_upload_file_document_rejects_invalid_source_type ) -def test_get_upload_file_id_for_upload_file_document_rejects_missing_upload_file_id(db_session_with_containers): +def test_get_upload_file_id_for_upload_file_document_rejects_missing_upload_file_id( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -248,7 +251,7 @@ def test_get_upload_file_id_for_upload_file_document_rejects_missing_upload_file ) -def test_get_upload_file_id_for_upload_file_document_returns_string_id(db_session_with_containers): +def test_get_upload_file_id_for_upload_file_document_returns_string_id(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -265,7 +268,9 @@ def test_get_upload_file_id_for_upload_file_document_returns_string_id(db_sessio assert result == "99" -def test_get_upload_file_for_upload_file_document_raises_when_file_service_returns_nothing(db_session_with_containers): +def test_get_upload_file_for_upload_file_document_raises_when_file_service_returns_nothing( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -278,7 +283,7 @@ def test_get_upload_file_for_upload_file_document_raises_when_file_service_retur DocumentService._get_upload_file_for_upload_file_document(document) -def test_get_upload_file_for_upload_file_document_returns_upload_file(db_session_with_containers): +def test_get_upload_file_for_upload_file_document_returns_upload_file(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) upload_file = DocumentServiceIntegrationFactory.create_upload_file( db_session_with_containers, @@ -296,7 +301,9 @@ def test_get_upload_file_for_upload_file_document_returns_upload_file(db_session assert result.id == upload_file.id -def test_get_upload_files_by_document_id_for_zip_download_raises_for_missing_documents(db_session_with_containers): +def test_get_upload_files_by_document_id_for_zip_download_raises_for_missing_documents( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) with pytest.raises(NotFound, match="Document not found"): @@ -307,7 +314,9 @@ def test_get_upload_files_by_document_id_for_zip_download_raises_for_missing_doc ) -def test_get_upload_files_by_document_id_for_zip_download_rejects_cross_tenant_access(db_session_with_containers): +def test_get_upload_files_by_document_id_for_zip_download_rejects_cross_tenant_access( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) upload_file = DocumentServiceIntegrationFactory.create_upload_file( db_session_with_containers, @@ -329,7 +338,9 @@ def test_get_upload_files_by_document_id_for_zip_download_rejects_cross_tenant_a ) -def test_get_upload_files_by_document_id_for_zip_download_rejects_missing_upload_files(db_session_with_containers): +def test_get_upload_files_by_document_id_for_zip_download_rejects_missing_upload_files( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -345,7 +356,9 @@ def test_get_upload_files_by_document_id_for_zip_download_rejects_missing_upload ) -def test_get_upload_files_by_document_id_for_zip_download_returns_document_keyed_mapping(db_session_with_containers): +def test_get_upload_files_by_document_id_for_zip_download_returns_document_keyed_mapping( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) upload_file_a = DocumentServiceIntegrationFactory.create_upload_file( db_session_with_containers, @@ -395,7 +408,7 @@ def test_prepare_document_batch_download_zip_raises_not_found_for_missing_datase def test_prepare_document_batch_download_zip_translates_permission_error_to_forbidden( - db_session_with_containers, + db_session_with_containers: Session, current_user_mock, ): dataset = DocumentServiceIntegrationFactory.create_dataset( @@ -418,7 +431,7 @@ def test_prepare_document_batch_download_zip_translates_permission_error_to_forb def test_prepare_document_batch_download_zip_returns_upload_files_in_requested_order( - db_session_with_containers, + db_session_with_containers: Session, current_user_mock, ): dataset = DocumentServiceIntegrationFactory.create_dataset( @@ -461,7 +474,7 @@ def test_prepare_document_batch_download_zip_returns_upload_files_in_requested_o assert download_name.endswith(".zip") -def test_get_document_by_dataset_id_returns_enabled_documents(db_session_with_containers): +def test_get_document_by_dataset_id_returns_enabled_documents(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) enabled_document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -480,7 +493,9 @@ def test_get_document_by_dataset_id_returns_enabled_documents(db_session_with_co assert [document.id for document in result] == [enabled_document.id] -def test_get_working_documents_by_dataset_id_returns_completed_enabled_unarchived_documents(db_session_with_containers): +def test_get_working_documents_by_dataset_id_returns_completed_enabled_unarchived_documents( + db_session_with_containers: Session, +): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) available_document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -501,7 +516,7 @@ def test_get_working_documents_by_dataset_id_returns_completed_enabled_unarchive assert [document.id for document in result] == [available_document.id] -def test_get_error_documents_by_dataset_id_returns_error_and_paused_documents(db_session_with_containers): +def test_get_error_documents_by_dataset_id_returns_error_and_paused_documents(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) error_document = DocumentServiceIntegrationFactory.create_document( db_session_with_containers, @@ -526,7 +541,7 @@ def test_get_error_documents_by_dataset_id_returns_error_and_paused_documents(db assert {document.id for document in result} == {error_document.id, paused_document.id} -def test_get_batch_documents_filters_by_current_user_tenant(db_session_with_containers): +def test_get_batch_documents_filters_by_current_user_tenant(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) batch = f"batch-{uuid4()}" matching_document = DocumentServiceIntegrationFactory.create_document( @@ -549,7 +564,7 @@ def test_get_batch_documents_filters_by_current_user_tenant(db_session_with_cont assert [document.id for document in result] == [matching_document.id] -def test_get_document_file_detail_returns_upload_file(db_session_with_containers): +def test_get_document_file_detail_returns_upload_file(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) upload_file = DocumentServiceIntegrationFactory.create_upload_file( db_session_with_containers, @@ -563,7 +578,7 @@ def test_get_document_file_detail_returns_upload_file(db_session_with_containers assert result.id == upload_file.id -def test_delete_document_emits_signal_and_commits(db_session_with_containers): +def test_delete_document_emits_signal_and_commits(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) upload_file = DocumentServiceIntegrationFactory.create_upload_file( db_session_with_containers, @@ -588,7 +603,7 @@ def test_delete_document_emits_signal_and_commits(db_session_with_containers): ) -def test_delete_documents_ignores_empty_input(db_session_with_containers): +def test_delete_documents_ignores_empty_input(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) with patch("services.dataset_service.batch_clean_document_task.delay") as delay: @@ -597,7 +612,7 @@ def test_delete_documents_ignores_empty_input(db_session_with_containers): delay.assert_not_called() -def test_delete_documents_deletes_rows_and_dispatches_cleanup_task(db_session_with_containers): +def test_delete_documents_deletes_rows_and_dispatches_cleanup_task(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) dataset.chunk_structure = IndexStructureType.PARAGRAPH_INDEX db_session_with_containers.commit() @@ -637,14 +652,14 @@ def test_delete_documents_deletes_rows_and_dispatches_cleanup_task(db_session_wi assert set(args[3]) == {upload_file_a.id, upload_file_b.id} -def test_get_documents_position_returns_next_position_when_documents_exist(db_session_with_containers): +def test_get_documents_position_returns_next_position_when_documents_exist(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) DocumentServiceIntegrationFactory.create_document(db_session_with_containers, dataset=dataset, position=3) assert DocumentService.get_documents_position(dataset.id) == 4 -def test_get_documents_position_defaults_to_one_when_dataset_is_empty(db_session_with_containers): +def test_get_documents_position_defaults_to_one_when_dataset_is_empty(db_session_with_containers: Session): dataset = DocumentServiceIntegrationFactory.create_dataset(db_session_with_containers) assert DocumentService.get_documents_position(dataset.id) == 1 diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py index 87239b2cb3..bd8f5371b8 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py @@ -13,9 +13,9 @@ from uuid import uuid4 from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, DatasetPermissionEnum, Document, DocumentSegment -from models.enums import DataSourceType, DocumentCreatedFrom +from models.enums import DataSourceType, DocumentCreatedFrom, SegmentStatus from services.dataset_service import SegmentService @@ -35,13 +35,13 @@ class SegmentServiceTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() if tenant is None: - tenant = Tenant(name=f"tenant-{uuid4()}", status="normal") + tenant = Tenant(name=f"tenant-{uuid4()}", status=TenantStatus.NORMAL) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -103,7 +103,7 @@ class SegmentServiceTestDataFactory: created_by: str, position: int = 1, content: str = "Test content", - status: str = "completed", + status: SegmentStatus = SegmentStatus.COMPLETED, word_count: int = 10, tokens: int = 15, ) -> DocumentSegment: @@ -203,7 +203,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -212,7 +212,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="indexing", + status=SegmentStatus.INDEXING, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -221,7 +221,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=3, - status="waiting", + status=SegmentStatus.WAITING, ) # Act @@ -257,7 +257,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -266,7 +266,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="indexing", + status=SegmentStatus.INDEXING, ) # Act @@ -415,7 +415,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, content="This is important information", ) SegmentServiceTestDataFactory.create_segment( @@ -425,7 +425,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="indexing", + status=SegmentStatus.INDEXING, content="This is also important", ) SegmentServiceTestDataFactory.create_segment( @@ -435,7 +435,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=3, - status="completed", + status=SegmentStatus.COMPLETED, content="This is irrelevant", ) @@ -477,7 +477,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -486,7 +486,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="waiting", + status=SegmentStatus.WAITING, ) # Act diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py index 1b4179c9c7..0603a1e27f 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py @@ -6,6 +6,7 @@ from unittest.mock import patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound @@ -363,7 +364,7 @@ class TestDatasetServicePermissionsAndLifecycle: DatasetService.check_dataset_operator_permission(user=operator, dataset=dataset) - def test_update_dataset_api_status_raises_not_found_for_missing_dataset(self, flask_app_with_containers): + def test_update_dataset_api_status_raises_not_found_for_missing_dataset(self, flask_app_with_containers: Flask): with flask_app_with_containers.app_context(): with pytest.raises(NotFound, match="Dataset not found"): DatasetService.update_dataset_api_status(str(uuid4()), True) @@ -473,7 +474,7 @@ class TestDatasetCollectionBindingServiceIntegration: assert persisted.type == "dataset" assert persisted.collection_name - def test_get_dataset_collection_binding_by_id_and_type_raises_when_missing(self, flask_app_with_containers): + def test_get_dataset_collection_binding_by_id_and_type_raises_when_missing(self, flask_app_with_containers: Flask): with flask_app_with_containers.app_context(): with pytest.raises(ValueError, match="Dataset collection binding not found"): DatasetCollectionBindingService.get_dataset_collection_binding_by_id_and_type(str(uuid4())) diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py index 2f90d16176..0c610311bb 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py @@ -16,6 +16,7 @@ from uuid import uuid4 from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType +from models import AccountStatus, CreatorUserRole, TenantStatus from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import ( AppDatasetJoin, @@ -25,7 +26,7 @@ from models.dataset import ( DatasetProcessRule, DatasetQuery, ) -from models.enums import DatasetQuerySource, DataSourceType, ProcessRuleMode +from models.enums import DatasetQuerySource, DataSourceType, ProcessRuleMode, TagType from models.model import Tag, TagBinding from services.dataset_service import DatasetService, DocumentService @@ -42,11 +43,11 @@ class DatasetRetrievalTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) tenant = Tenant( name=f"tenant-{uuid4()}", - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add_all([account, tenant]) db_session_with_containers.flush() @@ -72,7 +73,7 @@ class DatasetRetrievalTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.flush() @@ -130,7 +131,7 @@ class DatasetRetrievalTestDataFactory: @staticmethod def create_process_rule( - db_session_with_containers: Session, dataset_id: str, created_by: str, mode: str, rules: dict + db_session_with_containers: Session, dataset_id: str, created_by: str, mode: ProcessRuleMode, rules: dict ) -> DatasetProcessRule: """Create a dataset process rule.""" process_rule = DatasetProcessRule( @@ -153,7 +154,7 @@ class DatasetRetrievalTestDataFactory: content=content, source=DatasetQuerySource.APP, source_app_id=None, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=created_by, ) db_session_with_containers.add(dataset_query) @@ -176,7 +177,7 @@ class DatasetRetrievalTestDataFactory: """Create a knowledge tag and bind it to the target dataset.""" tag = Tag( tenant_id=tenant_id, - type="knowledge", + type=TagType.KNOWLEDGE, name=f"tag-{uuid4()}", created_by=created_by, ) diff --git a/api/tests/test_containers_integration_tests/services/test_delete_archived_workflow_run.py b/api/tests/test_containers_integration_tests/services/test_delete_archived_workflow_run.py index fe426ae516..69c39b8bfb 100644 --- a/api/tests/test_containers_integration_tests/services/test_delete_archived_workflow_run.py +++ b/api/tests/test_containers_integration_tests/services/test_delete_archived_workflow_run.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta from uuid import uuid4 from sqlalchemy import select +from sqlalchemy.orm import Session from graphon.enums import WorkflowExecutionStatus from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom @@ -46,7 +47,7 @@ class TestArchivedWorkflowRunDeletion: db_session_with_containers.commit() return run - def _create_archive_log(self, db_session_with_containers, *, run: WorkflowRun) -> None: + def _create_archive_log(self, db_session_with_containers: Session, *, run: WorkflowRun) -> None: archive_log = WorkflowArchiveLog( tenant_id=run.tenant_id, app_id=run.app_id, @@ -72,7 +73,7 @@ class TestArchivedWorkflowRunDeletion: db_session_with_containers.add(archive_log) db_session_with_containers.commit() - def test_delete_by_run_id_returns_error_when_run_missing(self, db_session_with_containers): + def test_delete_by_run_id_returns_error_when_run_missing(self, db_session_with_containers: Session): deleter = ArchivedWorkflowRunDeletion() missing_run_id = str(uuid4()) @@ -81,7 +82,7 @@ class TestArchivedWorkflowRunDeletion: assert result.success is False assert result.error == f"Workflow run {missing_run_id} not found" - def test_delete_by_run_id_returns_error_when_not_archived(self, db_session_with_containers): + def test_delete_by_run_id_returns_error_when_not_archived(self, db_session_with_containers: Session): tenant_id = str(uuid4()) run = self._create_workflow_run( db_session_with_containers, @@ -95,7 +96,7 @@ class TestArchivedWorkflowRunDeletion: assert result.success is False assert result.error == f"Workflow run {run.id} is not archived" - def test_delete_batch_uses_repo(self, db_session_with_containers): + def test_delete_batch_uses_repo(self, db_session_with_containers: Session): tenant_id = str(uuid4()) base_time = datetime.now(UTC) run1 = self._create_workflow_run(db_session_with_containers, tenant_id=tenant_id, created_at=base_time) @@ -124,7 +125,7 @@ class TestArchivedWorkflowRunDeletion: ).all() assert remaining_runs == [] - def test_delete_run_calls_repo(self, db_session_with_containers): + def test_delete_run_calls_repo(self, db_session_with_containers: Session): tenant_id = str(uuid4()) run = self._create_workflow_run( db_session_with_containers, @@ -142,7 +143,7 @@ class TestArchivedWorkflowRunDeletion: deleted_run = db_session_with_containers.get(WorkflowRun, run_id) assert deleted_run is None - def test_delete_run_dry_run(self, db_session_with_containers): + def test_delete_run_dry_run(self, db_session_with_containers: Session): """Dry run should return success without actually deleting.""" tenant_id = str(uuid4()) run = self._create_workflow_run( @@ -161,7 +162,7 @@ class TestArchivedWorkflowRunDeletion: db_session_with_containers.expire_all() assert db_session_with_containers.get(WorkflowRun, run_id) is not None - def test_delete_run_exception_returns_error(self, db_session_with_containers): + def test_delete_run_exception_returns_error(self, db_session_with_containers: Session): """Exception during deletion should return failure result.""" from unittest.mock import MagicMock, patch @@ -183,7 +184,7 @@ class TestArchivedWorkflowRunDeletion: assert result.success is False assert result.error == "Database error" - def test_delete_by_run_id_success(self, db_session_with_containers): + def test_delete_by_run_id_success(self, db_session_with_containers: Session): """Successfully delete an archived workflow run by ID.""" tenant_id = str(uuid4()) base_time = datetime.now(UTC) @@ -202,7 +203,7 @@ class TestArchivedWorkflowRunDeletion: db_session_with_containers.expunge_all() assert db_session_with_containers.get(WorkflowRun, run_id) is None - def test_get_workflow_run_repo_caches_instance(self, db_session_with_containers): + def test_get_workflow_run_repo_caches_instance(self, db_session_with_containers: Session): """_get_workflow_run_repo should return a cached repo on subsequent calls.""" deleter = ArchivedWorkflowRunDeletion() diff --git a/api/tests/test_containers_integration_tests/services/test_document_service_display_status.py b/api/tests/test_containers_integration_tests/services/test_document_service_display_status.py index c0047df810..383a5f6374 100644 --- a/api/tests/test_containers_integration_tests/services/test_document_service_display_status.py +++ b/api/tests/test_containers_integration_tests/services/test_document_service_display_status.py @@ -2,6 +2,7 @@ import datetime from uuid import uuid4 from sqlalchemy import select +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType from models.dataset import Dataset, Document @@ -58,7 +59,7 @@ def _create_document( return document -def test_build_display_status_filters_available(db_session_with_containers): +def test_build_display_status_filters_available(db_session_with_containers: Session): dataset = _create_dataset(db_session_with_containers) available_doc = _create_document( db_session_with_containers, @@ -97,7 +98,7 @@ def test_build_display_status_filters_available(db_session_with_containers): assert [row.id for row in rows] == [available_doc.id] -def test_apply_display_status_filter_applies_when_status_present(db_session_with_containers): +def test_apply_display_status_filter_applies_when_status_present(db_session_with_containers: Session): dataset = _create_dataset(db_session_with_containers) waiting_doc = _create_document( db_session_with_containers, @@ -121,7 +122,7 @@ def test_apply_display_status_filter_applies_when_status_present(db_session_with assert [row.id for row in rows] == [waiting_doc.id] -def test_apply_display_status_filter_returns_same_when_invalid(db_session_with_containers): +def test_apply_display_status_filter_returns_same_when_invalid(db_session_with_containers: Session): dataset = _create_dataset(db_session_with_containers) doc1 = _create_document( db_session_with_containers, diff --git a/api/tests/test_containers_integration_tests/services/test_end_user_service.py b/api/tests/test_containers_integration_tests/services/test_end_user_service.py index cafabc939b..3f611d92f7 100644 --- a/api/tests/test_containers_integration_tests/services/test_end_user_service.py +++ b/api/tests/test_containers_integration_tests/services/test_end_user_service.py @@ -4,8 +4,10 @@ from unittest.mock import patch from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom +from models import TenantAccountRole from models.account import Account, Tenant, TenantAccountJoin from models.model import App, DefaultEndUserSessionID, EndUser from services.end_user_service import EndUserService @@ -15,7 +17,7 @@ class TestEndUserServiceFactory: """Factory class for creating test data and mock objects for end user service tests.""" @staticmethod - def create_app_and_account(db_session_with_containers): + def create_app_and_account(db_session_with_containers: Session): tenant = Tenant(name=f"Tenant {uuid4()}") db_session_with_containers.add(tenant) db_session_with_containers.flush() @@ -34,7 +36,7 @@ class TestEndUserServiceFactory: tenant_join = TenantAccountJoin( tenant_id=tenant.id, account_id=account.id, - role="owner", + role=TenantAccountRole.OWNER, current=True, ) db_session_with_containers.add(tenant_join) @@ -102,7 +104,7 @@ class TestEndUserServiceGetOrCreateEndUser: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_get_or_create_end_user_with_custom_user_id(self, db_session_with_containers, factory): + def test_get_or_create_end_user_with_custom_user_id(self, db_session_with_containers: Session, factory): """Test getting or creating end user with custom user_id.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -118,7 +120,7 @@ class TestEndUserServiceGetOrCreateEndUser: assert result.type == InvokeFrom.SERVICE_API assert result.is_anonymous is False - def test_get_or_create_end_user_without_user_id(self, db_session_with_containers, factory): + def test_get_or_create_end_user_without_user_id(self, db_session_with_containers: Session, factory): """Test getting or creating end user without user_id uses default session.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -131,7 +133,7 @@ class TestEndUserServiceGetOrCreateEndUser: # Verify _is_anonymous is set correctly (property always returns False) assert result._is_anonymous is True - def test_get_existing_end_user(self, db_session_with_containers, factory): + def test_get_existing_end_user(self, db_session_with_containers: Session, factory): """Test retrieving an existing end user.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -167,7 +169,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_create_end_user_service_api_type(self, db_session_with_containers, factory): + def test_create_end_user_service_api_type(self, db_session_with_containers: Session, factory): """Test creating new end user with SERVICE_API type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -189,7 +191,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.app_id == app_id assert result.session_id == user_id - def test_create_end_user_web_app_type(self, db_session_with_containers, factory): + def test_create_end_user_web_app_type(self, db_session_with_containers: Session, factory): """Test creating new end user with WEB_APP type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -209,7 +211,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.type == InvokeFrom.WEB_APP @patch("services.end_user_service.logger") - def test_upgrade_legacy_end_user_type(self, mock_logger, db_session_with_containers, factory): + def test_upgrade_legacy_end_user_type(self, mock_logger, db_session_with_containers: Session, factory): """Test upgrading legacy end user with different type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -243,7 +245,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert "Upgrading legacy EndUser" in log_call @patch("services.end_user_service.logger") - def test_get_existing_end_user_matching_type(self, mock_logger, db_session_with_containers, factory): + def test_get_existing_end_user_matching_type(self, mock_logger, db_session_with_containers: Session, factory): """Test retrieving existing end user with matching type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -272,7 +274,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.type == InvokeFrom.SERVICE_API mock_logger.info.assert_not_called() - def test_create_anonymous_user_with_default_session(self, db_session_with_containers, factory): + def test_create_anonymous_user_with_default_session(self, db_session_with_containers: Session, factory): """Test creating anonymous user when user_id is None.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -293,7 +295,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result._is_anonymous is True assert result.external_user_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID - def test_query_ordering_prioritizes_matching_type(self, db_session_with_containers, factory): + def test_query_ordering_prioritizes_matching_type(self, db_session_with_containers: Session, factory): """Test that query ordering prioritizes records with matching type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -328,7 +330,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert result.id == matching.id assert result.id != non_matching.id - def test_external_user_id_matches_session_id(self, db_session_with_containers, factory): + def test_external_user_id_matches_session_id(self, db_session_with_containers: Session, factory): """Test that external_user_id is set to match session_id.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -357,7 +359,9 @@ class TestEndUserServiceGetOrCreateEndUserByType: InvokeFrom.DEBUGGER, ], ) - def test_create_end_user_with_different_invoke_types(self, db_session_with_containers, invoke_type, factory): + def test_create_end_user_with_different_invoke_types( + self, db_session_with_containers: Session, invoke_type, factory + ): """Test creating end users with different InvokeFrom types.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) @@ -385,7 +389,7 @@ class TestEndUserServiceGetEndUserById: """Provide test data factory.""" return TestEndUserServiceFactory() - def test_get_end_user_by_id_returns_end_user(self, db_session_with_containers, factory): + def test_get_end_user_by_id_returns_end_user(self, db_session_with_containers: Session, factory): app = factory.create_app_and_account(db_session_with_containers) existing_user = factory.create_end_user( db_session_with_containers, @@ -404,7 +408,7 @@ class TestEndUserServiceGetEndUserById: assert result is not None assert result.id == existing_user.id - def test_get_end_user_by_id_returns_none(self, db_session_with_containers, factory): + def test_get_end_user_by_id_returns_none(self, db_session_with_containers: Session, factory): app = factory.create_app_and_account(db_session_with_containers) result = EndUserService.get_end_user_by_id( @@ -423,7 +427,7 @@ class TestEndUserServiceCreateBatch: def factory(self): return TestEndUserServiceFactory() - def _create_multiple_apps(self, db_session_with_containers, factory, count: int = 3): + def _create_multiple_apps(self, db_session_with_containers: Session, factory, count: int = 3): """Create multiple apps under the same tenant.""" first_app = factory.create_app_and_account(db_session_with_containers) tenant_id = first_app.tenant_id @@ -452,13 +456,13 @@ class TestEndUserServiceCreateBatch: all_apps = db_session_with_containers.query(App).filter(App.tenant_id == tenant_id).all() return tenant_id, all_apps - def test_create_batch_empty_app_ids(self, db_session_with_containers): + def test_create_batch_empty_app_ids(self, db_session_with_containers: Session): result = EndUserService.create_end_user_batch( type=InvokeFrom.SERVICE_API, tenant_id=str(uuid4()), app_ids=[], user_id="user-1" ) assert result == {} - def test_create_batch_creates_users_for_all_apps(self, db_session_with_containers, factory): + def test_create_batch_creates_users_for_all_apps(self, db_session_with_containers: Session, factory): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3) app_ids = [a.id for a in apps] user_id = f"user-{uuid4()}" @@ -473,7 +477,7 @@ class TestEndUserServiceCreateBatch: assert result[app_id].session_id == user_id assert result[app_id].type == InvokeFrom.SERVICE_API - def test_create_batch_default_session_id(self, db_session_with_containers, factory): + def test_create_batch_default_session_id(self, db_session_with_containers: Session, factory): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [a.id for a in apps] @@ -486,7 +490,7 @@ class TestEndUserServiceCreateBatch: assert end_user.session_id == DefaultEndUserSessionID.DEFAULT_SESSION_ID assert end_user._is_anonymous is True - def test_create_batch_deduplicate_app_ids(self, db_session_with_containers, factory): + def test_create_batch_deduplicate_app_ids(self, db_session_with_containers: Session, factory): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [apps[0].id, apps[1].id, apps[0].id, apps[1].id] user_id = f"user-{uuid4()}" @@ -497,7 +501,7 @@ class TestEndUserServiceCreateBatch: assert len(result) == 2 - def test_create_batch_returns_existing_users(self, db_session_with_containers, factory): + def test_create_batch_returns_existing_users(self, db_session_with_containers: Session, factory): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=2) app_ids = [a.id for a in apps] user_id = f"user-{uuid4()}" @@ -516,7 +520,7 @@ class TestEndUserServiceCreateBatch: for app_id in app_ids: assert first_result[app_id].id == second_result[app_id].id - def test_create_batch_partial_existing_users(self, db_session_with_containers, factory): + def test_create_batch_partial_existing_users(self, db_session_with_containers: Session, factory): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=3) user_id = f"user-{uuid4()}" @@ -545,7 +549,7 @@ class TestEndUserServiceCreateBatch: "invoke_type", [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER], ) - def test_create_batch_all_invoke_types(self, db_session_with_containers, invoke_type, factory): + def test_create_batch_all_invoke_types(self, db_session_with_containers: Session, invoke_type, factory): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=1) user_id = f"user-{uuid4()}" diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index 315936d721..a678e37b41 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest from faker import Faker +from sqlalchemy.orm import Session from enums.cloud_plan import CloudPlan from services.feature_service import ( @@ -81,7 +82,7 @@ class TestFeatureService: fake = Faker() return fake.uuid4() - def test_get_features_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test successful feature retrieval with billing and enterprise enabled. @@ -156,7 +157,7 @@ class TestFeatureService: tenant_id ) - def test_get_features_sandbox_plan(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_sandbox_plan(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test feature retrieval for sandbox plan with specific limitations. @@ -222,7 +223,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) - def test_get_knowledge_rate_limit_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_knowledge_rate_limit_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test successful knowledge rate limit retrieval with billing enabled. @@ -255,7 +258,7 @@ class TestFeatureService: tenant_id ) - def test_get_system_features_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_system_features_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test successful system features retrieval with enterprise and marketplace enabled. @@ -332,7 +335,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() - def test_get_system_features_unauthenticated(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_system_features_unauthenticated( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test system features retrieval for an unauthenticated user. @@ -386,7 +391,9 @@ class TestFeatureService: # Marketplace should be visible assert result.enable_marketplace is True - def test_get_system_features_basic_config(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_system_features_basic_config( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test system features retrieval with basic configuration (no enterprise). @@ -436,7 +443,9 @@ class TestFeatureService: # Verify plugin package size (uses default value from dify_config) assert result.max_plugin_package_size == 15728640 - def test_get_features_billing_disabled(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_billing_disabled( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval when billing is disabled. @@ -492,7 +501,7 @@ class TestFeatureService: assert result.webapp_copyright_enabled is False def test_get_knowledge_rate_limit_billing_disabled( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test knowledge rate limit retrieval when billing is disabled. @@ -523,7 +532,9 @@ class TestFeatureService: # Verify no billing service calls mock_external_service_dependencies["billing_service"].get_knowledge_rate_limit.assert_not_called() - def test_get_features_enterprise_only(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_enterprise_only( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval with enterprise enabled but billing disabled. @@ -583,7 +594,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_not_called() def test_get_system_features_enterprise_disabled( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features retrieval when enterprise is disabled. @@ -633,14 +644,14 @@ class TestFeatureService: assert result.max_plugin_package_size == 15728640 # Verify default license status - assert result.license.status.value == "none" + assert result.license.status == "none" assert result.license.expired_at == "" assert result.license.workspaces.enabled is False # Verify no enterprise service calls mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called() - def test_get_features_no_tenant_id(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_no_tenant_id(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test feature retrieval without tenant ID (billing disabled). @@ -686,7 +697,9 @@ class TestFeatureService: # Verify no billing service calls mock_external_service_dependencies["billing_service"].get_info.assert_not_called() - def test_get_features_partial_billing_info(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_partial_billing_info( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval with partial billing information. @@ -746,7 +759,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) - def test_get_features_edge_case_vector_space(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_edge_case_vector_space( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval with edge case vector space configuration. @@ -807,7 +822,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_get_system_features_edge_case_webapp_auth( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features retrieval with edge case webapp auth configuration. @@ -863,7 +878,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() - def test_get_features_edge_case_members_quota(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_edge_case_members_quota( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval with edge case members quota configuration. @@ -924,7 +941,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_plugin_installation_permission_scopes( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features retrieval with different plugin installation permission scopes. @@ -1023,7 +1040,7 @@ class TestFeatureService: assert result.plugin_installation_permission.restrict_to_marketplace_only is True def test_get_features_workspace_members_missing( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test feature retrieval when workspace members info is missing from enterprise. @@ -1064,7 +1081,9 @@ class TestFeatureService: tenant_id ) - def test_get_system_features_license_inactive(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_system_features_license_inactive( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test system features retrieval with inactive license. @@ -1117,7 +1136,7 @@ class TestFeatureService: mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() def test_get_system_features_partial_enterprise_info( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features retrieval with partial enterprise information. @@ -1186,7 +1205,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() - def test_get_features_edge_case_limits(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_edge_case_limits( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval with edge case limit values. @@ -1244,7 +1265,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_get_system_features_edge_case_protocols( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features retrieval with edge case protocol values. @@ -1297,7 +1318,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() - def test_get_features_edge_case_education(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_features_edge_case_education( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test feature retrieval with edge case education configuration. @@ -1353,7 +1376,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_license_limitation_model_is_available( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test LicenseLimitationModel.is_available method with various scenarios. @@ -1394,7 +1417,7 @@ class TestFeatureService: assert exact_limit.is_available(3) is True def test_get_features_workspace_members_disabled( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test feature retrieval when workspace members are disabled in enterprise. @@ -1433,7 +1456,9 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["enterprise_service"].get_workspace_info.assert_called_once_with(tenant_id) - def test_get_system_features_license_expired(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_system_features_license_expired( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test system features retrieval with expired license. @@ -1486,7 +1511,7 @@ class TestFeatureService: mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() def test_get_features_edge_case_docs_processing( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test feature retrieval with edge case document processing configuration. @@ -1544,7 +1569,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_get_system_features_edge_case_branding( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features retrieval with edge case branding configuration. @@ -1606,7 +1631,7 @@ class TestFeatureService: mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() def test_get_features_edge_case_annotation_quota( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test feature retrieval with edge case annotation quota configuration. @@ -1668,7 +1693,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_get_features_edge_case_documents_upload( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test feature retrieval with edge case documents upload settings. @@ -1733,7 +1758,7 @@ class TestFeatureService: mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) def test_get_system_features_edge_case_license_lost( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test system features with lost license status. @@ -1784,7 +1809,7 @@ class TestFeatureService: mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once() def test_get_features_edge_case_education_disabled( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test feature retrieval with education feature disabled. diff --git a/api/tests/test_containers_integration_tests/services/test_feedback_service.py b/api/tests/test_containers_integration_tests/services/test_feedback_service.py index 3dcd6586e2..a4663450d4 100644 --- a/api/tests/test_containers_integration_tests/services/test_feedback_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feedback_service.py @@ -23,7 +23,7 @@ class TestFeedbackService: """Test FeedbackService methods.""" @pytest.fixture - def mock_db_session(self, monkeypatch): + def mock_db_session(self, monkeypatch: pytest.MonkeyPatch): """Mock database session.""" mock_session = mock.Mock() monkeypatch.setattr(db, "session", mock_session) diff --git a/api/tests/test_containers_integration_tests/services/test_file_service.py b/api/tests/test_containers_integration_tests/services/test_file_service.py index 42dbdef1c9..4532005836 100644 --- a/api/tests/test_containers_integration_tests/services/test_file_service.py +++ b/api/tests/test_containers_integration_tests/services/test_file_service.py @@ -514,7 +514,7 @@ class TestFileService: db_session_with_containers.commit() - result = FileService(engine).get_file_preview(file_id=upload_file.id) + result = FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id) assert result == "extracted text content" mock_external_service_dependencies["extract_processor"].load_from_upload_file.assert_called_once() @@ -529,7 +529,7 @@ class TestFileService: non_existent_id = str(fake.uuid4()) with pytest.raises(NotFound, match="File not found"): - FileService(engine).get_file_preview(file_id=non_existent_id) + FileService(engine).get_file_preview(file_id=non_existent_id, tenant_id=str(fake.uuid4())) def test_get_file_preview_unsupported_file_type( self, db_session_with_containers: Session, engine, mock_external_service_dependencies @@ -549,7 +549,7 @@ class TestFileService: db_session_with_containers.commit() with pytest.raises(UnsupportedFileTypeError): - FileService(engine).get_file_preview(file_id=upload_file.id) + FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id) def test_get_file_preview_text_truncation( self, db_session_with_containers: Session, engine, mock_external_service_dependencies @@ -572,7 +572,7 @@ class TestFileService: long_text = "x" * 5000 # Longer than PREVIEW_WORDS_LIMIT mock_external_service_dependencies["extract_processor"].load_from_upload_file.return_value = long_text - result = FileService(engine).get_file_preview(file_id=upload_file.id) + result = FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id) assert len(result) == 3000 # PREVIEW_WORDS_LIMIT assert result == "x" * 3000 diff --git a/api/tests/test_containers_integration_tests/services/test_file_service_zip_and_lookup.py b/api/tests/test_containers_integration_tests/services/test_file_service_zip_and_lookup.py index 4e0a726cc7..1101d834a0 100644 --- a/api/tests/test_containers_integration_tests/services/test_file_service_zip_and_lookup.py +++ b/api/tests/test_containers_integration_tests/services/test_file_service_zip_and_lookup.py @@ -15,6 +15,7 @@ from uuid import uuid4 from zipfile import ZipFile import pytest +from sqlalchemy.orm import Session import services.file_service as file_service_module from extensions.storage.storage_type import StorageType @@ -23,7 +24,7 @@ from models.model import UploadFile from services.file_service import FileService -def _create_upload_file(db_session, *, tenant_id: str, key: str, name: str) -> UploadFile: +def _create_upload_file(db_session: Session, *, tenant_id: str, key: str, name: str) -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id, storage_type=StorageType.OPENDAL, @@ -66,12 +67,12 @@ def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch assert zf.read("b (2).txt") == b"three" -def test_get_upload_files_by_ids_returns_empty_when_no_ids(db_session_with_containers) -> None: +def test_get_upload_files_by_ids_returns_empty_when_no_ids(db_session_with_containers: Session) -> None: """Ensure empty input returns an empty mapping without hitting the database.""" assert FileService.get_upload_files_by_ids(str(uuid4()), []) == {} -def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_containers) -> None: +def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_containers: Session) -> None: """Ensure batch lookup returns a dict keyed by stringified UploadFile ids.""" tenant_id = str(uuid4()) file1 = _create_upload_file(db_session_with_containers, tenant_id=tenant_id, key="k1", name="file1.txt") @@ -84,7 +85,7 @@ def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_contai assert result[file2.id].id == file2.id -def test_get_upload_files_by_ids_filters_by_tenant(db_session_with_containers) -> None: +def test_get_upload_files_by_ids_filters_by_tenant(db_session_with_containers: Session) -> None: """Ensure files from other tenants are not returned.""" tenant_a = str(uuid4()) tenant_b = str(uuid4()) diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py index ed75363f3b..454b8096d1 100644 --- a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test_service.py @@ -5,7 +5,9 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from configs import dify_config from core.workflow.human_input_adapter import ( @@ -88,7 +90,7 @@ class TestDeliveryTestRegistry: with pytest.raises(DeliveryTestUnsupportedError, match="Delivery method does not support test send."): registry.dispatch(context=context, method=method) - def test_default(self, flask_app_with_containers, db_session_with_containers): + def test_default(self, flask_app_with_containers: Flask, db_session_with_containers: Session): registry = DeliveryTestRegistry.default() assert len(registry._handlers) == 1 assert isinstance(registry._handlers[0], EmailDeliveryTestHandler) @@ -121,7 +123,7 @@ class TestEmailDeliveryTestHandler: with pytest.raises(DeliveryTestUnsupportedError): handler.send_test(context=MagicMock(), method=MagicMock()) - def test_send_test_feature_disabled(self, monkeypatch): + def test_send_test_feature_disabled(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( service_module.FeatureService, "get_features", @@ -136,7 +138,7 @@ class TestEmailDeliveryTestHandler: with pytest.raises(DeliveryTestError, match="Email delivery is not available"): handler.send_test(context=context, method=method) - def test_send_test_mail_not_inited(self, monkeypatch): + def test_send_test_mail_not_inited(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( service_module.FeatureService, "get_features", @@ -153,7 +155,7 @@ class TestEmailDeliveryTestHandler: with pytest.raises(DeliveryTestError, match="Mail client is not initialized."): handler.send_test(context=context, method=method) - def test_send_test_no_recipients(self, monkeypatch): + def test_send_test_no_recipients(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( service_module.FeatureService, "get_features", @@ -172,7 +174,7 @@ class TestEmailDeliveryTestHandler: with pytest.raises(DeliveryTestError, match="No recipients configured"): handler.send_test(context=context, method=method) - def test_send_test_success(self, monkeypatch): + def test_send_test_success(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( service_module.FeatureService, "get_features", @@ -208,7 +210,7 @@ class TestEmailDeliveryTestHandler: assert kwargs["to"] == "test@example.com" assert "RENDERED_Subj" in kwargs["subject"] - def test_send_test_sanitizes_subject(self, monkeypatch): + def test_send_test_sanitizes_subject(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( service_module.FeatureService, "get_features", @@ -260,7 +262,7 @@ class TestEmailDeliveryTestHandler: ) assert handler._resolve_recipients(tenant_id="t1", method=method) == ["ext@example.com"] - def test_resolve_recipients_member(self, flask_app_with_containers, db_session_with_containers): + def test_resolve_recipients_member(self, flask_app_with_containers: Flask, db_session_with_containers: Session): tenant_id = str(uuid4()) account = Account(name="Test User", email="member@example.com") db_session_with_containers.add(account) @@ -282,7 +284,9 @@ class TestEmailDeliveryTestHandler: ) assert handler._resolve_recipients(tenant_id=tenant_id, method=method) == ["member@example.com"] - def test_resolve_recipients_whole_workspace(self, flask_app_with_containers, db_session_with_containers): + def test_resolve_recipients_whole_workspace( + self, flask_app_with_containers: Flask, db_session_with_containers: Session + ): tenant_id = str(uuid4()) account1 = Account(name="User 1", email=f"u1-{uuid4()}@example.com") account2 = Account(name="User 2", email=f"u2-{uuid4()}@example.com") diff --git a/api/tests/test_containers_integration_tests/services/test_message_service.py b/api/tests/test_containers_integration_tests/services/test_message_service.py index bdf6d9b951..6d0d281c6b 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from models.enums import ConversationFromSource, FeedbackRating, InvokeFrom from models.model import MessageFeedback -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.errors.message import ( FirstMessageNotExistsError, LastMessageNotExistsError, @@ -103,16 +103,16 @@ class TestMessageService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "advanced-chat", # Use advanced-chat mode to use mocked workflow - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="advanced-chat", # Use advanced-chat mode to use mocked workflow, + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app app_service = AppService() diff --git a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py index 44e5a82868..52ebc0131f 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from sqlalchemy.orm import Session from services.message_service import MessageService from tests.test_containers_integration_tests.helpers.execution_extra_content import ( @@ -9,7 +10,7 @@ from tests.test_containers_integration_tests.helpers.execution_extra_content imp @pytest.mark.usefixtures("flask_req_ctx_with_containers") -def test_pagination_returns_extra_contents(db_session_with_containers): +def test_pagination_returns_extra_contents(db_session_with_containers: Session): fixture = create_human_input_message_fixture(db_session_with_containers) pagination = MessageService.pagination_by_first_id( diff --git a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py index cd63d3ad6c..1a1efe0337 100644 --- a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py +++ b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py @@ -165,7 +165,7 @@ class TestMessagesCleanServiceIntegration: return app - def _create_conversation(self, db_session_with_containers: Session, app): + def _create_conversation(self, db_session_with_containers: Session, app: App): """Helper to create a conversation.""" conversation = Conversation( app_id=app.id, diff --git a/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py b/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py index b55a19eaa9..f3ab9eb3da 100644 --- a/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py +++ b/api/tests/test_containers_integration_tests/services/test_metadata_partial_update.py @@ -4,7 +4,9 @@ from unittest.mock import Mock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy import select +from sqlalchemy.orm import Session from models.dataset import Dataset, DatasetMetadataBinding, Document from models.enums import DataSourceType, DocumentCreatedFrom @@ -16,7 +18,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( from services.metadata_service import MetadataService -def _create_dataset(db_session, *, tenant_id: str, built_in_field_enabled: bool = False) -> Dataset: +def _create_dataset(db_session: Session, *, tenant_id: str, built_in_field_enabled: bool = False) -> Dataset: dataset = Dataset( tenant_id=tenant_id, name=f"dataset-{uuid4()}", @@ -30,7 +32,9 @@ def _create_dataset(db_session, *, tenant_id: str, built_in_field_enabled: bool return dataset -def _create_document(db_session, *, dataset_id: str, tenant_id: str, doc_metadata: dict | None = None) -> Document: +def _create_document( + db_session: Session, *, dataset_id: str, tenant_id: str, doc_metadata: dict | None = None +) -> Document: document = Document( tenant_id=tenant_id, dataset_id=dataset_id, @@ -65,7 +69,11 @@ class TestMetadataPartialUpdate: yield account def test_partial_update_merges_metadata( - self, flask_app_with_containers, db_session_with_containers, tenant_id, mock_current_account + self, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id: str, + mock_current_account, ): dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( @@ -92,7 +100,11 @@ class TestMetadataPartialUpdate: assert updated_doc.doc_metadata["new_key"] == "new_value" def test_full_update_replaces_metadata( - self, flask_app_with_containers, db_session_with_containers, tenant_id, mock_current_account + self, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id: str, + mock_current_account, ): dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( @@ -119,7 +131,12 @@ class TestMetadataPartialUpdate: assert "existing_key" not in updated_doc.doc_metadata def test_partial_update_skips_existing_binding( - self, flask_app_with_containers, db_session_with_containers, tenant_id, user_id, mock_current_account + self, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id, + user_id, + mock_current_account, ): dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( @@ -159,7 +176,11 @@ class TestMetadataPartialUpdate: assert len(bindings) == 1 def test_rollback_called_on_commit_failure( - self, flask_app_with_containers, db_session_with_containers, tenant_id, mock_current_account + self, + flask_app_with_containers: Flask, + db_session_with_containers: Session, + tenant_id: str, + mock_current_account, ): dataset = _create_dataset(db_session_with_containers, tenant_id=tenant_id) document = _create_document( diff --git a/api/tests/test_containers_integration_tests/services/test_oauth_server_service.py b/api/tests/test_containers_integration_tests/services/test_oauth_server_service.py index c146a5924b..5fa5de6d80 100644 --- a/api/tests/test_containers_integration_tests/services/test_oauth_server_service.py +++ b/api/tests/test_containers_integration_tests/services/test_oauth_server_service.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest from models.model import OAuthProviderApp @@ -25,7 +26,7 @@ from services.oauth_server import ( class TestOAuthServerServiceGetProviderApp: """DB-backed tests for get_oauth_provider_app.""" - def _create_oauth_provider_app(self, db_session_with_containers, *, client_id: str) -> OAuthProviderApp: + def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp: app = OAuthProviderApp( app_icon="icon.png", client_id=client_id, @@ -38,7 +39,7 @@ class TestOAuthServerServiceGetProviderApp: db_session_with_containers.commit() return app - def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers): + def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers: Session): client_id = f"client-{uuid4()}" created = self._create_oauth_provider_app(db_session_with_containers, client_id=client_id) @@ -48,7 +49,7 @@ class TestOAuthServerServiceGetProviderApp: assert result.client_id == client_id assert result.id == created.id - def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers): + def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session): result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}") assert result is None diff --git a/api/tests/test_containers_integration_tests/services/test_ops_service.py b/api/tests/test_containers_integration_tests/services/test_ops_service.py index e2e1a228b2..ff76bce416 100644 --- a/api/tests/test_containers_integration_tests/services/test_ops_service.py +++ b/api/tests/test_containers_integration_tests/services/test_ops_service.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from core.ops.entities.config_entity import TracingProviderEnum from models.model import TraceAppConfig from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.ops_service import OpsService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -57,14 +57,14 @@ class TestOpsService: app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ), account, ) return app, account diff --git a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py index ccc4188dbf..ddaf08c0a0 100644 --- a/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_recommended_app_service.py @@ -352,6 +352,32 @@ class TestRecommendedAppServiceTrialFeatures: assert result["id"] == app_id assert result["can_trial"] is has_trial_app + def test_get_detail_returns_none_when_not_found_and_trial_enabled( + self, + db_session_with_containers: Session, + monkeypatch: pytest.MonkeyPatch, + ): + """Regression: accessing result['id'] when result is None must not crash.""" + retrieval_instance = MagicMock() + retrieval_instance.get_recommend_app_detail.return_value = None + retrieval_factory = MagicMock(return_value=retrieval_instance) + monkeypatch.setattr(service_module.dify_config, "HOSTED_FETCH_APP_TEMPLATES_MODE", "remote", raising=False) + monkeypatch.setattr( + service_module.RecommendAppRetrievalFactory, + "get_recommend_app_factory", + MagicMock(return_value=retrieval_factory), + ) + monkeypatch.setattr( + service_module.FeatureService, + "get_system_features", + MagicMock(return_value=SimpleNamespace(enable_trial_app=True)), + ) + + result = RecommendedAppService.get_recommend_app_detail("nonexistent") + + assert result is None + retrieval_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") + def test_add_trial_app_record_increments_count_for_existing(self, db_session_with_containers: Session): app_id = str(uuid.uuid4()) account_id = str(uuid.uuid4()) diff --git a/api/tests/test_containers_integration_tests/services/test_restore_archived_workflow_run.py b/api/tests/test_containers_integration_tests/services/test_restore_archived_workflow_run.py index 7036524918..2f20949611 100644 --- a/api/tests/test_containers_integration_tests/services/test_restore_archived_workflow_run.py +++ b/api/tests/test_containers_integration_tests/services/test_restore_archived_workflow_run.py @@ -8,6 +8,7 @@ from datetime import datetime from uuid import uuid4 from sqlalchemy import select +from sqlalchemy.orm import Session from models.workflow import WorkflowPause, WorkflowRun from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore @@ -39,7 +40,7 @@ class TestWorkflowRunRestore: assert result["created_at"].month == 1 assert result["name"] == "test" - def test_restore_table_records_returns_rowcount(self, db_session_with_containers): + def test_restore_table_records_returns_rowcount(self, db_session_with_containers: Session): """Restore should return inserted rowcount.""" restore = WorkflowRunRestore() record_id = str(uuid4()) @@ -65,7 +66,7 @@ class TestWorkflowRunRestore: restored_pause = db_session_with_containers.scalar(select(WorkflowPause).where(WorkflowPause.id == record_id)) assert restored_pause is not None - def test_restore_table_records_unknown_table(self, db_session_with_containers): + def test_restore_table_records_unknown_table(self, db_session_with_containers: Session): """Unknown table names should be ignored gracefully.""" restore = WorkflowRunRestore() diff --git a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py index 70aa813142..7368ad4249 100644 --- a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py @@ -4,10 +4,11 @@ import pytest from faker import Faker from sqlalchemy.orm import Session +from models import App, CreatorUserRole from models.enums import ConversationFromSource from models.model import EndUser, Message from models.web import SavedMessage -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.saved_message_service import SavedMessageService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -72,23 +73,23 @@ class TestSavedMessageService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) return app, account - def _create_test_end_user(self, db_session_with_containers: Session, app): + def _create_test_end_user(self, db_session_with_containers: Session, app: App): """ Helper method to create a test end user for testing. @@ -116,7 +117,7 @@ class TestSavedMessageService: return end_user - def _create_test_message(self, db_session_with_containers: Session, app, user): + def _create_test_message(self, db_session_with_containers: Session, app: App, user): """ Helper method to create a test message for testing. @@ -199,13 +200,13 @@ class TestSavedMessageService: saved_message1 = SavedMessage( app_id=app.id, message_id=message1.id, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=account.id, ) saved_message2 = SavedMessage( app_id=app.id, message_id=message2.id, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=account.id, ) @@ -272,13 +273,13 @@ class TestSavedMessageService: saved_message1 = SavedMessage( app_id=app.id, message_id=message1.id, - created_by_role="end_user", + created_by_role=CreatorUserRole.END_USER, created_by=end_user.id, ) saved_message2 = SavedMessage( app_id=app.id, message_id=message2.id, - created_by_role="end_user", + created_by_role=CreatorUserRole.END_USER, created_by=end_user.id, ) @@ -449,7 +450,7 @@ class TestSavedMessageService: saved_message = SavedMessage( app_id=app.id, message_id=message.id, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=account.id, ) @@ -540,7 +541,9 @@ class TestSavedMessageService: message = self._create_test_message(db_session_with_containers, app, account) # Pre-create a saved message - saved = SavedMessage(app_id=app.id, message_id=message.id, created_by_role="account", created_by=account.id) + saved = SavedMessage( + app_id=app.id, message_id=message.id, created_by_role=CreatorUserRole.ACCOUNT, created_by=account.id + ) db_session_with_containers.add(saved) db_session_with_containers.commit() @@ -571,7 +574,9 @@ class TestSavedMessageService: end_user = self._create_test_end_user(db_session_with_containers, app) message = self._create_test_message(db_session_with_containers, app, end_user) - saved = SavedMessage(app_id=app.id, message_id=message.id, created_by_role="end_user", created_by=end_user.id) + saved = SavedMessage( + app_id=app.id, message_id=message.id, created_by_role=CreatorUserRole.END_USER, created_by=end_user.id + ) db_session_with_containers.add(saved) db_session_with_containers.commit() @@ -596,10 +601,10 @@ class TestSavedMessageService: # Both users save the same message saved_account = SavedMessage( - app_id=app.id, message_id=message.id, created_by_role="account", created_by=account1.id + app_id=app.id, message_id=message.id, created_by_role=CreatorUserRole.ACCOUNT, created_by=account1.id ) saved_end_user = SavedMessage( - app_id=app.id, message_id=message.id, created_by_role="end_user", created_by=end_user.id + app_id=app.id, message_id=message.id, created_by_role=CreatorUserRole.END_USER, created_by=end_user.id ) db_session_with_containers.add_all([saved_account, saved_end_user]) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/services/test_tag_service.py b/api/tests/test_containers_integration_tests/services/test_tag_service.py index 5a6bf0466e..f088cc964d 100644 --- a/api/tests/test_containers_integration_tests/services/test_tag_service.py +++ b/api/tests/test_containers_integration_tests/services/test_tag_service.py @@ -759,7 +759,7 @@ class TestTagService: tag = TagService.save_tags(tag_args) # Update args - update_args = UpdateTagPayload(name="updated_name", type="knowledge") + update_args = UpdateTagPayload(name="updated_name") # Act: Execute the method under test result = TagService.update_tags(update_args, tag.id) @@ -799,7 +799,7 @@ class TestTagService: non_existent_tag_id = str(uuid.uuid4()) - update_args = UpdateTagPayload(name="updated_name", type="knowledge") + update_args = UpdateTagPayload(name="updated_name") # Act & Assert: Verify proper error handling with pytest.raises(NotFound) as exc_info: @@ -830,7 +830,7 @@ class TestTagService: tag2 = TagService.save_tags(tag2_args) # Try to update second tag with first tag's name - update_args = UpdateTagPayload(name="first_tag", type="app") + update_args = UpdateTagPayload(name="first_tag") # Act & Assert: Verify proper error handling with pytest.raises(ValueError) as exc_info: @@ -1099,38 +1099,39 @@ class TestTagService: db_session_with_containers, mock_external_service_dependencies ) - # Create tag - tag = self._create_test_tags( - db_session_with_containers, mock_external_service_dependencies, tenant.id, "knowledge", 1 - )[0] + # Create tags + tags = self._create_test_tags( + db_session_with_containers, mock_external_service_dependencies, tenant.id, "knowledge", 2 + ) - # Create dataset and bind tag + # Create dataset and bind tags dataset = self._create_test_dataset(db_session_with_containers, mock_external_service_dependencies, tenant.id) self._create_test_tag_bindings( - db_session_with_containers, mock_external_service_dependencies, [tag], dataset.id, tenant.id + db_session_with_containers, mock_external_service_dependencies, tags, dataset.id, tenant.id ) - # Verify binding exists before deletion - - binding_before = ( + # Verify bindings exist before deletion + bindings_before = ( db_session_with_containers.query(TagBinding) - .where(TagBinding.tag_id == tag.id, TagBinding.target_id == dataset.id) - .first() + .where(TagBinding.tag_id.in_([tag.id for tag in tags]), TagBinding.target_id == dataset.id) + .all() ) - assert binding_before is not None + assert len(bindings_before) == 2 # Act: Execute the method under test - delete_payload = TagBindingDeletePayload(type="knowledge", target_id=dataset.id, tag_id=tag.id) + delete_payload = TagBindingDeletePayload( + type="knowledge", target_id=dataset.id, tag_ids=[tag.id for tag in tags] + ) TagService.delete_tag_binding(delete_payload) # Assert: Verify the expected outcomes - # Verify tag binding was deleted - binding_after = ( + # Verify tag bindings were deleted + bindings_after = ( db_session_with_containers.query(TagBinding) - .where(TagBinding.tag_id == tag.id, TagBinding.target_id == dataset.id) - .first() + .where(TagBinding.tag_id.in_([tag.id for tag in tags]), TagBinding.target_id == dataset.id) + .all() ) - assert binding_after is None + assert len(bindings_after) == 0 def test_delete_tag_binding_non_existent_binding( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1156,7 +1157,7 @@ class TestTagService: app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, tenant.id) # Act: Try to delete non-existent binding - delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_id=tag.id) + delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_ids=[tag.id]) TagService.delete_tag_binding(delete_payload) # Assert: Verify the expected outcomes diff --git a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py index f2307fbd7d..8e53a2d6cd 100644 --- a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py @@ -6,12 +6,12 @@ from sqlalchemy import select from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom -from models import Account +from models import Account, App from models.enums import ConversationFromSource from models.model import Conversation, EndUser from models.web import PinnedConversation from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.web_conversation_service import WebConversationService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -77,23 +77,23 @@ class TestWebConversationService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) return app, account - def _create_test_end_user(self, db_session_with_containers: Session, app): + def _create_test_end_user(self, db_session_with_containers: Session, app: App): """ Helper method to create a test end user for testing. diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service.py b/api/tests/test_containers_integration_tests/services/test_webhook_service.py index 970da98c55..52b1229302 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker from flask import Flask +from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from models.enums import AppTriggerStatus, AppTriggerType @@ -52,7 +53,7 @@ class TestWebhookService: } @pytest.fixture - def test_data(self, db_session_with_containers, mock_external_dependencies): + def test_data(self, db_session_with_containers: Session, mock_external_dependencies): """Create test data for webhook service tests.""" fake = Faker() @@ -160,7 +161,7 @@ class TestWebhookService: "app_trigger": app_trigger, } - def test_get_webhook_trigger_and_workflow_success(self, test_data, flask_app_with_containers): + def test_get_webhook_trigger_and_workflow_success(self, test_data, flask_app_with_containers: Flask): """Test successful retrieval of webhook trigger and workflow.""" webhook_id = test_data["webhook_id"] @@ -175,7 +176,7 @@ class TestWebhookService: assert node_config["id"] == "webhook_node" assert node_config["data"].title == "Test Webhook" - def test_get_webhook_trigger_and_workflow_not_found(self, flask_app_with_containers): + def test_get_webhook_trigger_and_workflow_not_found(self, flask_app_with_containers: Flask): """Test webhook trigger not found scenario.""" with flask_app_with_containers.app_context(): with pytest.raises(ValueError, match="Webhook not found"): @@ -421,7 +422,9 @@ class TestWebhookService: assert result["files"] == {} - def test_trigger_workflow_execution_success(self, test_data, mock_external_dependencies, flask_app_with_containers): + def test_trigger_workflow_execution_success( + self, test_data, mock_external_dependencies, flask_app_with_containers: Flask + ): """Test successful workflow execution trigger.""" webhook_data = { "method": "POST", @@ -452,7 +455,7 @@ class TestWebhookService: mock_external_dependencies["async_service"].trigger_workflow_async.assert_called_once() def test_trigger_workflow_execution_end_user_service_failure( - self, test_data, mock_external_dependencies, flask_app_with_containers + self, test_data, mock_external_dependencies, flask_app_with_containers: Flask ): """Test workflow execution trigger when EndUserService fails.""" webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}} @@ -540,8 +543,8 @@ class TestWebhookService: "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), } - files["good_file"].read.return_value = b"content" - files["bad_file"].read.side_effect = Exception("Read error") + files["good_file"].stream.read.return_value = b"content" + files["bad_file"].stream.read.side_effect = Exception("Read error") webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py index 85ce3a6ba6..69cde847f8 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from sqlalchemy import select from sqlalchemy.orm import Session @@ -165,7 +166,7 @@ class WebhookServiceRelationshipFactory: class TestWebhookServiceLookupWithContainers: def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_missing( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -182,7 +183,7 @@ class TestWebhookServiceLookupWithContainers: WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_rate_limited( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -202,7 +203,7 @@ class TestWebhookServiceLookupWithContainers: WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -222,7 +223,7 @@ class TestWebhookServiceLookupWithContainers: WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) def test_get_webhook_trigger_and_workflow_raises_when_workflow_missing( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -239,7 +240,7 @@ class TestWebhookServiceLookupWithContainers: WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id) def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -275,7 +276,7 @@ class TestWebhookServiceLookupWithContainers: class TestWebhookServiceTriggerExecutionWithContainers: def test_trigger_workflow_execution_triggers_async_workflow_successfully( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -318,7 +319,7 @@ class TestWebhookServiceTriggerExecutionWithContainers: assert trigger_args[2].root_node_id == webhook_trigger.node_id def test_trigger_workflow_execution_marks_tenant_rate_limited_when_quota_exceeded( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -354,7 +355,7 @@ class TestWebhookServiceTriggerExecutionWithContainers: mock_mark_rate_limited.assert_called_once_with(tenant.id) def test_trigger_workflow_execution_logs_and_reraises_unexpected_errors( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -386,7 +387,7 @@ class TestWebhookServiceTriggerExecutionWithContainers: class TestWebhookServiceRelationshipSyncWithContainers: def test_sync_webhook_relationships_raises_when_workflow_exceeds_node_limit( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -401,7 +402,7 @@ class TestWebhookServiceRelationshipSyncWithContainers: WebhookService.sync_webhook_relationships(app, workflow) def test_sync_webhook_relationships_raises_when_lock_not_acquired( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -418,7 +419,7 @@ class TestWebhookServiceRelationshipSyncWithContainers: WebhookService.sync_webhook_relationships(app, workflow) def test_sync_webhook_relationships_creates_missing_records_and_deletes_stale_records( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -455,7 +456,7 @@ class TestWebhookServiceRelationshipSyncWithContainers: assert db_session_with_containers.get(WorkflowWebhookTrigger, stale_trigger_id) is None def test_sync_webhook_relationships_sets_redis_cache_for_new_record( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory @@ -481,7 +482,7 @@ class TestWebhookServiceRelationshipSyncWithContainers: assert cached_payload["webhook_id"] == "cache-webhook-id-00001" def test_sync_webhook_relationships_logs_when_lock_release_fails( - self, db_session_with_containers: Session, flask_app_with_containers + self, db_session_with_containers: Session, flask_app_with_containers: Flask ): del flask_app_with_containers factory = WebhookServiceRelationshipFactory diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py index 1e57b5603d..07a49130d0 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py @@ -17,7 +17,7 @@ from models.workflow import WorkflowAppLogCreatedFrom from services.account_service import AccountService, TenantService # Delay import of AppService to avoid circular dependency -# from services.app_service import AppService +# from services.app_service import AppService, CreateAppParams from services.workflow_app_service import LogView, WorkflowAppService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -82,20 +82,20 @@ class TestWorkflowAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -146,20 +146,20 @@ class TestWorkflowAppService: """ fake = Faker() - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1530,7 +1530,7 @@ class TestWorkflowAppService: assert result_cross_tenant["total"] == 0 def test_get_paginate_workflow_app_logs_raises_when_account_filter_email_not_found( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) service = WorkflowAppService() @@ -1543,7 +1543,7 @@ class TestWorkflowAppService: ) def test_get_paginate_workflow_app_logs_filters_by_account( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) service = WorkflowAppService() @@ -1558,7 +1558,9 @@ class TestWorkflowAppService: assert result["total"] >= 0 assert isinstance(result["data"], list) - def test_get_paginate_workflow_archive_logs(self, db_session_with_containers, mock_external_service_dependencies): + def test_get_paginate_workflow_archive_logs( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies) service = WorkflowAppService() diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_draft_variable_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_draft_variable_service.py index 86cf2327c7..82fe391b08 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_draft_variable_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_draft_variable_service.py @@ -45,7 +45,9 @@ class TestWorkflowDraftVariableService: # WorkflowDraftVariableService doesn't have external dependencies that need mocking return {} - def _create_test_app(self, db_session_with_containers: Session, mock_external_service_dependencies, fake=None): + def _create_test_app( + self, db_session_with_containers: Session, mock_external_service_dependencies, fake: Faker | None = None + ): """ Helper method to create a test app with realistic data for testing. @@ -80,7 +82,7 @@ class TestWorkflowDraftVariableService: db_session_with_containers.commit() return app - def _create_test_workflow(self, db_session_with_containers: Session, app, fake=None): + def _create_test_workflow(self, db_session_with_containers: Session, app, fake: Faker | None = None): """ Helper method to create a test workflow associated with an app. diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py index d02a078281..09fe1570bc 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py @@ -13,7 +13,7 @@ from models.model import ( ) from models.workflow import WorkflowRun from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.workflow_run_service import WorkflowRunService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -79,16 +79,16 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -535,13 +535,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Create workflow run without node executions @@ -586,13 +586,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Use invalid workflow run ID @@ -637,13 +637,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Create workflow run diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_service.py index b5ce8a53de..9ba1fda08b 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_service.py @@ -12,7 +12,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session -from models import Account, App, Workflow +from models import Account, AccountStatus, App, TenantStatus, Workflow from models.model import AppMode from models.workflow import WorkflowType from services.workflow_service import WorkflowService @@ -33,7 +33,7 @@ class TestWorkflowService: and realistic testing environment with actual database interactions. """ - def _create_test_account(self, db_session_with_containers: Session, fake=None): + def _create_test_account(self, db_session_with_containers: Session, fake: Faker | None = None): """ Helper method to create a test account with realistic data. @@ -49,7 +49,7 @@ class TestWorkflowService: email=fake.email(), name=fake.name(), avatar=fake.url(), - status="active", + status=AccountStatus.ACTIVE, interface_language="en-US", # Set interface language for Site creation ) account.created_at = fake.date_time_this_year() @@ -62,7 +62,7 @@ class TestWorkflowService: tenant = Tenant( name=f"Test Tenant {fake.company()}", plan="basic", - status="normal", + status=TenantStatus.NORMAL, ) tenant.id = account.current_tenant_id tenant.created_at = fake.date_time_this_year() @@ -77,7 +77,7 @@ class TestWorkflowService: return account - def _create_test_app(self, db_session_with_containers: Session, fake=None): + def _create_test_app(self, db_session_with_containers: Session, fake: Faker | None = None): """ Helper method to create a test app with realistic data. @@ -109,7 +109,7 @@ class TestWorkflowService: db_session_with_containers.commit() return app - def _create_test_workflow(self, db_session_with_containers: Session, app, account, fake=None): + def _create_test_workflow(self, db_session_with_containers: Session, app, account, fake: Faker | None = None): """ Helper method to create a test workflow associated with an app. diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py index 21a1975879..9b574fe2df 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py @@ -11,7 +11,7 @@ from core.tools.errors import WorkflowToolHumanInputNotSupportedError from models.tools import WorkflowToolProvider from models.workflow import Workflow as WorkflowModel from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.tools.workflow_tools_manage_service import WorkflowToolManageService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -94,16 +94,16 @@ class TestWorkflowToolManageService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_deletion.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_deletion.py index 29e1e240b4..afc4908c15 100644 --- a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_deletion.py +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_deletion.py @@ -100,7 +100,7 @@ class TestWorkflowDeletion: session.flush() return provider - def test_delete_workflow_success(self, db_session_with_containers): + def test_delete_workflow_success(self, db_session_with_containers: Session): tenant, account = self._create_tenant_and_account(db_session_with_containers) app = self._create_app(db_session_with_containers, tenant=tenant, account=account) workflow = self._create_workflow( @@ -118,7 +118,7 @@ class TestWorkflowDeletion: db_session_with_containers.expire_all() assert db_session_with_containers.get(Workflow, workflow_id) is None - def test_delete_draft_workflow_raises_error(self, db_session_with_containers): + def test_delete_draft_workflow_raises_error(self, db_session_with_containers: Session): tenant, account = self._create_tenant_and_account(db_session_with_containers) app = self._create_app(db_session_with_containers, tenant=tenant, account=account) workflow = self._create_workflow( @@ -130,7 +130,7 @@ class TestWorkflowDeletion: with pytest.raises(DraftWorkflowDeletionError): service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id) - def test_delete_workflow_in_use_by_app_raises_error(self, db_session_with_containers): + def test_delete_workflow_in_use_by_app_raises_error(self, db_session_with_containers: Session): tenant, account = self._create_tenant_and_account(db_session_with_containers) app = self._create_app(db_session_with_containers, tenant=tenant, account=account) workflow = self._create_workflow( @@ -144,7 +144,7 @@ class TestWorkflowDeletion: with pytest.raises(WorkflowInUseError, match="currently in use by app"): service.delete_workflow(session=db_session_with_containers, workflow_id=workflow.id, tenant_id=tenant.id) - def test_delete_workflow_published_as_tool_raises_error(self, db_session_with_containers): + def test_delete_workflow_published_as_tool_raises_error(self, db_session_with_containers: Session): tenant, account = self._create_tenant_and_account(db_session_with_containers) app = self._create_app(db_session_with_containers, tenant=tenant, account=account) workflow = self._create_workflow( diff --git a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_node_execution_service_repository.py b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_node_execution_service_repository.py index 4dab895135..32b76c3469 100644 --- a/api/tests/test_containers_integration_tests/services/workflow/test_workflow_node_execution_service_repository.py +++ b/api/tests/test_containers_integration_tests/services/workflow/test_workflow_node_execution_service_repository.py @@ -64,7 +64,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: db_session_with_containers.commit() return execution - def test_get_node_last_execution_found(self, db_session_with_containers): + def test_get_node_last_execution_found(self, db_session_with_containers: Session): """Test getting the last execution for a node when it exists.""" # Arrange tenant_id = str(uuid4()) @@ -110,7 +110,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: assert result.id == expected.id assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED - def test_get_node_last_execution_not_found(self, db_session_with_containers): + def test_get_node_last_execution_not_found(self, db_session_with_containers: Session): """Test getting the last execution for a node when it doesn't exist.""" # Arrange tenant_id = str(uuid4()) @@ -129,7 +129,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: # Assert assert result is None - def test_get_executions_by_workflow_run_empty(self, db_session_with_containers): + def test_get_executions_by_workflow_run_empty(self, db_session_with_containers: Session): """Test getting executions for a workflow run when none exist.""" # Arrange tenant_id = str(uuid4()) @@ -147,7 +147,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: # Assert assert result == [] - def test_get_execution_by_id_found(self, db_session_with_containers): + def test_get_execution_by_id_found(self, db_session_with_containers: Session): """Test getting execution by ID when it exists.""" # Arrange execution = self._create_execution( @@ -170,7 +170,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: assert result is not None assert result.id == execution.id - def test_get_execution_by_id_not_found(self, db_session_with_containers): + def test_get_execution_by_id_not_found(self, db_session_with_containers: Session): """Test getting execution by ID when it doesn't exist.""" # Arrange repository = self._create_repository(db_session_with_containers) @@ -182,7 +182,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: # Assert assert result is None - def test_delete_expired_executions(self, db_session_with_containers): + def test_delete_expired_executions(self, db_session_with_containers: Session): """Test deleting expired executions.""" # Arrange tenant_id = str(uuid4()) @@ -248,7 +248,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: assert old_execution_2_id not in remaining_ids assert kept_execution_id in remaining_ids - def test_delete_executions_by_app(self, db_session_with_containers): + def test_delete_executions_by_app(self, db_session_with_containers: Session): """Test deleting executions by app.""" # Arrange tenant_id = str(uuid4()) @@ -313,7 +313,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: assert deleted_2_id not in remaining_ids assert kept_id in remaining_ids - def test_get_expired_executions_batch(self, db_session_with_containers): + def test_get_expired_executions_batch(self, db_session_with_containers: Session): """Test getting expired executions batch for backup.""" # Arrange tenant_id = str(uuid4()) @@ -370,7 +370,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: assert old_execution_1.id in result_ids assert old_execution_2.id in result_ids - def test_delete_executions_by_ids(self, db_session_with_containers): + def test_delete_executions_by_ids(self, db_session_with_containers: Session): """Test deleting executions by IDs.""" # Arrange tenant_id = str(uuid4()) @@ -424,7 +424,7 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository: ).all() assert remaining == [] - def test_delete_executions_by_ids_empty_list(self, db_session_with_containers): + def test_delete_executions_by_ids_empty_list(self, db_session_with_containers: Session): """Test deleting executions with empty ID list.""" # Arrange repository = self._create_repository(db_session_with_containers) diff --git a/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py index fcc15aad42..94fd7602f5 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py @@ -128,7 +128,6 @@ class TestAddDocumentToIndexTask: for i in range(3): segment = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -451,7 +450,6 @@ class TestAddDocumentToIndexTask: segments = [] for i in range(3): segment = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -630,7 +628,6 @@ class TestAddDocumentToIndexTask: # Segment 1: Should be processed (enabled=False, status=SegmentStatus.COMPLETED) segment1 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -650,7 +647,6 @@ class TestAddDocumentToIndexTask: # Segment 2: Should be processed (enabled=True, status=SegmentStatus.COMPLETED) # Note: Implementation doesn't filter by enabled status, only by status=SegmentStatus.COMPLETED segment2 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -669,7 +665,6 @@ class TestAddDocumentToIndexTask: # Segment 3: Should NOT be processed (enabled=False, status="processing") segment3 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -688,7 +683,6 @@ class TestAddDocumentToIndexTask: # Segment 4: Should be processed (enabled=False, status=SegmentStatus.COMPLETED) segment4 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py index e29ca7ebab..436c8f11b0 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py @@ -177,7 +177,6 @@ class TestBatchCleanDocumentTask: fake = Faker() segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=document.dataset_id, document_id=document.id, @@ -290,10 +289,9 @@ class TestBatchCleanDocumentTask: account = self._create_test_account(db_session_with_containers) dataset = self._create_test_dataset(db_session_with_containers, account) document = self._create_test_document(db_session_with_containers, dataset, account) - + assert account.current_tenant # Create segment with simple content (no image references) segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=document.dataset_id, document_id=document.id, @@ -692,9 +690,9 @@ class TestBatchCleanDocumentTask: # Create multiple segments for the document segments = [] + assert account.current_tenant for i in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=document.dataset_id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py index 32bc2fc0bd..a31552a09e 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py @@ -220,7 +220,6 @@ class TestCleanDatasetTask: DocumentSegment: Created document segment instance """ segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -232,8 +231,6 @@ class TestCleanDatasetTask: status=SegmentStatus.COMPLETED, index_node_id=str(uuid.uuid4()), index_node_hash="test_hash", - created_at=datetime.now(), - updated_at=datetime.now(), ) db_session_with_containers.add(segment) @@ -614,7 +611,6 @@ class TestCleanDatasetTask: """ segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -626,8 +622,6 @@ class TestCleanDatasetTask: status=SegmentStatus.COMPLETED, index_node_id=str(uuid.uuid4()), index_node_hash="test_hash", - created_at=datetime.now(), - updated_at=datetime.now(), ) db_session_with_containers.add(segment) @@ -729,8 +723,6 @@ class TestCleanDatasetTask: type=DatasetMetadataType.STRING, created_by=account.id, ) - metadata.id = str(uuid.uuid4()) - metadata.created_at = datetime.now() metadata_items.append(metadata) # Create binding for each metadata item @@ -741,8 +733,6 @@ class TestCleanDatasetTask: document_id=documents[i % len(documents)].id, created_by=account.id, ) - binding.id = str(uuid.uuid4()) - binding.created_at = datetime.now() bindings.append(binding) db_session_with_containers.add_all(metadata_items) @@ -946,7 +936,6 @@ class TestCleanDatasetTask: long_content = "Very long content " * 100 # Long content within reasonable limits segment_content = f"Segment with special chars: {special_content}\n{long_content}" segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -958,8 +947,6 @@ class TestCleanDatasetTask: status=SegmentStatus.COMPLETED, index_node_id=str(uuid.uuid4()), index_node_hash="test_hash_" + "x" * 50, # Long hash within limits - created_at=datetime.now(), - updated_at=datetime.now(), ) db_session_with_containers.add(segment) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py index 7e5c374b5d..ef65b90508 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py @@ -71,7 +71,7 @@ class TestCleanNotionDocumentTask: yield mock_factory def test_clean_notion_document_task_success( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test successful cleanup of Notion documents with proper database operations. @@ -132,11 +132,10 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() document_ids.append(document.id) - + assert tenant # Create segments for each document for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -176,7 +175,7 @@ class TestCleanNotionDocumentTask: # 5. The task completes without errors def test_clean_notion_document_task_dataset_not_found( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task behavior when dataset is not found. @@ -196,7 +195,7 @@ class TestCleanNotionDocumentTask: mock_index_processor_factory.return_value.init_index_processor.assert_not_called() def test_clean_notion_document_task_empty_document_list( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task behavior with empty document list. @@ -240,7 +239,7 @@ class TestCleanNotionDocumentTask: assert args[1] == [] def test_clean_notion_document_task_with_different_index_types( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with different dataset index types. @@ -297,10 +296,9 @@ class TestCleanNotionDocumentTask: ) db_session_with_containers.add(document) db_session_with_containers.flush() - + assert tenant # Create test segment segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -328,7 +326,7 @@ class TestCleanNotionDocumentTask: mock_index_processor_factory.reset_mock() def test_clean_notion_document_task_with_segments_no_index_node_ids( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with segments that have no index_node_ids. @@ -379,12 +377,11 @@ class TestCleanNotionDocumentTask: ) db_session_with_containers.add(document) db_session_with_containers.flush() - + assert tenant # Create segments without index_node_ids segments = [] for i in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -411,7 +408,7 @@ class TestCleanNotionDocumentTask: # are properly deleted from the database. def test_clean_notion_document_task_partial_document_cleanup( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with partial document cleanup scenario. @@ -468,11 +465,10 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() documents.append(document) - + assert tenant # Create segments for each document for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -513,7 +509,7 @@ class TestCleanNotionDocumentTask: # The database operations work correctly, isolating only the specified documents. def test_clean_notion_document_task_with_mixed_segment_statuses( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with segments in different statuses. @@ -569,10 +565,9 @@ class TestCleanNotionDocumentTask: segment_statuses = [SegmentStatus.WAITING, SegmentStatus.INDEXING, SegmentStatus.COMPLETED, SegmentStatus.ERROR] segments = [] index_node_ids = [] - + assert tenant for i, status in enumerate(segment_statuses): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -603,7 +598,7 @@ class TestCleanNotionDocumentTask: # IndexProcessor verification would require more sophisticated mocking. def test_clean_notion_document_task_continues_when_index_processor_fails( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Index processor failure (e.g. transient billing API error propagated via @@ -665,10 +660,9 @@ class TestCleanNotionDocumentTask: ) db_session_with_containers.add(document) db_session_with_containers.flush() - + assert tenant # Create segment segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -707,7 +701,7 @@ class TestCleanNotionDocumentTask: assert _count_segments(db_session_with_containers, DocumentSegment.document_id == document.id) == 0 def test_clean_notion_document_task_with_large_number_of_documents( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with a large number of documents and segments. @@ -765,12 +759,11 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() documents.append(document) - + assert tenant # Create multiple segments for each document num_segments_per_doc = 5 for j in range(num_segments_per_doc): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -806,7 +799,7 @@ class TestCleanNotionDocumentTask: # The database efficiently handles large-scale deletions. def test_clean_notion_document_task_with_documents_from_different_tenants( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with documents from different tenants. @@ -875,7 +868,6 @@ class TestCleanNotionDocumentTask: # Create segments for each document for j in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -918,7 +910,7 @@ class TestCleanNotionDocumentTask: # Only documents from the target dataset are affected, maintaining tenant separation. def test_clean_notion_document_task_with_documents_in_different_states( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with documents in different indexing states. @@ -984,11 +976,10 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() documents.append(document) - + assert tenant # Create segments for each document for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -1024,7 +1015,7 @@ class TestCleanNotionDocumentTask: # All documents are deleted regardless of their indexing status. def test_clean_notion_document_task_with_documents_having_metadata( - self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_index_processor_factory, mock_external_service_dependencies ): """ Test cleanup task with documents that have rich metadata. @@ -1093,10 +1084,9 @@ class TestCleanNotionDocumentTask: # Create segments with metadata segments = [] index_node_ids = [] - + assert tenant for i in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py index 9084667c31..a8d295e6a9 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_create_segment_to_index_task.py @@ -12,10 +12,11 @@ from uuid import uuid4 import pytest from faker import Faker from sqlalchemy import delete +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from extensions.ext_redis import redis_client -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, Document, DocumentSegment from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus from tasks.create_segment_to_index_task import create_segment_to_index_task @@ -25,7 +26,7 @@ class TestCreateSegmentToIndexTask: """Integration tests for create_segment_to_index_task using testcontainers.""" @pytest.fixture(autouse=True) - def cleanup_database(self, db_session_with_containers): + def cleanup_database(self, db_session_with_containers: Session): """Clean up database and Redis before each test to ensure isolation.""" # Clear all test data using fixture session @@ -55,7 +56,7 @@ class TestCreateSegmentToIndexTask: "index_processor": mock_processor, } - def _create_test_account_and_tenant(self, db_session_with_containers): + def _create_test_account_and_tenant(self, db_session_with_containers: Session): """ Helper method to create a test account and tenant for testing. @@ -72,7 +73,7 @@ class TestCreateSegmentToIndexTask: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) @@ -81,7 +82,7 @@ class TestCreateSegmentToIndexTask: # Create tenant tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, plan="basic", ) db_session_with_containers.add(tenant) @@ -102,7 +103,7 @@ class TestCreateSegmentToIndexTask: return account, tenant - def _create_test_dataset_and_document(self, db_session_with_containers, tenant_id, account_id): + def _create_test_dataset_and_document(self, db_session_with_containers: Session, tenant_id, account_id): """ Helper method to create a test dataset and document for testing. @@ -151,7 +152,13 @@ class TestCreateSegmentToIndexTask: return dataset, document def _create_test_segment( - self, db_session_with_containers, dataset_id, document_id, tenant_id, account_id, status=SegmentStatus.WAITING + self, + db_session_with_containers: Session, + dataset_id, + document_id, + tenant_id, + account_id, + status=SegmentStatus.WAITING, ): """ Helper method to create a test document segment for testing. @@ -189,7 +196,9 @@ class TestCreateSegmentToIndexTask: return segment - def test_create_segment_to_index_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_create_segment_to_index_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test successful creation of segment to index. @@ -225,7 +234,7 @@ class TestCreateSegmentToIndexTask: assert redis_client.exists(cache_key) == 0 def test_create_segment_to_index_segment_not_found( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of non-existent segment ID. @@ -246,7 +255,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor_factory"].assert_not_called() def test_create_segment_to_index_invalid_status( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of segment with invalid status. @@ -277,7 +286,9 @@ class TestCreateSegmentToIndexTask: # Verify no index processor calls were made mock_external_service_dependencies["index_processor_factory"].assert_not_called() - def test_create_segment_to_index_no_dataset(self, db_session_with_containers, mock_external_service_dependencies): + def test_create_segment_to_index_no_dataset( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test handling of segment without associated dataset. @@ -330,7 +341,9 @@ class TestCreateSegmentToIndexTask: # Verify no index processor calls were made mock_external_service_dependencies["index_processor_factory"].assert_not_called() - def test_create_segment_to_index_no_document(self, db_session_with_containers, mock_external_service_dependencies): + def test_create_segment_to_index_no_document( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test handling of segment without associated document. @@ -367,7 +380,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor_factory"].assert_not_called() def test_create_segment_to_index_document_disabled( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of segment with disabled document. @@ -403,7 +416,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor_factory"].assert_not_called() def test_create_segment_to_index_document_archived( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of segment with archived document. @@ -439,7 +452,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor_factory"].assert_not_called() def test_create_segment_to_index_document_indexing_incomplete( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of segment with document that has incomplete indexing. @@ -475,7 +488,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor_factory"].assert_not_called() def test_create_segment_to_index_processor_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of index processor exceptions. @@ -511,7 +524,7 @@ class TestCreateSegmentToIndexTask: assert redis_client.exists(cache_key) == 0 def test_create_segment_to_index_with_keywords( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with custom keywords. @@ -543,7 +556,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor"].load.assert_called_once() def test_create_segment_to_index_different_doc_forms( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with different document forms. @@ -586,7 +599,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor_factory"].assert_called_with(doc_form) def test_create_segment_to_index_performance_timing( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing performance and timing. @@ -617,7 +630,7 @@ class TestCreateSegmentToIndexTask: assert segment.status == SegmentStatus.COMPLETED def test_create_segment_to_index_concurrent_execution( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test concurrent execution of segment indexing tasks. @@ -654,7 +667,7 @@ class TestCreateSegmentToIndexTask: assert mock_external_service_dependencies["index_processor_factory"].call_count == 3 def test_create_segment_to_index_large_content( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with large content. @@ -703,7 +716,7 @@ class TestCreateSegmentToIndexTask: assert segment.completed_at is not None def test_create_segment_to_index_redis_failure( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing when Redis operations fail. @@ -743,7 +756,7 @@ class TestCreateSegmentToIndexTask: assert redis_client.exists(cache_key) == 1 def test_create_segment_to_index_database_transaction_rollback( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with database transaction handling. @@ -775,7 +788,7 @@ class TestCreateSegmentToIndexTask: assert segment.error is not None def test_create_segment_to_index_metadata_validation( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with metadata validation. @@ -817,7 +830,7 @@ class TestCreateSegmentToIndexTask: assert doc is not None def test_create_segment_to_index_status_transition_flow( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test complete status transition flow during indexing. @@ -852,7 +865,7 @@ class TestCreateSegmentToIndexTask: assert segment.indexing_at <= segment.completed_at def test_create_segment_to_index_with_empty_content( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with empty or minimal content. @@ -894,7 +907,7 @@ class TestCreateSegmentToIndexTask: assert segment.completed_at is not None def test_create_segment_to_index_with_special_characters( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with special characters and unicode content. @@ -940,7 +953,7 @@ class TestCreateSegmentToIndexTask: assert segment.completed_at is not None def test_create_segment_to_index_with_long_keywords( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with long keyword lists. @@ -974,7 +987,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor"].load.assert_called_once() def test_create_segment_to_index_tenant_isolation( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with proper tenant isolation. @@ -1017,7 +1030,7 @@ class TestCreateSegmentToIndexTask: assert segment1.tenant_id != segment2.tenant_id def test_create_segment_to_index_with_none_keywords( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test segment indexing with None keywords parameter. @@ -1048,7 +1061,7 @@ class TestCreateSegmentToIndexTask: mock_external_service_dependencies["index_processor"].load.assert_called_once() def test_create_segment_to_index_comprehensive_integration( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Comprehensive integration test covering multiple scenarios. diff --git a/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py index 684097851b..5287cd06db 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py @@ -7,11 +7,12 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker from sqlalchemy import select +from sqlalchemy.orm import Session from core.indexing_runner import DocumentIsPausedError from core.rag.index_processor.constant.index_type import IndexTechniqueType from enums.cloud_plan import CloudPlan -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, Document from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus from tasks.document_indexing_task import ( @@ -53,7 +54,7 @@ class _TrackedSessionContext: @pytest.fixture(autouse=True) -def _ensure_testcontainers_db(db_session_with_containers): +def _ensure_testcontainers_db(db_session_with_containers: Session): """Ensure this suite always runs on testcontainers infrastructure.""" return db_session_with_containers @@ -120,12 +121,12 @@ class TestDatasetIndexingTaskIntegration: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.flush() - tenant = Tenant(name=fake.company(), status="normal") + tenant = Tenant(name=fake.company(), status=TenantStatus.NORMAL) db_session_with_containers.add(tenant) db_session_with_containers.flush() @@ -174,11 +175,11 @@ class TestDatasetIndexingTaskIntegration: return dataset, documents - def _query_document(self, db_session_with_containers, document_id: str) -> Document | None: + def _query_document(self, db_session_with_containers: Session, document_id: str) -> Document | None: """Return the latest persisted document state.""" return db_session_with_containers.scalar(select(Document).where(Document.id == document_id).limit(1)) - def _assert_documents_parsing(self, db_session_with_containers, document_ids: Sequence[str]) -> None: + def _assert_documents_parsing(self, db_session_with_containers: Session, document_ids: Sequence[str]) -> None: """Assert all target documents are persisted in parsing status.""" db_session_with_containers.expire_all() for document_id in document_ids: @@ -212,7 +213,9 @@ class TestDatasetIndexingTaskIntegration: assert len(opened) >= 2 assert opened_ids <= closed_ids - def test_legacy_document_indexing_task_still_works(self, db_session_with_containers, patched_external_dependencies): + def test_legacy_document_indexing_task_still_works( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Ensure the legacy task entrypoint still updates parsing status.""" # Arrange dataset, documents = self._create_test_dataset_and_documents(db_session_with_containers, document_count=2) @@ -225,7 +228,9 @@ class TestDatasetIndexingTaskIntegration: patched_external_dependencies["indexing_runner_instance"].run.assert_called_once() self._assert_documents_parsing(db_session_with_containers, document_ids) - def test_batch_processing_multiple_documents(self, db_session_with_containers, patched_external_dependencies): + def test_batch_processing_multiple_documents( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Process multiple documents in one batch.""" # Arrange dataset, documents = self._create_test_dataset_and_documents(db_session_with_containers, document_count=3) @@ -240,7 +245,9 @@ class TestDatasetIndexingTaskIntegration: assert len(run_args) == len(document_ids) self._assert_documents_parsing(db_session_with_containers, document_ids) - def test_batch_processing_with_limit_check(self, db_session_with_containers, patched_external_dependencies): + def test_batch_processing_with_limit_check( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Reject batches larger than configured upload limit. This test patches config only to force a deterministic limit branch while keeping SQL writes real. @@ -263,7 +270,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_documents_error_contains(db_session_with_containers, document_ids, "batch upload limit") def test_batch_processing_sandbox_plan_single_document_only( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Reject multi-document upload under sandbox plan.""" # Arrange @@ -280,7 +287,9 @@ class TestDatasetIndexingTaskIntegration: patched_external_dependencies["indexing_runner_instance"].run.assert_not_called() self._assert_documents_error_contains(db_session_with_containers, document_ids, "does not support batch upload") - def test_batch_processing_empty_document_list(self, db_session_with_containers, patched_external_dependencies): + def test_batch_processing_empty_document_list( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Handle empty list input without failing.""" # Arrange dataset, _ = self._create_test_dataset_and_documents(db_session_with_containers, document_count=0) @@ -292,7 +301,7 @@ class TestDatasetIndexingTaskIntegration: patched_external_dependencies["indexing_runner_instance"].run.assert_called_once_with([]) def test_tenant_queue_dispatches_next_task_after_completion( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Dispatch the next queued task after current tenant task completes. @@ -337,7 +346,7 @@ class TestDatasetIndexingTaskIntegration: delete_key_spy.assert_not_called() def test_tenant_queue_deletes_running_key_when_no_follow_up_tasks( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Delete tenant running flag when queue has no pending tasks. @@ -362,7 +371,7 @@ class TestDatasetIndexingTaskIntegration: delete_key_spy.assert_called_once() def test_validation_failure_sets_error_status_when_vector_space_at_limit( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Set error status when vector space validation fails before runner phase.""" # Arrange @@ -382,7 +391,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_documents_error_contains(db_session_with_containers, document_ids, "over the limit") def test_runner_exception_does_not_crash_indexing_task( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Catch generic runner exceptions without crashing the task.""" # Arrange @@ -397,7 +406,7 @@ class TestDatasetIndexingTaskIntegration: patched_external_dependencies["indexing_runner_instance"].run.assert_called_once() self._assert_documents_parsing(db_session_with_containers, document_ids) - def test_document_paused_error_handling(self, db_session_with_containers, patched_external_dependencies): + def test_document_paused_error_handling(self, db_session_with_containers: Session, patched_external_dependencies): """Handle DocumentIsPausedError and keep persisted state consistent.""" # Arrange dataset, documents = self._create_test_dataset_and_documents(db_session_with_containers, document_count=2) @@ -424,7 +433,7 @@ class TestDatasetIndexingTaskIntegration: patched_external_dependencies["indexing_runner_instance"].run.assert_not_called() def test_tenant_queue_error_handling_still_processes_next_task( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Even on current task failure, enqueue the next waiting tenant task. @@ -491,7 +500,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_all_opened_sessions_closed(session_close_tracker) def test_multiple_documents_with_mixed_success_and_failure( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Process only existing documents when request includes missing ids.""" # Arrange @@ -508,7 +517,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_documents_parsing(db_session_with_containers, existing_ids) def test_tenant_queue_dispatches_up_to_concurrency_limit( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Dispatch only up to configured concurrency under queued backlog burst. @@ -543,7 +552,7 @@ class TestDatasetIndexingTaskIntegration: assert task_dispatch_spy.apply_async.call_count == concurrency_limit assert set_waiting_spy.call_count == concurrency_limit - def test_task_queue_fifo_ordering(self, db_session_with_containers, patched_external_dependencies): + def test_task_queue_fifo_ordering(self, db_session_with_containers: Session, patched_external_dependencies): """Keep FIFO ordering when dispatching next queued tasks. Queue APIs are patched to isolate dispatch side effects while preserving DB assertions. @@ -576,7 +585,9 @@ class TestDatasetIndexingTaskIntegration: call_kwargs = task_dispatch_spy.apply_async.call_args_list[index].kwargs.get("kwargs", {}) assert call_kwargs.get("document_ids") == expected_task["document_ids"] - def test_billing_disabled_skips_limit_checks(self, db_session_with_containers, patched_external_dependencies): + def test_billing_disabled_skips_limit_checks( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Skip limit checks when billing feature is disabled.""" # Arrange large_document_ids = [str(uuid.uuid4()) for _ in range(100)] @@ -595,7 +606,7 @@ class TestDatasetIndexingTaskIntegration: assert len(run_args) == 100 self._assert_documents_parsing(db_session_with_containers, large_document_ids) - def test_complete_workflow_normal_task(self, db_session_with_containers, patched_external_dependencies): + def test_complete_workflow_normal_task(self, db_session_with_containers: Session, patched_external_dependencies): """Run end-to-end normal queue workflow with tenant queue cleanup. Queue APIs are patched to isolate dispatch side effects while preserving DB assertions. @@ -618,7 +629,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_documents_parsing(db_session_with_containers, document_ids) delete_key_spy.assert_called_once() - def test_complete_workflow_priority_task(self, db_session_with_containers, patched_external_dependencies): + def test_complete_workflow_priority_task(self, db_session_with_containers: Session, patched_external_dependencies): """Run end-to-end priority queue workflow with tenant queue cleanup. Queue APIs are patched to isolate dispatch side effects while preserving DB assertions. @@ -641,7 +652,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_documents_parsing(db_session_with_containers, document_ids) delete_key_spy.assert_called_once() - def test_single_document_processing(self, db_session_with_containers, patched_external_dependencies): + def test_single_document_processing(self, db_session_with_containers: Session, patched_external_dependencies): """Process the minimum batch size (single document).""" # Arrange dataset, documents = self._create_test_dataset_and_documents(db_session_with_containers, document_count=1) @@ -655,7 +666,9 @@ class TestDatasetIndexingTaskIntegration: assert len(run_args) == 1 self._assert_documents_parsing(db_session_with_containers, [document_id]) - def test_document_with_special_characters_in_id(self, db_session_with_containers, patched_external_dependencies): + def test_document_with_special_characters_in_id( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Handle standard UUID ids with hyphen characters safely.""" # Arrange special_document_id = str(uuid.uuid4()) @@ -670,7 +683,9 @@ class TestDatasetIndexingTaskIntegration: # Assert self._assert_documents_parsing(db_session_with_containers, [special_document_id]) - def test_zero_vector_space_limit_allows_unlimited(self, db_session_with_containers, patched_external_dependencies): + def test_zero_vector_space_limit_allows_unlimited( + self, db_session_with_containers: Session, patched_external_dependencies + ): """Treat vector limit 0 as unlimited and continue indexing.""" # Arrange dataset, documents = self._create_test_dataset_and_documents(db_session_with_containers, document_count=3) @@ -689,7 +704,7 @@ class TestDatasetIndexingTaskIntegration: self._assert_documents_parsing(db_session_with_containers, document_ids) def test_negative_vector_space_values_handled_gracefully( - self, db_session_with_containers, patched_external_dependencies + self, db_session_with_containers: Session, patched_external_dependencies ): """Treat negative vector limits as non-blocking and continue indexing.""" # Arrange @@ -708,7 +723,7 @@ class TestDatasetIndexingTaskIntegration: patched_external_dependencies["indexing_runner_instance"].run.assert_called_once() self._assert_documents_parsing(db_session_with_containers, document_ids) - def test_large_document_batch_processing(self, db_session_with_containers, patched_external_dependencies): + def test_large_document_batch_processing(self, db_session_with_containers: Session, patched_external_dependencies): """Process a batch exactly at configured upload limit. This test patches config only to force a deterministic limit branch while keeping SQL writes real. diff --git a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py index 48fec441c5..aba2458d55 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py @@ -12,6 +12,7 @@ from unittest.mock import ANY, Mock, patch import pytest from faker import Faker from sqlalchemy import select +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType from models.dataset import Dataset, Document, DocumentSegment @@ -55,7 +56,7 @@ class TestDealDatasetVectorIndexTask: yield mock_factory @pytest.fixture - def account_and_tenant(self, db_session_with_containers, mock_external_service_dependencies): + def account_and_tenant(self, db_session_with_containers: Session, mock_external_service_dependencies): """Create an account with an owner tenant for testing. Returns a tuple of (account, tenant) where tenant is guaranteed to be non-None. @@ -73,7 +74,7 @@ class TestDealDatasetVectorIndexTask: return account, tenant def test_deal_dataset_vector_index_task_remove_action_success( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test successful removal of dataset vector index. @@ -89,7 +90,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -131,7 +131,7 @@ class TestDealDatasetVectorIndexTask: assert mock_processor.clean.call_count >= 0 # For now, just check it doesn't fail def test_deal_dataset_vector_index_task_add_action_success( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test successful addition of dataset vector index. @@ -149,7 +149,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -201,7 +200,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -233,7 +231,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_called_once() def test_deal_dataset_vector_index_task_update_action_success( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test successful update of dataset vector index. @@ -252,7 +250,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset with parent-child index dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -304,7 +301,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -337,7 +333,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_called_once() def test_deal_dataset_vector_index_task_dataset_not_found_error( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task behavior when dataset is not found. @@ -357,7 +353,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_not_called() def test_deal_dataset_vector_index_task_add_action_no_documents( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test add action when no documents exist for the dataset. @@ -370,7 +366,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset without documents dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -389,7 +384,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_not_called() def test_deal_dataset_vector_index_task_add_action_no_segments( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test add action when documents exist but have no segments. @@ -402,7 +397,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -447,7 +441,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_not_called() def test_deal_dataset_vector_index_task_update_action_no_documents( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test update action when no documents exist for the dataset. @@ -460,7 +454,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset without documents dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -480,7 +473,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_not_called() def test_deal_dataset_vector_index_task_add_action_with_exception_handling( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test add action with exception handling during processing. @@ -493,7 +486,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -545,7 +537,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -578,7 +569,7 @@ class TestDealDatasetVectorIndexTask: assert "Test exception during indexing" in updated_document.error def test_deal_dataset_vector_index_task_with_custom_index_type( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task behavior with custom index type (QA_INDEX). @@ -591,7 +582,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset with custom index type dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -623,7 +613,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -656,7 +645,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_called_once() def test_deal_dataset_vector_index_task_with_default_index_type( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task behavior with default index type (PARAGRAPH_INDEX). @@ -669,7 +658,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset without doc_form (should use default) dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -701,7 +689,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -734,7 +721,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_called_once() def test_deal_dataset_vector_index_task_multiple_documents_processing( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task processing with multiple documents and segments. @@ -747,7 +734,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -805,7 +791,6 @@ class TestDealDatasetVectorIndexTask: for i, document in enumerate(documents): for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -831,6 +816,7 @@ class TestDealDatasetVectorIndexTask: updated_document = db_session_with_containers.scalar( select(Document).where(Document.id == document.id).limit(1) ) + assert updated_document assert updated_document.indexing_status == IndexingStatus.COMPLETED # Verify index processor load was called multiple times @@ -839,7 +825,7 @@ class TestDealDatasetVectorIndexTask: assert mock_processor.load.call_count == 3 def test_deal_dataset_vector_index_task_document_status_transitions( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test document status transitions during task execution. @@ -852,7 +838,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -904,7 +889,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -938,7 +922,7 @@ class TestDealDatasetVectorIndexTask: assert updated_document.indexing_status == IndexingStatus.COMPLETED def test_deal_dataset_vector_index_task_with_disabled_documents( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task behavior with disabled documents. @@ -951,7 +935,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -1023,7 +1006,6 @@ class TestDealDatasetVectorIndexTask: # Create segments for enabled document only segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=enabled_document.id, @@ -1061,7 +1043,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_called_once() def test_deal_dataset_vector_index_task_with_archived_documents( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task behavior with archived documents. @@ -1074,7 +1056,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -1146,7 +1127,6 @@ class TestDealDatasetVectorIndexTask: # Create segments for active document only segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=active_document.id, @@ -1184,7 +1164,7 @@ class TestDealDatasetVectorIndexTask: mock_processor.load.assert_called_once() def test_deal_dataset_vector_index_task_with_incomplete_documents( - self, db_session_with_containers, mock_index_processor_factory, account_and_tenant + self, db_session_with_containers: Session, mock_index_processor_factory, account_and_tenant ): """ Test task behavior with documents that have incomplete indexing status. @@ -1197,7 +1177,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -1269,7 +1248,6 @@ class TestDealDatasetVectorIndexTask: # Create segments for completed document only segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=completed_document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py index 8a69707b38..a7edf4f77a 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py @@ -11,9 +11,19 @@ import logging from unittest.mock import MagicMock, patch from faker import Faker +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from models import Account, Dataset, Document, DocumentSegment, Tenant +from models import ( + Account, + AccountStatus, + Dataset, + DatasetPermissionEnum, + Document, + DocumentSegment, + Tenant, + TenantStatus, +) from models.enums import DataSourceType, DocumentCreatedFrom, DocumentDocType, IndexingStatus, SegmentStatus from tasks.delete_segment_from_index_task import delete_segment_from_index_task @@ -37,7 +47,7 @@ class TestDeleteSegmentFromIndexTask: and realistic testing environment with actual database interactions. """ - def _create_test_tenant(self, db_session_with_containers, fake=None): + def _create_test_tenant(self, db_session_with_containers: Session, fake: Faker | None = None): """ Helper method to create a test tenant with realistic data. @@ -49,7 +59,7 @@ class TestDeleteSegmentFromIndexTask: Tenant: Created test tenant instance """ fake = fake or Faker() - tenant = Tenant(name=f"Test Tenant {fake.company()}", plan="basic", status="normal") + tenant = Tenant(name=f"Test Tenant {fake.company()}", plan="basic", status=TenantStatus.NORMAL) tenant.id = fake.uuid4() tenant.created_at = fake.date_time_this_year() tenant.updated_at = tenant.created_at @@ -58,7 +68,7 @@ class TestDeleteSegmentFromIndexTask: db_session_with_containers.commit() return tenant - def _create_test_account(self, db_session_with_containers, tenant, fake=None): + def _create_test_account(self, db_session_with_containers: Session, tenant, fake: Faker | None = None): """ Helper method to create a test account with realistic data. @@ -75,7 +85,7 @@ class TestDeleteSegmentFromIndexTask: name=fake.name(), email=fake.email(), avatar=fake.url(), - status="active", + status=AccountStatus.ACTIVE, interface_language="en-US", ) account.id = fake.uuid4() @@ -86,7 +96,9 @@ class TestDeleteSegmentFromIndexTask: db_session_with_containers.commit() return account - def _create_test_dataset(self, db_session_with_containers, tenant, account, fake=None): + def _create_test_dataset( + self, db_session_with_containers: Session, tenant: Tenant, account: Account, fake: Faker | None = None + ): """ Helper method to create a test dataset with realistic data. @@ -106,7 +118,7 @@ class TestDeleteSegmentFromIndexTask: dataset.name = f"Test Dataset {fake.word()}" dataset.description = fake.text(max_nb_chars=200) dataset.provider = "vendor" - dataset.permission = "only_me" + dataset.permission = DatasetPermissionEnum.ONLY_ME dataset.data_source_type = DataSourceType.UPLOAD_FILE dataset.indexing_technique = IndexTechniqueType.HIGH_QUALITY dataset.index_struct = '{"type": "paragraph"}' @@ -122,7 +134,7 @@ class TestDeleteSegmentFromIndexTask: db_session_with_containers.commit() return dataset - def _create_test_document(self, db_session_with_containers, dataset, account, fake=None, **kwargs): + def _create_test_document(self, db_session_with_containers: Session, dataset, account, fake=None, **kwargs): """ Helper method to create a test document with realistic data. @@ -172,7 +184,14 @@ class TestDeleteSegmentFromIndexTask: db_session_with_containers.commit() return document - def _create_test_document_segments(self, db_session_with_containers, document, account, count=3, fake=None): + def _create_test_document_segments( + self, + db_session_with_containers: Session, + document: Document, + account: Account, + count: int = 3, + fake: Faker | None = None, + ): """ Helper method to create test document segments with realistic data. @@ -190,26 +209,25 @@ class TestDeleteSegmentFromIndexTask: segments = [] for i in range(count): - segment = DocumentSegment() - segment.id = fake.uuid4() - segment.tenant_id = document.tenant_id - segment.dataset_id = document.dataset_id - segment.document_id = document.id - segment.position = i + 1 - segment.content = f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}" - segment.answer = f"Test segment answer {i + 1}: {fake.text(max_nb_chars=100)}" - segment.word_count = fake.random_int(min=10, max=100) - segment.tokens = fake.random_int(min=5, max=50) - segment.keywords = [fake.word() for _ in range(3)] - segment.index_node_id = f"node_{fake.uuid4()}" - segment.index_node_hash = fake.sha256() - segment.hit_count = 0 - segment.enabled = True - segment.status = SegmentStatus.COMPLETED - segment.created_by = account.id - segment.created_at = fake.date_time_this_year() - segment.updated_by = account.id - segment.updated_at = segment.created_at + created_at = fake.date_time_this_year() + segment = DocumentSegment( + tenant_id=document.tenant_id, + dataset_id=document.dataset_id, + document_id=document.id, + position=i + 1, + content=f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}", + answer=f"Test segment answer {i + 1}: {fake.text(max_nb_chars=100)}", + word_count=fake.random_int(min=10, max=100), + tokens=fake.random_int(min=5, max=50), + keywords=[fake.word() for _ in range(3)], + index_node_id=f"node_{fake.uuid4()}", + index_node_hash=fake.sha256(), + hit_count=0, + enabled=True, + status=SegmentStatus.COMPLETED, + created_by=account.id, + updated_by=account.id, + ) db_session_with_containers.add(segment) segments.append(segment) @@ -218,7 +236,9 @@ class TestDeleteSegmentFromIndexTask: return segments @patch("tasks.delete_segment_from_index_task.IndexProcessorFactory", autospec=True) - def test_delete_segment_from_index_task_success(self, mock_index_processor_factory, db_session_with_containers): + def test_delete_segment_from_index_task_success( + self, mock_index_processor_factory, db_session_with_containers: Session + ): """ Test successful segment deletion from index with comprehensive verification. @@ -267,7 +287,7 @@ class TestDeleteSegmentFromIndexTask: assert call_args[1]["with_keywords"] is True assert call_args[1]["delete_child_chunks"] is True - def test_delete_segment_from_index_task_dataset_not_found(self, db_session_with_containers): + def test_delete_segment_from_index_task_dataset_not_found(self, db_session_with_containers: Session): """ Test task behavior when dataset is not found. @@ -288,7 +308,7 @@ class TestDeleteSegmentFromIndexTask: # Verify the task completed without exceptions assert result is None # Task should return None when dataset not found - def test_delete_segment_from_index_task_document_not_found(self, db_session_with_containers): + def test_delete_segment_from_index_task_document_not_found(self, db_session_with_containers: Session): """ Test task behavior when document is not found. @@ -314,7 +334,7 @@ class TestDeleteSegmentFromIndexTask: # Verify the task completed without exceptions assert result is None # Task should return None when document not found - def test_delete_segment_from_index_task_document_disabled(self, db_session_with_containers): + def test_delete_segment_from_index_task_document_disabled(self, db_session_with_containers: Session): """ Test task behavior when document is disabled. @@ -342,7 +362,7 @@ class TestDeleteSegmentFromIndexTask: # Verify the task completed without exceptions assert result is None # Task should return None when document is disabled - def test_delete_segment_from_index_task_document_archived(self, db_session_with_containers): + def test_delete_segment_from_index_task_document_archived(self, db_session_with_containers: Session): """ Test task behavior when document is archived. @@ -370,7 +390,7 @@ class TestDeleteSegmentFromIndexTask: # Verify the task completed without exceptions assert result is None # Task should return None when document is archived - def test_delete_segment_from_index_task_document_not_completed(self, db_session_with_containers): + def test_delete_segment_from_index_task_document_not_completed(self, db_session_with_containers: Session): """ Test task behavior when document indexing is not completed. diff --git a/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py index 5bdf7d1389..34e2ce4e80 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py @@ -159,7 +159,7 @@ class TestDisableSegmentFromIndexTask: dataset: Dataset, tenant: Tenant, account: Account, - status: str = "completed", + status: SegmentStatus = SegmentStatus.COMPLETED, enabled: bool = True, ) -> DocumentSegment: """ diff --git a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py index 6e03bd9351..cb5fb5483c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py @@ -7,13 +7,14 @@ The task is responsible for removing document segments from the search index whe """ from unittest.mock import MagicMock, patch +from uuid import uuid4 from faker import Faker from sqlalchemy import select from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from models import Account, Dataset, DocumentSegment +from models import Account, AccountStatus, Dataset, DocumentSegment, TenantAccountRole, TenantStatus from models import Document as DatasetDocument from models.dataset import DatasetProcessRule from models.enums import DataSourceType, DocumentCreatedFrom, ProcessRuleMode, SegmentStatus @@ -35,7 +36,7 @@ class TestDisableSegmentsFromIndexTask: and realistic testing environment with actual database interactions. """ - def _create_test_account(self, db_session_with_containers: Session, fake=None): + def _create_test_account(self, db_session_with_containers: Session, fake: Faker | None = None): """ Helper method to create a test account with realistic data. @@ -51,24 +52,23 @@ class TestDisableSegmentsFromIndexTask: email=fake.email(), name=fake.name(), avatar=fake.url(), - status="active", + status=AccountStatus.ACTIVE, interface_language="en-US", ) - account.id = fake.uuid4() # monkey-patch attributes for test setup + account.updated_at = fake.date_time_this_year() + account.created_at = fake.date_time_this_year() + account.role = TenantAccountRole.OWNER + account.id = fake.uuid4() account.tenant_id = fake.uuid4() account.type = "normal" - account.role = "owner" - account.created_at = fake.date_time_this_year() - account.updated_at = account.created_at - # Create a tenant for the account from models.account import Tenant tenant = Tenant( name=f"Test Tenant {fake.company()}", plan="basic", - status="normal", + status=TenantStatus.NORMAL, ) tenant.id = account.tenant_id tenant.created_at = fake.date_time_this_year() @@ -83,7 +83,7 @@ class TestDisableSegmentsFromIndexTask: return account - def _create_test_dataset(self, db_session_with_containers: Session, account, fake=None): + def _create_test_dataset(self, db_session_with_containers: Session, account: Account, fake: Faker | None = None): """ Helper method to create a test dataset with realistic data. @@ -117,7 +117,9 @@ class TestDisableSegmentsFromIndexTask: return dataset - def _create_test_document(self, db_session_with_containers: Session, dataset, account, fake=None): + def _create_test_document( + self, db_session_with_containers: Session, dataset: Dataset, account: Account, fake: Faker | None = None + ): """ Helper method to create a test document with realistic data. @@ -163,7 +165,7 @@ class TestDisableSegmentsFromIndexTask: return document def _create_test_segments( - self, db_session_with_containers: Session, document, dataset, account, count=3, fake=None + self, db_session_with_containers: Session, document, dataset: Dataset, account: Account, count=3, fake=None ): """ Helper method to create test document segments with realistic data. @@ -183,30 +185,31 @@ class TestDisableSegmentsFromIndexTask: segments = [] for i in range(count): - segment = DocumentSegment() - segment.id = fake.uuid4() - segment.tenant_id = dataset.tenant_id - segment.dataset_id = dataset.id - segment.document_id = document.id - segment.position = i + 1 - segment.content = f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}" - segment.answer = f"Test answer {i + 1}" if i % 2 == 0 else None - segment.word_count = fake.random_int(min=10, max=100) - segment.tokens = fake.random_int(min=5, max=50) - segment.keywords = [fake.word() for _ in range(3)] - segment.index_node_id = f"node_{segment.id}" - segment.index_node_hash = fake.sha256() - segment.hit_count = 0 - segment.enabled = True - segment.disabled_at = None - segment.disabled_by = None - segment.status = SegmentStatus.COMPLETED - segment.created_by = account.id - segment.updated_by = account.id - segment.indexing_at = fake.date_time_this_year() - segment.completed_at = fake.date_time_this_year() - segment.error = None - segment.stopped_at = None + id = fake.uuid4() + segment = DocumentSegment( + tenant_id=dataset.tenant_id, + dataset_id=dataset.id, + document_id=document.id, + position=i + 1, + content=f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}", + answer=f"Test answer {i + 1}" if i % 2 == 0 else None, + word_count=fake.random_int(min=10, max=100), + tokens=fake.random_int(min=5, max=50), + keywords=[fake.word() for _ in range(3)], + index_node_id=f"node_{id}", + index_node_hash=fake.sha256(), + hit_count=0, + enabled=True, + disabled_at=None, + disabled_by=None, + status=SegmentStatus.COMPLETED, + created_by=account.id, + updated_by=account.id, + indexing_at=fake.date_time_this_year(), + completed_at=fake.date_time_this_year(), + error=None, + stopped_at=None, + ) segments.append(segment) @@ -216,7 +219,9 @@ class TestDisableSegmentsFromIndexTask: return segments - def _create_dataset_process_rule(self, db_session_with_containers: Session, dataset, fake=None): + def _create_dataset_process_rule( + self, db_session_with_containers: Session, dataset: Dataset, fake: Faker | None = None + ): """ Helper method to create a dataset process rule. @@ -229,21 +234,19 @@ class TestDisableSegmentsFromIndexTask: DatasetProcessRule: Created process rule instance """ fake = fake or Faker() - process_rule = DatasetProcessRule() - process_rule.id = fake.uuid4() - process_rule.tenant_id = dataset.tenant_id - process_rule.dataset_id = dataset.id - process_rule.mode = ProcessRuleMode.AUTOMATIC - process_rule.rules = ( - "{" - '"mode": "automatic", ' - '"rules": {' - '"pre_processing_rules": [], "segmentation": ' - '{"separator": "\\n\\n", "max_tokens": 1000, "chunk_overlap": 50}}' - "}" + process_rule = DatasetProcessRule( + dataset_id=dataset.id, + mode=ProcessRuleMode.AUTOMATIC, + rules=( + "{" + '"mode": "automatic", ' + '"rules": {' + '"pre_processing_rules": [], "segmentation": ' + '{"separator": "\\n\\n", "max_tokens": 1000, "chunk_overlap": 50}}' + "}" + ), + created_by=str(uuid4()), ) - process_rule.created_by = dataset.created_by - process_rule.updated_by = dataset.updated_by db_session_with_containers.add(process_rule) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_sync_task.py b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_sync_task.py index b6e7e6e5c9..77cd259833 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_sync_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_sync_task.py @@ -13,6 +13,7 @@ from uuid import uuid4 import pytest from sqlalchemy import delete, func, select, update +from sqlalchemy.orm import Session from core.indexing_runner import DocumentIsPausedError, IndexingRunner from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType @@ -162,7 +163,7 @@ class TestDocumentIndexingSyncTask: "indexing_runner": indexing_runner, } - def _create_notion_sync_context(self, db_session_with_containers, *, data_source_info: dict | None = None): + def _create_notion_sync_context(self, db_session_with_containers: Session, *, data_source_info: dict | None = None): account, tenant = DocumentIndexingSyncTaskTestDataFactory.create_account_with_tenant(db_session_with_containers) dataset = DocumentIndexingSyncTaskTestDataFactory.create_dataset( db_session_with_containers, @@ -206,7 +207,7 @@ class TestDocumentIndexingSyncTask: "notion_info": notion_info, } - def test_document_not_found(self, db_session_with_containers, mock_external_dependencies): + def test_document_not_found(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task handles missing document gracefully.""" # Arrange dataset_id = str(uuid4()) @@ -219,7 +220,7 @@ class TestDocumentIndexingSyncTask: mock_external_dependencies["datasource_service"].get_datasource_credentials.assert_not_called() mock_external_dependencies["indexing_runner"].run.assert_not_called() - def test_missing_notion_workspace_id(self, db_session_with_containers, mock_external_dependencies): + def test_missing_notion_workspace_id(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task raises error when notion_workspace_id is missing.""" # Arrange context = self._create_notion_sync_context( @@ -235,7 +236,7 @@ class TestDocumentIndexingSyncTask: with pytest.raises(ValueError, match="no notion page found"): document_indexing_sync_task(context["dataset"].id, context["document"].id) - def test_missing_notion_page_id(self, db_session_with_containers, mock_external_dependencies): + def test_missing_notion_page_id(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task raises error when notion_page_id is missing.""" # Arrange context = self._create_notion_sync_context( @@ -251,7 +252,7 @@ class TestDocumentIndexingSyncTask: with pytest.raises(ValueError, match="no notion page found"): document_indexing_sync_task(context["dataset"].id, context["document"].id) - def test_empty_data_source_info(self, db_session_with_containers, mock_external_dependencies): + def test_empty_data_source_info(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task raises error when data_source_info is empty.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers, data_source_info=None) @@ -264,7 +265,7 @@ class TestDocumentIndexingSyncTask: with pytest.raises(ValueError, match="no notion page found"): document_indexing_sync_task(context["dataset"].id, context["document"].id) - def test_credential_not_found(self, db_session_with_containers, mock_external_dependencies): + def test_credential_not_found(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task sets document error state when credential is missing.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) @@ -284,7 +285,7 @@ class TestDocumentIndexingSyncTask: assert updated_document.stopped_at is not None mock_external_dependencies["indexing_runner"].run.assert_not_called() - def test_page_not_updated(self, db_session_with_containers, mock_external_dependencies): + def test_page_not_updated(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task exits early when notion page is unchanged.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) @@ -310,7 +311,7 @@ class TestDocumentIndexingSyncTask: mock_external_dependencies["index_processor"].clean.assert_not_called() mock_external_dependencies["indexing_runner"].run.assert_not_called() - def test_successful_sync_when_page_updated(self, db_session_with_containers, mock_external_dependencies): + def test_successful_sync_when_page_updated(self, db_session_with_containers: Session, mock_external_dependencies): """Test full successful sync flow with SQL state updates and side effects.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) @@ -349,7 +350,7 @@ class TestDocumentIndexingSyncTask: assert len(run_documents) == 1 assert getattr(run_documents[0], "id", None) == context["document"].id - def test_dataset_not_found_during_cleaning(self, db_session_with_containers, mock_external_dependencies): + def test_dataset_not_found_during_cleaning(self, db_session_with_containers: Session, mock_external_dependencies): """Test that task still updates document and reindexes if dataset vanishes before clean.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) @@ -376,7 +377,9 @@ class TestDocumentIndexingSyncTask: mock_external_dependencies["index_processor"].clean.assert_not_called() mock_external_dependencies["indexing_runner"].run.assert_called_once() - def test_cleaning_error_continues_to_indexing(self, db_session_with_containers, mock_external_dependencies): + def test_cleaning_error_continues_to_indexing( + self, db_session_with_containers: Session, mock_external_dependencies + ): """Test that indexing continues when index cleanup fails.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) @@ -400,7 +403,9 @@ class TestDocumentIndexingSyncTask: assert remaining_segments == 0 mock_external_dependencies["indexing_runner"].run.assert_called_once() - def test_indexing_runner_document_paused_error(self, db_session_with_containers, mock_external_dependencies): + def test_indexing_runner_document_paused_error( + self, db_session_with_containers: Session, mock_external_dependencies + ): """Test that DocumentIsPausedError does not flip document into error state.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) @@ -418,7 +423,7 @@ class TestDocumentIndexingSyncTask: assert updated_document.indexing_status == IndexingStatus.PARSING assert updated_document.error is None - def test_indexing_runner_general_error(self, db_session_with_containers, mock_external_dependencies): + def test_indexing_runner_general_error(self, db_session_with_containers: Session, mock_external_dependencies): """Test that indexing errors are persisted to document state.""" # Arrange context = self._create_notion_sync_context(db_session_with_containers) diff --git a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py index cf1a8666f3..6c1454b6d8 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py @@ -3,11 +3,12 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker +from sqlalchemy.orm import Session from core.entities.document_task import DocumentTask from core.rag.index_processor.constant.index_type import IndexTechniqueType from enums.cloud_plan import CloudPlan -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, Document from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus from tasks.document_indexing_task import ( @@ -51,7 +52,7 @@ class TestDocumentIndexingTasks: } def _create_test_dataset_and_documents( - self, db_session_with_containers, mock_external_service_dependencies, document_count=3 + self, db_session_with_containers: Session, mock_external_service_dependencies, document_count=3 ): """ Helper method to create a test dataset and documents for testing. @@ -71,14 +72,14 @@ class TestDocumentIndexingTasks: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -133,7 +134,7 @@ class TestDocumentIndexingTasks: return dataset, documents def _create_test_dataset_with_billing_features( - self, db_session_with_containers, mock_external_service_dependencies, billing_enabled=True + self, db_session_with_containers: Session, mock_external_service_dependencies, billing_enabled=True ): """ Helper method to create a test dataset with billing features configured. @@ -153,14 +154,14 @@ class TestDocumentIndexingTasks: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -221,7 +222,9 @@ class TestDocumentIndexingTasks: return dataset, documents - def test_document_indexing_task_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_document_indexing_task_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test successful document indexing with multiple documents. @@ -262,7 +265,7 @@ class TestDocumentIndexingTasks: assert len(processed_documents) == 3 def test_document_indexing_task_dataset_not_found( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of non-existent dataset. @@ -286,7 +289,7 @@ class TestDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_not_called() def test_document_indexing_task_document_not_found_in_dataset( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling when some documents don't exist in the dataset. @@ -332,7 +335,7 @@ class TestDocumentIndexingTasks: assert len(processed_documents) == 2 # Only existing documents def test_document_indexing_task_indexing_runner_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of IndexingRunner exceptions. @@ -373,7 +376,7 @@ class TestDocumentIndexingTasks: assert updated_document.processing_started_at is not None def test_document_indexing_task_mixed_document_states( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test processing documents with mixed initial states. @@ -456,7 +459,7 @@ class TestDocumentIndexingTasks: assert len(processed_documents) == 4 def test_document_indexing_task_billing_sandbox_plan_batch_limit( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test billing validation for sandbox plan batch upload limit. @@ -518,7 +521,7 @@ class TestDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner"].assert_not_called() def test_document_indexing_task_billing_disabled_success( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful processing when billing is disabled. @@ -554,7 +557,7 @@ class TestDocumentIndexingTasks: assert updated_document.processing_started_at is not None def test_document_indexing_task_document_is_paused_error( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of DocumentIsPausedError from IndexingRunner. @@ -597,7 +600,9 @@ class TestDocumentIndexingTasks: assert updated_document.processing_started_at is not None # ==================== NEW TESTS FOR REFACTORED FUNCTIONS ==================== - def test_old_document_indexing_task_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_old_document_indexing_task_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test document_indexing_task basic functionality. @@ -619,7 +624,7 @@ class TestDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once() def test_normal_document_indexing_task_success( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test normal_document_indexing_task basic functionality. @@ -643,7 +648,7 @@ class TestDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once() def test_priority_document_indexing_task_success( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test priority_document_indexing_task basic functionality. @@ -667,7 +672,7 @@ class TestDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once() def test_document_indexing_with_tenant_queue_success( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test _document_indexing_with_tenant_queue function with no waiting tasks. @@ -717,7 +722,7 @@ class TestDocumentIndexingTasks: mock_task_func.delay.assert_not_called() def test_document_indexing_with_tenant_queue_with_waiting_tasks( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test _document_indexing_with_tenant_queue function with waiting tasks in queue using real Redis. @@ -776,7 +781,7 @@ class TestDocumentIndexingTasks: assert len(remaining_tasks) == 1 def test_document_indexing_with_tenant_queue_error_handling( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test error handling in _document_indexing_with_tenant_queue using real Redis. @@ -848,7 +853,7 @@ class TestDocumentIndexingTasks: assert len(remaining_tasks) == 0 def test_document_indexing_with_tenant_queue_tenant_isolation( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test tenant isolation in _document_indexing_with_tenant_queue using real Redis. diff --git a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py index a9a8c0f30c..208fc1aa1d 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py @@ -3,9 +3,10 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, Document, DocumentSegment from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus from tasks.document_indexing_update_task import document_indexing_update_task @@ -33,7 +34,7 @@ class TestDocumentIndexingUpdateTask: "runner_instance": runner_instance, } - def _create_dataset_document_with_segments(self, db_session_with_containers, *, segment_count: int = 2): + def _create_dataset_document_with_segments(self, db_session_with_containers: Session, *, segment_count: int = 2): fake = Faker() # Account and tenant @@ -41,12 +42,12 @@ class TestDocumentIndexingUpdateTask: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() - tenant = Tenant(name=fake.company(), status="normal") + tenant = Tenant(name=fake.company(), status=TenantStatus.NORMAL) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -114,7 +115,7 @@ class TestDocumentIndexingUpdateTask: return dataset, document, node_ids - def test_cleans_segments_and_reindexes(self, db_session_with_containers, mock_external_dependencies): + def test_cleans_segments_and_reindexes(self, db_session_with_containers: Session, mock_external_dependencies): dataset, document, node_ids = self._create_dataset_document_with_segments(db_session_with_containers) # Act @@ -153,7 +154,9 @@ class TestDocumentIndexingUpdateTask: first = run_docs[0] assert getattr(first, "id", None) == document.id - def test_clean_error_is_logged_and_indexing_continues(self, db_session_with_containers, mock_external_dependencies): + def test_clean_error_is_logged_and_indexing_continues( + self, db_session_with_containers: Session, mock_external_dependencies + ): dataset, document, node_ids = self._create_dataset_document_with_segments(db_session_with_containers) # Force clean to raise; task should continue to indexing @@ -173,7 +176,7 @@ class TestDocumentIndexingUpdateTask: ) assert remaining > 0 - def test_document_not_found_noop(self, db_session_with_containers, mock_external_dependencies): + def test_document_not_found_noop(self, db_session_with_containers: Session, mock_external_dependencies): fake = Faker() # Act with non-existent document id document_indexing_update_task(dataset_id=fake.uuid4(), document_id=fake.uuid4()) diff --git a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py index 39c58987fd..e1c7e3e09a 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker from sqlalchemy import select +from sqlalchemy.orm import Session from core.indexing_runner import DocumentIsPausedError from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType @@ -62,7 +63,7 @@ class TestDuplicateDocumentIndexingTasks: } def _create_test_dataset_and_documents( - self, db_session_with_containers, mock_external_service_dependencies, document_count=3 + self, db_session_with_containers: Session, mock_external_service_dependencies, document_count=3 ): """ Helper method to create a test dataset and documents for testing. @@ -145,7 +146,11 @@ class TestDuplicateDocumentIndexingTasks: return dataset, documents def _create_test_dataset_with_segments( - self, db_session_with_containers, mock_external_service_dependencies, document_count=3, segments_per_doc=2 + self, + db_session_with_containers: Session, + mock_external_service_dependencies, + document_count=3, + segments_per_doc=2, ): """ Helper method to create a test dataset with documents and segments. @@ -170,7 +175,6 @@ class TestDuplicateDocumentIndexingTasks: for document in documents: for i in range(segments_per_doc): segment = DocumentSegment( - id=fake.uuid4(), tenant_id=dataset.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -197,7 +201,7 @@ class TestDuplicateDocumentIndexingTasks: return dataset, documents, segments def _create_test_dataset_with_billing_features( - self, db_session_with_containers, mock_external_service_dependencies, billing_enabled=True + self, db_session_with_containers: Session, mock_external_service_dependencies, billing_enabled=True ): """ Helper method to create a test dataset with billing features configured. @@ -287,7 +291,7 @@ class TestDuplicateDocumentIndexingTasks: return dataset, documents def _test_duplicate_document_indexing_task_success( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful duplicate document indexing with multiple documents. @@ -329,7 +333,7 @@ class TestDuplicateDocumentIndexingTasks: assert len(processed_documents) == 3 def _test_duplicate_document_indexing_task_with_segment_cleanup( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test duplicate document indexing with existing segments that need cleanup. @@ -379,7 +383,7 @@ class TestDuplicateDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once() def _test_duplicate_document_indexing_task_dataset_not_found( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of non-existent dataset. @@ -404,7 +408,7 @@ class TestDuplicateDocumentIndexingTasks: mock_external_service_dependencies["index_processor"].clean.assert_not_called() def test_duplicate_document_indexing_task_document_not_found_in_dataset( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling when some documents don't exist in the dataset. @@ -450,7 +454,7 @@ class TestDuplicateDocumentIndexingTasks: assert len(processed_documents) == 2 # Only existing documents def _test_duplicate_document_indexing_task_indexing_runner_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of IndexingRunner exceptions. @@ -491,7 +495,7 @@ class TestDuplicateDocumentIndexingTasks: assert updated_document.processing_started_at is not None def _test_duplicate_document_indexing_task_billing_sandbox_plan_batch_limit( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test billing validation for sandbox plan batch upload limit. @@ -554,7 +558,7 @@ class TestDuplicateDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_not_called() def _test_duplicate_document_indexing_task_billing_vector_space_limit_exceeded( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test billing validation for vector space limit. @@ -596,7 +600,7 @@ class TestDuplicateDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_not_called() def test_duplicate_document_indexing_task_with_empty_document_list( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test handling of empty document list. @@ -622,7 +626,7 @@ class TestDuplicateDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once_with([]) def test_deprecated_duplicate_document_indexing_task_delegates_to_core( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test that deprecated duplicate_document_indexing_task delegates to core function. @@ -655,7 +659,7 @@ class TestDuplicateDocumentIndexingTasks: @patch("tasks.duplicate_document_indexing_task.TenantIsolatedTaskQueue", autospec=True) def test_normal_duplicate_document_indexing_task_with_tenant_queue( - self, mock_queue_class, db_session_with_containers, mock_external_service_dependencies + self, mock_queue_class, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test normal_duplicate_document_indexing_task with tenant isolation queue. @@ -698,7 +702,7 @@ class TestDuplicateDocumentIndexingTasks: @patch("tasks.duplicate_document_indexing_task.TenantIsolatedTaskQueue", autospec=True) def test_priority_duplicate_document_indexing_task_with_tenant_queue( - self, mock_queue_class, db_session_with_containers, mock_external_service_dependencies + self, mock_queue_class, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test priority_duplicate_document_indexing_task with tenant isolation queue. @@ -742,7 +746,7 @@ class TestDuplicateDocumentIndexingTasks: @patch("tasks.duplicate_document_indexing_task.TenantIsolatedTaskQueue", autospec=True) def test_tenant_queue_wrapper_processes_next_tasks( - self, mock_queue_class, db_session_with_containers, mock_external_service_dependencies + self, mock_queue_class, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test tenant queue wrapper processes next queued tasks. @@ -789,7 +793,7 @@ class TestDuplicateDocumentIndexingTasks: mock_queue.delete_task_key.assert_not_called() def test_successful_duplicate_document_indexing( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test successful duplicate document indexing flow.""" self._test_duplicate_document_indexing_task_success( @@ -797,7 +801,7 @@ class TestDuplicateDocumentIndexingTasks: ) def test_duplicate_document_indexing_dataset_not_found( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test duplicate document indexing when dataset is not found.""" self._test_duplicate_document_indexing_task_dataset_not_found( @@ -805,7 +809,7 @@ class TestDuplicateDocumentIndexingTasks: ) def test_duplicate_document_indexing_with_billing_enabled_sandbox_plan( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test duplicate document indexing with billing enabled and sandbox plan.""" self._test_duplicate_document_indexing_task_billing_sandbox_plan_batch_limit( @@ -813,7 +817,7 @@ class TestDuplicateDocumentIndexingTasks: ) def test_duplicate_document_indexing_with_billing_limit_exceeded( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test duplicate document indexing when billing limit is exceeded.""" self._test_duplicate_document_indexing_task_billing_vector_space_limit_exceeded( @@ -821,7 +825,7 @@ class TestDuplicateDocumentIndexingTasks: ) def test_duplicate_document_indexing_runner_error( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test duplicate document indexing when IndexingRunner raises an error.""" self._test_duplicate_document_indexing_task_indexing_runner_exception( @@ -829,7 +833,7 @@ class TestDuplicateDocumentIndexingTasks: ) def _test_duplicate_document_indexing_task_document_is_paused( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test duplicate document indexing when document is paused.""" # Arrange @@ -860,7 +864,7 @@ class TestDuplicateDocumentIndexingTasks: mock_external_service_dependencies["indexing_runner_instance"].run.assert_called_once() def test_duplicate_document_indexing_document_is_paused( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test duplicate document indexing when document is paused.""" self._test_duplicate_document_indexing_task_document_is_paused( @@ -868,7 +872,7 @@ class TestDuplicateDocumentIndexingTasks: ) def test_duplicate_document_indexing_cleans_old_segments( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """Test that duplicate document indexing cleans old segments.""" self._test_duplicate_document_indexing_task_with_segment_cleanup( diff --git a/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py index e2f35067e3..6d3b90d41c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py @@ -139,7 +139,6 @@ class TestEnableSegmentsToIndexTask: for i in range(count): text = fake.text(max_nb_chars=200) segment = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_account_deletion_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_account_deletion_task.py index ff72232d12..c4895839c9 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_account_deletion_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_account_deletion_task.py @@ -5,6 +5,7 @@ from faker import Faker from sqlalchemy.orm import Session from libs.email_i18n import EmailType +from models import TenantStatus from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from tasks.mail_account_deletion_task import send_account_deletion_verification_code, send_deletion_success_task @@ -55,7 +56,7 @@ class TestMailAccountDeletionTask: # Create tenant tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_change_mail_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_change_mail_task.py index 177af266fb..a697878bb6 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_change_mail_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_change_mail_task.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest from faker import Faker +from sqlalchemy.orm import Session from libs.email_i18n import EmailType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -29,7 +30,7 @@ class TestMailChangeMailTask: "get_email_i18n_service": mock_get_email_i18n_service, } - def _create_test_account(self, db_session_with_containers): + def _create_test_account(self, db_session_with_containers: Session): """ Helper method to create a test account for testing. @@ -72,7 +73,7 @@ class TestMailChangeMailTask: return account def test_send_change_mail_task_success_old_email_phase( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful change email task execution for old_email phase. @@ -103,7 +104,7 @@ class TestMailChangeMailTask: ) def test_send_change_mail_task_success_new_email_phase( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful change email task execution for new_email phase. @@ -134,7 +135,7 @@ class TestMailChangeMailTask: ) def test_send_change_mail_task_mail_not_initialized( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test change email task when mail service is not initialized. @@ -159,7 +160,7 @@ class TestMailChangeMailTask: mock_external_service_dependencies["email_i18n_service"].send_change_email.assert_not_called() def test_send_change_mail_task_email_service_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test change email task when email service raises an exception. @@ -191,7 +192,7 @@ class TestMailChangeMailTask: ) def test_send_change_mail_completed_notification_task_success( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful change email completed notification task execution. @@ -224,7 +225,7 @@ class TestMailChangeMailTask: ) def test_send_change_mail_completed_notification_task_mail_not_initialized( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test change email completed notification task when mail service is not initialized. @@ -247,7 +248,7 @@ class TestMailChangeMailTask: mock_external_service_dependencies["email_i18n_service"].send_email.assert_not_called() def test_send_change_mail_completed_notification_task_email_service_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test change email completed notification task when email service raises an exception. diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py index 8343711998..0eec166fe2 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_email_code_login_task.py @@ -15,8 +15,10 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker from sqlalchemy import delete +from sqlalchemy.orm import Session from libs.email_i18n import EmailType +from models import AccountStatus, TenantStatus from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from tasks.mail_email_code_login import send_email_code_login_mail_task @@ -37,7 +39,7 @@ class TestSendEmailCodeLoginMailTask: """ @pytest.fixture(autouse=True) - def cleanup_database(self, db_session_with_containers): + def cleanup_database(self, db_session_with_containers: Session): """Clean up database before each test to ensure isolation.""" from extensions.ext_redis import redis_client @@ -71,7 +73,7 @@ class TestSendEmailCodeLoginMailTask: "email_service_instance": mock_email_service_instance, } - def _create_test_account(self, db_session_with_containers, fake=None): + def _create_test_account(self, db_session_with_containers: Session, fake: Faker | None = None): """ Helper method to create a test account for testing. @@ -90,7 +92,7 @@ class TestSendEmailCodeLoginMailTask: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) @@ -98,7 +100,7 @@ class TestSendEmailCodeLoginMailTask: return account - def _create_test_tenant_and_account(self, db_session_with_containers, fake=None): + def _create_test_tenant_and_account(self, db_session_with_containers: Session, fake: Faker | None = None): """ Helper method to create a test tenant and account for testing. @@ -119,7 +121,7 @@ class TestSendEmailCodeLoginMailTask: tenant = Tenant( name=fake.company(), plan="basic", - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) @@ -138,7 +140,7 @@ class TestSendEmailCodeLoginMailTask: return account, tenant def test_send_email_code_login_mail_task_success_english( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful email code login mail sending in English. @@ -182,7 +184,7 @@ class TestSendEmailCodeLoginMailTask: ) def test_send_email_code_login_mail_task_success_chinese( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful email code login mail sending in Chinese. @@ -221,7 +223,7 @@ class TestSendEmailCodeLoginMailTask: ) def test_send_email_code_login_mail_task_success_multiple_languages( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test successful email code login mail sending with multiple languages. @@ -261,7 +263,7 @@ class TestSendEmailCodeLoginMailTask: assert call_args[1]["template_context"]["code"] == test_codes[i] def test_send_email_code_login_mail_task_mail_not_initialized( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email code login mail task when mail service is not initialized. @@ -299,7 +301,7 @@ class TestSendEmailCodeLoginMailTask: mock_email_service_instance.send_email.assert_not_called() def test_send_email_code_login_mail_task_email_service_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email code login mail task when email service raises an exception. @@ -346,7 +348,7 @@ class TestSendEmailCodeLoginMailTask: ) def test_send_email_code_login_mail_task_invalid_parameters( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email code login mail task with invalid parameters. @@ -388,7 +390,7 @@ class TestSendEmailCodeLoginMailTask: mock_email_service_instance.send_email.assert_called_once() def test_send_email_code_login_mail_task_edge_cases( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email code login mail task with edge cases and boundary conditions. @@ -451,7 +453,7 @@ class TestSendEmailCodeLoginMailTask: ) def test_send_email_code_login_mail_task_database_integration( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email code login mail task with database integration. @@ -497,7 +499,7 @@ class TestSendEmailCodeLoginMailTask: assert account.status == "active" def test_send_email_code_login_mail_task_redis_integration( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email code login mail task with Redis integration. @@ -541,7 +543,7 @@ class TestSendEmailCodeLoginMailTask: redis_client.delete(cache_key) def test_send_email_code_login_mail_task_error_handling_comprehensive( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test comprehensive error handling for email code login mail task. diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py index 95a867dbb5..a452bee9f8 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from sqlalchemy import delete +from sqlalchemy.orm import Session from configs import dify_config from core.app.app_config.entities import WorkflowUIBasedAppConfig @@ -30,7 +31,7 @@ from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task @pytest.fixture(autouse=True) -def cleanup_database(db_session_with_containers): +def cleanup_database(db_session_with_containers: Session): db_session_with_containers.execute(delete(HumanInputFormRecipient)) db_session_with_containers.execute(delete(HumanInputDelivery)) db_session_with_containers.execute(delete(HumanInputForm)) @@ -42,7 +43,7 @@ def cleanup_database(db_session_with_containers): db_session_with_containers.commit() -def _create_workspace_member(db_session_with_containers): +def _create_workspace_member(db_session_with_containers: Session): account = Account( email="owner@example.com", name="Owner", @@ -172,7 +173,9 @@ def _create_workflow_pause_state( db_session_with_containers.commit() -def test_dispatch_human_input_email_task_integration(monkeypatch: pytest.MonkeyPatch, db_session_with_containers): +def test_dispatch_human_input_email_task_integration( + monkeypatch: pytest.MonkeyPatch, db_session_with_containers: Session +): tenant, account = _create_workspace_member(db_session_with_containers) workflow_run_id = str(uuid.uuid4()) workflow_id = str(uuid.uuid4()) diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_inner_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_inner_task.py index 1a20b6deec..f8e54ea9e6 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_inner_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_inner_task.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest from faker import Faker +from sqlalchemy.orm import Session from tasks.mail_inner_task import send_inner_email_task @@ -51,7 +52,7 @@ class TestMailInnerTask: }, } - def test_send_inner_email_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_send_inner_email_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ Test successful email sending with valid data. @@ -90,7 +91,9 @@ class TestMailInnerTask: html_content="Test email content", ) - def test_send_inner_email_single_recipient(self, db_session_with_containers, mock_external_service_dependencies): + def test_send_inner_email_single_recipient( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test email sending with single recipient. @@ -126,7 +129,9 @@ class TestMailInnerTask: html_content="Test email content", ) - def test_send_inner_email_empty_substitutions(self, db_session_with_containers, mock_external_service_dependencies): + def test_send_inner_email_empty_substitutions( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test email sending with empty substitutions. @@ -163,7 +168,7 @@ class TestMailInnerTask: ) def test_send_inner_email_mail_not_initialized( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email sending when mail service is not initialized. @@ -193,7 +198,7 @@ class TestMailInnerTask: mock_external_service_dependencies["email_service"].send_raw_email.assert_not_called() def test_send_inner_email_template_rendering_error( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email sending when template rendering fails. @@ -222,7 +227,9 @@ class TestMailInnerTask: # Verify no email service calls due to exception mock_external_service_dependencies["email_service"].send_raw_email.assert_not_called() - def test_send_inner_email_service_error(self, db_session_with_containers, mock_external_service_dependencies): + def test_send_inner_email_service_error( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test email sending when email service fails. diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py index d34828c4b1..c8c7a4d961 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_invite_member_task.py @@ -18,6 +18,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker from sqlalchemy import delete, select +from sqlalchemy.orm import Session from extensions.ext_redis import redis_client from libs.email_i18n import EmailType @@ -42,7 +43,7 @@ class TestMailInviteMemberTask: """ @pytest.fixture(autouse=True) - def cleanup_database(self, db_session_with_containers): + def cleanup_database(self, db_session_with_containers: Session): """Clean up database before each test to ensure isolation.""" # Clear all test data db_session_with_containers.execute(delete(TenantAccountJoin)) @@ -78,7 +79,7 @@ class TestMailInviteMemberTask: "config": mock_config, } - def _create_test_account_and_tenant(self, db_session_with_containers): + def _create_test_account_and_tenant(self, db_session_with_containers: Session): """ Helper method to create a test account and tenant for testing. @@ -147,7 +148,7 @@ class TestMailInviteMemberTask: redis_client.setex(cache_key, 24 * 60 * 60, json.dumps(invitation_data)) # 24 hours return token - def _create_pending_account_for_invitation(self, db_session_with_containers, email, tenant): + def _create_pending_account_for_invitation(self, db_session_with_containers: Session, email, tenant): """ Helper method to create a pending account for invitation testing. @@ -185,7 +186,9 @@ class TestMailInviteMemberTask: return account - def test_send_invite_member_mail_success(self, db_session_with_containers, mock_external_service_dependencies): + def test_send_invite_member_mail_success( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): """ Test successful invitation email sending with all parameters. @@ -231,7 +234,7 @@ class TestMailInviteMemberTask: assert template_context["url"] == f"https://console.dify.ai/activate?token={token}" def test_send_invite_member_mail_different_languages( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test invitation email sending with different language codes. @@ -263,7 +266,7 @@ class TestMailInviteMemberTask: assert call_args[1]["language_code"] == language def test_send_invite_member_mail_mail_not_initialized( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test behavior when mail service is not initialized. @@ -292,7 +295,7 @@ class TestMailInviteMemberTask: mock_email_service.send_email.assert_not_called() def test_send_invite_member_mail_email_service_exception( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test error handling when email service raises an exception. @@ -322,7 +325,7 @@ class TestMailInviteMemberTask: assert "Send invite member mail to %s failed" in error_call def test_send_invite_member_mail_template_context_validation( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test template context contains all required fields for email rendering. @@ -368,7 +371,7 @@ class TestMailInviteMemberTask: assert template_context["url"] == f"https://console.dify.ai/activate?token={token}" def test_send_invite_member_mail_integration_with_redis_token( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test integration with Redis token validation. @@ -407,7 +410,7 @@ class TestMailInviteMemberTask: assert invitation_data["workspace_id"] == tenant.id def test_send_invite_member_mail_with_special_characters( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test email sending with special characters in names and workspace names. @@ -449,7 +452,7 @@ class TestMailInviteMemberTask: assert template_context["workspace_name"] == workspace_name def test_send_invite_member_mail_real_database_integration( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test real database integration with actual invitation flow. @@ -501,7 +504,7 @@ class TestMailInviteMemberTask: assert tenant_join.role == TenantAccountRole.NORMAL def test_send_invite_member_mail_token_lifecycle_management( - self, db_session_with_containers, mock_external_service_dependencies + self, db_session_with_containers: Session, mock_external_service_dependencies ): """ Test token lifecycle management and validation. diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_owner_transfer_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_owner_transfer_task.py index e08b099480..176645a4ab 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_owner_transfer_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_owner_transfer_task.py @@ -11,6 +11,7 @@ from unittest.mock import patch import pytest from faker import Faker +from sqlalchemy.orm import Session from libs.email_i18n import EmailType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -44,7 +45,7 @@ class TestMailOwnerTransferTask: "get_email_service": mock_get_email_service, } - def _create_test_account_and_tenant(self, db_session_with_containers): + def _create_test_account_and_tenant(self, db_session_with_containers: Session): """ Helper method to create test account and tenant for testing. @@ -86,7 +87,9 @@ class TestMailOwnerTransferTask: return account, tenant - def test_send_owner_transfer_confirm_task_success(self, db_session_with_containers, mock_mail_dependencies): + def test_send_owner_transfer_confirm_task_success( + self, db_session_with_containers: Session, mock_mail_dependencies + ): """ Test successful owner transfer confirmation email sending. @@ -127,7 +130,7 @@ class TestMailOwnerTransferTask: assert call_args[1]["template_context"]["WorkspaceName"] == test_workspace def test_send_owner_transfer_confirm_task_mail_not_initialized( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test owner transfer confirmation email when mail service is not initialized. @@ -158,7 +161,7 @@ class TestMailOwnerTransferTask: mock_mail_dependencies["email_service"].send_email.assert_not_called() def test_send_owner_transfer_confirm_task_exception_handling( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test exception handling in owner transfer confirmation email. @@ -192,7 +195,7 @@ class TestMailOwnerTransferTask: mock_mail_dependencies["email_service"].send_email.assert_called_once() def test_send_old_owner_transfer_notify_email_task_success( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test successful old owner transfer notification email sending. @@ -234,7 +237,7 @@ class TestMailOwnerTransferTask: assert call_args[1]["template_context"]["NewOwnerEmail"] == test_new_owner_email def test_send_old_owner_transfer_notify_email_task_mail_not_initialized( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test old owner transfer notification email when mail service is not initialized. @@ -265,7 +268,7 @@ class TestMailOwnerTransferTask: mock_mail_dependencies["email_service"].send_email.assert_not_called() def test_send_old_owner_transfer_notify_email_task_exception_handling( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test exception handling in old owner transfer notification email. @@ -299,7 +302,7 @@ class TestMailOwnerTransferTask: mock_mail_dependencies["email_service"].send_email.assert_called_once() def test_send_new_owner_transfer_notify_email_task_success( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test successful new owner transfer notification email sending. @@ -338,7 +341,7 @@ class TestMailOwnerTransferTask: assert call_args[1]["template_context"]["WorkspaceName"] == test_workspace def test_send_new_owner_transfer_notify_email_task_mail_not_initialized( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test new owner transfer notification email when mail service is not initialized. @@ -367,7 +370,7 @@ class TestMailOwnerTransferTask: mock_mail_dependencies["email_service"].send_email.assert_not_called() def test_send_new_owner_transfer_notify_email_task_exception_handling( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """ Test exception handling in new owner transfer notification email. diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_register_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_register_task.py index cced6f7780..071971f324 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_mail_register_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_mail_register_task.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from faker import Faker +from sqlalchemy.orm import Session from libs.email_i18n import EmailType from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist @@ -35,7 +36,7 @@ class TestMailRegisterTask: "get_email_service": mock_get_email_service, } - def test_send_email_register_mail_task_success(self, db_session_with_containers, mock_mail_dependencies): + def test_send_email_register_mail_task_success(self, db_session_with_containers: Session, mock_mail_dependencies): """Test successful email registration mail sending.""" fake = Faker() language = "en-US" @@ -56,7 +57,7 @@ class TestMailRegisterTask: ) def test_send_email_register_mail_task_mail_not_initialized( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """Test email registration task when mail service is not initialized.""" mock_mail_dependencies["mail"].is_inited.return_value = False @@ -66,7 +67,9 @@ class TestMailRegisterTask: mock_mail_dependencies["get_email_service"].assert_not_called() mock_mail_dependencies["email_service"].send_email.assert_not_called() - def test_send_email_register_mail_task_exception_handling(self, db_session_with_containers, mock_mail_dependencies): + def test_send_email_register_mail_task_exception_handling( + self, db_session_with_containers: Session, mock_mail_dependencies + ): """Test email registration task exception handling.""" mock_mail_dependencies["email_service"].send_email.side_effect = Exception("Email service error") @@ -79,7 +82,7 @@ class TestMailRegisterTask: mock_logger.exception.assert_called_once_with("Send email register mail to %s failed", to_email) def test_send_email_register_mail_task_when_account_exist_success( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """Test successful email registration mail sending when account exists.""" fake = Faker() @@ -105,7 +108,7 @@ class TestMailRegisterTask: ) def test_send_email_register_mail_task_when_account_exist_mail_not_initialized( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """Test account exist email task when mail service is not initialized.""" mock_mail_dependencies["mail"].is_inited.return_value = False @@ -118,7 +121,7 @@ class TestMailRegisterTask: mock_mail_dependencies["email_service"].send_email.assert_not_called() def test_send_email_register_mail_task_when_account_exist_exception_handling( - self, db_session_with_containers, mock_mail_dependencies + self, db_session_with_containers: Session, mock_mail_dependencies ): """Test account exist email task exception handling.""" mock_mail_dependencies["email_service"].send_email.side_effect = Exception("Email service error") diff --git a/api/tests/test_containers_integration_tests/tasks/test_rag_pipeline_run_tasks.py b/api/tests/test_containers_integration_tests/tasks/test_rag_pipeline_run_tasks.py index f01fcc1742..5eea985fdc 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_rag_pipeline_run_tasks.py +++ b/api/tests/test_containers_integration_tests/tasks/test_rag_pipeline_run_tasks.py @@ -4,12 +4,13 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker +from flask import Flask from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom, RagPipelineGenerateEntity from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Pipeline from models.workflow import Workflow from tasks.rag_pipeline.priority_rag_pipeline_run_task import ( @@ -69,14 +70,14 @@ class TestRagPipelineRunTasks: email=fake.email(), name=fake.name(), interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() tenant = Tenant( name=fake.company(), - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -725,7 +726,7 @@ class TestRagPipelineRunTasks: assert queue1._task_key != queue2._task_key def test_run_single_rag_pipeline_task_success( - self, db_session_with_containers: Session, mock_pipeline_generator, flask_app_with_containers + self, db_session_with_containers: Session, mock_pipeline_generator, flask_app_with_containers: Flask ): """ Test successful run_single_rag_pipeline_task execution. @@ -760,7 +761,7 @@ class TestRagPipelineRunTasks: assert isinstance(call_kwargs["application_generate_entity"], RagPipelineGenerateEntity) def test_run_single_rag_pipeline_task_entity_validation_error( - self, db_session_with_containers: Session, mock_pipeline_generator, flask_app_with_containers + self, db_session_with_containers: Session, mock_pipeline_generator, flask_app_with_containers: Flask ): """ Test run_single_rag_pipeline_task with invalid entity data. @@ -805,7 +806,7 @@ class TestRagPipelineRunTasks: mock_pipeline_generator.assert_not_called() def test_run_single_rag_pipeline_task_database_entity_not_found( - self, db_session_with_containers: Session, mock_pipeline_generator, flask_app_with_containers + self, db_session_with_containers: Session, mock_pipeline_generator, flask_app_with_containers: Flask ): """ Test run_single_rag_pipeline_task with non-existent database entities. diff --git a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py index b43b622870..204f533978 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, call, patch import pytest from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session from core.db.session_factory import session_factory from extensions.storage.storage_type import StorageType @@ -20,7 +21,7 @@ from tasks.remove_app_and_related_data_task import ( @pytest.fixture(autouse=True) -def cleanup_database(db_session_with_containers): +def cleanup_database(db_session_with_containers: Session): db_session_with_containers.execute(delete(WorkflowDraftVariable)) db_session_with_containers.execute(delete(WorkflowDraftVariableFile)) db_session_with_containers.execute(delete(UploadFile)) @@ -29,7 +30,7 @@ def cleanup_database(db_session_with_containers): db_session_with_containers.commit() -def _create_tenant_and_app(db_session_with_containers): +def _create_tenant_and_app(db_session_with_containers: Session): tenant = Tenant(name=f"test_tenant_{uuid.uuid4()}") db_session_with_containers.add(tenant) db_session_with_containers.flush() @@ -117,7 +118,7 @@ def _create_offload_data(db_session_with_containers, *, tenant_id: str, app_id: class TestDeleteDraftVariablesBatch: - def test_delete_draft_variables_batch_success(self, db_session_with_containers): + def test_delete_draft_variables_batch_success(self, db_session_with_containers: Session): """Test successful deletion of draft variables in batches.""" _, app1 = _create_tenant_and_app(db_session_with_containers) _, app2 = _create_tenant_and_app(db_session_with_containers) @@ -137,7 +138,7 @@ class TestDeleteDraftVariablesBatch: assert app1_remaining_count == 0 assert app2_remaining_count == 100 - def test_delete_draft_variables_batch_empty_result(self, db_session_with_containers): + def test_delete_draft_variables_batch_empty_result(self, db_session_with_containers: Session): """Test deletion when no draft variables exist for the app.""" result = delete_draft_variables_batch(str(uuid.uuid4()), 1000) @@ -176,7 +177,7 @@ class TestDeleteDraftVariableOffloadData: """Test the Offload data cleanup functionality.""" @patch("extensions.ext_storage.storage") - def test_delete_draft_variable_offload_data_success(self, mock_storage, db_session_with_containers): + def test_delete_draft_variable_offload_data_success(self, mock_storage, db_session_with_containers: Session): """Test successful deletion of offload data.""" tenant, app = _create_tenant_and_app(db_session_with_containers) offload_data = _create_offload_data(db_session_with_containers, tenant_id=tenant.id, app_id=app.id, count=3) diff --git a/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py b/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py index 34a1941c39..6365207661 100644 --- a/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py +++ b/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py @@ -1,12 +1,14 @@ from pathlib import Path +import pytest + from extensions.storage.opendal_storage import OpenDALStorage class TestOpenDALFsDefaultRoot: """Test that OpenDALStorage with scheme='fs' works correctly when no root is provided.""" - def test_fs_without_root_uses_default(self, tmp_path, monkeypatch): + def test_fs_without_root_uses_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """When no root is specified, the default 'storage' should be used and passed to the Operator.""" # Change to tmp_path so the default "storage" dir is created there monkeypatch.chdir(tmp_path) @@ -25,7 +27,7 @@ class TestOpenDALFsDefaultRoot: # Cleanup storage.delete("test_default_root.txt") - def test_fs_with_explicit_root(self, tmp_path): + def test_fs_with_explicit_root(self, tmp_path: Path): """When root is explicitly provided, it should be used.""" custom_root = str(tmp_path / "custom_storage") storage = OpenDALStorage(scheme="fs", root=custom_root) @@ -38,7 +40,7 @@ class TestOpenDALFsDefaultRoot: # Cleanup storage.delete("test_explicit_root.txt") - def test_fs_with_env_var_root(self, tmp_path, monkeypatch): + def test_fs_with_env_var_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """When OPENDAL_FS_ROOT env var is set, it should be picked up via _get_opendal_kwargs.""" env_root = str(tmp_path / "env_storage") monkeypatch.setenv("OPENDAL_FS_ROOT", env_root) diff --git a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py index b00d827e37..6402e7da2b 100644 --- a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py +++ b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py @@ -175,7 +175,7 @@ class TestWorkflowPauseIntegration: """Comprehensive integration tests for workflow pause functionality.""" @pytest.fixture(autouse=True) - def setup_test_data(self, db_session_with_containers): + def setup_test_data(self, db_session_with_containers: Session): """Set up test data for each test method using TestContainers.""" # Create test tenant and account diff --git a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_javascript.py b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_javascript.py index 19a41b6186..a5086b4c5d 100644 --- a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_javascript.py +++ b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_javascript.py @@ -1,12 +1,14 @@ from textwrap import dedent +from flask import Flask + from .test_utils import CodeExecutorTestMixin class TestJavaScriptCodeExecutor(CodeExecutorTestMixin): """Test class for JavaScript code executor functionality.""" - def test_javascript_plain(self, flask_app_with_containers): + def test_javascript_plain(self, flask_app_with_containers: Flask): """Test basic JavaScript code execution with console.log output""" CodeExecutor, CodeLanguage = self.code_executor_imports @@ -14,7 +16,7 @@ class TestJavaScriptCodeExecutor(CodeExecutorTestMixin): result_message = CodeExecutor.execute_code(language=CodeLanguage.JAVASCRIPT, preload="", code=code) assert result_message == "Hello World\n" - def test_javascript_json(self, flask_app_with_containers): + def test_javascript_json(self, flask_app_with_containers: Flask): """Test JavaScript code execution with JSON output""" CodeExecutor, CodeLanguage = self.code_executor_imports @@ -25,7 +27,7 @@ class TestJavaScriptCodeExecutor(CodeExecutorTestMixin): result = CodeExecutor.execute_code(language=CodeLanguage.JAVASCRIPT, preload="", code=code) assert result == '{"Hello":"World"}\n' - def test_javascript_with_code_template(self, flask_app_with_containers): + def test_javascript_with_code_template(self, flask_app_with_containers: Flask): """Test JavaScript workflow code template execution with inputs""" CodeExecutor, CodeLanguage = self.code_executor_imports JavascriptCodeProvider, _ = self.javascript_imports @@ -37,7 +39,7 @@ class TestJavaScriptCodeExecutor(CodeExecutorTestMixin): ) assert result == {"result": "HelloWorld"} - def test_javascript_get_runner_script(self, flask_app_with_containers): + def test_javascript_get_runner_script(self, flask_app_with_containers: Flask): """Test JavaScript template transformer runner script generation""" _, NodeJsTemplateTransformer = self.javascript_imports diff --git a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_jinja2.py b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_jinja2.py index ddb079f00c..8b4c3c3d4a 100644 --- a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_jinja2.py +++ b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_jinja2.py @@ -1,12 +1,14 @@ import base64 +from flask import Flask + from .test_utils import CodeExecutorTestMixin class TestJinja2CodeExecutor(CodeExecutorTestMixin): """Test class for Jinja2 code executor functionality.""" - def test_jinja2(self, flask_app_with_containers): + def test_jinja2(self, flask_app_with_containers: Flask): """Test basic Jinja2 template execution with variable substitution""" CodeExecutor, CodeLanguage = self.code_executor_imports _, Jinja2TemplateTransformer = self.jinja2_imports @@ -25,7 +27,7 @@ class TestJinja2CodeExecutor(CodeExecutorTestMixin): ) assert result == "<>Hello World<>\n" - def test_jinja2_with_code_template(self, flask_app_with_containers): + def test_jinja2_with_code_template(self, flask_app_with_containers: Flask): """Test Jinja2 workflow code template execution with inputs""" CodeExecutor, CodeLanguage = self.code_executor_imports @@ -34,7 +36,7 @@ class TestJinja2CodeExecutor(CodeExecutorTestMixin): ) assert result == {"result": "Hello World"} - def test_jinja2_get_runner_script(self, flask_app_with_containers): + def test_jinja2_get_runner_script(self, flask_app_with_containers: Flask): """Test Jinja2 template transformer runner script generation""" _, Jinja2TemplateTransformer = self.jinja2_imports @@ -43,7 +45,7 @@ class TestJinja2CodeExecutor(CodeExecutorTestMixin): assert runner_script.count(Jinja2TemplateTransformer._inputs_placeholder) == 1 assert runner_script.count(Jinja2TemplateTransformer._result_tag) == 2 - def test_jinja2_template_with_special_characters(self, flask_app_with_containers): + def test_jinja2_template_with_special_characters(self, flask_app_with_containers: Flask): """ Test that templates with special characters (quotes, newlines) render correctly. This is a regression test for issue #26818 where textarea pre-fill values diff --git a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_python3.py b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_python3.py index 6d93df2472..0de41e1312 100644 --- a/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_python3.py +++ b/api/tests/test_containers_integration_tests/workflow/nodes/code_executor/test_code_python3.py @@ -1,12 +1,14 @@ from textwrap import dedent +from flask import Flask + from .test_utils import CodeExecutorTestMixin class TestPython3CodeExecutor(CodeExecutorTestMixin): """Test class for Python3 code executor functionality.""" - def test_python3_plain(self, flask_app_with_containers): + def test_python3_plain(self, flask_app_with_containers: Flask): """Test basic Python3 code execution with print output""" CodeExecutor, CodeLanguage = self.code_executor_imports @@ -14,7 +16,7 @@ class TestPython3CodeExecutor(CodeExecutorTestMixin): result = CodeExecutor.execute_code(language=CodeLanguage.PYTHON3, preload="", code=code) assert result == "Hello World\n" - def test_python3_json(self, flask_app_with_containers): + def test_python3_json(self, flask_app_with_containers: Flask): """Test Python3 code execution with JSON output""" CodeExecutor, CodeLanguage = self.code_executor_imports @@ -25,7 +27,7 @@ class TestPython3CodeExecutor(CodeExecutorTestMixin): result = CodeExecutor.execute_code(language=CodeLanguage.PYTHON3, preload="", code=code) assert result == '{"Hello": "World"}\n' - def test_python3_with_code_template(self, flask_app_with_containers): + def test_python3_with_code_template(self, flask_app_with_containers: Flask): """Test Python3 workflow code template execution with inputs""" CodeExecutor, CodeLanguage = self.code_executor_imports Python3CodeProvider, _ = self.python3_imports @@ -37,7 +39,7 @@ class TestPython3CodeExecutor(CodeExecutorTestMixin): ) assert result == {"result": "HelloWorld"} - def test_python3_get_runner_script(self, flask_app_with_containers): + def test_python3_get_runner_script(self, flask_app_with_containers: Flask): """Test Python3 template transformer runner script generation""" _, Python3TemplateTransformer = self.python3_imports diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py new file mode 100644 index 0000000000..b65db70b50 --- /dev/null +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -0,0 +1,296 @@ +"""Unit tests for the Markdown API docs generator.""" + +import importlib.util +import json +import sys +from pathlib import Path + + +def _load_generate_swagger_markdown_docs_module(): + api_dir = Path(__file__).resolve().parents[3] + script_path = api_dir / "dev" / "generate_swagger_markdown_docs.py" + + spec = importlib.util.spec_from_file_location("generate_swagger_markdown_docs", script_path) + assert spec + assert spec.loader + + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) # type: ignore[attr-defined] + return module + + +def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_console(tmp_path, monkeypatch): + module = _load_generate_swagger_markdown_docs_module() + swagger_dir = tmp_path / "openapi" + markdown_dir = tmp_path / "markdown" + stale_combined_doc = markdown_dir / "api-reference.md" + markdown_dir.mkdir() + stale_combined_doc.write_text("stale", encoding="utf-8") + + def write_specs(output_dir: Path) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for target in module.SPEC_TARGETS: + path = output_dir / target.filename + path.write_text("{}", encoding="utf-8") + paths.append(path) + return paths + + def write_fastopenapi_specs(output_dir: Path) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / module.FASTOPENAPI_SPEC_TARGETS[0].filename + path.write_text("{}", encoding="utf-8") + return [path] + + def convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: + markdown_path.write_text(f"# {spec_path.stem}\n\n## Routes\n", encoding="utf-8") + + monkeypatch.setattr(module, "generate_specs", write_specs) + monkeypatch.setattr(module, "generate_fastopenapi_specs", write_fastopenapi_specs) + monkeypatch.setattr(module, "_convert_spec_to_markdown", convert_spec_to_markdown) + + written_paths = module.generate_markdown_docs(swagger_dir, markdown_dir) + + assert [path.name for path in written_paths] == [ + "console-swagger.md", + "web-swagger.md", + "service-swagger.md", + ] + assert not stale_combined_doc.exists() + assert not list(swagger_dir.glob("*.json")) + + console_markdown = (markdown_dir / "console-swagger.md").read_text(encoding="utf-8") + assert "## FastOpenAPI Preview (OpenAPI 3.0)" in console_markdown + assert "### fastopenapi-console-openapi" in console_markdown + assert "#### Routes" in console_markdown + assert "FastOpenAPI Preview" not in (markdown_dir / "web-swagger.md").read_text(encoding="utf-8") + assert "FastOpenAPI Preview" not in (markdown_dir / "service-swagger.md").read_text(encoding="utf-8") + + +def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagger_dir(tmp_path, monkeypatch): + module = _load_generate_swagger_markdown_docs_module() + swagger_dir = tmp_path / "swagger" + markdown_dir = tmp_path / "markdown" + swagger_dir.mkdir() + existing_file = swagger_dir / "existing.txt" + existing_file.write_text("keep me", encoding="utf-8") + + def write_specs(output_dir: Path) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for target in module.SPEC_TARGETS: + path = output_dir / target.filename + path.write_text("{}", encoding="utf-8") + paths.append(path) + return paths + + def write_fastopenapi_specs(output_dir: Path) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / module.FASTOPENAPI_SPEC_TARGETS[0].filename + path.write_text("{}", encoding="utf-8") + return [path] + + def convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: + markdown_path.write_text(f"# {spec_path.stem}\n", encoding="utf-8") + + monkeypatch.setattr(module, "generate_specs", write_specs) + monkeypatch.setattr(module, "generate_fastopenapi_specs", write_fastopenapi_specs) + monkeypatch.setattr(module, "_convert_spec_to_markdown", convert_spec_to_markdown) + + module.generate_markdown_docs(swagger_dir, markdown_dir) + + assert existing_file.read_text(encoding="utf-8") == "keep me" + assert not list(swagger_dir.glob("*.json")) + + +def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-swagger.json" + spec_path.write_text( + json.dumps( + { + "definitions": { + "FormInputConfig": { + "oneOf": [ + {"$ref": "#/definitions/ParagraphInputConfig"}, + {"$ref": "#/definitions/SelectInputConfig"}, + {"$ref": "#/definitions/FileInputConfig"}, + ], + }, + "ParagraphInputConfig": { + "properties": { + "default": { + "anyOf": [ + {"$ref": "#/definitions/StringSource"}, + {"type": "null"}, + ], + }, + "output_variable_name": {"type": "string"}, + }, + }, + "SelectInputConfig": { + "properties": { + "option_source": {"$ref": "#/definitions/StringListSource"}, + }, + }, + "FileInputConfig": { + "properties": { + "allowed_file_types": { + "type": "array", + "items": {"$ref": "#/definitions/FileType"}, + }, + }, + }, + } + } + ), + encoding="utf-8", + ) + markdown = """#### FormInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormInputConfig | | | | + +#### ParagraphInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | | | No | +| output_variable_name | string | | Yes | + +#### SelectInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| option_source | | | Yes | + +#### FileInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| allowed_file_types | | | No | +""" + + patched = module._patch_union_schema_markdown(markdown, spec_path) + + assert ( + "| FormInputConfig | " + "[ParagraphInputConfig](#paragraphinputconfig)
" + "[SelectInputConfig](#selectinputconfig)
" + "[FileInputConfig](#fileinputconfig) | | |" + ) in patched + assert "| default | [StringSource](#stringsource) | | No |" in patched + assert "| output_variable_name | string | | Yes |" in patched + assert "| option_source | [StringListSource](#stringlistsource) | | Yes |" in patched + assert "| allowed_file_types | [ [FileType](#filetype) ] | | No |" in patched + + +def test_patch_union_schema_markdown_ignores_specs_without_definitions(tmp_path): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-swagger.json" + spec_path.write_text("{}", encoding="utf-8") + + assert module._patch_union_schema_markdown("unchanged", spec_path) == "unchanged" + + +def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-swagger.json" + spec_path.write_text( + json.dumps( + { + "definitions": { + "NotAMapping": [], + "BrokenUnion": { + "oneOf": [ + {}, + {"$ref": "#/definitions/Missing"}, + {"$ref": "#/definitions/NoPropertyMapping"}, + ], + }, + "NoPropertyMapping": {"properties": []}, + } + } + ), + encoding="utf-8", + ) + + assert module._definition_ref_name(None) is None + assert module._schema_markdown_type(None) == "" + assert module._schema_markdown_type({"anyOf": [{"type": "null"}]}) == "" + assert module._replace_schema_table_type("unchanged", "Definition", "field", "") == "unchanged" + assert ( + module._replace_schema_table_type( + "#### Definition\n#### Next\n| field | | | No |", + "Definition", + "field", + "string", + ) + == "#### Definition\n#### Next\n| field | | | No |" + ) + assert ( + module._replace_schema_table_type("#### Definition\n| field |", "Definition", "field", "string") + == "#### Definition\n| field |" + ) + + assert module._patch_union_schema_markdown("#### BrokenUnion\n", spec_path) == "#### BrokenUnion" + + +def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path, monkeypatch): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-swagger.json" + output_path = tmp_path / "console-swagger.md" + spec_path.write_text( + json.dumps( + { + "definitions": { + "FormInputConfig": { + "oneOf": [ + {"$ref": "#/definitions/ParagraphInputConfig"}, + ], + }, + "ParagraphInputConfig": { + "properties": { + "default": { + "anyOf": [ + {"$ref": "#/definitions/StringSource"}, + {"type": "null"}, + ], + }, + }, + }, + } + } + ), + encoding="utf-8", + ) + + def run_converter(args, **kwargs): + assert kwargs["check"] is False + markdown_path = Path(args[args.index("-o") + 1]) + markdown_path.write_text( + """#### FormInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| FormInputConfig | | | | + +#### ParagraphInputConfig + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | | | No | +""", + encoding="utf-8", + ) + return module.subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="") + + monkeypatch.setattr(module.subprocess, "run", run_converter) + + module._convert_spec_to_markdown(spec_path, output_path) + + converted = output_path.read_text(encoding="utf-8") + assert "| FormInputConfig | [ParagraphInputConfig](#paragraphinputconfig) | | |" in converted + assert "| default | [StringSource](#stringsource) | | No |" in converted diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index e77e875081..79a577087d 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -6,6 +6,16 @@ import sys from pathlib import Path +def _walk_values(value): + yield value + if isinstance(value, dict): + for child in value.values(): + yield from _walk_values(child) + elif isinstance(value, list): + for child in value: + yield from _walk_values(child) + + def _load_generate_swagger_specs_module(): api_dir = Path(__file__).resolve().parents[3] script_path = api_dir / "dev" / "generate_swagger_specs.py" @@ -35,3 +45,32 @@ def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path): payload = json.loads(path.read_text(encoding="utf-8")) assert payload["swagger"] == "2.0" assert "paths" in payload + + +def test_generate_specs_writes_swagger_with_resolvable_references_and_no_nulls(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + + for path in written_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + definitions = payload["definitions"] + refs = { + item["$ref"].removeprefix("#/definitions/") + for item in _walk_values(payload) + if isinstance(item, dict) and isinstance(item.get("$ref"), str) + } + + assert refs <= set(definitions) + assert all(value is not None for value in _walk_values(payload)) + + +def test_generate_specs_is_idempotent(tmp_path): + module = _load_generate_swagger_specs_module() + + first_paths = module.generate_specs(tmp_path / "first") + second_paths = module.generate_specs(tmp_path / "second") + + assert [path.name for path in first_paths] == [path.name for path in second_paths] + for first_path, second_path in zip(first_paths, second_paths): + assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8") diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py new file mode 100644 index 0000000000..31b4d71d0f --- /dev/null +++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py @@ -0,0 +1,108 @@ +"""Unit tests for the reset-encrypt-key-pair CLI command (#35396). + +The command must purge every table that stores ciphertext encrypted with the +tenant's asymmetric key, otherwise stale rows cause downstream API failures +such as `/console/api/workspaces/current/tool-providers` returning 500. +""" + +from unittest.mock import MagicMock, patch + +import commands +from commands import system as system_commands +from models.provider import Provider, ProviderModel +from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider + + +def _invoke_reset() -> int: + try: + commands.reset_encrypt_key_pair.callback() + except SystemExit as e: + return int(e.code or 0) + return 0 + + +def _delete_targets(session_mock: MagicMock) -> list: + """Extract the model class targeted by each `delete(...)` call on the session.""" + targets = [] + for call in session_mock.execute.call_args_list: + stmt = call.args[0] + # `delete(Foo)` constructs a `Delete` statement whose entity is `Foo`. + try: + targets.append(stmt.table.name) + except AttributeError: + targets.append(repr(stmt)) + return targets + + +def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): + monkeypatch.setattr(system_commands.dify_config, "EDITION", "CLOUD") + + exit_code = _invoke_reset() + captured = capsys.readouterr() + + assert exit_code == 0 + assert "only for SELF_HOSTED" in captured.out + + +def test_reset_purges_provider_and_tool_tables_for_each_tenant(monkeypatch, capsys): + """The command must purge LLM provider rows AND every tool provider table + that stores ciphertext encrypted under the tenant key (#35396).""" + monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") + + fake_tenant = MagicMock(id="tenant-abc", encrypt_public_key="old-key") + session = MagicMock() + session.scalars.return_value.all.return_value = [fake_tenant] + + fake_sessionmaker = MagicMock() + fake_sessionmaker.begin.return_value.__enter__.return_value = session + fake_sessionmaker.begin.return_value.__exit__.return_value = False + + with ( + patch.object(system_commands, "db", MagicMock()), + patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker), + ): + exit_code = _invoke_reset() + + captured = capsys.readouterr() + assert exit_code == 0 + assert "tenant-abc" in captured.out + + # New key pair generated and assigned. + assert fake_tenant.encrypt_public_key == "new-key-tenant-abc" + + # Every encrypted-credential table should have been purged for this tenant. + table_names = _delete_targets(session) + expected = { + Provider.__tablename__, + ProviderModel.__tablename__, + BuiltinToolProvider.__tablename__, + ApiToolProvider.__tablename__, + MCPToolProvider.__tablename__, + } + assert expected.issubset(set(table_names)), f"missing purges: expected {expected}, got {table_names}" + + +def test_reset_iterates_all_tenants(monkeypatch, capsys): + """Multi-tenant deployments must purge every tenant, not just the first.""" + monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") + + tenants = [MagicMock(id=f"tenant-{i}", encrypt_public_key="old") for i in range(3)] + session = MagicMock() + session.scalars.return_value.all.return_value = tenants + + fake_sessionmaker = MagicMock() + fake_sessionmaker.begin.return_value.__enter__.return_value = session + fake_sessionmaker.begin.return_value.__exit__.return_value = False + + with ( + patch.object(system_commands, "db", MagicMock()), + patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker), + ): + _invoke_reset() + + # Five purges per tenant × 3 tenants = 15 execute calls. + assert session.execute.call_count == 15 + for tenant in tenants: + assert tenant.encrypt_public_key == f"new-key-{tenant.id}" diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index bad246a4bb..81f3f92a7c 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -8,6 +8,47 @@ from yarl import URL from configs.app_config import DifyConfig +def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None: + os.environ.clear() + monkeypatch.setenv("CONSOLE_API_URL", "https://example.com") + monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com") + monkeypatch.setenv("DB_TYPE", "postgresql") + monkeypatch.setenv("DB_USERNAME", "postgres") + monkeypatch.setenv("DB_PASSWORD", "postgres") + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "5432") + monkeypatch.setenv("DB_DATABASE", "dify") + + +def test_dify_config_keeps_secret_key_empty_when_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.delenv("SECRET_KEY", raising=False) + monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path)) + + config = DifyConfig(_env_file=None) + + assert config.SECRET_KEY == "" + assert not hasattr(config, "OPENDAL_FS_ROOT") + assert not (tmp_path / ".dify_secret_key").exists() + + +def test_dify_config_preserves_explicit_secret_key( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.setenv("SECRET_KEY", "explicit") + monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path)) + + config = DifyConfig(_env_file=None) + + assert config.SECRET_KEY == "explicit" + assert not (tmp_path / ".dify_secret_key").exists() + + def test_dify_config(monkeypatch: pytest.MonkeyPatch): # clear system environment variables os.environ.clear() @@ -89,7 +130,7 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("CODE_EXECUTION_ENDPOINT", "http://127.0.0.1:8194/") # Disable `.env` loading to ensure test stability across environments - flask_app.config.from_mapping(DifyConfig(_env_file=None).model_dump()) # pyright: ignore + flask_app.config.from_mapping(DifyConfig(_env_file=None).model_dump()) config = flask_app.config # configs read from pydantic-settings @@ -114,8 +155,8 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch): "pool_recycle": 3600, "pool_size": 30, "pool_use_lifo": False, - "pool_reset_on_return": None, "pool_timeout": 30, + "pool_reset_on_return": "rollback", } assert config["CONSOLE_WEB_URL"] == "https://example.com" diff --git a/api/tests/unit_tests/controllers/common/test_helpers.py b/api/tests/unit_tests/controllers/common/test_helpers.py index 59c463177c..376a7a90c5 100644 --- a/api/tests/unit_tests/controllers/common/test_helpers.py +++ b/api/tests/unit_tests/controllers/common/test_helpers.py @@ -57,7 +57,7 @@ class TestGuessFileInfoFromResponse: (False, "bin"), ], ) - def test_generated_filename_when_missing(self, monkeypatch, magic_available, expected_ext): + def test_generated_filename_when_missing(self, monkeypatch: pytest.MonkeyPatch, magic_available, expected_ext): if magic_available: if helpers.magic is None: pytest.skip("python-magic is not installed, cannot run 'magic_available=True' test variant") @@ -155,7 +155,7 @@ class TestMagicImportWarnings: ) def test_magic_import_warning_per_platform( self, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, platform_name, expected_message, ): diff --git a/api/tests/unit_tests/controllers/common/test_schema.py b/api/tests/unit_tests/controllers/common/test_schema.py index 56c8160f02..14b0833dcf 100644 --- a/api/tests/unit_tests/controllers/common/test_schema.py +++ b/api/tests/unit_tests/controllers/common/test_schema.py @@ -1,10 +1,11 @@ import sys from enum import StrEnum +from typing import Literal from unittest.mock import MagicMock, patch import pytest from flask_restx import Namespace -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field class UserModel(BaseModel): @@ -17,6 +18,46 @@ class ProductModel(BaseModel): price: float +class ChildModel(BaseModel): + value: str + + +class ParentModel(BaseModel): + child: ChildModel + + +class StatusEnum(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +class PriorityEnum(StrEnum): + HIGH = "high" + LOW = "low" + + +class QueryModel(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + page: int = Field(default=1, ge=1, le=100, description="Page number") + keyword: str | None = Field(default=None, min_length=1, max_length=50, description="Search keyword") + status: Literal["active", "inactive"] | None = Field(default=None, description="Status filter") + app_id: str = Field(..., alias="appId", description="Application ID") + tag_ids: list[str] = Field(default_factory=list, min_length=1, max_length=3, description="Tag IDs") + ambiguous: int | str | None = Field(default=None, description="Ambiguous query parameter") + + +class NullableSchemaModel(BaseModel): + name: str | None = None + tags: list[str] | None = None + owner: UserModel | None = None + ambiguous: int | str | None = None + + +class ResponseAliasModel(BaseModel): + public_name: str = Field(validation_alias="internal_name") + + @pytest.fixture(autouse=True) def mock_console_ns(): """Mock the console_ns to avoid circular imports during test collection.""" @@ -64,6 +105,22 @@ def test_register_schema_model_passes_schema_from_pydantic(): assert schema == expected_schema +def test_register_schema_model_promotes_nested_pydantic_definitions(): + from controllers.common.schema import DEFAULT_REF_TEMPLATE_SWAGGER_2_0, register_schema_model + + namespace = MagicMock(spec=Namespace) + + register_schema_model(namespace, ParentModel) + + called_schemas = {call.args[0]: call.args[1] for call in namespace.schema_model.call_args_list} + parent_schema = ParentModel.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0) + + assert set(called_schemas) == {"ParentModel", "ChildModel"} + assert "$defs" not in called_schemas["ParentModel"] + assert called_schemas["ParentModel"]["properties"]["child"]["$ref"] == "#/definitions/ChildModel" + assert called_schemas["ChildModel"] == parent_schema["$defs"]["ChildModel"] + + def test_register_schema_models_registers_multiple_models(): from controllers.common.schema import register_schema_models @@ -77,7 +134,7 @@ def test_register_schema_models_registers_multiple_models(): assert called_names == ["UserModel", "ProductModel"] -def test_register_schema_models_calls_register_schema_model(monkeypatch): +def test_register_schema_models_calls_register_schema_model(monkeypatch: pytest.MonkeyPatch): from controllers.common.schema import register_schema_models namespace = MagicMock(spec=Namespace) @@ -100,14 +157,39 @@ def test_register_schema_models_calls_register_schema_model(monkeypatch): ] -class StatusEnum(StrEnum): - ACTIVE = "active" - INACTIVE = "inactive" +def test_register_response_schema_model_uses_serialized_field_names(): + from controllers.common.schema import register_response_schema_model + + namespace = MagicMock(spec=Namespace) + + register_response_schema_model(namespace, ResponseAliasModel) + + model_name, schema = namespace.schema_model.call_args.args + + assert model_name == "ResponseAliasModel" + assert "public_name" in schema["properties"] + assert "internal_name" not in schema["properties"] -class PriorityEnum(StrEnum): - HIGH = "high" - LOW = "low" +def test_register_schema_model_flattens_simple_nullable_any_of_for_swagger_2(): + from controllers.common.schema import register_schema_model + + namespace = MagicMock(spec=Namespace) + + register_schema_model(namespace, NullableSchemaModel) + + called_schemas = {call.args[0]: call.args[1] for call in namespace.schema_model.call_args_list} + properties = called_schemas["NullableSchemaModel"]["properties"] + + assert properties["name"]["type"] == "string" + assert properties["name"]["x-nullable"] is True + assert "anyOf" not in properties["name"] + assert properties["tags"]["type"] == "array" + assert properties["tags"]["items"] == {"type": "string"} + assert properties["tags"]["x-nullable"] is True + assert properties["owner"]["$ref"] == "#/definitions/UserModel" + assert properties["owner"]["x-nullable"] is True + assert "anyOf" in properties["ambiguous"] def test_get_or_create_model_returns_existing_model(mock_console_ns): @@ -187,3 +269,54 @@ def test_register_enum_models_uses_correct_ref_template(): # Verify the schema contains enum values assert "enum" in schema or "anyOf" in schema + + +def test_query_params_from_model_builds_flask_restx_doc_params(): + from controllers.common.schema import query_params_from_model + + params = query_params_from_model(QueryModel) + + assert params["page"] == { + "in": "query", + "required": False, + "description": "Page number", + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 100, + } + assert params["keyword"] == { + "in": "query", + "required": False, + "description": "Search keyword", + "type": "string", + "minLength": 1, + "maxLength": 50, + } + assert params["status"] == { + "in": "query", + "required": False, + "description": "Status filter", + "type": "string", + "enum": ["active", "inactive"], + } + assert params["appId"] == { + "in": "query", + "required": True, + "description": "Application ID", + "type": "string", + } + assert params["tag_ids"] == { + "in": "query", + "required": False, + "description": "Tag IDs", + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 3, + } + assert params["ambiguous"] == { + "in": "query", + "required": False, + "description": "Ambiguous query parameter", + } diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 9c4678aed3..e690968ffb 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from flask import Flask from controllers.console.app import app_import as app_import_module from services.app_dsl_service import ImportStatus @@ -48,7 +49,9 @@ class TestAppImportApi: def api(self): return app_import_module.AppImportApi() - def test_import_post_returns_failed_status_and_rolls_back(self, api, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_returns_failed_status_and_rolls_back( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: method = _unwrap(api.post) _install_features(monkeypatch, enabled=False) @@ -68,7 +71,9 @@ class TestAppImportApi: assert status == 400 assert response["status"] == ImportStatus.FAILED - def test_import_post_returns_pending_status_and_commits(self, api, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_returns_pending_status_and_commits( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: method = _unwrap(api.post) _install_features(monkeypatch, enabled=False) @@ -88,7 +93,9 @@ class TestAppImportApi: assert status == 202 assert response["status"] == ImportStatus.PENDING - def test_import_post_updates_webapp_auth_when_enabled(self, api, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_import_post_updates_webapp_auth_when_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: method = _unwrap(api.post) _install_features(monkeypatch, enabled=True) @@ -118,7 +125,7 @@ class TestAppImportConfirmApi: return app_import_module.AppImportConfirmApi() def test_import_confirm_returns_failed_status_and_rolls_back( - self, api, app, monkeypatch: pytest.MonkeyPatch + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch ) -> None: method = _unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 35d07a987d..80e7c41a9e 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -10,6 +10,8 @@ from typing import Any import pytest from flask.views import MethodView +from pydantic import ValidationError +from werkzeug.datastructures import MultiDict # kombu references MethodView as a global when importing celery/kombu pools. if not hasattr(builtins, "MethodView"): @@ -174,6 +176,101 @@ def _dummy_workflow(): ) +def test_app_list_query_normalizes_orpc_bracket_tag_ids(app_module): + first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" + second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c" + query_args = MultiDict( + [ + ("page", "1"), + ("limit", "30"), + ("tag_ids[1]", second_tag_id), + ("tag_ids[0]", first_tag_id), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert query.tag_ids == [first_tag_id, second_tag_id] + + +def test_app_list_query_preserves_regular_query_params(app_module): + query_args = MultiDict( + [ + ("page", "2"), + ("limit", "50"), + ("mode", "chat"), + ("name", "Sales Copilot"), + ("is_created_by_me", "true"), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert normalized == { + "page": "2", + "limit": "50", + "mode": "chat", + "name": "Sales Copilot", + "is_created_by_me": "true", + } + assert query.page == 2 + assert query.limit == 50 + assert query.mode == "chat" + assert query.name == "Sales Copilot" + assert query.is_created_by_me is True + assert query.tag_ids is None + + +def test_app_list_query_normalizes_empty_bracket_tag_ids_to_none(app_module): + query_args = MultiDict( + [ + ("tag_ids[0]", ""), + ("tag_ids[1]", " "), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert normalized == {"tag_ids": ["", " "]} + assert query.tag_ids is None + + +def test_app_list_query_rejects_invalid_bracket_tag_id(app_module): + normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids[0]", "not-a-uuid")])) + + with pytest.raises(ValidationError): + app_module.AppListQuery.model_validate(normalized) + + +def test_app_list_query_sorts_bracket_tag_ids_by_index(app_module): + first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" + second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c" + third_tag_id = "9d5ec0f7-4f2b-4e7f-9c13-1e7a034d0eb1" + query_args = MultiDict( + [ + ("tag_ids[2]", third_tag_id), + ("tag_ids[1]", second_tag_id), + ("tag_ids[0]", first_tag_id), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert query.tag_ids == [first_tag_id, second_tag_id, third_tag_id] + + +def test_app_list_query_rejects_flat_tag_ids(app_module): + tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" + normalized = app_module._normalize_app_list_query_args(MultiDict([("tag_ids", tag_id)])) + + with pytest.raises(ValidationError): + app_module.AppListQuery.model_validate(normalized) + + def test_app_partial_serialization_uses_aliases(app_models): AppPartial = app_models.AppPartial created_at = _ts() diff --git a/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py b/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py index 1a412aff29..71b6a1aa37 100644 --- a/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_conversation_variables_api.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime from types import SimpleNamespace import pytest +from flask import Flask from pydantic import ValidationError from controllers.console.app import conversation_variables as conversation_variables_module @@ -20,7 +21,7 @@ def _unwrap(func): return func -def test_get_conversation_variables_returns_paginated_response(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_conversation_variables_returns_paginated_response(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = conversation_variables_module.ConversationVariablesApi() method = _unwrap(api.get) @@ -63,7 +64,9 @@ def test_get_conversation_variables_returns_paginated_response(app, monkeypatch: assert response["data"][0]["updated_at"] == int(updated_at.timestamp()) -def test_get_conversation_variables_normalizes_value_type_and_value(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_conversation_variables_normalizes_value_type_and_value( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: api = conversation_variables_module.ConversationVariablesApi() method = _unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index 7c470eb9a8..e7fc1f8042 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -3,14 +3,18 @@ from __future__ import annotations import json from datetime import datetime from types import SimpleNamespace +from typing import cast from unittest.mock import Mock import pytest +from pydantic import ValidationError from werkzeug.exceptions import HTTPException, NotFound from controllers.console.app import workflow as workflow_module from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync from graphon.file import File, FileTransferMethod, FileType +from graphon.variables import SecretVariable, StringVariable +from graphon.variables.variables import RAGPipelineVariable def _unwrap(func): @@ -19,11 +23,67 @@ def _unwrap(func): return func +def _make_workflow(**overrides): + workflow = SimpleNamespace( + id="workflow-1", + graph_dict={"nodes": [], "edges": []}, + features_dict={"file_upload": {"enabled": False}}, + unique_hash="hash-1", + version="1", + marked_name="Release 1", + marked_comment="Initial release", + created_by_account=SimpleNamespace(id="user-1", name="Alice", email="alice@example.com"), + created_at=datetime(2024, 1, 1, 12, 0, 0), + updated_by_account=None, + updated_at=datetime(2024, 1, 1, 12, 1, 0), + tool_published=False, + environment_variables=[ + { + "id": "env-1", + "name": "API_KEY", + "value": "[__HIDDEN__]", + "value_type": "secret", + "description": "API key", + } + ], + conversation_variables=[ + { + "id": "conv-1", + "name": "topic", + "value": "hello", + "value_type": "string", + "description": "Topic", + } + ], + rag_pipeline_variables=[ + { + "variable": "query", + "type": "text-input", + "label": "Query", + "belong_to_node_id": "shared", + "max_length": 0, + "required": False, + "unit": "", + "default_value": "", + "options": [], + "placeholder": "", + "tooltips": "", + "allowed_file_types": ["custom"], + "allowed_file_extensions": [".pdf"], + "allowed_file_upload_methods": ["local_file"], + } + ], + ) + for key, value in overrides.items(): + setattr(workflow, key, value) + return workflow + + def test_parse_file_no_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(workflow_module.FileUploadConfigManager, "convert", lambda *_args, **_kwargs: None) workflow = SimpleNamespace(features_dict={}, tenant_id="t1") - assert workflow_module._parse_file(workflow, files=[{"id": "f"}]) == [] + assert workflow_module._parse_file(cast(workflow_module.Workflow, workflow), files=[{"id": "f"}]) == [] def test_parse_file_with_config(monkeypatch: pytest.MonkeyPatch) -> None: @@ -41,7 +101,7 @@ def test_parse_file_with_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(workflow_module.file_factory, "build_from_mappings", build_mock) workflow = SimpleNamespace(features_dict={}, tenant_id="t1") - result = workflow_module._parse_file(workflow, files=[{"id": "f"}]) + result = workflow_module._parse_file(cast(workflow_module.Workflow, workflow), files=[{"id": "f"}]) assert result == file_list build_mock.assert_called_once() @@ -259,7 +319,7 @@ def test_restore_published_workflow_to_draft_returns_400_for_invalid_structure( assert exc.value.description == "invalid workflow graph" -def test_get_published_workflows_marshals_items_before_session_closes(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_published_workflows_serializes_items_before_session_closes(app, monkeypatch: pytest.MonkeyPatch) -> None: api = workflow_module.PublishedAllWorkflowApi() handler = _unwrap(api.get) @@ -278,7 +338,12 @@ def test_get_published_workflows_marshals_items_before_session_closes(app, monke def begin(self): return _SessionContext() + base_workflow = _make_workflow() + class _Workflow: + def __getattr__(self, name): + return getattr(base_workflow, name) + @property def id(self): assert session_state["open"] is True @@ -295,12 +360,6 @@ def test_get_published_workflows_marshals_items_before_session_closes(app, monke ), ) - def _fake_marshal(items, fields): - assert session_state["open"] is True - return [{"id": item.id} for item in items] - - monkeypatch.setattr(workflow_module, "marshal", _fake_marshal) - with app.test_request_context( "/apps/app/workflows", method="GET", @@ -308,12 +367,153 @@ def test_get_published_workflows_marshals_items_before_session_closes(app, monke ): response = handler(api, app_model=SimpleNamespace(id="app", workflow_id="wf-1")) - assert response == { - "items": [{"id": "w1"}], - "page": 1, - "limit": 10, - "has_more": False, - } + assert response["items"][0]["id"] == "w1" + assert response["page"] == 1 + assert response["limit"] == 10 + assert response["has_more"] is False + + +def test_draft_workflow_get_serializes_response_model(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow() + monkeypatch.setattr( + workflow_module, "WorkflowService", lambda: SimpleNamespace(get_draft_workflow=lambda **_kwargs: workflow) + ) + + api = workflow_module.DraftWorkflowApi() + handler = _unwrap(api.get) + + response = handler(api, app_model=SimpleNamespace(id="app")) + + assert response["id"] == "workflow-1" + assert response["graph"] == {"nodes": [], "edges": []} + assert response["features"] == {"file_upload": {"enabled": False}} + assert response["hash"] == "hash-1" + assert response["created_by"] == {"id": "user-1", "name": "Alice", "email": "alice@example.com"} + assert response["updated_by"] is None + assert response["created_at"] == int(datetime(2024, 1, 1, 12, 0, 0).timestamp()) + assert response["updated_at"] == int(datetime(2024, 1, 1, 12, 1, 0).timestamp()) + assert response["environment_variables"] == [ + { + "id": "env-1", + "name": "API_KEY", + "value": "[__HIDDEN__]", + "value_type": "secret", + "description": "API key", + } + ] + assert response["conversation_variables"] == [ + { + "id": "conv-1", + "name": "topic", + "value": "hello", + "value_type": "string", + "description": "Topic", + } + ] + assert response["rag_pipeline_variables"] == [ + { + "label": "Query", + "variable": "query", + "type": "text-input", + "belong_to_node_id": "shared", + "max_length": 0, + "required": False, + "unit": "", + "default_value": "", + "options": [], + "placeholder": "", + "tooltips": "", + "allowed_file_types": ["custom"], + "allowed_file_extensions": [".pdf"], + "allowed_file_upload_methods": ["local_file"], + } + ] + + +def test_pipeline_variable_response_accepts_legacy_file_field_names() -> None: + response = workflow_module.PipelineVariableResponse.model_validate( + { + "label": "Query", + "variable": "query", + "type": "single-file", + "belong_to_node_id": "shared", + "max_length": 0, + "required": False, + "unit": "", + "default_value": "", + "options": [], + "placeholder": "", + "tooltips": "", + "allowed_file_types": [], + "allow_file_extension": [".txt"], + "allow_file_upload_methods": ["remote_url"], + } + ).model_dump(mode="json") + + assert response["allowed_file_extensions"] == [".txt"] + assert response["allowed_file_upload_methods"] == ["remote_url"] + + +def test_pipeline_variable_response_accepts_explicit_null_optional_fields() -> None: + pipeline_variable = RAGPipelineVariable.model_validate( + { + "label": "Query", + "variable": "query", + "type": "text-input", + "belong_to_node_id": "shared", + "max_length": None, + "unit": None, + "default_value": None, + "options": None, + "placeholder": None, + "tooltips": None, + "allowed_file_types": None, + "allowed_file_extensions": None, + "allowed_file_upload_methods": None, + } + ).model_dump(mode="json") + + response = workflow_module.PipelineVariableResponse.model_validate(pipeline_variable).model_dump(mode="json") + + assert response["max_length"] is None + assert response["allowed_file_types"] is None + assert response["allowed_file_extensions"] is None + assert response["allowed_file_upload_methods"] is None + + +def test_workflow_response_masks_secret_environment_variables() -> None: + workflow = _make_workflow( + environment_variables=[ + SecretVariable(id="env-secret", name="API_KEY", value="plain-token", selector=["env", "API_KEY"]), + StringVariable(id="env-string", name="REGION", value="us-east-1", selector=["env", "REGION"]), + ] + ) + + response = workflow_module.WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + + assert response["environment_variables"] == [ + { + "id": "env-secret", + "name": "API_KEY", + "value": workflow_module.encrypter.full_mask_token(), + "value_type": "secret", + "description": "", + }, + { + "id": "env-string", + "name": "REGION", + "value": "us-east-1", + "value_type": "string", + "description": "", + }, + ] + + +def test_workflow_response_rejects_invalid_environment_variable_dict() -> None: + workflow = _make_workflow(environment_variables=[{"value_type": "not-a-segment-type"}]) + + with pytest.raises(ValidationError): + workflow_module.WorkflowResponse.model_validate(workflow, from_attributes=True) def test_draft_workflow_get_not_found(monkeypatch: pytest.MonkeyPatch) -> None: @@ -373,10 +573,33 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p "avatar": "avatar-file-id", "sid": "sid-1", } - ) + ), + b"sid-malformed": json.dumps({"avatar": "avatar-file-id", "sid": "sid-malformed"}), + b"sid-invalid-avatar": json.dumps( + { + "user_id": "u-2", + "username": "Bob", + "avatar": {"file_id": "avatar-file-id"}, + } + ), + b"sid-invalid-user-id": json.dumps( + { + "user_id": 42, + "username": "Carol", + "avatar": "avatar-file-id", + } + ), + b"sid-invalid-username": json.dumps( + { + "user_id": "u-4", + "username": ["Dave"], + "avatar": "avatar-file-id", + } + ), } ] - workflow_module.redis_client.pipeline.return_value = redis_pipeline + redis_pipeline_factory = Mock(return_value=redis_pipeline) + monkeypatch.setattr(workflow_module.redis_client, "pipeline", redis_pipeline_factory) api = workflow_module.WorkflowOnlineUsersApi() handler = _unwrap(api.post) @@ -397,13 +620,17 @@ def test_workflow_online_users_filters_inaccessible_workflow(app, monkeypatch: p "user_id": "u-1", "username": "Alice", "avatar": signed_avatar_url, - "sid": "sid-1", - } + }, + { + "user_id": "u-2", + "username": "Bob", + "avatar": None, + }, ], } ] } - workflow_module.redis_client.pipeline.assert_called_once_with(transaction=False) + redis_pipeline_factory.assert_called_once_with(transaction=False) redis_pipeline.hgetall.assert_called_once_with(f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}") redis_pipeline.execute.assert_called_once_with() sign_avatar.assert_called_once_with("avatar-file-id") @@ -422,7 +649,8 @@ def test_workflow_online_users_batches_redis_reads(app, monkeypatch: pytest.Monk first_pipeline.execute.return_value = [{} for _ in range(workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE)] second_pipeline = Mock() second_pipeline.execute.return_value = [{}] - workflow_module.redis_client.pipeline.side_effect = [first_pipeline, second_pipeline] + redis_pipeline_factory = Mock(side_effect=[first_pipeline, second_pipeline]) + monkeypatch.setattr(workflow_module.redis_client, "pipeline", redis_pipeline_factory) api = workflow_module.WorkflowOnlineUsersApi() handler = _unwrap(api.post) @@ -435,7 +663,7 @@ def test_workflow_online_users_batches_redis_reads(app, monkeypatch: pytest.Monk response = handler(api) assert len(response["data"]) == len(app_ids) - assert workflow_module.redis_client.pipeline.call_count == 2 + assert redis_pipeline_factory.call_count == 2 assert first_pipeline.hgetall.call_count == workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE assert second_pipeline.hgetall.call_count == 1 @@ -463,5 +691,6 @@ def test_workflow_online_users_rejects_excessive_workflow_ids(app, monkeypatch: handler(api) assert exc.value.code == 400 + assert exc.value.description is not None assert "Maximum" in exc.value.description accessible_app_ids.assert_not_called() diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py index 85afcf0e60..baa21999f9 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py @@ -17,6 +17,15 @@ from controllers.console.app import wraps as app_wraps from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole +JAN_1_2024_NOON = datetime(2024, 1, 1, 12, 0, 0) +JAN_1_2024_NOON_TS = int(JAN_1_2024_NOON.timestamp()) +JAN_1_2024_1201 = datetime(2024, 1, 1, 12, 1, 0) +JAN_1_2024_1201_TS = int(JAN_1_2024_1201.timestamp()) +JAN_1_2024_1202 = datetime(2024, 1, 1, 12, 2, 0) +JAN_1_2024_1202_TS = int(JAN_1_2024_1202.timestamp()) +JAN_1_2024_1203 = datetime(2024, 1, 1, 12, 3, 0) +JAN_1_2024_1203_TS = int(JAN_1_2024_1203.timestamp()) + def _make_account(role: TenantAccountRole) -> Account: account = Account(name="tester", email="tester@example.com") @@ -78,6 +87,30 @@ class WriteCase: payload: dict[str, object] | None = None +@dataclass(frozen=True) +class MutationResponseCase: + resource_cls: type + method_name: str + path: str + kwargs: dict[str, str] + service_method_name: str + service_return: object + expected_response: dict[str, object] + payload: dict[str, object] | None = None + expected_status: int | None = None + + +def _unwrap_response(result: object) -> tuple[dict[str, object], int | None]: + if isinstance(result, tuple): + response, status = result + assert isinstance(response, dict) + assert isinstance(status, int) + return response, status + + assert isinstance(result, dict) + return result, None + + @pytest.mark.parametrize( "case", [ @@ -151,17 +184,20 @@ def test_create_comment_allows_editor(app: Flask, monkeypatch: pytest.MonkeyPatc create_comment_mock = MagicMock(return_value={"id": "comment-1"}) monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "create_comment", create_comment_mock) - payload = {"content": "hello", "position_x": 1.0, "position_y": 2.0, "mentioned_user_ids": []} + payload: dict[str, object] = { + "content": "hello", + "position_x": 1.0, + "position_y": 2.0, + "mentioned_user_ids": [], + } with app.test_request_context("/console/api/apps/app-123/workflow/comments", method="POST", json=payload): with _patch_payload(payload): result = workflow_comment_module.WorkflowCommentListApi().post(app_id="app-123") - if isinstance(result, tuple): - response = result[0] - else: - response = result + response, status = _unwrap_response(result) assert response["id"] == "comment-1" + assert status == 201 create_comment_mock.assert_called_once_with( tenant_id="tenant-123", app_id="app-123", @@ -181,14 +217,17 @@ def test_update_comment_omits_mentions_when_payload_does_not_include_them( app_model = _make_app() _patch_console_guards(monkeypatch, account, app_model) - update_comment_mock = MagicMock(return_value={"id": "comment-1", "updated_at": datetime(2024, 1, 1, 12, 0, 0)}) + update_comment_mock = MagicMock(return_value={"id": "comment-1", "updated_at": JAN_1_2024_NOON}) monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "update_comment", update_comment_mock) - payload = {"content": "hello", "position_x": 10.0, "position_y": 20.0} + payload: dict[str, object] = {"content": "hello", "position_x": 10.0, "position_y": 20.0} with app.test_request_context("/console/api/apps/app-123/workflow/comments/comment-1", method="PUT", json=payload): with _patch_payload(payload): - workflow_comment_module.WorkflowCommentDetailApi().put(app_id="app-123", comment_id="comment-1") + result = workflow_comment_module.WorkflowCommentDetailApi().put(app_id="app-123", comment_id="comment-1") + response, status = _unwrap_response(result) + assert response == {"id": "comment-1", "updated_at": JAN_1_2024_NOON_TS} + assert status is None update_comment_mock.assert_called_once_with( tenant_id="tenant-123", app_id="app-123", @@ -199,3 +238,254 @@ def test_update_comment_omits_mentions_when_payload_does_not_include_them( position_y=20.0, mentioned_user_ids=None, ) + + +def test_list_comments_serializes_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + app.config.setdefault("RESTX_MASK_HEADER", "X-Fields") + account = _make_account(TenantAccountRole.NORMAL) + app_model = _make_app() + _patch_console_guards(monkeypatch, account, app_model) + + comment_author = SimpleNamespace( + id="account-123", + name="tester", + email="tester@example.com", + avatar="https://example.com/avatar.png", + ) + comment = SimpleNamespace( + id="comment-1", + position_x=1.5, + position_y=2.5, + content="hello", + created_by="account-123", + created_by_account=comment_author, + created_at=1_700_000_000, + updated_at=1_700_000_001, + resolved=False, + resolved_at=None, + resolved_by=None, + resolved_by_account=None, + reply_count=0, + mention_count=0, + participants=[comment_author], + ) + get_comments_mock = MagicMock(return_value=[comment]) + monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "get_comments", get_comments_mock) + + with app.test_request_context("/console/api/apps/app-123/workflow/comments", method="GET"): + response = workflow_comment_module.WorkflowCommentListApi().get(app_id="app-123") + + assert response == { + "data": [ + { + "id": "comment-1", + "position_x": 1.5, + "position_y": 2.5, + "content": "hello", + "created_by": "account-123", + "created_by_account": { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + }, + "created_at": 1_700_000_000, + "updated_at": 1_700_000_001, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "resolved_by_account": None, + "reply_count": 0, + "mention_count": 0, + "participants": [ + { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + } + ], + } + ] + } + get_comments_mock.assert_called_once_with(tenant_id="tenant-123", app_id="app-123") + + +def test_get_comment_serializes_detail_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + app.config.setdefault("RESTX_MASK_HEADER", "X-Fields") + account = _make_account(TenantAccountRole.NORMAL) + app_model = _make_app() + _patch_console_guards(monkeypatch, account, app_model) + + comment_author = SimpleNamespace( + id="account-123", + name="tester", + email="tester@example.com", + avatar="https://example.com/avatar.png", + ) + mentioned_user = SimpleNamespace( + id="account-456", + name="mentioned", + email="mentioned@example.com", + avatar=None, + ) + comment = SimpleNamespace( + id="comment-1", + position_x=1.5, + position_y=2.5, + content="hello", + created_by="account-123", + created_by_account=comment_author, + created_at=JAN_1_2024_NOON, + updated_at=JAN_1_2024_1201, + resolved=True, + resolved_at=JAN_1_2024_1202, + resolved_by="account-123", + resolved_by_account=comment_author, + replies=[ + SimpleNamespace( + id="reply-1", + content="reply", + created_by="account-456", + created_by_account=mentioned_user, + created_at=JAN_1_2024_1203, + ) + ], + mentions=[ + SimpleNamespace( + mentioned_user_id="account-456", + mentioned_user_account=mentioned_user, + reply_id="reply-1", + ) + ], + ) + get_comment_mock = MagicMock(return_value=comment) + monkeypatch.setattr(workflow_comment_module.WorkflowCommentService, "get_comment", get_comment_mock) + + with app.test_request_context("/console/api/apps/app-123/workflow/comments/comment-1", method="GET"): + response = workflow_comment_module.WorkflowCommentDetailApi().get(app_id="app-123", comment_id="comment-1") + + assert response == { + "id": "comment-1", + "position_x": 1.5, + "position_y": 2.5, + "content": "hello", + "created_by": "account-123", + "created_by_account": { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + }, + "created_at": JAN_1_2024_NOON_TS, + "updated_at": JAN_1_2024_1201_TS, + "resolved": True, + "resolved_at": JAN_1_2024_1202_TS, + "resolved_by": "account-123", + "resolved_by_account": { + "id": "account-123", + "name": "tester", + "email": "tester@example.com", + "avatar_url": "https://example.com/avatar.png", + }, + "replies": [ + { + "id": "reply-1", + "content": "reply", + "created_by": "account-456", + "created_by_account": { + "id": "account-456", + "name": "mentioned", + "email": "mentioned@example.com", + "avatar_url": None, + }, + "created_at": JAN_1_2024_1203_TS, + } + ], + "mentions": [ + { + "mentioned_user_id": "account-456", + "mentioned_user_account": { + "id": "account-456", + "name": "mentioned", + "email": "mentioned@example.com", + "avatar_url": None, + }, + "reply_id": "reply-1", + } + ], + } + get_comment_mock.assert_called_once_with(tenant_id="tenant-123", app_id="app-123", comment_id="comment-1") + + +@pytest.mark.parametrize( + "case", + [ + MutationResponseCase( + resource_cls=workflow_comment_module.WorkflowCommentResolveApi, + method_name="post", + path="/console/api/apps/app-123/workflow/comments/comment-1/resolve", + kwargs={"app_id": "app-123", "comment_id": "comment-1"}, + service_method_name="resolve_comment", + service_return={ + "id": "comment-1", + "resolved": True, + "resolved_at": JAN_1_2024_NOON, + "resolved_by": "account-123", + }, + expected_response={ + "id": "comment-1", + "resolved": True, + "resolved_at": JAN_1_2024_NOON_TS, + "resolved_by": "account-123", + }, + ), + MutationResponseCase( + resource_cls=workflow_comment_module.WorkflowCommentReplyApi, + method_name="post", + path="/console/api/apps/app-123/workflow/comments/comment-1/replies", + kwargs={"app_id": "app-123", "comment_id": "comment-1"}, + payload={"content": "reply", "mentioned_user_ids": []}, + service_method_name="create_reply", + service_return={"id": "reply-1", "created_at": JAN_1_2024_NOON}, + expected_response={"id": "reply-1", "created_at": JAN_1_2024_NOON_TS}, + expected_status=201, + ), + MutationResponseCase( + resource_cls=workflow_comment_module.WorkflowCommentReplyDetailApi, + method_name="put", + path="/console/api/apps/app-123/workflow/comments/comment-1/replies/reply-1", + kwargs={"app_id": "app-123", "comment_id": "comment-1", "reply_id": "reply-1"}, + payload={"content": "reply", "mentioned_user_ids": []}, + service_method_name="update_reply", + service_return={"id": "reply-1", "updated_at": JAN_1_2024_NOON}, + expected_response={"id": "reply-1", "updated_at": JAN_1_2024_NOON_TS}, + ), + ], +) +def test_mutation_endpoints_serialize_response_models( + app: Flask, monkeypatch: pytest.MonkeyPatch, case: MutationResponseCase +) -> None: + app.config.setdefault("RESTX_MASK_HEADER", "X-Fields") + account = _make_account(TenantAccountRole.EDITOR) + app_model = _make_app() + _patch_console_guards(monkeypatch, account, app_model) + _patch_write_services(monkeypatch) + monkeypatch.setattr( + workflow_comment_module.WorkflowCommentService, + case.service_method_name, + MagicMock(return_value=case.service_return), + ) + + with app.test_request_context(case.path, method=case.method_name.upper(), json=case.payload): + with _patch_payload(case.payload): + result = getattr(case.resource_cls(), case.method_name)(**case.kwargs) + + response, status = _unwrap_response(result) + assert response == case.expected_response + assert status == case.expected_status + + +def test_workflow_comment_response_schemas_are_registered() -> None: + assert workflow_comment_module.WorkflowCommentBasicList.__name__ in workflow_comment_module.console_ns.models + assert workflow_comment_module.WorkflowCommentDetail.__name__ in workflow_comment_module.console_ns.models diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py index c4a8148446..58274f1688 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py @@ -12,7 +12,7 @@ from controllers.console.app import workflow_run as workflow_run_module from controllers.web.error import NotFoundError from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus -from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig from graphon.nodes.human_input.enums import FormInputType from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole @@ -20,8 +20,11 @@ from models.workflow import WorkflowRun def _make_account() -> Account: - account = Account(name="tester", email="tester@example.com") - account.status = AccountStatus.ACTIVE + account = Account( + name="tester", + email="tester@example.com", + status=AccountStatus.ACTIVE, + ) account.role = TenantAccountRole.OWNER account.id = "account-123" # type: ignore[assignment] account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined] @@ -63,8 +66,8 @@ def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pyte reason = HumanInputRequired( form_id="form-1", form_content="content", - inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], - actions=[UserAction(id="approve", title="Approve")], + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + actions=[UserActionConfig(id="approve", title="Approve")], node_id="node-1", node_title="Ask Name", ) @@ -112,3 +115,24 @@ def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPa with pytest.raises(NotFoundError): with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"): response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1") + + +def test_pause_details_returns_empty_response_for_non_paused_run(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + account = _make_account() + _patch_console_guards(monkeypatch, account) + + workflow_run = Mock(spec=WorkflowRun) + workflow_run.tenant_id = "tenant-123" + workflow_run.status = WorkflowExecutionStatus.RUNNING + fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run)) + monkeypatch.setattr(workflow_run_module, "db", fake_db) + + with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"): + response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1") + + assert status == 200 + assert response == {"paused_at": None, "paused_nodes": []} + + +def test_pause_details_response_schema_is_registered() -> None: + assert workflow_run_module.WorkflowPauseDetailsResponse.__name__ in workflow_run_module.console_ns.models diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py new file mode 100644 index 0000000000..e225e31563 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_run_api.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any + +import pytest +from flask import Flask +from flask_restx import marshal + +from controllers.console.app import workflow_run as workflow_run_module + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def _serialize_200_response(handler, payload: Any) -> Any: + response_doc = getattr(handler, "__apidoc__", {}).get("responses", {}).get("200") + if response_doc is None: + return payload + + response_model = response_doc[1] + if isinstance(response_model, dict): + return marshal(payload, response_model) + return payload + + +def _account() -> SimpleNamespace: + return SimpleNamespace(id="account-1", name="Alice", email="alice@example.com") + + +def _workflow_run_summary(**overrides) -> SimpleNamespace: + created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + payload = { + "id": "run-1", + "version": "v1", + "status": "succeeded", + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "created_by_account": _account(), + "created_at": created_at, + "finished_at": created_at, + "exceptions_count": 0, + "retry_index": 0, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + +def _workflow_run_node_execution(**overrides) -> SimpleNamespace: + created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + payload = { + "id": "node-exec-1", + "index": 1, + "predecessor_node_id": None, + "node_id": "node-1", + "node_type": "start", + "title": "Start", + "inputs_dict": {"query": "hello"}, + "process_data_dict": {"step": "prepared"}, + "outputs_dict": {"answer": "world"}, + "status": "succeeded", + "error": None, + "elapsed_time": 1.0, + "execution_metadata_dict": {"total_tokens": 3}, + "extras": {}, + "created_at": created_at, + "created_by_role": "account", + "created_by_account": _account(), + "created_by_end_user": None, + "finished_at": created_at, + "inputs_truncated": False, + "outputs_truncated": False, + "process_data_truncated": False, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + +def test_workflow_run_list_returns_frontend_history_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + class WorkflowRunService: + def get_paginate_workflow_runs(self, **_kwargs): + return { + "limit": 10, + "has_more": False, + "data": [_workflow_run_summary()], + } + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + + api = workflow_run_module.WorkflowRunListApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflow-runs?limit=10", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1")) + + response = _serialize_200_response(api.get, payload) + + assert response["limit"] == 10 + assert response["has_more"] is False + assert response["data"][0] == { + "id": "run-1", + "version": "v1", + "status": "succeeded", + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "created_by_account": {"id": "account-1", "name": "Alice", "email": "alice@example.com"}, + "created_at": 1767323045, + "finished_at": 1767323045, + "exceptions_count": 0, + "retry_index": 0, + } + + +def test_advanced_chat_workflow_run_list_keeps_message_fields(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + class WorkflowRunService: + def get_paginate_advanced_chat_workflow_runs(self, **_kwargs): + return { + "limit": 1, + "has_more": True, + "data": [ + _workflow_run_summary( + conversation_id="conversation-1", + message_id="message-1", + ) + ], + } + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + + api = workflow_run_module.AdvancedChatAppWorkflowRunListApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/advanced-chat/workflow-runs?limit=1", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1")) + + response = _serialize_200_response(api.get, payload) + + assert response["data"][0]["conversation_id"] == "conversation-1" + assert response["data"][0]["message_id"] == "message-1" + + +def test_workflow_run_detail_returns_frontend_detail_contract(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) + workflow_run = SimpleNamespace( + id="run-1", + version="v1", + graph_dict={"nodes": []}, + inputs_dict={"query": "hello"}, + status="succeeded", + outputs_dict={"answer": "world"}, + error=None, + elapsed_time=1.5, + total_tokens=10, + total_steps=2, + created_by_role="account", + created_by_account=_account(), + created_by_end_user=None, + created_at=created_at, + finished_at=created_at, + exceptions_count=0, + ) + + class WorkflowRunService: + def get_workflow_run(self, **_kwargs): + return workflow_run + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + + api = workflow_run_module.WorkflowRunDetailApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflow-runs/run-1", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1") + + response = _serialize_200_response(api.get, payload) + + assert response == { + "id": "run-1", + "version": "v1", + "graph": {"nodes": []}, + "inputs": {"query": "hello"}, + "status": "succeeded", + "outputs": {"answer": "world"}, + "error": None, + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "created_by_role": "account", + "created_by_account": {"id": "account-1", "name": "Alice", "email": "alice@example.com"}, + "created_by_end_user": None, + "created_at": 1767323045, + "finished_at": 1767323045, + "exceptions_count": 0, + } + + +def test_workflow_run_node_executions_return_frontend_trace_contract( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: + class WorkflowRunService: + def get_workflow_run_node_executions(self, **_kwargs): + return [_workflow_run_node_execution()] + + monkeypatch.setattr(workflow_run_module, "WorkflowRunService", WorkflowRunService) + monkeypatch.setattr(workflow_run_module, "current_user", SimpleNamespace(id="account-1")) + + api = workflow_run_module.WorkflowRunNodeExecutionListApi() + handler = _unwrap(api.get) + + with app.test_request_context("/apps/app-1/workflow-runs/run-1/node-executions", method="GET"): + payload = handler(api, app_model=SimpleNamespace(id="app-1", tenant_id="tenant-1"), run_id="run-1") + + response = _serialize_200_response(api.get, payload) + + assert response == { + "data": [ + { + "id": "node-exec-1", + "index": 1, + "predecessor_node_id": None, + "node_id": "node-1", + "node_type": "start", + "title": "Start", + "inputs": {"query": "hello"}, + "process_data": {"step": "prepared"}, + "outputs": {"answer": "world"}, + "status": "succeeded", + "error": None, + "elapsed_time": 1.0, + "execution_metadata": {"total_tokens": 3}, + "extras": {}, + "created_at": 1767323045, + "created_by_role": "account", + "created_by_account": {"id": "account-1", "name": "Alice", "email": "alice@example.com"}, + "created_by_end_user": None, + "finished_at": 1767323045, + "inputs_truncated": False, + "outputs_truncated": False, + "process_data_truncated": False, + } + ] + } diff --git a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py index d3e864a75a..0fb0ebc330 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py +++ b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py @@ -67,7 +67,7 @@ class TestActivateCheckApi: assert response["data"]["email"] == "invitee@example.com" @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") - def test_check_invalid_invitation_token(self, mock_get_invitation, app): + def test_check_invalid_invitation_token(self, mock_get_invitation, app: Flask): """ Test checking invalid invitation token. @@ -185,7 +185,7 @@ class TestActivateApi: mock_db, mock_revoke_token, mock_get_invitation, - app, + app: Flask, mock_invitation, mock_account, ): @@ -227,7 +227,7 @@ class TestActivateApi: mock_db.session.commit.assert_called_once() @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") - def test_activation_with_invalid_token(self, mock_get_invitation, app): + def test_activation_with_invalid_token(self, mock_get_invitation, app: Flask): """ Test account activation with invalid token. @@ -263,7 +263,7 @@ class TestActivateApi: mock_db, mock_revoke_token, mock_get_invitation, - app, + app: Flask, mock_invitation, mock_account, ): @@ -312,7 +312,7 @@ class TestActivateApi: mock_db, mock_revoke_token, mock_get_invitation, - app, + app: Flask, mock_invitation, mock_account, language, @@ -358,7 +358,7 @@ class TestActivateApi: mock_db, mock_revoke_token, mock_get_invitation, - app, + app: Flask, mock_invitation, ): """ @@ -398,7 +398,7 @@ class TestActivateApi: mock_db, mock_revoke_token, mock_get_invitation, - app, + app: Flask, mock_invitation, ): """ @@ -438,7 +438,7 @@ class TestActivateApi: mock_db, mock_revoke_token, mock_get_invitation, - app, + app: Flask, mock_invitation, mock_account, ): diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py b/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py new file mode 100644 index 0000000000..df282880af --- /dev/null +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from controllers.console.auth.email_register import EmailRegisterResetApi, EmailRegisterResetPayload + + +@patch("controllers.console.auth.email_register.AccountService.create_account_and_tenant") +def test_create_new_account_uses_requested_language(mock_create_account): + account = MagicMock() + mock_create_account.return_value = account + + result = EmailRegisterResetApi()._create_new_account( + "invitee@example.com", + "ValidPass123!", + timezone="Asia/Shanghai", + language="zh-Hans", + ) + + assert result is account + mock_create_account.assert_called_once_with( + email="invitee@example.com", + name="invitee@example.com", + password="ValidPass123!", + interface_language="zh-Hans", + timezone="Asia/Shanghai", + ) + + +def test_reset_payload_rejects_invalid_timezone(): + with pytest.raises(ValidationError): + EmailRegisterResetPayload.model_validate( + { + "token": "token-123", + "new_password": "ValidPass123!", + "password_confirm": "ValidPass123!", + "timezone": "", + } + ) diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index b7bc73da5f..fa23942c65 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -13,9 +13,10 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from pydantic import ValidationError from controllers.console.auth.error import EmailCodeError, InvalidEmailError, InvalidTokenError -from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi +from controllers.console.auth.login import EmailCodeLoginApi, EmailCodeLoginPayload, EmailCodeLoginSendEmailApi from controllers.console.error import ( AccountInFreezeError, AccountNotFound, @@ -31,6 +32,18 @@ def encode_code(code: str) -> str: return base64.b64encode(code.encode("utf-8")).decode() +def test_email_code_login_payload_rejects_invalid_timezone(): + with pytest.raises(ValidationError): + EmailCodeLoginPayload.model_validate( + { + "email": "newuser@example.com", + "code": "123456", + "token": "token-123", + "timezone": "", + } + ) + + class TestEmailCodeLoginSendEmailApi: """Test cases for sending email verification codes.""" @@ -140,7 +153,7 @@ class TestEmailCodeLoginSendEmailApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit") - def test_send_email_code_ip_rate_limited(self, mock_is_ip_limit, mock_db, app): + def test_send_email_code_ip_rate_limited(self, mock_is_ip_limit, mock_db, app: Flask): """ Test email code sending blocked by IP rate limit. @@ -160,7 +173,7 @@ class TestEmailCodeLoginSendEmailApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.is_email_send_ip_limit") @patch("controllers.console.auth.login.AccountService.get_user_through_email") - def test_send_email_code_frozen_account(self, mock_get_user, mock_is_ip_limit, mock_db, app): + def test_send_email_code_frozen_account(self, mock_get_user, mock_is_ip_limit, mock_db, app: Flask): """ Test email code sending to frozen account. @@ -195,7 +208,7 @@ class TestEmailCodeLoginSendEmailApi: mock_get_user, mock_is_ip_limit, mock_db, - app, + app: Flask, mock_account, language_input, expected_language, @@ -267,7 +280,7 @@ class TestEmailCodeLoginApi: mock_revoke_token, mock_get_data, mock_db, - app, + app: Flask, mock_account, mock_token_pair, ): @@ -315,7 +328,7 @@ class TestEmailCodeLoginApi: mock_revoke_token, mock_get_data, mock_db, - app, + app: Flask, mock_account, mock_token_pair, ): @@ -342,6 +355,7 @@ class TestEmailCodeLoginApi: "code": encode_code("123456"), "token": "valid_token", "language": "en-US", + "timezone": "Asia/Shanghai", }, ): api = EmailCodeLoginApi() @@ -349,11 +363,16 @@ class TestEmailCodeLoginApi: # Assert assert response.json["result"] == "success" - mock_create_account.assert_called_once() + mock_create_account.assert_called_once_with( + email="newuser@example.com", + name="newuser@example.com", + interface_language="en-US", + timezone="Asia/Shanghai", + ) @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - def test_email_code_login_invalid_token(self, mock_get_data, mock_db, app): + def test_email_code_login_invalid_token(self, mock_get_data, mock_db, app: Flask): """ Test email code login with invalid token. @@ -375,7 +394,7 @@ class TestEmailCodeLoginApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - def test_email_code_login_email_mismatch(self, mock_get_data, mock_db, app): + def test_email_code_login_email_mismatch(self, mock_get_data, mock_db, app: Flask): """ Test email code login with mismatched email. @@ -397,7 +416,7 @@ class TestEmailCodeLoginApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.AccountService.get_email_code_login_data") - def test_email_code_login_wrong_code(self, mock_get_data, mock_db, app): + def test_email_code_login_wrong_code(self, mock_get_data, mock_db, app: Flask): """ Test email code login with incorrect code. @@ -431,7 +450,7 @@ class TestEmailCodeLoginApi: mock_revoke_token, mock_get_data, mock_db, - app, + app: Flask, mock_account, ): """ @@ -474,7 +493,7 @@ class TestEmailCodeLoginApi: mock_revoke_token, mock_get_data, mock_db, - app, + app: Flask, mock_account, ): """ @@ -515,7 +534,7 @@ class TestEmailCodeLoginApi: mock_revoke_token, mock_get_data, mock_db, - app, + app: Flask, mock_account, ): """ diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index d089be8905..ace2ce5706 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -9,7 +9,7 @@ This module tests the core authentication endpoints including: """ import base64 -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from flask import Flask @@ -52,12 +52,12 @@ class TestLoginApi: return app @pytest.fixture - def api(self, app): + def api(self, app: Flask): """Create Flask-RESTX API instance.""" return Api(app) @pytest.fixture - def client(self, app, api): + def client(self, app: Flask, api: Api): """Create test client.""" api.add_resource(LoginApi, "/login") return app.test_client() @@ -97,7 +97,7 @@ class TestLoginApi: mock_get_invitation, mock_is_rate_limit, mock_db, - app, + app: Flask, mock_account, mock_token_pair, ): @@ -141,14 +141,14 @@ class TestLoginApi: @patch("controllers.console.auth.login.AccountService.reset_login_error_rate_limit") def test_successful_login_with_valid_invitation( self, - mock_reset_rate_limit, + mock_reset_rate_limit: Mock, mock_login, mock_get_tenants, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, - app, + app: Flask, mock_account, mock_token_pair, ): @@ -188,7 +188,7 @@ class TestLoginApi: @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") - def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app): + def test_login_fails_when_rate_limited(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask): """ Test login rejection when rate limit is exceeded. @@ -216,7 +216,7 @@ class TestLoginApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True) @patch("controllers.console.auth.login.BillingService.is_email_in_freeze") - def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app): + def test_login_fails_when_account_frozen(self, mock_is_frozen, mock_db, app: Flask): """ Test login rejection for frozen accounts. @@ -253,7 +253,7 @@ class TestLoginApi: mock_get_invitation, mock_is_rate_limit, mock_db, - app, + app: Flask, ): """ Test login failure with invalid credentials. @@ -290,7 +290,7 @@ class TestLoginApi: @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") def test_login_fails_for_banned_account( - self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app + self, mock_authenticate, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask ): """ Test login rejection for banned accounts. @@ -328,14 +328,14 @@ class TestLoginApi: @patch("controllers.console.auth.login.FeatureService.get_system_features") def test_login_fails_when_no_workspace_and_limit_exceeded( self, - mock_get_features, - mock_get_tenants, - mock_authenticate, - mock_get_invitation, - mock_is_rate_limit, - mock_db, - app, - mock_account, + mock_get_features: MagicMock, + mock_get_tenants: MagicMock, + mock_authenticate: MagicMock, + mock_get_invitation: MagicMock, + mock_is_rate_limit: MagicMock, + mock_db: MagicMock, + app: Flask, + mock_account: MagicMock, ): """ Test login failure when user has no workspace and workspace limit exceeded. @@ -367,7 +367,7 @@ class TestLoginApi: @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") - def test_login_invitation_email_mismatch(self, mock_get_invitation, mock_is_rate_limit, mock_db, app): + def test_login_invitation_email_mismatch(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask): """ Test login failure when invitation email doesn't match login email. @@ -412,7 +412,7 @@ class TestLoginApi: mock_get_invitation, mock_is_rate_limit, mock_db, - app, + app: Flask, mock_account, mock_token_pair, ): @@ -448,7 +448,7 @@ class TestLoginApi: mock_revoke_token, mock_get_token_data, mock_db, - app, + app: Flask, ): mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} mock_get_account.side_effect = Unauthorized("Account is banned.") @@ -491,7 +491,7 @@ class TestLogoutApi: @patch("controllers.console.auth.login.AccountService.logout") @patch("controllers.console.auth.login.flask_login.logout_user") def test_successful_logout( - self, mock_logout_user, mock_service_logout, mock_current_account, mock_db, app, mock_account + self, mock_logout_user, mock_service_logout, mock_current_account, mock_db, app: Flask, mock_account ): """ Test successful logout flow. @@ -518,7 +518,7 @@ class TestLogoutApi: @patch("controllers.console.wraps.db") @patch("controllers.console.auth.login.current_account_with_tenant") @patch("controllers.console.auth.login.flask_login") - def test_logout_anonymous_user(self, mock_flask_login, mock_current_account, mock_db, app): + def test_logout_anonymous_user(self, mock_flask_login, mock_current_account, mock_db, app: Flask): """ Test logout for anonymous (not logged in) user. diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py new file mode 100644 index 0000000000..36c707dbf9 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py @@ -0,0 +1,123 @@ +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +from controllers.console.auth.oauth import OAuthLogin, _generate_account +from libs.oauth import OAuthUserInfo +from services.errors.account import AccountRegisterError + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +@patch("controllers.console.auth.oauth.redirect") +@patch("controllers.console.auth.oauth.get_oauth_providers") +def test_oauth_login_passes_language_and_timezone_to_authorization_url( + mock_get_oauth_providers, + mock_redirect, + app: Flask, +): + oauth_provider = MagicMock() + oauth_provider.get_authorization_url.return_value = "https://github.com/login/oauth/authorize?state=..." + mock_get_oauth_providers.return_value = {"github": oauth_provider} + + with app.test_request_context("/oauth/login/github?language=zh-Hans&timezone=Asia/Shanghai"): + OAuthLogin().get("github") + + oauth_provider.get_authorization_url.assert_called_once_with( + invite_token=None, + timezone="Asia/Shanghai", + language="zh-Hans", + ) + mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?state=...") + + +@patch("controllers.console.auth.oauth.AccountService.link_account_integrate") +@patch("controllers.console.auth.oauth.RegisterService") +@patch("controllers.console.auth.oauth.FeatureService") +@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) +def test_generate_account_registers_with_browser_timezone( + mock_get_account, + mock_feature_service, + mock_register_service, + mock_link_account, + app: Flask, +): + account = MagicMock() + mock_register_service.register.return_value = account + mock_feature_service.get_system_features.return_value.is_allow_register = True + user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com") + + with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}): + result, oauth_new_user = _generate_account("github", user_info, timezone="Asia/Shanghai") + + assert result is account + assert oauth_new_user is True + mock_register_service.register.assert_called_once_with( + email="user@example.com", + name="Test User", + password=None, + open_id="github-123", + provider="github", + language="zh-Hans", + timezone="Asia/Shanghai", + ) + mock_link_account.assert_called_once_with("github", "github-123", account) + + +@patch("controllers.console.auth.oauth.AccountService.link_account_integrate") +@patch("controllers.console.auth.oauth.RegisterService") +@patch("controllers.console.auth.oauth.FeatureService") +@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) +def test_generate_account_prefers_state_language_over_accept_language( + mock_get_account, + mock_feature_service, + mock_register_service, + mock_link_account, + app: Flask, +): + account = MagicMock() + mock_register_service.register.return_value = account + mock_feature_service.get_system_features.return_value.is_allow_register = True + user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com") + + with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): + _generate_account("github", user_info, language="zh-Hans") + + mock_register_service.register.assert_called_once_with( + email="user@example.com", + name="Test User", + password=None, + open_id="github-123", + provider="github", + language="zh-Hans", + timezone=None, + ) + mock_link_account.assert_called_once_with("github", "github-123", account) + + +@patch("controllers.console.auth.oauth.dify_config") +@patch("controllers.console.auth.oauth.RegisterService") +@patch("controllers.console.auth.oauth.FeatureService") +@patch("controllers.console.auth.oauth._get_account_by_openid_or_email", return_value=None) +def test_generate_account_rejects_new_user_when_registration_disabled( + mock_get_account, + mock_feature_service, + mock_register_service, + mock_config, + app: Flask, +): + mock_feature_service.get_system_features.return_value.is_allow_register = False + mock_config.BILLING_ENABLED = False + user_info = OAuthUserInfo(id="github-123", name="Test User", email="user@example.com") + + with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): + with pytest.raises(AccountRegisterError): + _generate_account("github", user_info) + + mock_register_service.register.assert_not_called() diff --git a/api/tests/unit_tests/controllers/console/auth/test_token_refresh.py b/api/tests/unit_tests/controllers/console/auth/test_token_refresh.py index d010f60866..22974ca416 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_token_refresh.py +++ b/api/tests/unit_tests/controllers/console/auth/test_token_refresh.py @@ -28,12 +28,12 @@ class TestRefreshTokenApi: return app @pytest.fixture - def api(self, app): + def api(self, app: Flask): """Create Flask-RESTX API instance.""" return Api(app) @pytest.fixture - def client(self, app, api): + def client(self, app: Flask, api: Api): """Create test client.""" api.add_resource(RefreshTokenApi, "/refresh-token") return app.test_client() @@ -74,7 +74,7 @@ class TestRefreshTokenApi: assert response.json["result"] == "success" @patch("controllers.console.auth.login.extract_refresh_token", autospec=True) - def test_refresh_fails_without_token(self, mock_extract_token, app): + def test_refresh_fails_without_token(self, mock_extract_token, app: Flask): """ Test token refresh failure when no refresh token provided. @@ -98,7 +98,7 @@ class TestRefreshTokenApi: @patch("controllers.console.auth.login.extract_refresh_token", autospec=True) @patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True) - def test_refresh_fails_with_invalid_token(self, mock_refresh_token, mock_extract_token, app): + def test_refresh_fails_with_invalid_token(self, mock_refresh_token, mock_extract_token, app: Flask): """ Test token refresh failure with invalid refresh token. @@ -123,7 +123,7 @@ class TestRefreshTokenApi: @patch("controllers.console.auth.login.extract_refresh_token", autospec=True) @patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True) - def test_refresh_fails_with_expired_token(self, mock_refresh_token, mock_extract_token, app): + def test_refresh_fails_with_expired_token(self, mock_refresh_token, mock_extract_token, app: Flask): """ Test token refresh failure with expired refresh token. @@ -148,7 +148,7 @@ class TestRefreshTokenApi: @patch("controllers.console.auth.login.extract_refresh_token", autospec=True) @patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True) - def test_refresh_with_empty_token(self, mock_refresh_token, mock_extract_token, app): + def test_refresh_with_empty_token(self, mock_refresh_token, mock_extract_token, app: Flask): """ Test token refresh with empty string token. diff --git a/api/tests/unit_tests/controllers/console/billing/test_billing.py b/api/tests/unit_tests/controllers/console/billing/test_billing.py index 810f1b94fc..defa9064fd 100644 --- a/api/tests/unit_tests/controllers/console/billing/test_billing.py +++ b/api/tests/unit_tests/controllers/console/billing/test_billing.py @@ -49,7 +49,7 @@ class TestPartnerTenants: mock_csrf.return_value = None yield {"db": mock_db, "csrf": mock_csrf} - def test_put_success(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_success(self, app: Flask, mock_account, mock_billing_service, mock_decorators): """Test successful partner tenants bindings sync.""" # Arrange partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8") @@ -79,7 +79,7 @@ class TestPartnerTenants: mock_account.id, "partner-key-123", click_id ) - def test_put_invalid_partner_key_base64(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_invalid_partner_key_base64(self, app: Flask, mock_account, mock_billing_service, mock_decorators): """Test that invalid base64 partner_key raises BadRequest.""" # Arrange invalid_partner_key = "invalid-base64-!@#$" @@ -104,7 +104,7 @@ class TestPartnerTenants: resource.put(invalid_partner_key) assert "Invalid partner_key" in str(exc_info.value) - def test_put_missing_click_id(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_missing_click_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators): """Test that missing click_id raises BadRequest.""" # Arrange partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8") @@ -128,7 +128,9 @@ class TestPartnerTenants: with pytest.raises(BadRequest): resource.put(partner_key_encoded) - def test_put_billing_service_json_decode_error(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_billing_service_json_decode_error( + self, app: Flask, mock_account, mock_billing_service, mock_decorators + ): """Test handling of billing service JSON decode error. When billing service returns non-200 status code with invalid JSON response, @@ -174,7 +176,7 @@ class TestPartnerTenants: assert isinstance(exc_info.value, json.JSONDecodeError) assert "Expecting value" in str(exc_info.value) - def test_put_empty_click_id(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_empty_click_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators): """Test that empty click_id raises BadRequest.""" # Arrange partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8") @@ -199,7 +201,7 @@ class TestPartnerTenants: resource.put(partner_key_encoded) assert "Invalid partner information" in str(exc_info.value) - def test_put_empty_partner_key_after_decode(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_empty_partner_key_after_decode(self, app: Flask, mock_account, mock_billing_service, mock_decorators): """Test that empty partner_key after decode raises BadRequest.""" # Arrange # Base64 encode an empty string @@ -225,7 +227,7 @@ class TestPartnerTenants: resource.put(empty_partner_key_encoded) assert "Invalid partner information" in str(exc_info.value) - def test_put_empty_user_id(self, app, mock_account, mock_billing_service, mock_decorators): + def test_put_empty_user_id(self, app: Flask, mock_account, mock_billing_service, mock_decorators): """Test that empty user id raises BadRequest.""" # Arrange partner_key_encoded = base64.b64encode(b"partner-key-123").decode("utf-8") diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py index 5136922e88..9c5b5ec256 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_auth.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden, NotFound from controllers.console import console_ns @@ -29,7 +30,7 @@ def unwrap(func): class TestDatasourcePluginOAuthAuthorizationUrl: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasourcePluginOAuthAuthorizationUrl() method = unwrap(api.get) @@ -61,7 +62,7 @@ class TestDatasourcePluginOAuthAuthorizationUrl: assert response.status_code == 200 - def test_get_no_oauth_config(self, app): + def test_get_no_oauth_config(self, app: Flask): api = DatasourcePluginOAuthAuthorizationUrl() method = unwrap(api.get) @@ -80,7 +81,7 @@ class TestDatasourcePluginOAuthAuthorizationUrl: with pytest.raises(ValueError): method(api, "notion") - def test_get_without_credential_id_sets_cookie(self, app): + def test_get_without_credential_id_sets_cookie(self, app: Flask): api = DatasourcePluginOAuthAuthorizationUrl() method = unwrap(api.get) @@ -115,7 +116,7 @@ class TestDatasourcePluginOAuthAuthorizationUrl: class TestDatasourceOAuthCallback: - def test_callback_success_new_credential(self, app): + def test_callback_success_new_credential(self, app: Flask): api = DatasourceOAuthCallback() method = unwrap(api.get) @@ -157,7 +158,7 @@ class TestDatasourceOAuthCallback: assert response.status_code == 302 - def test_callback_missing_context(self, app): + def test_callback_missing_context(self, app: Flask): api = DatasourceOAuthCallback() method = unwrap(api.get) @@ -165,7 +166,7 @@ class TestDatasourceOAuthCallback: with pytest.raises(Forbidden): method(api, "notion") - def test_callback_invalid_context(self, app): + def test_callback_invalid_context(self, app: Flask): api = DatasourceOAuthCallback() method = unwrap(api.get) @@ -180,7 +181,7 @@ class TestDatasourceOAuthCallback: with pytest.raises(Forbidden): method(api, "notion") - def test_callback_oauth_config_not_found(self, app): + def test_callback_oauth_config_not_found(self, app: Flask): api = DatasourceOAuthCallback() method = unwrap(api.get) @@ -202,7 +203,7 @@ class TestDatasourceOAuthCallback: with pytest.raises(NotFound): method(api, "notion") - def test_callback_reauthorize_existing_credential(self, app): + def test_callback_reauthorize_existing_credential(self, app: Flask): api = DatasourceOAuthCallback() method = unwrap(api.get) @@ -245,7 +246,7 @@ class TestDatasourceOAuthCallback: assert response.status_code == 302 assert "/oauth-callback" in response.location - def test_callback_context_id_from_cookie(self, app): + def test_callback_context_id_from_cookie(self, app: Flask): api = DatasourceOAuthCallback() method = unwrap(api.get) @@ -289,7 +290,7 @@ class TestDatasourceOAuthCallback: class TestDatasourceAuth: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = DatasourceAuth() method = unwrap(api.post) @@ -312,7 +313,7 @@ class TestDatasourceAuth: assert status == 200 - def test_post_invalid_credentials(self, app): + def test_post_invalid_credentials(self, app: Flask): api = DatasourceAuth() method = unwrap(api.post) @@ -334,7 +335,7 @@ class TestDatasourceAuth: with pytest.raises(ValueError): method(api, "notion") - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasourceAuth() method = unwrap(api.get) @@ -355,7 +356,7 @@ class TestDatasourceAuth: assert status == 200 assert response["result"] - def test_post_missing_credentials(self, app): + def test_post_missing_credentials(self, app: Flask): api = DatasourceAuth() method = unwrap(api.post) @@ -372,7 +373,7 @@ class TestDatasourceAuth: with pytest.raises(ValueError): method(api, "notion") - def test_get_empty_list(self, app): + def test_get_empty_list(self, app: Flask): api = DatasourceAuth() method = unwrap(api.get) @@ -395,7 +396,7 @@ class TestDatasourceAuth: class TestDatasourceAuthDeleteApi: - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = DatasourceAuthDeleteApi() method = unwrap(api.post) @@ -418,7 +419,7 @@ class TestDatasourceAuthDeleteApi: assert status == 200 - def test_delete_missing_credential_id(self, app): + def test_delete_missing_credential_id(self, app: Flask): api = DatasourceAuthDeleteApi() method = unwrap(api.post) @@ -437,7 +438,7 @@ class TestDatasourceAuthDeleteApi: class TestDatasourceAuthUpdateApi: - def test_update_success(self, app): + def test_update_success(self, app: Flask): api = DatasourceAuthUpdateApi() method = unwrap(api.post) @@ -460,7 +461,7 @@ class TestDatasourceAuthUpdateApi: assert status == 201 - def test_update_with_credentials_none(self, app): + def test_update_with_credentials_none(self, app: Flask): api = DatasourceAuthUpdateApi() method = unwrap(api.post) @@ -484,7 +485,7 @@ class TestDatasourceAuthUpdateApi: update_mock.assert_called_once() assert status == 201 - def test_update_name_only(self, app): + def test_update_name_only(self, app: Flask): api = DatasourceAuthUpdateApi() method = unwrap(api.post) @@ -507,7 +508,7 @@ class TestDatasourceAuthUpdateApi: assert status == 201 - def test_update_with_empty_credentials_dict(self, app): + def test_update_with_empty_credentials_dict(self, app: Flask): api = DatasourceAuthUpdateApi() method = unwrap(api.post) @@ -533,7 +534,7 @@ class TestDatasourceAuthUpdateApi: class TestDatasourceAuthListApi: - def test_list_success(self, app): + def test_list_success(self, app: Flask): api = DatasourceAuthListApi() method = unwrap(api.get) @@ -553,7 +554,7 @@ class TestDatasourceAuthListApi: assert status == 200 - def test_auth_list_empty(self, app): + def test_auth_list_empty(self, app: Flask): api = DatasourceAuthListApi() method = unwrap(api.get) @@ -574,7 +575,7 @@ class TestDatasourceAuthListApi: assert status == 200 assert response["result"] == [] - def test_hardcode_list_empty(self, app): + def test_hardcode_list_empty(self, app: Flask): api = DatasourceHardCodeAuthListApi() method = unwrap(api.get) @@ -597,7 +598,7 @@ class TestDatasourceAuthListApi: class TestDatasourceHardCodeAuthListApi: - def test_list_success(self, app): + def test_list_success(self, app: Flask): api = DatasourceHardCodeAuthListApi() method = unwrap(api.get) @@ -619,7 +620,7 @@ class TestDatasourceHardCodeAuthListApi: class TestDatasourceAuthOauthCustomClient: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = DatasourceAuthOauthCustomClient() method = unwrap(api.post) @@ -642,7 +643,7 @@ class TestDatasourceAuthOauthCustomClient: assert status == 200 - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = DatasourceAuthOauthCustomClient() method = unwrap(api.delete) @@ -662,7 +663,7 @@ class TestDatasourceAuthOauthCustomClient: assert status == 200 - def test_post_empty_payload(self, app): + def test_post_empty_payload(self, app: Flask): api = DatasourceAuthOauthCustomClient() method = unwrap(api.post) @@ -685,7 +686,7 @@ class TestDatasourceAuthOauthCustomClient: assert status == 200 - def test_post_disabled_flag(self, app): + def test_post_disabled_flag(self, app: Flask): api = DatasourceAuthOauthCustomClient() method = unwrap(api.post) @@ -714,7 +715,7 @@ class TestDatasourceAuthOauthCustomClient: class TestDatasourceAuthDefaultApi: - def test_set_default_success(self, app): + def test_set_default_success(self, app: Flask): api = DatasourceAuthDefaultApi() method = unwrap(api.post) @@ -737,7 +738,7 @@ class TestDatasourceAuthDefaultApi: assert status == 200 - def test_default_missing_id(self, app): + def test_default_missing_id(self, app: Flask): api = DatasourceAuthDefaultApi() method = unwrap(api.post) @@ -756,7 +757,7 @@ class TestDatasourceAuthDefaultApi: class TestDatasourceUpdateProviderNameApi: - def test_update_name_success(self, app): + def test_update_name_success(self, app: Flask): api = DatasourceUpdateProviderNameApi() method = unwrap(api.post) @@ -779,7 +780,7 @@ class TestDatasourceUpdateProviderNameApi: assert status == 200 - def test_update_name_too_long(self, app): + def test_update_name_too_long(self, app: Flask): api = DatasourceUpdateProviderNameApi() method = unwrap(api.post) @@ -799,7 +800,7 @@ class TestDatasourceUpdateProviderNameApi: with pytest.raises(ValueError): method(api, "notion") - def test_update_name_missing_credential_id(self, app): + def test_update_name_missing_credential_id(self, app: Flask): api = DatasourceUpdateProviderNameApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py index 7a8ccde55a..d4c6a775ec 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden from controllers.console import console_ns @@ -25,7 +26,7 @@ class TestDataSourceContentPreviewApi: "credential_id": "cred-1", } - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = DataSourceContentPreviewApi() method = unwrap(api.post) @@ -66,7 +67,7 @@ class TestDataSourceContentPreviewApi: assert status == 200 assert response == preview_result - def test_post_forbidden_non_account_user(self, app): + def test_post_forbidden_non_account_user(self, app: Flask): api = DataSourceContentPreviewApi() method = unwrap(api.post) @@ -85,7 +86,7 @@ class TestDataSourceContentPreviewApi: with pytest.raises(Forbidden): method(api, pipeline, "node-1") - def test_post_invalid_payload(self, app): + def test_post_invalid_payload(self, app: Flask): api = DataSourceContentPreviewApi() method = unwrap(api.post) @@ -108,7 +109,7 @@ class TestDataSourceContentPreviewApi: with pytest.raises(ValueError): method(api, pipeline, "node-1") - def test_post_without_credential_id(self, app): + def test_post_without_credential_id(self, app: Flask): api = DataSourceContentPreviewApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py index 63950736c5..8a65c4bbe5 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch import pytest -from flask import Response +from flask import Flask, Response from controllers.console import console_ns from controllers.console.app.error import DraftWorkflowNotExist @@ -46,7 +46,7 @@ def restx_config(app): class TestRagPipelineVariableCollectionApi: - def test_get_variables_success(self, app, fake_db, editor_user, restx_config): + def test_get_variables_success(self, app: Flask, fake_db, editor_user, restx_config): api = RagPipelineVariableCollectionApi() method = unwrap(api.get) @@ -80,7 +80,7 @@ class TestRagPipelineVariableCollectionApi: assert result["items"] == [] - def test_get_variables_workflow_not_exist(self, app, fake_db, editor_user): + def test_get_variables_workflow_not_exist(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableCollectionApi() method = unwrap(api.get) @@ -101,7 +101,7 @@ class TestRagPipelineVariableCollectionApi: with pytest.raises(DraftWorkflowNotExist): method(api, pipeline) - def test_delete_variables_success(self, app, fake_db, editor_user): + def test_delete_variables_success(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableCollectionApi() method = unwrap(api.delete) @@ -120,7 +120,7 @@ class TestRagPipelineVariableCollectionApi: class TestRagPipelineNodeVariableCollectionApi: - def test_get_node_variables_success(self, app, fake_db, editor_user, restx_config): + def test_get_node_variables_success(self, app: Flask, fake_db, editor_user, restx_config): api = RagPipelineNodeVariableCollectionApi() method = unwrap(api.get) @@ -146,7 +146,7 @@ class TestRagPipelineNodeVariableCollectionApi: assert result["items"] == [] - def test_get_node_variables_invalid_node(self, app, editor_user): + def test_get_node_variables_invalid_node(self, app: Flask, editor_user): api = RagPipelineNodeVariableCollectionApi() method = unwrap(api.get) @@ -159,7 +159,7 @@ class TestRagPipelineNodeVariableCollectionApi: class TestRagPipelineVariableApi: - def test_get_variable_not_found(self, app, fake_db, editor_user): + def test_get_variable_not_found(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableApi() method = unwrap(api.get) @@ -178,7 +178,7 @@ class TestRagPipelineVariableApi: with pytest.raises(NotFoundError): method(api, MagicMock(), "v1") - def test_patch_variable_invalid_file_payload(self, app, fake_db, editor_user): + def test_patch_variable_invalid_file_payload(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableApi() method = unwrap(api.patch) @@ -203,7 +203,7 @@ class TestRagPipelineVariableApi: with pytest.raises(InvalidArgumentError): method(api, pipeline, "v1") - def test_delete_variable_success(self, app, fake_db, editor_user): + def test_delete_variable_success(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableApi() method = unwrap(api.delete) @@ -228,7 +228,7 @@ class TestRagPipelineVariableApi: class TestRagPipelineVariableResetApi: - def test_reset_variable_success(self, app, fake_db, editor_user): + def test_reset_variable_success(self, app: Flask, fake_db, editor_user): api = RagPipelineVariableResetApi() method = unwrap(api.put) @@ -266,7 +266,7 @@ class TestRagPipelineVariableResetApi: class TestSystemAndEnvironmentVariablesApi: - def test_system_variables_success(self, app, fake_db, editor_user, restx_config): + def test_system_variables_success(self, app: Flask, fake_db, editor_user, restx_config): api = RagPipelineSystemVariableCollectionApi() method = unwrap(api.get) @@ -292,7 +292,7 @@ class TestSystemAndEnvironmentVariablesApi: assert result["items"] == [] - def test_environment_variables_success(self, app, editor_user): + def test_environment_variables_success(self, app: Flask, editor_user): api = RagPipelineEnvironmentVariableCollectionApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py new file mode 100644 index 0000000000..322f1baa96 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import PropertyMock, patch + +import pytest + +from controllers.console.datasets.rag_pipeline import rag_pipeline_workflow as module + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def _make_workflow(**overrides): + workflow = SimpleNamespace( + id="workflow-1", + graph_dict={"nodes": [], "edges": []}, + features_dict={"file_upload": {"enabled": False}}, + unique_hash="hash-1", + version="1", + marked_name="Release 1", + marked_comment="Initial release", + created_by_account=SimpleNamespace(id="user-1", name="Alice", email="alice@example.com"), + created_at=datetime(2024, 1, 1, 12, 0, 0), + updated_by_account=None, + updated_at=datetime(2024, 1, 1, 12, 1, 0), + tool_published=False, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + for key, value in overrides.items(): + setattr(workflow, key, value) + return workflow + + +def test_draft_rag_pipeline_workflow_get_serializes_response_model(monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow() + monkeypatch.setattr( + module, "RagPipelineService", lambda: SimpleNamespace(get_draft_workflow=lambda **_kwargs: workflow) + ) + + api = module.DraftRagPipelineApi() + handler = _unwrap(api.get) + + response = handler(api, pipeline=SimpleNamespace(id="pipeline-1")) + + assert response["id"] == "workflow-1" + assert response["graph"] == {"nodes": [], "edges": []} + assert response["features"] == {"file_upload": {"enabled": False}} + assert response["hash"] == "hash-1" + assert response["created_by"] == {"id": "user-1", "name": "Alice", "email": "alice@example.com"} + assert response["updated_by"] is None + assert response["created_at"] == int(datetime(2024, 1, 1, 12, 0, 0).timestamp()) + assert response["updated_at"] == int(datetime(2024, 1, 1, 12, 1, 0).timestamp()) + + +def test_published_rag_pipeline_workflows_serialize_items_before_session_closes( + app, monkeypatch: pytest.MonkeyPatch +) -> None: + api = module.PublishedAllRagPipelineApi() + handler = _unwrap(api.get) + session_state = {"open": False} + + class _SessionContext: + def __enter__(self): + session_state["open"] = True + return object() + + def __exit__(self, exc_type, exc, tb): + session_state["open"] = False + return False + + class _SessionMaker: + def begin(self): + return _SessionContext() + + base_workflow = _make_workflow() + + class _Workflow: + def __getattr__(self, name: str): + assert session_state["open"] is True + return getattr(base_workflow, name) + + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(module, "sessionmaker", lambda *_args, **_kwargs: _SessionMaker()) + monkeypatch.setattr(module, "current_account_with_tenant", lambda: (SimpleNamespace(id="user-1"), "tenant-1")) + monkeypatch.setattr( + module, + "RagPipelineService", + lambda: SimpleNamespace(get_all_published_workflow=lambda **_kwargs: ([_Workflow()], False)), + ) + + with app.test_request_context( + "/rag/pipelines/pipeline-1/workflows", + method="GET", + query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"}, + ): + response = handler(api, pipeline=SimpleNamespace(id="pipeline-1")) + + assert response["items"][0]["id"] == "workflow-1" + assert response["page"] == 1 + assert response["limit"] == 10 + assert response["has_more"] is False + + +def test_rag_pipeline_workflow_patch_serializes_response_model(app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = _make_workflow(marked_name="Updated release") + monkeypatch.setattr(module, "current_account_with_tenant", lambda: (SimpleNamespace(id="user-1"), "tenant-1")) + + class _SessionContext: + def __enter__(self): + return object() + + def __exit__(self, exc_type, exc, tb): + return False + + class _SessionMaker: + def begin(self): + return _SessionContext() + + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(module, "sessionmaker", lambda *_args, **_kwargs: _SessionMaker()) + monkeypatch.setattr( + module, + "RagPipelineService", + lambda: SimpleNamespace(update_workflow=lambda **_kwargs: workflow), + ) + payload: dict[str, object] = {"marked_name": "Updated release"} + + api = module.RagPipelineByIdApi() + handler = _unwrap(api.patch) + + with ( + app.test_request_context("/rag/pipelines/pipeline-1/workflows/workflow-1", method="PATCH", json=payload), + patch.object(type(module.console_ns), "payload", new_callable=PropertyMock, return_value=payload), + ): + response = handler( + api, + pipeline=SimpleNamespace(id="pipeline-1", tenant_id="tenant-1"), + workflow_id="workflow-1", + ) + + assert response["id"] == "workflow-1" + assert response["marked_name"] == "Updated release" + assert response["hash"] == "hash-1" diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 9465936f28..e28d68ee5a 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -2,6 +2,7 @@ import datetime from unittest.mock import MagicMock, PropertyMock, patch import pytest +from flask import Flask from werkzeug.exceptions import BadRequest, Forbidden, NotFound import services @@ -58,7 +59,7 @@ class TestDatasetList: user.is_dataset_editor = True return user - def test_get_success_basic(self, app): + def test_get_success_basic(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -93,7 +94,7 @@ class TestDatasetList: assert resp["total"] == 1 assert resp["data"][0]["embedding_available"] is True - def test_get_with_ids_filter(self, app): + def test_get_with_ids_filter(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -128,7 +129,7 @@ class TestDatasetList: assert status == 200 assert resp["total"] == 2 - def test_get_with_tag_ids(self, app): + def test_get_with_tag_ids(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -161,7 +162,7 @@ class TestDatasetList: assert status == 200 - def test_embedding_available_false(self, app): + def test_embedding_available_false(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -203,7 +204,7 @@ class TestDatasetList: assert resp["data"][0]["embedding_available"] is False - def test_partial_members_permission(self, app): + def test_partial_members_permission(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -242,7 +243,7 @@ class TestDatasetList: class TestDatasetListApiPost: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -290,7 +291,7 @@ class TestDatasetListApiPost: assert status == 201 - def test_post_forbidden(self, app): + def test_post_forbidden(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -310,7 +311,7 @@ class TestDatasetListApiPost: with pytest.raises(Forbidden): method(api) - def test_post_duplicate_name(self, app): + def test_post_duplicate_name(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -335,7 +336,7 @@ class TestDatasetListApiPost: with pytest.raises(DatasetNameDuplicateError): method(api) - def test_post_invalid_payload_missing_name(self, app): + def test_post_invalid_payload_missing_name(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -343,7 +344,7 @@ class TestDatasetListApiPost: with pytest.raises(ValueError): method(api) - def test_post_invalid_indexing_technique(self, app): + def test_post_invalid_indexing_technique(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -356,7 +357,7 @@ class TestDatasetListApiPost: with pytest.raises(ValueError, match="Invalid indexing technique"): method(api) - def test_post_invalid_provider(self, app): + def test_post_invalid_provider(self, app: Flask): api = DatasetListApi() method = unwrap(api.post) @@ -371,7 +372,7 @@ class TestDatasetListApiPost: class TestDatasetApiGet: - def test_get_success_basic(self, app): + def test_get_success_basic(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -427,7 +428,7 @@ class TestDatasetApiGet: assert status == 200 assert data["embedding_available"] is True - def test_get_dataset_not_found(self, app): + def test_get_dataset_not_found(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -448,7 +449,7 @@ class TestDatasetApiGet: with pytest.raises(NotFound, match="Dataset not found"): method(api, dataset_id) - def test_get_permission_denied(self, app): + def test_get_permission_denied(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -475,7 +476,7 @@ class TestDatasetApiGet: with pytest.raises(Forbidden, match="no access"): method(api, dataset_id) - def test_get_high_quality_embedding_unavailable(self, app): + def test_get_high_quality_embedding_unavailable(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -530,7 +531,7 @@ class TestDatasetApiGet: assert data["embedding_available"] is False - def test_get_partial_members_permission(self, app): + def test_get_partial_members_permission(self, app: Flask): api = DatasetApi() method = unwrap(api.get) @@ -590,7 +591,7 @@ class TestDatasetApiGet: class TestDatasetApiPatch: - def test_patch_success_basic(self, app): + def test_patch_success_basic(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -659,7 +660,7 @@ class TestDatasetApiPatch: assert status == 200 assert result["partial_member_list"] == [] - def test_patch_dataset_not_found(self, app): + def test_patch_dataset_not_found(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -674,7 +675,7 @@ class TestDatasetApiPatch: with pytest.raises(NotFound, match="Dataset not found"): method(api, "missing") - def test_patch_permission_denied(self, app): + def test_patch_permission_denied(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -704,7 +705,7 @@ class TestDatasetApiPatch: with pytest.raises(Forbidden): method(api, dataset_id) - def test_patch_partial_members_update(self, app): + def test_patch_partial_members_update(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -773,7 +774,7 @@ class TestDatasetApiPatch: assert result["partial_member_list"] == payload["partial_member_list"] - def test_patch_clear_partial_members(self, app): + def test_patch_clear_partial_members(self, app: Flask): api = DatasetApi() method = unwrap(api.patch) @@ -843,7 +844,7 @@ class TestDatasetApiPatch: class TestDatasetApiDelete: - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = DatasetApi() method = unwrap(api.delete) @@ -874,7 +875,7 @@ class TestDatasetApiDelete: assert status == 204 assert result == {"result": "success"} - def test_delete_forbidden_no_permission(self, app): + def test_delete_forbidden_no_permission(self, app: Flask): api = DatasetApi() method = unwrap(api.delete) @@ -893,7 +894,7 @@ class TestDatasetApiDelete: with pytest.raises(Forbidden): method(api, dataset_id) - def test_delete_dataset_not_found(self, app): + def test_delete_dataset_not_found(self, app: Flask): api = DatasetApi() method = unwrap(api.delete) @@ -917,7 +918,7 @@ class TestDatasetApiDelete: with pytest.raises(NotFound, match="Dataset not found"): method(api, dataset_id) - def test_delete_dataset_in_use(self, app): + def test_delete_dataset_in_use(self, app: Flask): api = DatasetApi() method = unwrap(api.delete) @@ -943,7 +944,7 @@ class TestDatasetApiDelete: class TestDatasetUseCheckApi: - def test_get_use_check_true(self, app): + def test_get_use_check_true(self, app: Flask): api = DatasetUseCheckApi() method = unwrap(api.get) @@ -962,7 +963,7 @@ class TestDatasetUseCheckApi: assert status == 200 assert result == {"is_using": True} - def test_get_use_check_false(self, app): + def test_get_use_check_false(self, app: Flask): api = DatasetUseCheckApi() method = unwrap(api.get) @@ -983,7 +984,7 @@ class TestDatasetUseCheckApi: class TestDatasetQueryApi: - def test_get_queries_success(self, app): + def test_get_queries_success(self, app: Flask): api = DatasetQueryApi() method = unwrap(api.get) @@ -1027,7 +1028,7 @@ class TestDatasetQueryApi: assert response["has_more"] is False assert len(response["data"]) == 2 - def test_get_queries_dataset_not_found(self, app): + def test_get_queries_dataset_not_found(self, app: Flask): api = DatasetQueryApi() method = unwrap(api.get) @@ -1049,7 +1050,7 @@ class TestDatasetQueryApi: with pytest.raises(NotFound, match="Dataset not found"): method(api, dataset_id) - def test_get_queries_permission_denied(self, app): + def test_get_queries_permission_denied(self, app: Flask): api = DatasetQueryApi() method = unwrap(api.get) @@ -1078,7 +1079,7 @@ class TestDatasetQueryApi: with pytest.raises(Forbidden): method(api, dataset_id) - def test_get_queries_pagination_has_more(self, app): + def test_get_queries_pagination_has_more(self, app: Flask): api = DatasetQueryApi() method = unwrap(api.get) @@ -1152,7 +1153,7 @@ class TestDatasetIndexingEstimateApi: "dataset_id": None, } - def test_post_success_upload_file(self, app): + def test_post_success_upload_file(self, app: Flask): api = DatasetIndexingEstimateApi() method = unwrap(api.post) @@ -1193,7 +1194,7 @@ class TestDatasetIndexingEstimateApi: assert status == 200 assert response == {"tokens": 100} - def test_post_file_not_found(self, app): + def test_post_file_not_found(self, app: Flask): api = DatasetIndexingEstimateApi() method = unwrap(api.post) @@ -1223,7 +1224,7 @@ class TestDatasetIndexingEstimateApi: with pytest.raises(NotFound): method(api) - def test_post_llm_bad_request_error(self, app): + def test_post_llm_bad_request_error(self, app: Flask): api = DatasetIndexingEstimateApi() method = unwrap(api.post) mock_file = self._upload_file() @@ -1258,7 +1259,7 @@ class TestDatasetIndexingEstimateApi: with pytest.raises(ProviderNotInitializeError): method(api) - def test_post_provider_token_not_init(self, app): + def test_post_provider_token_not_init(self, app: Flask): api = DatasetIndexingEstimateApi() method = unwrap(api.post) mock_file = self._upload_file() @@ -1293,7 +1294,7 @@ class TestDatasetIndexingEstimateApi: with pytest.raises(ProviderNotInitializeError): method(api) - def test_post_generic_exception(self, app): + def test_post_generic_exception(self, app: Flask): api = DatasetIndexingEstimateApi() method = unwrap(api.post) mock_file = self._upload_file() @@ -1330,7 +1331,7 @@ class TestDatasetIndexingEstimateApi: class TestDatasetRelatedAppListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) @@ -1368,7 +1369,7 @@ class TestDatasetRelatedAppListApi: assert response["total"] == 2 assert response["data"] == [app1, app2] - def test_get_dataset_not_found(self, app): + def test_get_dataset_not_found(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) @@ -1386,7 +1387,7 @@ class TestDatasetRelatedAppListApi: with pytest.raises(NotFound): method(api, "dataset-1") - def test_get_permission_denied(self, app): + def test_get_permission_denied(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) @@ -1410,7 +1411,7 @@ class TestDatasetRelatedAppListApi: with pytest.raises(Forbidden): method(api, "dataset-1") - def test_get_filters_none_apps(self, app): + def test_get_filters_none_apps(self, app: Flask): api = DatasetRelatedAppListApi() method = unwrap(api.get) @@ -1449,7 +1450,7 @@ class TestDatasetRelatedAppListApi: class TestDatasetIndexingStatusApi: - def test_get_success_with_documents(self, app): + def test_get_success_with_documents(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) @@ -1490,7 +1491,7 @@ class TestDatasetIndexingStatusApi: assert item["completed_segments"] == 3 assert item["total_segments"] == 3 - def test_get_success_no_documents(self, app): + def test_get_success_no_documents(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) @@ -1510,7 +1511,7 @@ class TestDatasetIndexingStatusApi: assert status == 200 assert response == {"data": []} - def test_segment_counts_different_values(self, app): + def test_segment_counts_different_values(self, app: Flask): api = DatasetIndexingStatusApi() method = unwrap(api.get) @@ -1550,7 +1551,7 @@ class TestDatasetIndexingStatusApi: class TestDatasetApiKeyApi: - def test_get_api_keys_success(self, app): + def test_get_api_keys_success(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.get) @@ -1587,7 +1588,7 @@ class TestDatasetApiKeyApi: assert response["data"][1]["id"] == "key-2" assert response["data"][1]["token"] == "ds-def" - def test_post_create_api_key_success(self, app): + def test_post_create_api_key_success(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.post) @@ -1632,7 +1633,7 @@ class TestDatasetApiKeyApi: assert response["type"] == "dataset" assert response["created_at"] is not None - def test_post_exceed_max_keys(self, app): + def test_post_exceed_max_keys(self, app: Flask): api = DatasetApiKeyApi() method = unwrap(api.post) @@ -1658,7 +1659,7 @@ class TestDatasetApiKeyApi: class TestDatasetApiDeleteApi: - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = DatasetApiDeleteApi() method = unwrap(api.delete) @@ -1688,7 +1689,7 @@ class TestDatasetApiDeleteApi: assert status == 204 assert response["result"] == "success" - def test_delete_key_not_found(self, app): + def test_delete_key_not_found(self, app: Flask): api = DatasetApiDeleteApi() method = unwrap(api.delete) @@ -1708,7 +1709,7 @@ class TestDatasetApiDeleteApi: class TestDatasetEnableApiApi: - def test_enable_api(self, app): + def test_enable_api(self, app: Flask): api = DatasetEnableApiApi() method = unwrap(api.post) @@ -1724,7 +1725,7 @@ class TestDatasetEnableApiApi: assert status == 200 assert response["result"] == "success" - def test_disable_api(self, app): + def test_disable_api(self, app: Flask): api = DatasetEnableApiApi() method = unwrap(api.post) @@ -1742,7 +1743,7 @@ class TestDatasetEnableApiApi: class TestDatasetApiBaseUrlApi: - def test_get_api_base_url_from_config(self, app): + def test_get_api_base_url_from_config(self, app: Flask): api = DatasetApiBaseUrlApi() method = unwrap(api.get) @@ -1757,7 +1758,7 @@ class TestDatasetApiBaseUrlApi: assert response["api_base_url"] == "https://example.com/v1" - def test_get_api_base_url_from_request(self, app): + def test_get_api_base_url_from_request(self, app: Flask): api = DatasetApiBaseUrlApi() method = unwrap(api.get) @@ -1772,7 +1773,7 @@ class TestDatasetApiBaseUrlApi: assert response["api_base_url"] == "http://localhost:5000/v1" - def test_get_api_base_url_no_double_v1(self, app): + def test_get_api_base_url_no_double_v1(self, app: Flask): api = DatasetApiBaseUrlApi() method = unwrap(api.get) @@ -1789,7 +1790,7 @@ class TestDatasetApiBaseUrlApi: class TestDatasetRetrievalSettingApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetRetrievalSettingApi() method = unwrap(api.get) @@ -1810,7 +1811,7 @@ class TestDatasetRetrievalSettingApi: class TestDatasetRetrievalSettingMockApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetRetrievalSettingMockApi() method = unwrap(api.get) @@ -1827,7 +1828,7 @@ class TestDatasetRetrievalSettingMockApi: class TestDatasetErrorDocs: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetErrorDocs() method = unwrap(api.get) @@ -1850,7 +1851,7 @@ class TestDatasetErrorDocs: assert status == 200 assert response["total"] == 1 - def test_get_dataset_not_found(self, app): + def test_get_dataset_not_found(self, app: Flask): api = DatasetErrorDocs() method = unwrap(api.get) @@ -1866,7 +1867,7 @@ class TestDatasetErrorDocs: class TestDatasetPermissionUserListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetPermissionUserListApi() method = unwrap(api.get) @@ -1897,7 +1898,7 @@ class TestDatasetPermissionUserListApi: assert status == 200 assert response["data"] == users - def test_get_permission_denied(self, app): + def test_get_permission_denied(self, app: Flask): api = DatasetPermissionUserListApi() method = unwrap(api.get) @@ -1923,7 +1924,7 @@ class TestDatasetPermissionUserListApi: class TestDatasetAutoDisableLogApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetAutoDisableLogApi() method = unwrap(api.get) @@ -1946,7 +1947,7 @@ class TestDatasetAutoDisableLogApi: assert status == 200 assert response == logs - def test_get_dataset_not_found(self, app): + def test_get_dataset_not_found(self, app: Flask): api = DatasetAutoDisableLogApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py index d9b02ac453..c77895d940 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_document.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden, NotFound import services @@ -94,7 +95,7 @@ def patch_permission(): class TestGetProcessRuleApi: - def test_get_default_success(self, app, patch_tenant): + def test_get_default_success(self, app: Flask, patch_tenant): api = GetProcessRuleApi() method = unwrap(api.get) @@ -103,7 +104,7 @@ class TestGetProcessRuleApi: assert "rules" in response - def test_get_with_document_dataset_not_found(self, app, patch_tenant): + def test_get_with_document_dataset_not_found(self, app: Flask, patch_tenant): api = GetProcessRuleApi() method = unwrap(api.get) @@ -125,7 +126,7 @@ class TestGetProcessRuleApi: class TestDatasetDocumentListApi: - def test_get_with_fetch_true_counts_segments(self, app, patch_tenant, patch_dataset, patch_permission): + def test_get_with_fetch_true_counts_segments(self, app: Flask, patch_tenant, patch_dataset, patch_permission): api = DatasetDocumentListApi() method = unwrap(api.get) @@ -157,7 +158,9 @@ class TestDatasetDocumentListApi: assert resp["data"] - def test_get_with_search_status_and_created_at_sort(self, app, patch_tenant, patch_dataset, patch_permission): + def test_get_with_search_status_and_created_at_sort( + self, app: Flask, patch_tenant, patch_dataset, patch_permission + ): api = DatasetDocumentListApi() method = unwrap(api.get) @@ -186,7 +189,7 @@ class TestDatasetDocumentListApi: assert resp["total"] == 1 - def test_get_success(self, app, patch_tenant, patch_dataset, patch_permission): + def test_get_success(self, app: Flask, patch_tenant, patch_dataset, patch_permission): api = DatasetDocumentListApi() method = unwrap(api.get) @@ -211,7 +214,7 @@ class TestDatasetDocumentListApi: assert response["total"] == 1 - def test_post_success(self, app, patch_tenant, patch_dataset, patch_permission): + def test_post_success(self, app: Flask, patch_tenant, patch_dataset, patch_permission): api = DatasetDocumentListApi() method = unwrap(api.post) @@ -239,7 +242,7 @@ class TestDatasetDocumentListApi: assert "documents" in response - def test_post_forbidden(self, app): + def test_post_forbidden(self, app: Flask): api = DatasetDocumentListApi() method = unwrap(api.post) @@ -260,7 +263,7 @@ class TestDatasetDocumentListApi: with pytest.raises(Forbidden): method(api, "ds-1") - def test_get_with_fetch_true_and_invalid_fetch(self, app, patch_tenant, patch_dataset, patch_permission): + def test_get_with_fetch_true_and_invalid_fetch(self, app: Flask, patch_tenant, patch_dataset, patch_permission): api = DatasetDocumentListApi() method = unwrap(api.get) @@ -285,7 +288,7 @@ class TestDatasetDocumentListApi: assert response["total"] == 1 - def test_get_sort_hit_count(self, app, patch_tenant, patch_dataset, patch_permission): + def test_get_sort_hit_count(self, app: Flask, patch_tenant, patch_dataset, patch_permission): api = DatasetDocumentListApi() method = unwrap(api.get) @@ -308,7 +311,7 @@ class TestDatasetDocumentListApi: class TestDocumentApi: - def test_get_success(self, app, patch_tenant): + def test_get_success(self, app: Flask, patch_tenant): api = DocumentApi() method = unwrap(api.get) @@ -326,7 +329,7 @@ class TestDocumentApi: assert status == 200 - def test_get_invalid_metadata(self, app, patch_tenant): + def test_get_invalid_metadata(self, app: Flask, patch_tenant): api = DocumentApi() method = unwrap(api.get) @@ -334,7 +337,7 @@ class TestDocumentApi: with pytest.raises(InvalidMetadataError): method(api, "ds-1", "doc-1") - def test_delete_success(self, app, patch_tenant, patch_dataset): + def test_delete_success(self, app: Flask, patch_tenant, patch_dataset): api = DocumentApi() method = unwrap(api.delete) @@ -354,7 +357,7 @@ class TestDocumentApi: assert status == 204 - def test_delete_indexing_error(self, app, patch_tenant, patch_dataset): + def test_delete_indexing_error(self, app: Flask, patch_tenant, patch_dataset): api = DocumentApi() method = unwrap(api.delete) @@ -375,7 +378,7 @@ class TestDocumentApi: class TestDocumentDownloadApi: - def test_download_success(self, app, patch_tenant): + def test_download_success(self, app: Flask, patch_tenant): api = DocumentDownloadApi() method = unwrap(api.get) @@ -395,7 +398,7 @@ class TestDocumentDownloadApi: class TestDocumentProcessingApi: - def test_processing_forbidden_when_not_editor(self, app): + def test_processing_forbidden_when_not_editor(self, app: Flask): api = DocumentProcessingApi() method = unwrap(api.patch) @@ -412,7 +415,7 @@ class TestDocumentProcessingApi: with pytest.raises(Forbidden): method(api, "ds-1", "doc-1", "pause") - def test_resume_from_error_state(self, app, patch_tenant): + def test_resume_from_error_state(self, app: Flask, patch_tenant): api = DocumentProcessingApi() method = unwrap(api.patch) @@ -430,7 +433,7 @@ class TestDocumentProcessingApi: assert status == 200 - def test_resume_success(self, app, patch_tenant): + def test_resume_success(self, app: Flask, patch_tenant): api = DocumentProcessingApi() method = unwrap(api.patch) @@ -448,7 +451,7 @@ class TestDocumentProcessingApi: assert status == 200 - def test_pause_success(self, app, patch_tenant): + def test_pause_success(self, app: Flask, patch_tenant): api = DocumentProcessingApi() method = unwrap(api.patch) @@ -466,7 +469,7 @@ class TestDocumentProcessingApi: assert status == 200 - def test_pause_invalid(self, app, patch_tenant): + def test_pause_invalid(self, app: Flask, patch_tenant): api = DocumentProcessingApi() method = unwrap(api.patch) @@ -478,7 +481,7 @@ class TestDocumentProcessingApi: class TestDocumentMetadataApi: - def test_put_metadata_schema_filtering(self, app, patch_tenant): + def test_put_metadata_schema_filtering(self, app: Flask, patch_tenant): api = DocumentMetadataApi() method = unwrap(api.put) @@ -507,7 +510,7 @@ class TestDocumentMetadataApi: assert doc.doc_metadata == {"amount": 10} - def test_put_success(self, app, patch_tenant): + def test_put_success(self, app: Flask, patch_tenant): api = DocumentMetadataApi() method = unwrap(api.put) @@ -531,7 +534,7 @@ class TestDocumentMetadataApi: assert status == 200 - def test_put_invalid_payload(self, app, patch_tenant): + def test_put_invalid_payload(self, app: Flask, patch_tenant): api = DocumentMetadataApi() method = unwrap(api.put) @@ -539,7 +542,7 @@ class TestDocumentMetadataApi: with pytest.raises(ValueError): method(api, "ds-1", "doc-1") - def test_put_invalid_doc_type(self, app, patch_tenant): + def test_put_invalid_doc_type(self, app: Flask, patch_tenant): api = DocumentMetadataApi() method = unwrap(api.put) @@ -558,7 +561,7 @@ class TestDocumentMetadataApi: class TestDocumentStatusApi: - def test_patch_success(self, app, patch_tenant, patch_dataset): + def test_patch_success(self, app: Flask, patch_tenant, patch_dataset): api = DocumentStatusApi() method = unwrap(api.patch) @@ -581,7 +584,7 @@ class TestDocumentStatusApi: assert status == 200 - def test_patch_invalid_action(self, app, patch_tenant, patch_dataset): + def test_patch_invalid_action(self, app: Flask, patch_tenant, patch_dataset): api = DocumentStatusApi() method = unwrap(api.patch) @@ -605,7 +608,7 @@ class TestDocumentStatusApi: class TestDocumentRetryApi: - def test_retry_archived_document_skipped(self, app, patch_tenant, patch_dataset): + def test_retry_archived_document_skipped(self, app: Flask, patch_tenant, patch_dataset): api = DocumentRetryApi() method = unwrap(api.post) @@ -633,7 +636,7 @@ class TestDocumentRetryApi: assert status == 204 retry_mock.assert_called_once_with("ds-1", []) - def test_retry_success(self, app, patch_tenant, patch_dataset): + def test_retry_success(self, app: Flask, patch_tenant, patch_dataset): api = DocumentRetryApi() method = unwrap(api.post) @@ -662,7 +665,7 @@ class TestDocumentRetryApi: assert status == 204 retry_mock.assert_called_once_with("ds-1", [document]) - def test_retry_skips_completed_document(self, app, patch_tenant, patch_dataset): + def test_retry_skips_completed_document(self, app: Flask, patch_tenant, patch_dataset): api = DocumentRetryApi() method = unwrap(api.post) @@ -689,7 +692,7 @@ class TestDocumentRetryApi: class TestDocumentPipelineExecutionLogApi: - def test_get_log_success(self, app, patch_tenant, patch_dataset): + def test_get_log_success(self, app: Flask, patch_tenant, patch_dataset): api = DocumentPipelineExecutionLogApi() method = unwrap(api.get) @@ -717,7 +720,7 @@ class TestDocumentPipelineExecutionLogApi: class TestDocumentGenerateSummaryApi: - def test_generate_summary_missing_documents(self, app, patch_tenant, patch_permission): + def test_generate_summary_missing_documents(self, app: Flask, patch_tenant, patch_permission): api = DocumentGenerateSummaryApi() method = unwrap(api.post) @@ -743,7 +746,7 @@ class TestDocumentGenerateSummaryApi: with pytest.raises(NotFound): method(api, "ds-1") - def test_generate_not_enabled(self, app, patch_tenant, patch_permission): + def test_generate_not_enabled(self, app: Flask, patch_tenant, patch_permission): api = DocumentGenerateSummaryApi() method = unwrap(api.post) @@ -762,7 +765,7 @@ class TestDocumentGenerateSummaryApi: with pytest.raises(ValueError): method(api, "ds-1") - def test_generate_summary_success_with_qa_skip(self, app, patch_tenant, patch_permission): + def test_generate_summary_success_with_qa_skip(self, app: Flask, patch_tenant, patch_permission): api = DocumentGenerateSummaryApi() method = unwrap(api.post) @@ -798,7 +801,7 @@ class TestDocumentGenerateSummaryApi: class TestDocumentSummaryStatusApi: - def test_get_success(self, app, patch_tenant, patch_permission): + def test_get_success(self, app: Flask, patch_tenant, patch_permission): api = DocumentSummaryStatusApi() method = unwrap(api.get) @@ -819,7 +822,7 @@ class TestDocumentSummaryStatusApi: class TestDocumentIndexingEstimateApi: - def test_indexing_estimate_file_not_found(self, app, patch_tenant): + def test_indexing_estimate_file_not_found(self, app: Flask, patch_tenant): api = DocumentIndexingEstimateApi() method = unwrap(api.get) @@ -843,7 +846,7 @@ class TestDocumentIndexingEstimateApi: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_indexing_estimate_generic_exception(self, app, patch_tenant): + def test_indexing_estimate_generic_exception(self, app: Flask, patch_tenant): api = DocumentIndexingEstimateApi() method = unwrap(api.get) @@ -880,7 +883,7 @@ class TestDocumentIndexingEstimateApi: with pytest.raises(IndexingEstimateError): method(api, "ds-1", "doc-1") - def test_get_finished(self, app, patch_tenant): + def test_get_finished(self, app: Flask, patch_tenant): api = DocumentIndexingEstimateApi() method = unwrap(api.get) @@ -892,7 +895,7 @@ class TestDocumentIndexingEstimateApi: class TestDocumentBatchDownloadZipApi: - def test_post_no_documents(self, app, patch_tenant): + def test_post_no_documents(self, app: Flask, patch_tenant): api = DocumentBatchDownloadZipApi() method = unwrap(api.post) @@ -904,7 +907,7 @@ class TestDocumentBatchDownloadZipApi: class TestDatasetDocumentListApiDelete: - def test_delete_success(self, app, patch_tenant, patch_dataset): + def test_delete_success(self, app: Flask, patch_tenant, patch_dataset): """Test successful deletion of documents""" api = DatasetDocumentListApi() method = unwrap(api.delete) @@ -924,7 +927,7 @@ class TestDatasetDocumentListApiDelete: assert status == 204 - def test_delete_indexing_error(self, app, patch_tenant, patch_dataset): + def test_delete_indexing_error(self, app: Flask, patch_tenant, patch_dataset): """Test deletion with indexing error""" api = DatasetDocumentListApi() method = unwrap(api.delete) @@ -943,7 +946,7 @@ class TestDatasetDocumentListApiDelete: with pytest.raises(DocumentIndexingError): method(api, "ds-1") - def test_delete_dataset_not_found(self, app, patch_tenant): + def test_delete_dataset_not_found(self, app: Flask, patch_tenant): """Test deletion when dataset not found""" api = DatasetDocumentListApi() method = unwrap(api.delete) @@ -960,7 +963,7 @@ class TestDatasetDocumentListApiDelete: class TestDocumentBatchIndexingEstimateApi: - def test_batch_indexing_estimate_website(self, app, patch_tenant): + def test_batch_indexing_estimate_website(self, app: Flask, patch_tenant): api = DocumentBatchIndexingEstimateApi() method = unwrap(api.get) @@ -989,7 +992,7 @@ class TestDocumentBatchIndexingEstimateApi: assert status == 200 - def test_batch_indexing_estimate_notion(self, app, patch_tenant): + def test_batch_indexing_estimate_notion(self, app: Flask, patch_tenant): api = DocumentBatchIndexingEstimateApi() method = unwrap(api.get) @@ -1017,7 +1020,7 @@ class TestDocumentBatchIndexingEstimateApi: assert status == 200 - def test_batch_estimate_unsupported_datasource(self, app, patch_tenant): + def test_batch_estimate_unsupported_datasource(self, app: Flask, patch_tenant): api = DocumentBatchIndexingEstimateApi() method = unwrap(api.get) @@ -1032,7 +1035,7 @@ class TestDocumentBatchIndexingEstimateApi: with pytest.raises(ValueError): method(api, "ds-1", "batch-1") - def test_get_batch_estimate_invalid_batch(self, app, patch_tenant): + def test_get_batch_estimate_invalid_batch(self, app: Flask, patch_tenant): """Test batch estimation with invalid batch""" api = DocumentBatchIndexingEstimateApi() method = unwrap(api.get) @@ -1043,7 +1046,7 @@ class TestDocumentBatchIndexingEstimateApi: class TestDocumentBatchIndexingStatusApi: - def test_get_batch_status_invalid_batch(self, app, patch_tenant): + def test_get_batch_status_invalid_batch(self, app: Flask, patch_tenant): """Test batch status with invalid batch""" api = DocumentBatchIndexingStatusApi() method = unwrap(api.get) @@ -1054,7 +1057,7 @@ class TestDocumentBatchIndexingStatusApi: class TestDocumentIndexingStatusApi: - def test_get_status_document_not_found(self, app, patch_tenant): + def test_get_status_document_not_found(self, app: Flask, patch_tenant): """Test getting status for non-existent document""" api = DocumentIndexingStatusApi() method = unwrap(api.get) @@ -1065,7 +1068,7 @@ class TestDocumentIndexingStatusApi: class TestDocumentApiMetadata: - def test_get_with_only_option(self, app, patch_tenant): + def test_get_with_only_option(self, app: Flask, patch_tenant): """Test get with 'only' metadata option""" api = DocumentApi() method = unwrap(api.get) @@ -1084,7 +1087,7 @@ class TestDocumentApiMetadata: assert status == 200 - def test_get_with_without_option(self, app, patch_tenant): + def test_get_with_without_option(self, app: Flask, patch_tenant): """Test get with 'without' metadata option""" api = DocumentApi() method = unwrap(api.get) @@ -1105,7 +1108,7 @@ class TestDocumentApiMetadata: class TestDocumentGenerateSummaryApiSuccess: - def test_generate_not_enabled_high_quality(self, app, patch_tenant, patch_permission): + def test_generate_not_enabled_high_quality(self, app: Flask, patch_tenant, patch_permission): """Test summary generation on non-high-quality dataset""" api = DocumentGenerateSummaryApi() method = unwrap(api.post) @@ -1127,7 +1130,7 @@ class TestDocumentGenerateSummaryApiSuccess: class TestDocumentProcessingApiResume: - def test_resume_invalid_status(self, app, patch_tenant): + def test_resume_invalid_status(self, app: Flask, patch_tenant): """Test resume on non-paused document""" api = DocumentProcessingApi() method = unwrap(api.patch) @@ -1140,7 +1143,7 @@ class TestDocumentProcessingApiResume: class TestDocumentPermissionCases: - def test_document_batch_get_permission_denied(self, app, patch_tenant): + def test_document_batch_get_permission_denied(self, app: Flask, patch_tenant): api = DocumentBatchIndexingEstimateApi() method = unwrap(api.get) @@ -1158,7 +1161,7 @@ class TestDocumentPermissionCases: with pytest.raises(Forbidden): method(api, "ds-1", "batch-1") - def test_document_batch_get_documents_not_found(self, app, patch_tenant): + def test_document_batch_get_documents_not_found(self, app: Flask, patch_tenant): api = DocumentBatchIndexingEstimateApi() method = unwrap(api.get) @@ -1185,7 +1188,7 @@ class TestDocumentPermissionCases: "preview": [], } - def test_document_tenant_mismatch(self, app): + def test_document_tenant_mismatch(self, app: Flask): api = DocumentApi() method = unwrap(api.get) @@ -1217,7 +1220,7 @@ class TestDocumentPermissionCases: with pytest.raises(Forbidden): method(api, "ds-1", "doc-1") - def test_process_rule_get_by_document_success(self, app, patch_tenant): + def test_process_rule_get_by_document_success(self, app: Flask, patch_tenant): api = GetProcessRuleApi() method = unwrap(api.get) @@ -1253,7 +1256,7 @@ class TestDocumentPermissionCases: assert status == 200 assert response["mode"] == "custom" - def test_process_rule_permission_denied(self, app): + def test_process_rule_permission_denied(self, app: Flask): api = GetProcessRuleApi() method = unwrap(api.get) @@ -1283,7 +1286,7 @@ class TestDocumentPermissionCases: class TestDocumentListAdvancedCases: - def test_document_list_with_multiple_sort_options(self, app, patch_tenant, patch_dataset, patch_permission): + def test_document_list_with_multiple_sort_options(self, app: Flask, patch_tenant, patch_dataset, patch_permission): """Test document list with different sort options""" api = DatasetDocumentListApi() method = unwrap(api.get) @@ -1309,7 +1312,7 @@ class TestDocumentListAdvancedCases: assert response["total"] == 1 - def test_document_metadata_with_schema_validation(self, app, patch_tenant): + def test_document_metadata_with_schema_validation(self, app: Flask, patch_tenant): """Test document metadata update with schema validation""" api = DocumentMetadataApi() method = unwrap(api.put) @@ -1341,7 +1344,7 @@ class TestDocumentListAdvancedCases: class TestDocumentIndexingEdgeCases: - def test_document_indexing_with_extraction_setting(self, app, patch_tenant): + def test_document_indexing_with_extraction_setting(self, app: Flask, patch_tenant): api = DocumentIndexingEstimateApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py index 693b06e95b..66d257ee66 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets_segments.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden, NotFound import services @@ -67,7 +68,7 @@ def _segment(): ) -def test_get_segment_with_summary(monkeypatch): +def test_get_segment_with_summary(monkeypatch: pytest.MonkeyPatch): segment = _segment() summary = SimpleNamespace(summary_content="summary") @@ -82,7 +83,7 @@ def test_get_segment_with_summary(monkeypatch): class TestDatasetDocumentSegmentListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetDocumentSegmentListApi() method = unwrap(api.get) @@ -132,7 +133,7 @@ class TestDatasetDocumentSegmentListApi: assert status == 200 - def test_get_dataset_not_found(self, app): + def test_get_dataset_not_found(self, app: Flask): api = DatasetDocumentSegmentListApi() method = unwrap(api.get) @@ -150,7 +151,7 @@ class TestDatasetDocumentSegmentListApi: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_get_permission_denied(self, app): + def test_get_permission_denied(self, app: Flask): api = DatasetDocumentSegmentListApi() method = unwrap(api.get) @@ -176,7 +177,7 @@ class TestDatasetDocumentSegmentListApi: class TestDatasetDocumentSegmentApi: - def test_patch_success(self, app): + def test_patch_success(self, app: Flask): api = DatasetDocumentSegmentApi() method = unwrap(api.patch) @@ -221,7 +222,7 @@ class TestDatasetDocumentSegmentApi: assert status == 200 assert response["result"] == "success" - def test_patch_document_indexing_in_progress(self, app): + def test_patch_document_indexing_in_progress(self, app: Flask): api = DatasetDocumentSegmentApi() method = unwrap(api.patch) @@ -264,7 +265,7 @@ class TestDatasetDocumentSegmentApi: with pytest.raises(InvalidActionError): method(api, "ds-1", "doc-1", "disable") - def test_patch_llm_bad_request(self, app): + def test_patch_llm_bad_request(self, app: Flask): api = DatasetDocumentSegmentApi() method = unwrap(api.patch) @@ -308,7 +309,7 @@ class TestDatasetDocumentSegmentApi: with pytest.raises(ProviderNotInitializeError): method(api, "ds-1", "doc-1", "enable") - def test_patch_provider_token_not_init(self, app): + def test_patch_provider_token_not_init(self, app: Flask): api = DatasetDocumentSegmentApi() method = unwrap(api.patch) @@ -354,7 +355,7 @@ class TestDatasetDocumentSegmentApi: class TestDatasetDocumentSegmentAddApi: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = DatasetDocumentSegmentAddApi() method = unwrap(api.post) @@ -413,7 +414,7 @@ class TestDatasetDocumentSegmentAddApi: assert status == 200 assert response["data"]["id"] == "seg-1" - def test_post_llm_bad_request(self, app): + def test_post_llm_bad_request(self, app: Flask): api = DatasetDocumentSegmentAddApi() method = unwrap(api.post) @@ -452,7 +453,7 @@ class TestDatasetDocumentSegmentAddApi: with pytest.raises(ProviderNotInitializeError): method(api, "ds-1", "doc-1") - def test_post_provider_token_not_init(self, app): + def test_post_provider_token_not_init(self, app: Flask): api = DatasetDocumentSegmentAddApi() method = unwrap(api.post) @@ -493,7 +494,7 @@ class TestDatasetDocumentSegmentAddApi: class TestDatasetDocumentSegmentUpdateApi: - def test_patch_success(self, app): + def test_patch_success(self, app: Flask): api = DatasetDocumentSegmentUpdateApi() method = unwrap(api.patch) @@ -551,7 +552,7 @@ class TestDatasetDocumentSegmentUpdateApi: assert status == 200 assert "data" in response - def test_patch_llm_bad_request(self, app): + def test_patch_llm_bad_request(self, app: Flask): api = DatasetDocumentSegmentUpdateApi() method = unwrap(api.patch) @@ -596,7 +597,7 @@ class TestDatasetDocumentSegmentUpdateApi: class TestDatasetDocumentSegmentBatchImportApi: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -638,7 +639,7 @@ class TestDatasetDocumentSegmentBatchImportApi: assert status == 200 assert response["job_status"] == "waiting" - def test_post_dataset_not_found(self, app): + def test_post_dataset_not_found(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -659,7 +660,7 @@ class TestDatasetDocumentSegmentBatchImportApi: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_post_document_not_found(self, app): + def test_post_document_not_found(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -684,7 +685,7 @@ class TestDatasetDocumentSegmentBatchImportApi: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_post_upload_file_not_found(self, app): + def test_post_upload_file_not_found(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -713,7 +714,7 @@ class TestDatasetDocumentSegmentBatchImportApi: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_post_invalid_file_type(self, app): + def test_post_invalid_file_type(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -745,7 +746,7 @@ class TestDatasetDocumentSegmentBatchImportApi: with pytest.raises(ValueError): method(api, "ds-1", "doc-1") - def test_post_async_task_failure(self, app): + def test_post_async_task_failure(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -783,7 +784,7 @@ class TestDatasetDocumentSegmentBatchImportApi: assert status == 500 assert "error" in response - def test_get_job_not_found_in_redis(self, app): + def test_get_job_not_found_in_redis(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.get) @@ -799,7 +800,7 @@ class TestDatasetDocumentSegmentBatchImportApi: class TestChildChunkAddApi: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = ChildChunkAddApi() method = unwrap(api.post) @@ -852,7 +853,7 @@ class TestChildChunkAddApi: assert status == 200 assert response["data"]["id"] == "cc-1" - def test_post_child_chunk_indexing_error(self, app): + def test_post_child_chunk_indexing_error(self, app: Flask): api = ChildChunkAddApi() method = unwrap(api.post) @@ -897,7 +898,7 @@ class TestChildChunkAddApi: class TestChildChunkUpdateApi: - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = ChildChunkUpdateApi() method = unwrap(api.delete) @@ -941,7 +942,7 @@ class TestChildChunkUpdateApi: assert status == 204 assert response["result"] == "success" - def test_delete_child_chunk_index_error(self, app): + def test_delete_child_chunk_index_error(self, app: Flask): api = ChildChunkUpdateApi() method = unwrap(api.delete) @@ -984,7 +985,7 @@ class TestChildChunkUpdateApi: class TestSegmentListAdvancedCases: - def test_segment_list_with_keyword_filter(self, app): + def test_segment_list_with_keyword_filter(self, app: Flask): api = DatasetDocumentSegmentListApi() method = unwrap(api.get) @@ -1035,7 +1036,7 @@ class TestSegmentListAdvancedCases: assert status == 200 assert response["total"] == 1 - def test_segment_list_permission_denied(self, app): + def test_segment_list_permission_denied(self, app: Flask): """Test segment list with permission denied""" api = DatasetDocumentSegmentListApi() method = unwrap(api.get) @@ -1058,7 +1059,7 @@ class TestSegmentListAdvancedCases: with pytest.raises(Forbidden): method(api, "ds-1", "doc-1") - def test_segment_list_dataset_not_found(self, app): + def test_segment_list_dataset_not_found(self, app: Flask): """Test segment list with dataset not found""" api = DatasetDocumentSegmentListApi() method = unwrap(api.get) @@ -1079,7 +1080,7 @@ class TestSegmentListAdvancedCases: class TestSegmentOperationCases: - def test_segment_add_with_provider_token_error(self, app): + def test_segment_add_with_provider_token_error(self, app: Flask): """Test segment add with provider token not initialized""" api = DatasetDocumentSegmentAddApi() method = unwrap(api.post) @@ -1117,7 +1118,7 @@ class TestSegmentOperationCases: with pytest.raises(ProviderTokenNotInitError): method(api, "ds-1", "doc-1") - def test_batch_import_with_document_not_found(self, app): + def test_batch_import_with_document_not_found(self, app: Flask): """Test batch import with document not found""" api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -1146,7 +1147,7 @@ class TestSegmentOperationCases: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_batch_import_with_invalid_file(self, app): + def test_batch_import_with_invalid_file(self, app: Flask): """Test batch import with invalid file type""" api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -1181,7 +1182,7 @@ class TestSegmentOperationCases: with pytest.raises(NotFound): method(api, "ds-1", "doc-1") - def test_batch_import_with_async_task_failure(self, app): + def test_batch_import_with_async_task_failure(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.post) @@ -1226,7 +1227,7 @@ class TestSegmentOperationCases: assert status == 500 assert "error" in response - def test_batch_import_get_job_not_found(self, app): + def test_batch_import_get_job_not_found(self, app: Flask): api = DatasetDocumentSegmentBatchImportApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_external.py b/api/tests/unit_tests/controllers/console/datasets/test_external.py index 514bbbe040..186b379cbc 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_external.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_external.py @@ -57,7 +57,7 @@ def mock_auth(monkeypatch, current_user): class TestExternalApiTemplateListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = ExternalApiTemplateListApi() method = unwrap(api.get) @@ -78,7 +78,7 @@ class TestExternalApiTemplateListApi: assert resp["total"] == 1 assert resp["data"][0]["id"] == "1" - def test_post_forbidden(self, app, current_user): + def test_post_forbidden(self, app: Flask, current_user): current_user.is_dataset_editor = False api = ExternalApiTemplateListApi() method = unwrap(api.post) @@ -93,7 +93,7 @@ class TestExternalApiTemplateListApi: with pytest.raises(Forbidden): method(api) - def test_post_duplicate_name(self, app): + def test_post_duplicate_name(self, app: Flask): api = ExternalApiTemplateListApi() method = unwrap(api.post) @@ -114,7 +114,7 @@ class TestExternalApiTemplateListApi: class TestExternalApiTemplateApi: - def test_get_not_found(self, app): + def test_get_not_found(self, app: Flask): api = ExternalApiTemplateApi() method = unwrap(api.get) @@ -129,7 +129,7 @@ class TestExternalApiTemplateApi: with pytest.raises(NotFound): method(api, "api-id") - def test_delete_forbidden(self, app, current_user): + def test_delete_forbidden(self, app: Flask, current_user): current_user.has_edit_permission = False current_user.is_dataset_operator = False @@ -142,7 +142,7 @@ class TestExternalApiTemplateApi: class TestExternalApiUseCheckApi: - def test_get_scopes_usage_check_to_current_tenant(self, app): + def test_get_scopes_usage_check_to_current_tenant(self, app: Flask): api = ExternalApiUseCheckApi() method = unwrap(api.get) @@ -162,7 +162,7 @@ class TestExternalApiUseCheckApi: class TestExternalDatasetCreateApi: - def test_create_success(self, app): + def test_create_success(self, app: Flask): api = ExternalDatasetCreateApi() method = unwrap(api.post) @@ -206,7 +206,7 @@ class TestExternalDatasetCreateApi: assert status == 201 - def test_create_forbidden(self, app, current_user): + def test_create_forbidden(self, app: Flask, current_user): current_user.is_dataset_editor = False api = ExternalDatasetCreateApi() method = unwrap(api.post) @@ -226,7 +226,7 @@ class TestExternalDatasetCreateApi: class TestExternalKnowledgeHitTestingApi: - def test_hit_testing_dataset_not_found(self, app): + def test_hit_testing_dataset_not_found(self, app: Flask): api = ExternalKnowledgeHitTestingApi() method = unwrap(api.post) @@ -241,7 +241,7 @@ class TestExternalKnowledgeHitTestingApi: with pytest.raises(NotFound): method(api, "dataset-id") - def test_hit_testing_success(self, app): + def test_hit_testing_success(self, app: Flask): api = ExternalKnowledgeHitTestingApi() method = unwrap(api.post) @@ -266,7 +266,7 @@ class TestExternalKnowledgeHitTestingApi: class TestBedrockRetrievalApi: - def test_bedrock_retrieval(self, app): + def test_bedrock_retrieval(self, app: Flask): api = BedrockRetrievalApi() method = unwrap(api.post) @@ -292,7 +292,7 @@ class TestBedrockRetrievalApi: class TestExternalApiTemplateListApiAdvanced: - def test_post_duplicate_name_error(self, app, mock_auth, current_user): + def test_post_duplicate_name_error(self, app: Flask, mock_auth, current_user): api = ExternalApiTemplateListApi() method = unwrap(api.post) @@ -310,7 +310,7 @@ class TestExternalApiTemplateListApiAdvanced: with pytest.raises(DatasetNameDuplicateError): method(api) - def test_get_with_pagination(self, app, mock_auth, current_user): + def test_get_with_pagination(self, app: Flask, mock_auth, current_user): api = ExternalApiTemplateListApi() method = unwrap(api.get) @@ -331,7 +331,7 @@ class TestExternalApiTemplateListApiAdvanced: class TestExternalDatasetCreateApiAdvanced: - def test_create_forbidden(self, app, mock_auth, current_user): + def test_create_forbidden(self, app: Flask, mock_auth, current_user): """Test creating external dataset without permission""" api = ExternalDatasetCreateApi() method = unwrap(api.post) @@ -351,7 +351,7 @@ class TestExternalDatasetCreateApiAdvanced: class TestExternalKnowledgeHitTestingApiAdvanced: - def test_hit_testing_dataset_not_found(self, app, mock_auth, current_user): + def test_hit_testing_dataset_not_found(self, app: Flask, mock_auth, current_user): """Test hit testing on non-existent dataset""" api = ExternalKnowledgeHitTestingApi() method = unwrap(api.post) @@ -372,7 +372,7 @@ class TestExternalKnowledgeHitTestingApiAdvanced: with pytest.raises(NotFound): method(api, "ds-1") - def test_hit_testing_with_custom_retrieval_model(self, app, mock_auth, current_user): + def test_hit_testing_with_custom_retrieval_model(self, app: Flask, mock_auth, current_user): api = ExternalKnowledgeHitTestingApi() method = unwrap(api.post) @@ -402,7 +402,7 @@ class TestExternalKnowledgeHitTestingApiAdvanced: class TestBedrockRetrievalApiAdvanced: - def test_bedrock_retrieval_with_invalid_setting(self, app, mock_auth, current_user): + def test_bedrock_retrieval_with_invalid_setting(self, app: Flask, mock_auth, current_user): api = BedrockRetrievalApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py index 09ed2aaf69..4fa5d21493 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest from flask import Flask +from pytest_mock import MockerFixture from werkzeug.exceptions import NotFound from controllers.console import console_ns @@ -35,7 +36,7 @@ def dataset(): @pytest.fixture(autouse=True) -def bypass_decorators(mocker): +def bypass_decorators(mocker: MockerFixture): """Bypass all decorators on the API method.""" mocker.patch( "controllers.console.datasets.hit_testing.setup_required", @@ -56,7 +57,7 @@ def bypass_decorators(mocker): class TestHitTestingApi: - def test_hit_testing_success(self, app, dataset, dataset_id): + def test_hit_testing_success(self, app: Flask, dataset, dataset_id): api = HitTestingApi() method = unwrap(api.post) @@ -99,7 +100,7 @@ class TestHitTestingApi: assert "records" in result assert result["records"] == [] - def test_hit_testing_success_with_optional_record_fields(self, app, dataset, dataset_id): + def test_hit_testing_success_with_optional_record_fields(self, app: Flask, dataset, dataset_id): api = HitTestingApi() method = unwrap(api.post) @@ -150,7 +151,7 @@ class TestHitTestingApi: assert result["query"] == payload["query"] assert result["records"] == records - def test_hit_testing_dataset_not_found(self, app, dataset_id): + def test_hit_testing_dataset_not_found(self, app: Flask, dataset_id): api = HitTestingApi() method = unwrap(api.post) @@ -175,7 +176,7 @@ class TestHitTestingApi: with pytest.raises(NotFound, match="Dataset not found"): method(api, dataset_id) - def test_hit_testing_invalid_args(self, app, dataset, dataset_id): + def test_hit_testing_invalid_args(self, app: Flask, dataset, dataset_id): api = HitTestingApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py index d29b34beb2..77e9cfeb5b 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py @@ -120,7 +120,7 @@ class TestParseArgs: class TestPerformHitTesting: def test_success(self, dataset): response = { - "query": "hello", + "query": {"content": "hello"}, "records": [], } @@ -134,7 +134,7 @@ class TestPerformHitTesting: assert result["query"] == "hello" assert result["records"] == [] - def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset): + def test_success_prepares_nullable_list_fields(self, dataset): response = { "query": {"content": "hello"}, "records": [ @@ -170,6 +170,18 @@ class TestPerformHitTesting: } ] + def test_invalid_query_response_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid hit testing query response"): + DatasetsHitTestingBase._extract_hit_testing_query("hello") + + def test_invalid_records_response_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid hit testing records response"): + DatasetsHitTestingBase._prepare_hit_testing_records({"records": []}) + + def test_invalid_record_response_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid hit testing record response"): + DatasetsHitTestingBase._prepare_hit_testing_records(["record"]) + def test_index_not_initialized(self, dataset): with patch.object( HitTestingService, diff --git a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py index de834c2d4d..6322133536 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_metadata.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_metadata.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest from flask import Flask +from pytest_mock import MockerFixture from werkzeug.exceptions import NotFound from controllers.console import console_ns @@ -60,7 +61,7 @@ def metadata_id(): @pytest.fixture(autouse=True) -def bypass_decorators(mocker): +def bypass_decorators(mocker: MockerFixture): """Bypass setup/login/license decorators.""" mocker.patch( "controllers.console.datasets.metadata.setup_required", @@ -81,7 +82,7 @@ def bypass_decorators(mocker): class TestDatasetMetadataCreateApi: - def test_create_metadata_success(self, app, current_user, dataset, dataset_id): + def test_create_metadata_success(self, app: Flask, current_user, dataset, dataset_id): api = DatasetMetadataCreateApi() method = unwrap(api.post) @@ -124,7 +125,7 @@ class TestDatasetMetadataCreateApi: assert status == 201 assert result["name"] == "author" - def test_create_metadata_dataset_not_found(self, app, current_user, dataset_id): + def test_create_metadata_dataset_not_found(self, app: Flask, current_user, dataset_id): api = DatasetMetadataCreateApi() method = unwrap(api.post) @@ -161,7 +162,7 @@ class TestDatasetMetadataCreateApi: class TestDatasetMetadataGetApi: - def test_get_metadata_success(self, app, dataset, dataset_id): + def test_get_metadata_success(self, app: Flask, dataset, dataset_id): api = DatasetMetadataCreateApi() method = unwrap(api.get) @@ -183,7 +184,7 @@ class TestDatasetMetadataGetApi: assert status == 200 assert isinstance(result, list) - def test_get_metadata_dataset_not_found(self, app, dataset_id): + def test_get_metadata_dataset_not_found(self, app: Flask, dataset_id): api = DatasetMetadataCreateApi() method = unwrap(api.get) @@ -200,7 +201,7 @@ class TestDatasetMetadataGetApi: class TestDatasetMetadataApi: - def test_update_metadata_success(self, app, current_user, dataset, dataset_id, metadata_id): + def test_update_metadata_success(self, app: Flask, current_user, dataset, dataset_id, metadata_id): api = DatasetMetadataApi() method = unwrap(api.patch) @@ -238,7 +239,7 @@ class TestDatasetMetadataApi: assert status == 200 assert result["name"] == "updated-name" - def test_delete_metadata_success(self, app, current_user, dataset, dataset_id, metadata_id): + def test_delete_metadata_success(self, app: Flask, current_user, dataset, dataset_id, metadata_id): api = DatasetMetadataApi() method = unwrap(api.delete) @@ -269,7 +270,7 @@ class TestDatasetMetadataApi: class TestDatasetMetadataBuiltInFieldApi: - def test_get_built_in_fields(self, app): + def test_get_built_in_fields(self, app: Flask): api = DatasetMetadataBuiltInFieldApi() method = unwrap(api.get) @@ -288,7 +289,7 @@ class TestDatasetMetadataBuiltInFieldApi: class TestDatasetMetadataBuiltInFieldActionApi: - def test_enable_built_in_field(self, app, current_user, dataset, dataset_id): + def test_enable_built_in_field(self, app: Flask, current_user, dataset, dataset_id): api = DatasetMetadataBuiltInFieldActionApi() method = unwrap(api.post) @@ -319,7 +320,7 @@ class TestDatasetMetadataBuiltInFieldActionApi: class TestDocumentMetadataEditApi: - def test_update_document_metadata_success(self, app, current_user, dataset, dataset_id): + def test_update_document_metadata_success(self, app: Flask, current_user, dataset, dataset_id): api = DocumentMetadataEditApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_website.py b/api/tests/unit_tests/controllers/console/datasets/test_website.py index 9f0da6e76f..5c7b857c20 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_website.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_website.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, PropertyMock, patch import pytest from flask import Flask +from pytest_mock import MockerFixture from controllers.console import console_ns from controllers.console.datasets.error import WebsiteCrawlError @@ -31,7 +32,7 @@ def app(): @pytest.fixture(autouse=True) -def bypass_auth_and_setup(mocker): +def bypass_auth_and_setup(mocker: MockerFixture): """Bypass setup/login/account decorators.""" mocker.patch( "controllers.console.datasets.website.login_required", @@ -48,7 +49,7 @@ def bypass_auth_and_setup(mocker): class TestWebsiteCrawlApi: - def test_crawl_success(self, app, mocker): + def test_crawl_success(self, app: Flask, mocker: MockerFixture): api = WebsiteCrawlApi() method = unwrap(api.post) @@ -85,7 +86,7 @@ class TestWebsiteCrawlApi: assert status == 200 assert result["job_id"] == "job-1" - def test_crawl_invalid_payload(self, app, mocker): + def test_crawl_invalid_payload(self, app: Flask, mocker: MockerFixture): api = WebsiteCrawlApi() method = unwrap(api.post) @@ -113,7 +114,7 @@ class TestWebsiteCrawlApi: with pytest.raises(WebsiteCrawlError, match="invalid payload"): method(api) - def test_crawl_service_error(self, app, mocker): + def test_crawl_service_error(self, app: Flask, mocker: MockerFixture): api = WebsiteCrawlApi() method = unwrap(api.post) @@ -150,7 +151,7 @@ class TestWebsiteCrawlApi: class TestWebsiteCrawlStatusApi: - def test_get_status_success(self, app, mocker): + def test_get_status_success(self, app: Flask, mocker: MockerFixture): api = WebsiteCrawlStatusApi() method = unwrap(api.get) @@ -181,7 +182,7 @@ class TestWebsiteCrawlStatusApi: assert status == 200 assert result["status"] == "completed" - def test_get_status_invalid_provider(self, app, mocker): + def test_get_status_invalid_provider(self, app: Flask, mocker: MockerFixture): api = WebsiteCrawlStatusApi() method = unwrap(api.get) @@ -203,7 +204,7 @@ class TestWebsiteCrawlStatusApi: with pytest.raises(WebsiteCrawlError, match="invalid provider"): method(api, job_id) - def test_get_status_service_error(self, app, mocker): + def test_get_status_service_error(self, app: Flask, mocker: MockerFixture): api = WebsiteCrawlStatusApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_wraps.py b/api/tests/unit_tests/controllers/console/datasets/test_wraps.py index e358435de4..2cfa938af8 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_wraps.py @@ -1,6 +1,7 @@ from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture from controllers.console.datasets.error import PipelineNotFoundError from controllers.console.datasets.wraps import get_rag_pipeline @@ -16,7 +17,7 @@ class TestGetRagPipeline: with pytest.raises(ValueError, match="missing pipeline_id"): dummy_view() - def test_pipeline_not_found(self, mocker): + def test_pipeline_not_found(self, mocker: MockerFixture): @get_rag_pipeline def dummy_view(**kwargs): return "ok" @@ -34,7 +35,7 @@ class TestGetRagPipeline: with pytest.raises(PipelineNotFoundError): dummy_view(pipeline_id="pipeline-1") - def test_pipeline_found_and_injected(self, mocker): + def test_pipeline_found_and_injected(self, mocker: MockerFixture): pipeline = Mock(spec=Pipeline) pipeline.id = "pipeline-1" pipeline.tenant_id = "tenant-1" @@ -57,7 +58,7 @@ class TestGetRagPipeline: assert result is pipeline - def test_pipeline_id_removed_from_kwargs(self, mocker): + def test_pipeline_id_removed_from_kwargs(self, mocker: MockerFixture): pipeline = Mock(spec=Pipeline) @get_rag_pipeline @@ -79,7 +80,7 @@ class TestGetRagPipeline: assert result == "ok" - def test_pipeline_id_cast_to_string(self, mocker): + def test_pipeline_id_cast_to_string(self, mocker: MockerFixture): pipeline = Mock(spec=Pipeline) @get_rag_pipeline diff --git a/api/tests/unit_tests/controllers/console/explore/test_audio.py b/api/tests/unit_tests/controllers/console/explore/test_audio.py index b4b57022e2..a6642f8582 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_audio.py +++ b/api/tests/unit_tests/controllers/console/explore/test_audio.py @@ -2,6 +2,7 @@ from io import BytesIO from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import InternalServerError import controllers.console.explore.audio as audio_module @@ -52,7 +53,7 @@ class TestChatAudioApi: self.api = audio_module.ChatAudioApi() self.method = unwrap(self.api.post) - def test_post_success(self, app, installed_app, audio_file): + def test_post_success(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -69,7 +70,7 @@ class TestChatAudioApi: assert resp == {"text": "ok"} - def test_app_unavailable(self, app, installed_app, audio_file): + def test_app_unavailable(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -85,7 +86,7 @@ class TestChatAudioApi: with pytest.raises(AppUnavailableError): self.method(installed_app) - def test_no_audio_uploaded(self, app, installed_app, audio_file): + def test_no_audio_uploaded(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -101,7 +102,7 @@ class TestChatAudioApi: with pytest.raises(NoAudioUploadedError): self.method(installed_app) - def test_audio_too_large(self, app, installed_app, audio_file): + def test_audio_too_large(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -117,7 +118,7 @@ class TestChatAudioApi: with pytest.raises(AudioTooLargeError): self.method(installed_app) - def test_provider_quota_exceeded(self, app, installed_app, audio_file): + def test_provider_quota_exceeded(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -133,7 +134,7 @@ class TestChatAudioApi: with pytest.raises(ProviderQuotaExceededError): self.method(installed_app) - def test_unknown_exception(self, app, installed_app, audio_file): + def test_unknown_exception(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -149,7 +150,7 @@ class TestChatAudioApi: with pytest.raises(InternalServerError): self.method(installed_app) - def test_unsupported_audio_type(self, app, installed_app, audio_file): + def test_unsupported_audio_type(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -165,7 +166,7 @@ class TestChatAudioApi: with pytest.raises(audio_module.UnsupportedAudioTypeError): self.method(installed_app) - def test_provider_not_support_speech_to_text(self, app, installed_app, audio_file): + def test_provider_not_support_speech_to_text(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -181,7 +182,7 @@ class TestChatAudioApi: with pytest.raises(audio_module.ProviderNotSupportSpeechToTextError): self.method(installed_app) - def test_provider_not_initialized(self, app, installed_app, audio_file): + def test_provider_not_initialized(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -197,7 +198,7 @@ class TestChatAudioApi: with pytest.raises(ProviderNotInitializeError): self.method(installed_app) - def test_model_currently_not_supported(self, app, installed_app, audio_file): + def test_model_currently_not_supported(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -213,7 +214,7 @@ class TestChatAudioApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): self.method(installed_app) - def test_invoke_error_asr(self, app, installed_app, audio_file): + def test_invoke_error_asr(self, app: Flask, installed_app, audio_file): with ( app.test_request_context( "/", @@ -235,7 +236,7 @@ class TestChatTextApi: self.api = audio_module.ChatTextApi() self.method = unwrap(self.api.post) - def test_post_success(self, app, installed_app): + def test_post_success(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -251,7 +252,7 @@ class TestChatTextApi: assert resp == {"audio": "ok"} - def test_provider_not_initialized(self, app, installed_app): + def test_provider_not_initialized(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -266,7 +267,7 @@ class TestChatTextApi: with pytest.raises(ProviderNotInitializeError): self.method(installed_app) - def test_model_not_supported(self, app, installed_app): + def test_model_not_supported(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -281,7 +282,7 @@ class TestChatTextApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): self.method(installed_app) - def test_invoke_error(self, app, installed_app): + def test_invoke_error(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -296,7 +297,7 @@ class TestChatTextApi: with pytest.raises(CompletionRequestError): self.method(installed_app) - def test_unknown_exception(self, app, installed_app): + def test_unknown_exception(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -311,7 +312,7 @@ class TestChatTextApi: with pytest.raises(InternalServerError): self.method(installed_app) - def test_app_unavailable_tts(self, app, installed_app): + def test_app_unavailable_tts(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -326,7 +327,7 @@ class TestChatTextApi: with pytest.raises(AppUnavailableError): self.method(installed_app) - def test_no_audio_uploaded_tts(self, app, installed_app): + def test_no_audio_uploaded_tts(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -341,7 +342,7 @@ class TestChatTextApi: with pytest.raises(NoAudioUploadedError): self.method(installed_app) - def test_audio_too_large_tts(self, app, installed_app): + def test_audio_too_large_tts(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -356,7 +357,7 @@ class TestChatTextApi: with pytest.raises(AudioTooLargeError): self.method(installed_app) - def test_unsupported_audio_type_tts(self, app, installed_app): + def test_unsupported_audio_type_tts(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -371,7 +372,7 @@ class TestChatTextApi: with pytest.raises(audio_module.UnsupportedAudioTypeError): self.method(installed_app) - def test_provider_not_support_speech_to_text_tts(self, app, installed_app): + def test_provider_not_support_speech_to_text_tts(self, app: Flask, installed_app): with ( app.test_request_context( "/", @@ -386,7 +387,7 @@ class TestChatTextApi: with pytest.raises(audio_module.ProviderNotSupportSpeechToTextError): self.method(installed_app) - def test_quota_exceeded_tts(self, app, installed_app): + def test_quota_exceeded_tts(self, app: Flask, installed_app): with ( app.test_request_context( "/", diff --git a/api/tests/unit_tests/controllers/console/explore/test_banner.py b/api/tests/unit_tests/controllers/console/explore/test_banner.py index c8f674f515..d1cb6b6a03 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_banner.py +++ b/api/tests/unit_tests/controllers/console/explore/test_banner.py @@ -1,6 +1,8 @@ from datetime import datetime from unittest.mock import MagicMock, patch +from flask import Flask + import controllers.console.explore.banner as banner_module from models.enums import BannerStatus @@ -12,7 +14,7 @@ def unwrap(func): class TestBannerApi: - def test_get_banners_with_requested_language(self, app): + def test_get_banners_with_requested_language(self, app: Flask): api = banner_module.BannerApi() method = unwrap(api.get) @@ -41,7 +43,7 @@ class TestBannerApi: } ] - def test_get_banners_fallback_to_en_us(self, app): + def test_get_banners_fallback_to_en_us(self, app: Flask): api = banner_module.BannerApi() method = unwrap(api.get) @@ -76,7 +78,7 @@ class TestBannerApi: } ] - def test_get_banners_default_language_en_us(self, app): + def test_get_banners_default_language_en_us(self, app: Flask): api = banner_module.BannerApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/explore/test_completion.py b/api/tests/unit_tests/controllers/console/explore/test_completion.py index 1dd16f3c59..eac93b1c46 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_completion.py +++ b/api/tests/unit_tests/controllers/console/explore/test_completion.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest +from flask import Flask from werkzeug.exceptions import InternalServerError import controllers.console.explore.completion as completion_module @@ -51,7 +52,7 @@ def payload_patch(payload_data): class TestCompletionApi: - def test_post_success(self, app, completion_app, user, payload_patch): + def test_post_success(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -83,7 +84,7 @@ class TestCompletionApi: with pytest.raises(NotCompletionAppError): method(installed_app) - def test_conversation_completed(self, app, completion_app, user, payload_patch): + def test_conversation_completed(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -100,7 +101,7 @@ class TestCompletionApi: with pytest.raises(ConversationCompletedError): method(completion_app) - def test_internal_error(self, app, completion_app, user, payload_patch): + def test_internal_error(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -117,7 +118,7 @@ class TestCompletionApi: with pytest.raises(InternalServerError): method(completion_app) - def test_conversation_not_exists(self, app, completion_app, user, payload_patch): + def test_conversation_not_exists(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -134,7 +135,7 @@ class TestCompletionApi: with pytest.raises(completion_module.NotFound): method(completion_app) - def test_app_unavailable(self, app, completion_app, user, payload_patch): + def test_app_unavailable(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -151,7 +152,7 @@ class TestCompletionApi: with pytest.raises(completion_module.AppUnavailableError): method(completion_app) - def test_provider_not_initialized(self, app, completion_app, user, payload_patch): + def test_provider_not_initialized(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -168,7 +169,7 @@ class TestCompletionApi: with pytest.raises(completion_module.ProviderNotInitializeError): method(completion_app) - def test_quota_exceeded(self, app, completion_app, user, payload_patch): + def test_quota_exceeded(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -185,7 +186,7 @@ class TestCompletionApi: with pytest.raises(completion_module.ProviderQuotaExceededError): method(completion_app) - def test_model_not_supported(self, app, completion_app, user, payload_patch): + def test_model_not_supported(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -202,7 +203,7 @@ class TestCompletionApi: with pytest.raises(completion_module.ProviderModelCurrentlyNotSupportError): method(completion_app) - def test_invoke_error(self, app, completion_app, user, payload_patch): + def test_invoke_error(self, app: Flask, completion_app, user, payload_patch): api = completion_module.CompletionApi() method = unwrap(api.post) @@ -247,7 +248,7 @@ class TestCompletionStopApi: class TestChatApi: - def test_post_success(self, app, chat_app, user, payload_patch): + def test_post_success(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -279,7 +280,7 @@ class TestChatApi: with pytest.raises(NotChatAppError): method(installed_app) - def test_rate_limit_error(self, app, chat_app, user, payload_patch): + def test_rate_limit_error(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -296,7 +297,7 @@ class TestChatApi: with pytest.raises(InvokeRateLimitHttpError): method(chat_app) - def test_conversation_completed_chat(self, app, chat_app, user, payload_patch): + def test_conversation_completed_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -313,7 +314,7 @@ class TestChatApi: with pytest.raises(ConversationCompletedError): method(chat_app) - def test_conversation_not_exists_chat(self, app, chat_app, user, payload_patch): + def test_conversation_not_exists_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -330,7 +331,7 @@ class TestChatApi: with pytest.raises(completion_module.NotFound): method(chat_app) - def test_app_unavailable_chat(self, app, chat_app, user, payload_patch): + def test_app_unavailable_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -347,7 +348,7 @@ class TestChatApi: with pytest.raises(completion_module.AppUnavailableError): method(chat_app) - def test_provider_not_initialized_chat(self, app, chat_app, user, payload_patch): + def test_provider_not_initialized_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -364,7 +365,7 @@ class TestChatApi: with pytest.raises(completion_module.ProviderNotInitializeError): method(chat_app) - def test_quota_exceeded_chat(self, app, chat_app, user, payload_patch): + def test_quota_exceeded_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -381,7 +382,7 @@ class TestChatApi: with pytest.raises(completion_module.ProviderQuotaExceededError): method(chat_app) - def test_model_not_supported_chat(self, app, chat_app, user, payload_patch): + def test_model_not_supported_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -398,7 +399,7 @@ class TestChatApi: with pytest.raises(completion_module.ProviderModelCurrentlyNotSupportError): method(chat_app) - def test_invoke_error_chat(self, app, chat_app, user, payload_patch): + def test_invoke_error_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) @@ -415,7 +416,7 @@ class TestChatApi: with pytest.raises(completion_module.CompletionRequestError): method(chat_app) - def test_internal_error_chat(self, app, chat_app, user, payload_patch): + def test_internal_error_chat(self, app: Flask, chat_app, user, payload_patch): api = completion_module.ChatApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py index 93652e75d2..ec82803be4 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_installed_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_installed_app.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import MagicMock, PropertyMock, patch import pytest +from flask import Flask from werkzeug.exceptions import BadRequest, Forbidden, NotFound import controllers.console.explore.installed_app as module @@ -51,7 +52,7 @@ def payload_patch(): class TestInstalledAppsListApi: - def test_get_installed_apps(self, app, current_user, tenant_id, installed_app): + def test_get_installed_apps(self, app: Flask, current_user, tenant_id, installed_app): api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -75,7 +76,7 @@ class TestInstalledAppsListApi: assert result["installed_apps"][0]["editable"] is True assert result["installed_apps"][0]["uninstallable"] is False - def test_get_installed_apps_with_app_id_filter(self, app, current_user, tenant_id): + def test_get_installed_apps_with_app_id_filter(self, app: Flask, current_user, tenant_id): api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -97,7 +98,7 @@ class TestInstalledAppsListApi: assert result == {"installed_apps": []} - def test_get_installed_apps_with_webapp_auth_enabled(self, app, current_user, tenant_id, installed_app): + def test_get_installed_apps_with_webapp_auth_enabled(self, app: Flask, current_user, tenant_id, installed_app): """Test filtering when webapp_auth is enabled.""" api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -133,7 +134,7 @@ class TestInstalledAppsListApi: assert len(result["installed_apps"]) == 1 - def test_get_installed_apps_with_webapp_auth_user_denied(self, app, current_user, tenant_id, installed_app): + def test_get_installed_apps_with_webapp_auth_user_denied(self, app: Flask, current_user, tenant_id, installed_app): """Test filtering when user doesn't have access.""" api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -169,7 +170,7 @@ class TestInstalledAppsListApi: assert result["installed_apps"] == [] - def test_get_installed_apps_with_sso_verified_access(self, app, current_user, tenant_id, installed_app): + def test_get_installed_apps_with_sso_verified_access(self, app: Flask, current_user, tenant_id, installed_app): """Test that sso_verified access mode apps are skipped in filtering.""" api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -200,7 +201,7 @@ class TestInstalledAppsListApi: assert len(result["installed_apps"]) == 0 - def test_get_installed_apps_filters_null_apps(self, app, current_user, tenant_id): + def test_get_installed_apps_filters_null_apps(self, app: Flask, current_user, tenant_id): """Test that installed apps with null app are filtered out.""" api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -226,7 +227,7 @@ class TestInstalledAppsListApi: assert result["installed_apps"] == [] - def test_get_installed_apps_current_tenant_none(self, app, tenant_id, installed_app): + def test_get_installed_apps_current_tenant_none(self, app: Flask, tenant_id, installed_app): """Test error when current_user.current_tenant is None.""" api = module.InstalledAppsListApi() method = unwrap(api.get) @@ -247,7 +248,7 @@ class TestInstalledAppsListApi: class TestInstalledAppsCreateApi: - def test_post_success(self, app, tenant_id, payload_patch): + def test_post_success(self, app: Flask, tenant_id, payload_patch): api = module.InstalledAppsListApi() method = unwrap(api.post) @@ -276,7 +277,7 @@ class TestInstalledAppsCreateApi: assert result == {"message": "App installed successfully"} assert recommended.install_count == 1 - def test_post_recommended_not_found(self, app, payload_patch): + def test_post_recommended_not_found(self, app: Flask, payload_patch): api = module.InstalledAppsListApi() method = unwrap(api.post) @@ -291,7 +292,7 @@ class TestInstalledAppsCreateApi: with pytest.raises(NotFound): method(api) - def test_post_app_not_public(self, app, tenant_id, payload_patch): + def test_post_app_not_public(self, app: Flask, tenant_id, payload_patch): api = module.InstalledAppsListApi() method = unwrap(api.post) @@ -315,7 +316,7 @@ class TestInstalledAppsCreateApi: class TestInstalledAppApi: - def test_delete_success(self, tenant_id, installed_app): + def test_delete_success(self, tenant_id: str, installed_app): api = module.InstalledAppApi() method = unwrap(api.delete) @@ -328,7 +329,7 @@ class TestInstalledAppApi: assert status == 204 assert resp["result"] == "success" - def test_delete_owned_by_current_tenant(self, tenant_id): + def test_delete_owned_by_current_tenant(self, tenant_id: str): api = module.InstalledAppApi() method = unwrap(api.delete) @@ -338,7 +339,7 @@ class TestInstalledAppApi: with pytest.raises(BadRequest): method(installed_app) - def test_patch_update_pin(self, app, payload_patch, installed_app): + def test_patch_update_pin(self, app: Flask, payload_patch, installed_app): api = module.InstalledAppApi() method = unwrap(api.patch) @@ -352,7 +353,7 @@ class TestInstalledAppApi: assert installed_app.is_pinned is True assert result["result"] == "success" - def test_patch_no_change(self, app, payload_patch, installed_app): + def test_patch_no_change(self, app: Flask, payload_patch, installed_app): api = module.InstalledAppApi() method = unwrap(api.patch) diff --git a/api/tests/unit_tests/controllers/console/explore/test_message.py b/api/tests/unit_tests/controllers/console/explore/test_message.py index 145cc9cdd7..3d41489435 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_message.py +++ b/api/tests/unit_tests/controllers/console/explore/test_message.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import InternalServerError, NotFound import controllers.console.explore.message as module @@ -54,7 +55,7 @@ def make_message(): class TestMessageListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = module.MessageListApi() method = unwrap(api.get) @@ -96,7 +97,7 @@ class TestMessageListApi: with pytest.raises(NotChatAppError): method(installed_app) - def test_conversation_not_exists(self, app): + def test_conversation_not_exists(self, app: Flask): api = module.MessageListApi() method = unwrap(api.get) @@ -118,7 +119,7 @@ class TestMessageListApi: with pytest.raises(NotFound): method(installed_app) - def test_first_message_not_exists(self, app): + def test_first_message_not_exists(self, app: Flask): api = module.MessageListApi() method = unwrap(api.get) @@ -142,7 +143,7 @@ class TestMessageListApi: class TestMessageFeedbackApi: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = module.MessageFeedbackApi() method = unwrap(api.post) @@ -161,7 +162,7 @@ class TestMessageFeedbackApi: assert result["result"] == "success" - def test_message_not_exists(self, app): + def test_message_not_exists(self, app: Flask): api = module.MessageFeedbackApi() method = unwrap(api.post) @@ -182,7 +183,7 @@ class TestMessageFeedbackApi: class TestMessageMoreLikeThisApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -221,7 +222,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(NotCompletionAppError): method(installed_app, "mid") - def test_more_like_this_disabled(self, app): + def test_more_like_this_disabled(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -243,7 +244,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(AppMoreLikeThisDisabledError): method(installed_app, "mid") - def test_message_not_exists_more_like_this(self, app): + def test_message_not_exists_more_like_this(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -265,7 +266,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(NotFound): method(installed_app, "mid") - def test_provider_not_init_more_like_this(self, app): + def test_provider_not_init_more_like_this(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -287,7 +288,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(ProviderNotInitializeError): method(installed_app, "mid") - def test_quota_exceeded_more_like_this(self, app): + def test_quota_exceeded_more_like_this(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -309,7 +310,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(ProviderQuotaExceededError): method(installed_app, "mid") - def test_model_not_support_more_like_this(self, app): + def test_model_not_support_more_like_this(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -331,7 +332,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): method(installed_app, "mid") - def test_invoke_error_more_like_this(self, app): + def test_invoke_error_more_like_this(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) @@ -353,7 +354,7 @@ class TestMessageMoreLikeThisApi: with pytest.raises(CompletionRequestError): method(installed_app, "mid") - def test_unexpected_error_more_like_this(self, app): + def test_unexpected_error_more_like_this(self, app: Flask): api = module.MessageMoreLikeThisApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index 76c863577a..89cbea5ddc 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, patch +from flask import Flask + import controllers.console.explore.recommended_app as module from models.model import AppMode, IconType @@ -11,7 +13,7 @@ def unwrap(func): class TestRecommendedAppListApi: - def test_get_with_language_param(self, app): + def test_get_with_language_param(self, app: Flask): api = module.RecommendedAppListApi() method = unwrap(api.get) @@ -31,7 +33,7 @@ class TestRecommendedAppListApi: service_mock.assert_called_once_with("en-US") assert result == result_data - def test_get_fallback_to_user_language(self, app): + def test_get_fallback_to_user_language(self, app: Flask): api = module.RecommendedAppListApi() method = unwrap(api.get) @@ -51,7 +53,7 @@ class TestRecommendedAppListApi: service_mock.assert_called_once_with("fr-FR") assert result == result_data - def test_get_fallback_to_default_language(self, app): + def test_get_fallback_to_default_language(self, app: Flask): api = module.RecommendedAppListApi() method = unwrap(api.get) @@ -73,7 +75,7 @@ class TestRecommendedAppListApi: class TestRecommendedAppApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = module.RecommendedAppApi() method = unwrap(api.get) @@ -124,7 +126,7 @@ class TestRecommendedAppResponseModels: }, "app_id": "app-1", "description": "desc", - "category": "cat", + "categories": ["cat", "other"], "position": 1, "is_listed": True, "can_trial": False, @@ -135,4 +137,5 @@ class TestRecommendedAppResponseModels: ).model_dump(mode="json") assert response["recommended_apps"][0]["app_id"] == "app-1" + assert response["recommended_apps"][0]["categories"] == ["cat", "other"] assert response["categories"] == ["cat"] diff --git a/api/tests/unit_tests/controllers/console/explore/test_saved_message.py b/api/tests/unit_tests/controllers/console/explore/test_saved_message.py index bb7cdd55c4..49e5695e60 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_saved_message.py +++ b/api/tests/unit_tests/controllers/console/explore/test_saved_message.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, PropertyMock, patch from uuid import uuid4 import pytest +from flask import Flask from werkzeug.exceptions import NotFound import controllers.console.explore.saved_message as module @@ -42,7 +43,7 @@ def payload_patch(): class TestSavedMessageListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = module.SavedMessageListApi() method = unwrap(api.get) @@ -81,7 +82,7 @@ class TestSavedMessageListApi: with pytest.raises(NotCompletionAppError): method(installed_app) - def test_post_success(self, app, payload_patch): + def test_post_success(self, app: Flask, payload_patch): api = module.SavedMessageListApi() method = unwrap(api.post) @@ -101,7 +102,7 @@ class TestSavedMessageListApi: save_mock.assert_called_once() assert result == {"result": "success"} - def test_post_message_not_exists(self, app, payload_patch): + def test_post_message_not_exists(self, app: Flask, payload_patch): api = module.SavedMessageListApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 3625056af9..641209d1de 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask from werkzeug.exceptions import Forbidden, InternalServerError, NotFound import controllers.console.explore.trial as module @@ -87,8 +88,13 @@ def valid_parameters(): } +def test_trial_workflow_uses_trial_scoped_simple_account_model(): + assert module.simple_account_model.name == "TrialSimpleAccount" + assert hasattr(module.simple_account_model, "items") + + class TestTrialAppWorkflowRunApi: - def test_not_workflow_app(self, app): + def test_not_workflow_app(self, app: Flask): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -96,7 +102,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(NotWorkflowAppError): method(api, MagicMock(mode=AppMode.CHAT)) - def test_success(self, app, trial_app_workflow, account): + def test_success(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -110,7 +116,7 @@ class TestTrialAppWorkflowRunApi: assert result is not None - def test_workflow_provider_not_init(self, app, trial_app_workflow, account): + def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -126,7 +132,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(ProviderNotInitializeError): method(api, trial_app_workflow) - def test_workflow_quota_exceeded(self, app, trial_app_workflow, account): + def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -142,7 +148,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(ProviderQuotaExceededError): method(api, trial_app_workflow) - def test_workflow_model_not_support(self, app, trial_app_workflow, account): + def test_workflow_model_not_support(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -158,7 +164,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, trial_app_workflow) - def test_workflow_invoke_error(self, app, trial_app_workflow, account): + def test_workflow_invoke_error(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -174,7 +180,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(CompletionRequestError): method(api, trial_app_workflow) - def test_workflow_rate_limit_error(self, app, trial_app_workflow, account): + def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -190,7 +196,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(InvokeRateLimitHttpError): method(api, trial_app_workflow) - def test_workflow_value_error(self, app, trial_app_workflow, account): + def test_workflow_value_error(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -206,7 +212,7 @@ class TestTrialAppWorkflowRunApi: with pytest.raises(ValueError): method(api, trial_app_workflow) - def test_workflow_generic_exception(self, app, trial_app_workflow, account): + def test_workflow_generic_exception(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowRunApi() method = unwrap(api.post) @@ -224,7 +230,7 @@ class TestTrialAppWorkflowRunApi: class TestTrialChatApi: - def test_not_chat_app(self, app): + def test_not_chat_app(self, app: Flask): api = module.TrialChatApi() method = unwrap(api.post) @@ -232,7 +238,7 @@ class TestTrialChatApi: with pytest.raises(NotChatAppError): method(api, MagicMock(mode="completion")) - def test_success(self, app, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -246,7 +252,7 @@ class TestTrialChatApi: assert result is not None - def test_chat_conversation_not_exists(self, app, trial_app_chat, account): + def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -262,7 +268,7 @@ class TestTrialChatApi: with pytest.raises(NotFound): method(api, trial_app_chat) - def test_chat_conversation_completed(self, app, trial_app_chat, account): + def test_chat_conversation_completed(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -278,7 +284,7 @@ class TestTrialChatApi: with pytest.raises(ConversationCompletedError): method(api, trial_app_chat) - def test_chat_app_config_broken(self, app, trial_app_chat, account): + def test_chat_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -294,7 +300,7 @@ class TestTrialChatApi: with pytest.raises(AppUnavailableError): method(api, trial_app_chat) - def test_chat_provider_not_init(self, app, trial_app_chat, account): + def test_chat_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -310,7 +316,7 @@ class TestTrialChatApi: with pytest.raises(ProviderNotInitializeError): method(api, trial_app_chat) - def test_chat_quota_exceeded(self, app, trial_app_chat, account): + def test_chat_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -326,7 +332,7 @@ class TestTrialChatApi: with pytest.raises(ProviderQuotaExceededError): method(api, trial_app_chat) - def test_chat_model_not_support(self, app, trial_app_chat, account): + def test_chat_model_not_support(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -342,7 +348,7 @@ class TestTrialChatApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, trial_app_chat) - def test_chat_invoke_error(self, app, trial_app_chat, account): + def test_chat_invoke_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -358,7 +364,7 @@ class TestTrialChatApi: with pytest.raises(CompletionRequestError): method(api, trial_app_chat) - def test_chat_rate_limit_error(self, app, trial_app_chat, account): + def test_chat_rate_limit_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -374,7 +380,7 @@ class TestTrialChatApi: with pytest.raises(InvokeRateLimitHttpError): method(api, trial_app_chat) - def test_chat_value_error(self, app, trial_app_chat, account): + def test_chat_value_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -390,7 +396,7 @@ class TestTrialChatApi: with pytest.raises(ValueError): method(api, trial_app_chat) - def test_chat_generic_exception(self, app, trial_app_chat, account): + def test_chat_generic_exception(self, app: Flask, trial_app_chat, account): api = module.TrialChatApi() method = unwrap(api.post) @@ -408,7 +414,7 @@ class TestTrialChatApi: class TestTrialCompletionApi: - def test_not_completion_app(self, app): + def test_not_completion_app(self, app: Flask): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -416,7 +422,7 @@ class TestTrialCompletionApi: with pytest.raises(NotCompletionAppError): method(api, MagicMock(mode=AppMode.CHAT)) - def test_success(self, app, trial_app_completion, account): + def test_success(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -430,7 +436,7 @@ class TestTrialCompletionApi: assert result is not None - def test_completion_app_config_broken(self, app, trial_app_completion, account): + def test_completion_app_config_broken(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -446,7 +452,7 @@ class TestTrialCompletionApi: with pytest.raises(AppUnavailableError): method(api, trial_app_completion) - def test_completion_provider_not_init(self, app, trial_app_completion, account): + def test_completion_provider_not_init(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -462,7 +468,7 @@ class TestTrialCompletionApi: with pytest.raises(ProviderNotInitializeError): method(api, trial_app_completion) - def test_completion_quota_exceeded(self, app, trial_app_completion, account): + def test_completion_quota_exceeded(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -478,7 +484,7 @@ class TestTrialCompletionApi: with pytest.raises(ProviderQuotaExceededError): method(api, trial_app_completion) - def test_completion_model_not_support(self, app, trial_app_completion, account): + def test_completion_model_not_support(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -494,7 +500,7 @@ class TestTrialCompletionApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, trial_app_completion) - def test_completion_invoke_error(self, app, trial_app_completion, account): + def test_completion_invoke_error(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -510,7 +516,7 @@ class TestTrialCompletionApi: with pytest.raises(CompletionRequestError): method(api, trial_app_completion) - def test_completion_rate_limit_error(self, app, trial_app_completion, account): + def test_completion_rate_limit_error(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -526,7 +532,7 @@ class TestTrialCompletionApi: with pytest.raises(InternalServerError): method(api, trial_app_completion) - def test_completion_value_error(self, app, trial_app_completion, account): + def test_completion_value_error(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -542,7 +548,7 @@ class TestTrialCompletionApi: with pytest.raises(ValueError): method(api, trial_app_completion) - def test_completion_generic_exception(self, app, trial_app_completion, account): + def test_completion_generic_exception(self, app: Flask, trial_app_completion, account): api = module.TrialCompletionApi() method = unwrap(api.post) @@ -560,7 +566,7 @@ class TestTrialCompletionApi: class TestTrialMessageSuggestedQuestionApi: - def test_not_chat_app(self, app): + def test_not_chat_app(self, app: Flask): api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) @@ -568,7 +574,7 @@ class TestTrialMessageSuggestedQuestionApi: with pytest.raises(NotChatAppError): method(MagicMock(mode="completion"), str(uuid4())) - def test_success(self, app, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) @@ -585,7 +591,7 @@ class TestTrialMessageSuggestedQuestionApi: assert result == {"data": ["q1", "q2"]} - def test_conversation_not_exists(self, app, trial_app_chat, account): + def test_conversation_not_exists(self, app: Flask, trial_app_chat, account): api = module.TrialMessageSuggestedQuestionApi() method = unwrap(api.get) @@ -637,7 +643,7 @@ class TestTrialAppParameterApi: class TestTrialChatAudioApi: - def test_success(self, app, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -656,7 +662,7 @@ class TestTrialChatAudioApi: assert result == {"text": "hello"} - def test_app_config_broken(self, app, trial_app_chat, account): + def test_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -677,7 +683,7 @@ class TestTrialChatAudioApi: with pytest.raises(module.AppUnavailableError): method(api, trial_app_chat) - def test_no_audio_uploaded(self, app, trial_app_chat, account): + def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -698,7 +704,7 @@ class TestTrialChatAudioApi: with pytest.raises(module.NoAudioUploadedError): method(api, trial_app_chat) - def test_audio_too_large(self, app, trial_app_chat, account): + def test_audio_too_large(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -719,7 +725,7 @@ class TestTrialChatAudioApi: with pytest.raises(module.AudioTooLargeError): method(api, trial_app_chat) - def test_unsupported_audio_type(self, app, trial_app_chat, account): + def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -740,7 +746,7 @@ class TestTrialChatAudioApi: with pytest.raises(module.UnsupportedAudioTypeError): method(api, trial_app_chat) - def test_provider_not_support_tts(self, app, trial_app_chat, account): + def test_provider_not_support_tts(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -761,7 +767,7 @@ class TestTrialChatAudioApi: with pytest.raises(module.ProviderNotSupportSpeechToTextError): method(api, trial_app_chat) - def test_provider_not_init(self, app, trial_app_chat, account): + def test_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -778,7 +784,7 @@ class TestTrialChatAudioApi: with pytest.raises(ProviderNotInitializeError): method(api, trial_app_chat) - def test_quota_exceeded(self, app, trial_app_chat, account): + def test_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -797,7 +803,7 @@ class TestTrialChatAudioApi: class TestTrialChatTextApi: - def test_success(self, app, trial_app_chat, account): + def test_success(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -811,7 +817,7 @@ class TestTrialChatTextApi: assert result == {"audio": "base64_data"} - def test_app_config_broken(self, app, trial_app_chat, account): + def test_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -827,7 +833,7 @@ class TestTrialChatTextApi: with pytest.raises(module.AppUnavailableError): method(api, trial_app_chat) - def test_provider_not_support(self, app, trial_app_chat, account): + def test_provider_not_support(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -843,7 +849,7 @@ class TestTrialChatTextApi: with pytest.raises(module.ProviderNotSupportSpeechToTextError): method(api, trial_app_chat) - def test_audio_too_large(self, app, trial_app_chat, account): + def test_audio_too_large(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -859,7 +865,7 @@ class TestTrialChatTextApi: with pytest.raises(module.AudioTooLargeError): method(api, trial_app_chat) - def test_no_audio_uploaded(self, app, trial_app_chat, account): + def test_no_audio_uploaded(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -875,7 +881,7 @@ class TestTrialChatTextApi: with pytest.raises(module.NoAudioUploadedError): method(api, trial_app_chat) - def test_provider_not_init(self, app, trial_app_chat, account): + def test_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -887,7 +893,7 @@ class TestTrialChatTextApi: with pytest.raises(ProviderNotInitializeError): method(api, trial_app_chat) - def test_quota_exceeded(self, app, trial_app_chat, account): + def test_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -899,7 +905,7 @@ class TestTrialChatTextApi: with pytest.raises(ProviderQuotaExceededError): method(api, trial_app_chat) - def test_model_not_support(self, app, trial_app_chat, account): + def test_model_not_support(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -911,7 +917,7 @@ class TestTrialChatTextApi: with pytest.raises(ProviderModelCurrentlyNotSupportError): method(api, trial_app_chat) - def test_invoke_error(self, app, trial_app_chat, account): + def test_invoke_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -925,7 +931,7 @@ class TestTrialChatTextApi: class TestTrialAppWorkflowTaskStopApi: - def test_not_workflow_app(self, app, trial_app_chat): + def test_not_workflow_app(self, app: Flask, trial_app_chat): api = module.TrialAppWorkflowTaskStopApi() method = unwrap(api.post) @@ -933,7 +939,7 @@ class TestTrialAppWorkflowTaskStopApi: with pytest.raises(NotWorkflowAppError): method(api, trial_app_chat, str(uuid4())) - def test_success(self, app, trial_app_workflow, account): + def test_success(self, app: Flask, trial_app_workflow, account): api = module.TrialAppWorkflowTaskStopApi() method = unwrap(api.post) @@ -952,7 +958,7 @@ class TestTrialAppWorkflowTaskStopApi: class TestTrialSitApi: - def test_no_site(self, app): + def test_no_site(self, app: Flask): api = module.TrialSitApi() method = unwrap(api.get) app_model = MagicMock() @@ -963,7 +969,7 @@ class TestTrialSitApi: with pytest.raises(Forbidden): method(api, app_model) - def test_archived_tenant(self, app): + def test_archived_tenant(self, app: Flask): api = module.TrialSitApi() method = unwrap(api.get) @@ -978,7 +984,7 @@ class TestTrialSitApi: with pytest.raises(Forbidden): method(api, app_model) - def test_success(self, app): + def test_success(self, app: Flask): api = module.TrialSitApi() method = unwrap(api.get) @@ -1003,7 +1009,7 @@ class TestTrialSitApi: class TestTrialChatAudioApiExceptionHandlers: - def test_provider_not_init(self, app, trial_app_chat, account): + def test_provider_not_init(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -1024,7 +1030,7 @@ class TestTrialChatAudioApiExceptionHandlers: with pytest.raises(ProviderNotInitializeError): method(api, trial_app_chat) - def test_quota_exceeded(self, app, trial_app_chat, account): + def test_quota_exceeded(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -1045,7 +1051,7 @@ class TestTrialChatAudioApiExceptionHandlers: with pytest.raises(ProviderQuotaExceededError): method(api, trial_app_chat) - def test_invoke_error(self, app, trial_app_chat, account): + def test_invoke_error(self, app: Flask, trial_app_chat, account): api = module.TrialChatAudioApi() method = unwrap(api.post) @@ -1068,7 +1074,7 @@ class TestTrialChatAudioApiExceptionHandlers: class TestTrialChatTextApiExceptionHandlers: - def test_app_config_broken(self, app, trial_app_chat, account): + def test_app_config_broken(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) @@ -1084,7 +1090,7 @@ class TestTrialChatTextApiExceptionHandlers: with pytest.raises(module.AppUnavailableError): method(api, trial_app_chat) - def test_unsupported_audio_type(self, app, trial_app_chat, account): + def test_unsupported_audio_type(self, app: Flask, trial_app_chat, account): api = module.TrialChatTextApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/explore/test_workflow.py b/api/tests/unit_tests/controllers/console/explore/test_workflow.py index 445f887fd3..3a01925204 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/explore/test_workflow.py @@ -57,7 +57,7 @@ def payload(): class TestInstalledAppWorkflowRunApi: - def test_not_workflow_app(self, app, non_workflow_installed_app): + def test_not_workflow_app(self, app: Flask, non_workflow_installed_app): api = InstalledAppWorkflowRunApi() method = unwrap(api.post) @@ -71,7 +71,7 @@ class TestInstalledAppWorkflowRunApi: with pytest.raises(NotWorkflowAppError): method(non_workflow_installed_app) - def test_success(self, app, installed_workflow_app, user, payload): + def test_success(self, app: Flask, installed_workflow_app, user, payload): api = InstalledAppWorkflowRunApi() method = unwrap(api.post) @@ -91,7 +91,7 @@ class TestInstalledAppWorkflowRunApi: generate_mock.assert_called_once() assert result is not None - def test_rate_limit_error(self, app, installed_workflow_app, user, payload): + def test_rate_limit_error(self, app: Flask, installed_workflow_app, user, payload): api = InstalledAppWorkflowRunApi() method = unwrap(api.post) @@ -109,7 +109,7 @@ class TestInstalledAppWorkflowRunApi: with pytest.raises(InvokeRateLimitHttpError): method(installed_workflow_app) - def test_unexpected_exception(self, app, installed_workflow_app, user, payload): + def test_unexpected_exception(self, app: Flask, installed_workflow_app, user, payload): api = InstalledAppWorkflowRunApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 6405558bb4..f4916f013c 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -8,14 +8,13 @@ from werkzeug.exceptions import Forbidden import controllers.console.tag.tags as module from controllers.console import console_ns from controllers.console.tag.tags import ( - DeprecatedTagBindingCreateApi, - DeprecatedTagBindingRemoveApi, TagBindingCollectionApi, - TagBindingItemApi, + TagBindingRemoveApi, TagListApi, TagUpdateDeleteApi, ) from models.enums import TagType +from services.tag_service import UpdateTagPayload def unwrap(func): @@ -75,7 +74,7 @@ def payload_patch(): class TestTagListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = TagListApi() method = unwrap(api.get) @@ -102,7 +101,7 @@ class TestTagListApi: assert status == 200 assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}] - def test_post_success(self, app, admin_user, tag, payload_patch): + def test_post_success(self, app: Flask, admin_user, tag, payload_patch): api = TagListApi() method = unwrap(api.post) @@ -126,7 +125,7 @@ class TestTagListApi: assert result["name"] == "test-tag" assert result["binding_count"] == "0" - def test_post_forbidden(self, app, readonly_user, payload_patch): + def test_post_forbidden(self, app: Flask, readonly_user, payload_patch): api = TagListApi() method = unwrap(api.post) @@ -145,11 +144,11 @@ class TestTagListApi: class TestTagUpdateDeleteApi: - def test_patch_success(self, app, admin_user, tag, payload_patch): + def test_patch_success(self, app: Flask, admin_user, tag, payload_patch): api = TagUpdateDeleteApi() method = unwrap(api.patch) - payload = {"name": "updated", "type": "knowledge"} + payload = {"name": "updated"} with app.test_request_context("/", json=payload): with ( @@ -161,7 +160,7 @@ class TestTagUpdateDeleteApi: patch( "controllers.console.tag.tags.TagService.update_tags", return_value=tag, - ), + ) as update_tags_mock, patch( "controllers.console.tag.tags.TagService.get_tag_binding_count", return_value=3, @@ -170,9 +169,12 @@ class TestTagUpdateDeleteApi: result, status = method(api, "tag-1") assert status == 200 + update_payload, tag_id = update_tags_mock.call_args.args + assert update_payload == UpdateTagPayload(name="updated") + assert tag_id == "tag-1" assert result["binding_count"] == "3" - def test_patch_forbidden(self, app, readonly_user, payload_patch): + def test_patch_forbidden(self, app: Flask, readonly_user, payload_patch): api = TagUpdateDeleteApi() method = unwrap(api.patch) @@ -189,7 +191,7 @@ class TestTagUpdateDeleteApi: with pytest.raises(Forbidden): method(api, "tag-1") - def test_delete_success(self, app, admin_user): + def test_delete_success(self, app: Flask, admin_user): api = TagUpdateDeleteApi() method = unwrap(api.delete) @@ -208,7 +210,7 @@ class TestTagUpdateDeleteApi: class TestTagBindingCollectionApi: - def test_create_success(self, app, admin_user, payload_patch): + def test_create_success(self, app: Flask, admin_user, payload_patch): api = TagBindingCollectionApi() method = unwrap(api.post) @@ -233,7 +235,7 @@ class TestTagBindingCollectionApi: assert status == 200 assert result["result"] == "success" - def test_create_forbidden(self, app, readonly_user, payload_patch): + def test_create_forbidden(self, app: Flask, readonly_user, payload_patch): api = TagBindingCollectionApi() method = unwrap(api.post) @@ -249,39 +251,13 @@ class TestTagBindingCollectionApi: method(api) -class TestDeprecatedTagBindingCreateApi: - def test_create_success(self, app, admin_user, payload_patch): - api = DeprecatedTagBindingCreateApi() +class TestTagBindingRemoveApi: + def test_remove_success(self, app: Flask, admin_user, payload_patch): + api = TagBindingRemoveApi() method = unwrap(api.post) payload = { - "tag_ids": ["tag-1"], - "target_id": "target-1", - "type": "knowledge", - } - - with app.test_request_context("/", json=payload): - with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), - payload_patch(payload), - patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock, - ): - result, status = method(api) - - save_mock.assert_called_once() - assert status == 200 - assert result["result"] == "success" - - -class TestTagBindingItemApi: - def test_delete_success(self, app, admin_user, payload_patch): - api = TagBindingItemApi() - method = unwrap(api.delete) - - payload = { + "tag_ids": ["tag-1", "tag-2"], "target_id": "target-1", "type": "knowledge", } @@ -295,57 +271,16 @@ class TestTagBindingItemApi: payload_patch(payload), patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock, ): - result, status = method(api, "tag-1") + result, status = method(api) delete_mock.assert_called_once() delete_payload = delete_mock.call_args.args[0] - assert delete_payload.tag_id == "tag-1" - assert delete_payload.target_id == "target-1" - assert delete_payload.type == TagType.KNOWLEDGE + assert delete_payload.tag_ids == ["tag-1", "tag-2"] assert status == 200 assert result["result"] == "success" - def test_delete_forbidden(self, app, readonly_user): - api = TagBindingItemApi() - method = unwrap(api.delete) - - with app.test_request_context("/"): - with patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(readonly_user, None), - ): - with pytest.raises(Forbidden): - method(api, "tag-1") - - -class TestDeprecatedTagBindingRemoveApi: - def test_remove_success(self, app, admin_user, payload_patch): - api = DeprecatedTagBindingRemoveApi() - method = unwrap(api.post) - - payload = { - "tag_id": "tag-1", - "target_id": "target-1", - "type": "knowledge", - } - - with app.test_request_context("/", json=payload): - with ( - patch( - "controllers.console.tag.tags.current_account_with_tenant", - return_value=(admin_user, None), - ), - payload_patch(payload), - patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock, - ): - result, status = method(api) - - delete_mock.assert_called_once() - assert status == 200 - assert result["result"] == "success" - - def test_remove_forbidden(self, app, readonly_user, payload_patch): - api = DeprecatedTagBindingRemoveApi() + def test_remove_forbidden(self, app: Flask, readonly_user, payload_patch): + api = TagBindingRemoveApi() method = unwrap(api.post) with app.test_request_context("/", json={}): @@ -371,32 +306,30 @@ class TestTagResponseModel: class TestTagBindingRouteMetadata: - def test_legacy_write_routes_are_marked_deprecated(self): - assert DeprecatedTagBindingCreateApi.post.__apidoc__["deprecated"] is True - assert DeprecatedTagBindingRemoveApi.post.__apidoc__["deprecated"] is True + def test_write_routes_are_not_deprecated(self): assert TagBindingCollectionApi.post.__apidoc__.get("deprecated") is not True - assert TagBindingItemApi.delete.__apidoc__.get("deprecated") is not True + assert TagBindingRemoveApi.post.__apidoc__.get("deprecated") is not True def test_write_routes_have_stable_operation_ids(self): assert TagBindingCollectionApi.post.__apidoc__["id"] == "create_tag_binding" - assert TagBindingItemApi.delete.__apidoc__["id"] == "delete_tag_binding" - assert DeprecatedTagBindingCreateApi.post.__apidoc__["id"] == "create_tag_binding_deprecated" - assert DeprecatedTagBindingRemoveApi.post.__apidoc__["id"] == "delete_tag_binding_deprecated" + assert TagBindingRemoveApi.post.__apidoc__["id"] == "remove_tag_bindings" - def test_canonical_and_legacy_write_routes_are_registered(self): + def test_write_routes_are_registered(self): route_map = { resource.__name__: urls for resource, urls, _route_doc, _kwargs in console_ns.resources if resource.__name__ in { "TagBindingCollectionApi", - "TagBindingItemApi", - "DeprecatedTagBindingCreateApi", - "DeprecatedTagBindingRemoveApi", + "TagBindingRemoveApi", } } assert route_map["TagBindingCollectionApi"] == ("/tag-bindings",) - assert route_map["TagBindingItemApi"] == ("/tag-bindings/",) - assert route_map["DeprecatedTagBindingCreateApi"] == ("/tag-bindings/create",) - assert route_map["DeprecatedTagBindingRemoveApi"] == ("/tag-bindings/remove",) + assert route_map["TagBindingRemoveApi"] == ("/tag-bindings/remove",) + + def test_legacy_write_routes_are_not_registered(self): + urls = {url for _resource, resource_urls, _route_doc, _kwargs in console_ns.resources for url in resource_urls} + + assert "/tag-bindings/create" not in urls + assert "/tag-bindings/" not in urls diff --git a/api/tests/unit_tests/controllers/console/test_admin.py b/api/tests/unit_tests/controllers/console/test_admin.py deleted file mode 100644 index 16197fcd0c..0000000000 --- a/api/tests/unit_tests/controllers/console/test_admin.py +++ /dev/null @@ -1,877 +0,0 @@ -"""Final working unit tests for admin endpoints - tests business logic directly.""" - -import uuid -from unittest.mock import Mock, PropertyMock, patch - -import pytest -from werkzeug.exceptions import NotFound, Unauthorized - -from controllers.console.admin import ( - DeleteExploreBannerApi, - InsertExploreAppApi, - InsertExploreAppListApi, - InsertExploreAppPayload, - InsertExploreBannerApi, - InsertExploreBannerPayload, -) -from models.model import App, InstalledApp, RecommendedApp - - -@pytest.fixture(autouse=True) -def bypass_only_edition_cloud(mocker): - """ - Bypass only_edition_cloud decorator by setting EDITION to "CLOUD". - """ - mocker.patch( - "controllers.console.wraps.dify_config.EDITION", - new="CLOUD", - ) - - -@pytest.fixture -def mock_admin_auth(mocker): - """ - Provide valid admin authentication for controller tests. - """ - mocker.patch( - "controllers.console.admin.dify_config.ADMIN_API_KEY", - "test-admin-key", - ) - mocker.patch( - "controllers.console.admin.extract_access_token", - return_value="test-admin-key", - ) - - -@pytest.fixture -def mock_console_payload(mocker): - payload = { - "app_id": str(uuid.uuid4()), - "language": "en-US", - "category": "Productivity", - "position": 1, - } - - mocker.patch( - "flask_restx.namespace.Namespace.payload", - new_callable=PropertyMock, - return_value=payload, - ) - - return payload - - -@pytest.fixture -def mock_banner_payload(mocker): - mocker.patch( - "flask_restx.namespace.Namespace.payload", - new_callable=PropertyMock, - return_value={ - "title": "Test Banner", - "description": "Banner description", - "img-src": "https://example.com/banner.png", - "link": "https://example.com", - "sort": 1, - "category": "homepage", - }, - ) - - -@pytest.fixture -def mock_session_factory(mocker): - mock_session = Mock() - mock_session.execute = Mock() - mock_session.add = Mock() - mock_session.commit = Mock() - - mocker.patch( - "controllers.console.admin.session_factory.create_session", - return_value=Mock( - __enter__=lambda s: mock_session, - __exit__=Mock(return_value=False), - ), - ) - - -class TestDeleteExploreBannerApi: - def setup_method(self): - self.api = DeleteExploreBannerApi() - - def test_delete_banner_not_found(self, mocker, mock_admin_auth): - mocker.patch( - "controllers.console.admin.db.session.execute", - return_value=Mock(scalar_one_or_none=lambda: None), - ) - - with pytest.raises(NotFound, match="is not found"): - self.api.delete(uuid.uuid4()) - - def test_delete_banner_success(self, mocker, mock_admin_auth): - mock_banner = Mock() - - mocker.patch( - "controllers.console.admin.db.session.execute", - return_value=Mock(scalar_one_or_none=lambda: mock_banner), - ) - mocker.patch("controllers.console.admin.db.session.delete") - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.delete(uuid.uuid4()) - - assert status == 204 - assert response["result"] == "success" - - -class TestInsertExploreBannerApi: - def setup_method(self): - self.api = InsertExploreBannerApi() - - def test_insert_banner_success(self, mocker, mock_admin_auth, mock_banner_payload): - mocker.patch("controllers.console.admin.db.session.add") - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.post() - - assert status == 201 - assert response["result"] == "success" - - def test_banner_payload_valid_language(self): - payload = { - "title": "Test Banner", - "description": "Banner description", - "img-src": "https://example.com/banner.png", - "link": "https://example.com", - "sort": 1, - "category": "homepage", - "language": "en-US", - } - - model = InsertExploreBannerPayload.model_validate(payload) - assert model.language == "en-US" - - def test_banner_payload_invalid_language(self): - payload = { - "title": "Test Banner", - "description": "Banner description", - "img-src": "https://example.com/banner.png", - "link": "https://example.com", - "sort": 1, - "category": "homepage", - "language": "invalid-lang", - } - - with pytest.raises(ValueError, match="invalid-lang is not a valid language"): - InsertExploreBannerPayload.model_validate(payload) - - -class TestInsertExploreAppApiDelete: - def setup_method(self): - self.api = InsertExploreAppApi() - - def test_delete_when_not_in_explore(self, mocker, mock_admin_auth): - mocker.patch( - "controllers.console.admin.session_factory.create_session", - return_value=Mock( - __enter__=lambda s: s, - __exit__=Mock(return_value=False), - execute=lambda *_: Mock(scalar_one_or_none=lambda: None), - ), - ) - - response, status = self.api.delete(uuid.uuid4()) - - assert status == 204 - assert response["result"] == "success" - - def test_delete_when_in_explore_with_trial_app(self, mocker, mock_admin_auth): - """Test deleting an app from explore that has a trial app.""" - app_id = uuid.uuid4() - - mock_recommended = Mock(spec=RecommendedApp) - mock_recommended.app_id = "app-123" - - mock_app = Mock(spec=App) - mock_app.is_public = True - - mock_trial = Mock() - - # Mock session context manager and its execute - mock_session = Mock() - mock_session.execute = Mock() - mock_session.delete = Mock() - - # Set up side effects for execute calls - mock_session.execute.side_effect = [ - Mock(scalar_one_or_none=lambda: mock_recommended), - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalars=Mock(return_value=Mock(all=lambda: []))), - Mock(scalar_one_or_none=lambda: mock_trial), - ] - - mocker.patch( - "controllers.console.admin.session_factory.create_session", - return_value=Mock( - __enter__=lambda s: mock_session, - __exit__=Mock(return_value=False), - ), - ) - - mocker.patch("controllers.console.admin.db.session.delete") - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.delete(app_id) - - assert status == 204 - assert response["result"] == "success" - assert mock_app.is_public is False - - def test_delete_with_installed_apps(self, mocker, mock_admin_auth): - """Test deleting an app that has installed apps in other tenants.""" - app_id = uuid.uuid4() - - mock_recommended = Mock(spec=RecommendedApp) - mock_recommended.app_id = "app-123" - - mock_app = Mock(spec=App) - mock_app.is_public = True - - mock_installed_app = Mock(spec=InstalledApp) - - # Mock session - mock_session = Mock() - mock_session.execute = Mock() - mock_session.delete = Mock() - - mock_session.execute.side_effect = [ - Mock(scalar_one_or_none=lambda: mock_recommended), - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalars=Mock(return_value=Mock(all=lambda: [mock_installed_app]))), - Mock(scalar_one_or_none=lambda: None), - ] - - mocker.patch( - "controllers.console.admin.session_factory.create_session", - return_value=Mock( - __enter__=lambda s: mock_session, - __exit__=Mock(return_value=False), - ), - ) - - mocker.patch("controllers.console.admin.db.session.delete") - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.delete(app_id) - - assert status == 204 - assert mock_session.delete.called - - -class TestInsertExploreAppListApi: - def setup_method(self): - self.api = InsertExploreAppListApi() - - def test_app_not_found(self, mocker, mock_admin_auth, mock_console_payload): - mocker.patch( - "controllers.console.admin.db.session.execute", - return_value=Mock(scalar_one_or_none=lambda: None), - ) - - with pytest.raises(NotFound, match="is not found"): - self.api.post() - - def test_create_recommended_app( - self, - mocker, - mock_admin_auth, - mock_console_payload, - ): - mock_app = Mock(spec=App) - mock_app.id = "app-id" - mock_app.site = None - mock_app.tenant_id = "tenant" - mock_app.is_public = False - - # db.session.execute → fetch App - mocker.patch( - "controllers.console.admin.db.session.execute", - return_value=Mock(scalar_one_or_none=lambda: mock_app), - ) - - # session_factory.create_session → recommended_app lookup - mock_session = Mock() - mock_session.execute = Mock(return_value=Mock(scalar_one_or_none=lambda: None)) - - mocker.patch( - "controllers.console.admin.session_factory.create_session", - return_value=Mock( - __enter__=lambda s: mock_session, - __exit__=Mock(return_value=False), - ), - ) - - mocker.patch("controllers.console.admin.db.session.add") - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.post() - - assert status == 201 - assert response["result"] == "success" - assert mock_app.is_public is True - - def test_update_recommended_app(self, mocker, mock_admin_auth, mock_console_payload, mock_session_factory): - mock_app = Mock(spec=App) - mock_app.id = "app-id" - mock_app.site = None - mock_app.is_public = False - - mock_recommended = Mock(spec=RecommendedApp) - - mocker.patch( - "controllers.console.admin.db.session.execute", - side_effect=[ - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalar_one_or_none=lambda: mock_recommended), - ], - ) - - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.post() - - assert status == 200 - assert response["result"] == "success" - assert mock_app.is_public is True - - def test_site_data_overrides_payload( - self, - mocker, - mock_admin_auth, - mock_console_payload, - mock_session_factory, - ): - site = Mock() - site.description = "Site Desc" - site.copyright = "Site Copyright" - site.privacy_policy = "Site Privacy" - site.custom_disclaimer = "Site Disclaimer" - - mock_app = Mock(spec=App) - mock_app.id = "app-id" - mock_app.site = site - mock_app.tenant_id = "tenant" - mock_app.is_public = False - - mocker.patch( - "controllers.console.admin.db.session.execute", - side_effect=[ - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalar_one_or_none=lambda: None), - Mock(scalar_one_or_none=lambda: None), - ], - ) - - commit_spy = mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.post() - - assert status == 200 - assert response["result"] == "success" - assert mock_app.is_public is True - commit_spy.assert_called_once() - - def test_create_trial_app_when_can_trial_enabled( - self, - mocker, - mock_admin_auth, - mock_console_payload, - mock_session_factory, - ): - mock_console_payload["can_trial"] = True - mock_console_payload["trial_limit"] = 5 - - mock_app = Mock(spec=App) - mock_app.id = "app-id" - mock_app.site = None - mock_app.tenant_id = "tenant" - mock_app.is_public = False - - mocker.patch( - "controllers.console.admin.db.session.execute", - side_effect=[ - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalar_one_or_none=lambda: None), - Mock(scalar_one_or_none=lambda: None), - ], - ) - - add_spy = mocker.patch("controllers.console.admin.db.session.add") - mocker.patch("controllers.console.admin.db.session.commit") - - self.api.post() - - assert any(call.args[0].__class__.__name__ == "TrialApp" for call in add_spy.call_args_list) - - def test_update_recommended_app_with_trial( - self, - mocker, - mock_admin_auth, - mock_console_payload, - mock_session_factory, - ): - """Test updating a recommended app when trial is enabled.""" - mock_console_payload["can_trial"] = True - mock_console_payload["trial_limit"] = 10 - - mock_app = Mock(spec=App) - mock_app.id = "app-id" - mock_app.site = None - mock_app.is_public = False - mock_app.tenant_id = "tenant-123" - - mock_recommended = Mock(spec=RecommendedApp) - - mocker.patch( - "controllers.console.admin.db.session.execute", - side_effect=[ - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalar_one_or_none=lambda: mock_recommended), - Mock(scalar_one_or_none=lambda: None), - ], - ) - - add_spy = mocker.patch("controllers.console.admin.db.session.add") - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.post() - - assert status == 200 - assert response["result"] == "success" - assert mock_app.is_public is True - - def test_update_recommended_app_without_trial( - self, - mocker, - mock_admin_auth, - mock_console_payload, - mock_session_factory, - ): - """Test updating a recommended app without trial enabled.""" - mock_app = Mock(spec=App) - mock_app.id = "app-id" - mock_app.site = None - mock_app.is_public = False - - mock_recommended = Mock(spec=RecommendedApp) - - mocker.patch( - "controllers.console.admin.db.session.execute", - side_effect=[ - Mock(scalar_one_or_none=lambda: mock_app), - Mock(scalar_one_or_none=lambda: mock_recommended), - ], - ) - - mocker.patch("controllers.console.admin.db.session.commit") - - response, status = self.api.post() - - assert status == 200 - assert response["result"] == "success" - assert mock_app.is_public is True - - -class TestInsertExploreAppPayload: - """Test InsertExploreAppPayload validation.""" - - def test_valid_payload(self): - """Test creating payload with valid data.""" - payload_data = { - "app_id": str(uuid.uuid4()), - "desc": "Test app description", - "copyright": "© 2024 Test Company", - "privacy_policy": "https://example.com/privacy", - "custom_disclaimer": "Custom disclaimer text", - "language": "en-US", - "category": "Productivity", - "position": 1, - } - - payload = InsertExploreAppPayload.model_validate(payload_data) - - assert payload.app_id == payload_data["app_id"] - assert payload.desc == payload_data["desc"] - assert payload.copyright == payload_data["copyright"] - assert payload.privacy_policy == payload_data["privacy_policy"] - assert payload.custom_disclaimer == payload_data["custom_disclaimer"] - assert payload.language == payload_data["language"] - assert payload.category == payload_data["category"] - assert payload.position == payload_data["position"] - - def test_minimal_payload(self): - """Test creating payload with only required fields.""" - payload_data = { - "app_id": str(uuid.uuid4()), - "language": "en-US", - "category": "Productivity", - "position": 1, - } - - payload = InsertExploreAppPayload.model_validate(payload_data) - - assert payload.app_id == payload_data["app_id"] - assert payload.desc is None - assert payload.copyright is None - assert payload.privacy_policy is None - assert payload.custom_disclaimer is None - assert payload.language == payload_data["language"] - assert payload.category == payload_data["category"] - assert payload.position == payload_data["position"] - - def test_invalid_language(self): - """Test payload with invalid language code.""" - payload_data = { - "app_id": str(uuid.uuid4()), - "language": "invalid-lang", - "category": "Productivity", - "position": 1, - } - - with pytest.raises(ValueError, match="invalid-lang is not a valid language"): - InsertExploreAppPayload.model_validate(payload_data) - - -class TestAdminRequiredDecorator: - """Test admin_required decorator.""" - - def setup_method(self): - """Set up test fixtures.""" - # Mock dify_config - self.dify_config_patcher = patch("controllers.console.admin.dify_config") - self.mock_dify_config = self.dify_config_patcher.start() - self.mock_dify_config.ADMIN_API_KEY = "test-admin-key" - - # Mock extract_access_token - self.token_patcher = patch("controllers.console.admin.extract_access_token") - self.mock_extract_token = self.token_patcher.start() - - def teardown_method(self): - """Clean up test fixtures.""" - self.dify_config_patcher.stop() - self.token_patcher.stop() - - def test_admin_required_success(self): - """Test successful admin authentication.""" - from controllers.console.admin import admin_required - - @admin_required - def test_view(): - return {"success": True} - - self.mock_extract_token.return_value = "test-admin-key" - result = test_view() - assert result["success"] is True - - def test_admin_required_invalid_token(self): - """Test admin_required with invalid token.""" - from controllers.console.admin import admin_required - - @admin_required - def test_view(): - return {"success": True} - - self.mock_extract_token.return_value = "wrong-key" - with pytest.raises(Unauthorized, match="API key is invalid"): - test_view() - - def test_admin_required_no_api_key_configured(self): - """Test admin_required when no API key is configured.""" - from controllers.console.admin import admin_required - - self.mock_dify_config.ADMIN_API_KEY = None - - @admin_required - def test_view(): - return {"success": True} - - with pytest.raises(Unauthorized, match="API key is invalid"): - test_view() - - def test_admin_required_missing_authorization_header(self): - """Test admin_required with missing authorization header.""" - from controllers.console.admin import admin_required - - @admin_required - def test_view(): - return {"success": True} - - self.mock_extract_token.return_value = None - with pytest.raises(Unauthorized, match="Authorization header is missing"): - test_view() - - -class TestExploreAppBusinessLogicDirect: - """Test the core business logic of explore app management directly.""" - - def test_data_fusion_logic(self): - """Test the data fusion logic between payload and site data.""" - # Test cases for different data scenarios - test_cases = [ - { - "name": "site_data_overrides_payload", - "payload": {"desc": "Payload desc", "copyright": "Payload copyright"}, - "site": {"description": "Site desc", "copyright": "Site copyright"}, - "expected": { - "desc": "Site desc", - "copyright": "Site copyright", - "privacy_policy": "", - "custom_disclaimer": "", - }, - }, - { - "name": "payload_used_when_no_site", - "payload": {"desc": "Payload desc", "copyright": "Payload copyright"}, - "site": None, - "expected": { - "desc": "Payload desc", - "copyright": "Payload copyright", - "privacy_policy": "", - "custom_disclaimer": "", - }, - }, - { - "name": "empty_defaults_when_no_data", - "payload": {}, - "site": None, - "expected": {"desc": "", "copyright": "", "privacy_policy": "", "custom_disclaimer": ""}, - }, - ] - - for case in test_cases: - # Simulate the data fusion logic - payload_desc = case["payload"].get("desc") - payload_copyright = case["payload"].get("copyright") - payload_privacy_policy = case["payload"].get("privacy_policy") - payload_custom_disclaimer = case["payload"].get("custom_disclaimer") - - if case["site"]: - site_desc = case["site"].get("description") - site_copyright = case["site"].get("copyright") - site_privacy_policy = case["site"].get("privacy_policy") - site_custom_disclaimer = case["site"].get("custom_disclaimer") - - # Site data takes precedence - desc = site_desc or payload_desc or "" - copyright = site_copyright or payload_copyright or "" - privacy_policy = site_privacy_policy or payload_privacy_policy or "" - custom_disclaimer = site_custom_disclaimer or payload_custom_disclaimer or "" - else: - # Use payload data or empty defaults - desc = payload_desc or "" - copyright = payload_copyright or "" - privacy_policy = payload_privacy_policy or "" - custom_disclaimer = payload_custom_disclaimer or "" - - result = { - "desc": desc, - "copyright": copyright, - "privacy_policy": privacy_policy, - "custom_disclaimer": custom_disclaimer, - } - - assert result == case["expected"], f"Failed test case: {case['name']}" - - def test_app_visibility_logic(self): - """Test that apps are made public when added to explore list.""" - # Create a mock app - mock_app = Mock(spec=App) - mock_app.is_public = False - - # Simulate the business logic - mock_app.is_public = True - - assert mock_app.is_public is True - - def test_recommended_app_creation_logic(self): - """Test the creation of RecommendedApp objects.""" - app_id = str(uuid.uuid4()) - payload_data = { - "app_id": app_id, - "desc": "Test app description", - "copyright": "© 2024 Test Company", - "privacy_policy": "https://example.com/privacy", - "custom_disclaimer": "Custom disclaimer", - "language": "en-US", - "category": "Productivity", - "position": 1, - } - - # Simulate the creation logic - recommended_app = Mock(spec=RecommendedApp) - recommended_app.app_id = payload_data["app_id"] - recommended_app.description = payload_data["desc"] - recommended_app.copyright = payload_data["copyright"] - recommended_app.privacy_policy = payload_data["privacy_policy"] - recommended_app.custom_disclaimer = payload_data["custom_disclaimer"] - recommended_app.language = payload_data["language"] - recommended_app.category = payload_data["category"] - recommended_app.position = payload_data["position"] - - # Verify the data - assert recommended_app.app_id == app_id - assert recommended_app.description == "Test app description" - assert recommended_app.copyright == "© 2024 Test Company" - assert recommended_app.privacy_policy == "https://example.com/privacy" - assert recommended_app.custom_disclaimer == "Custom disclaimer" - assert recommended_app.language == "en-US" - assert recommended_app.category == "Productivity" - assert recommended_app.position == 1 - - def test_recommended_app_update_logic(self): - """Test the update logic for existing RecommendedApp objects.""" - mock_recommended_app = Mock(spec=RecommendedApp) - - update_data = { - "desc": "Updated description", - "copyright": "© 2024 Updated", - "language": "fr-FR", - "category": "Tools", - "position": 2, - } - - # Simulate the update logic - mock_recommended_app.description = update_data["desc"] - mock_recommended_app.copyright = update_data["copyright"] - mock_recommended_app.language = update_data["language"] - mock_recommended_app.category = update_data["category"] - mock_recommended_app.position = update_data["position"] - - # Verify the updates - assert mock_recommended_app.description == "Updated description" - assert mock_recommended_app.copyright == "© 2024 Updated" - assert mock_recommended_app.language == "fr-FR" - assert mock_recommended_app.category == "Tools" - assert mock_recommended_app.position == 2 - - def test_app_not_found_error_logic(self): - """Test error handling when app is not found.""" - app_id = str(uuid.uuid4()) - - # Simulate app lookup returning None - found_app = None - - # Test the error condition - if not found_app: - with pytest.raises(NotFound, match=f"App '{app_id}' is not found"): - raise NotFound(f"App '{app_id}' is not found") - - def test_recommended_app_not_found_error_logic(self): - """Test error handling when recommended app is not found for deletion.""" - app_id = str(uuid.uuid4()) - - # Simulate recommended app lookup returning None - found_recommended_app = None - - # Test the error condition - if not found_recommended_app: - with pytest.raises(NotFound, match=f"App '{app_id}' is not found in the explore list"): - raise NotFound(f"App '{app_id}' is not found in the explore list") - - def test_database_session_usage_patterns(self): - """Test the expected database session usage patterns.""" - # Mock session usage patterns - mock_session = Mock() - - # Test session.add pattern - mock_recommended_app = Mock(spec=RecommendedApp) - mock_session.add(mock_recommended_app) - mock_session.commit() - - # Verify session was used correctly - mock_session.add.assert_called_once_with(mock_recommended_app) - mock_session.commit.assert_called_once() - - # Test session.delete pattern - mock_recommended_app_to_delete = Mock(spec=RecommendedApp) - mock_session.delete(mock_recommended_app_to_delete) - mock_session.commit() - - # Verify delete pattern - mock_session.delete.assert_called_once_with(mock_recommended_app_to_delete) - - def test_payload_validation_integration(self): - """Test payload validation in the context of the business logic.""" - # Test valid payload - valid_payload_data = { - "app_id": str(uuid.uuid4()), - "desc": "Test app description", - "language": "en-US", - "category": "Productivity", - "position": 1, - } - - # This should succeed - payload = InsertExploreAppPayload.model_validate(valid_payload_data) - assert payload.app_id == valid_payload_data["app_id"] - - # Test invalid payload - invalid_payload_data = { - "app_id": str(uuid.uuid4()), - "language": "invalid-lang", # This should fail validation - "category": "Productivity", - "position": 1, - } - - # This should raise an exception - with pytest.raises(ValueError, match="invalid-lang is not a valid language"): - InsertExploreAppPayload.model_validate(invalid_payload_data) - - -class TestExploreAppDataHandling: - """Test specific data handling scenarios.""" - - def test_uuid_validation(self): - """Test UUID validation and handling.""" - # Test valid UUID - valid_uuid = str(uuid.uuid4()) - - # This should be a valid UUID - assert uuid.UUID(valid_uuid) is not None - - # Test invalid UUID - invalid_uuid = "not-a-valid-uuid" - - # This should raise a ValueError - with pytest.raises(ValueError): - uuid.UUID(invalid_uuid) - - def test_language_validation(self): - """Test language validation against supported languages.""" - from constants.languages import supported_language - - # Test supported language - assert supported_language("en-US") == "en-US" - assert supported_language("fr-FR") == "fr-FR" - - # Test unsupported language - with pytest.raises(ValueError, match="invalid-lang is not a valid language"): - supported_language("invalid-lang") - - def test_response_formatting(self): - """Test API response formatting.""" - # Test success responses - create_response = {"result": "success"} - update_response = {"result": "success"} - delete_response = None # 204 No Content returns None - - assert create_response["result"] == "success" - assert update_response["result"] == "success" - assert delete_response is None - - # Test status codes - create_status = 201 # Created - update_status = 200 # OK - delete_status = 204 # No Content - - assert create_status == 201 - assert update_status == 200 - assert delete_status == 204 diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index d8debc1f2c..1711aede61 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -1,3 +1,4 @@ +from pytest_mock import MockerFixture from werkzeug.exceptions import Unauthorized @@ -11,7 +12,7 @@ def unwrap(func): class TestFeatureApi: - def test_get_tenant_features_success(self, mocker): + def test_get_tenant_features_success(self, mocker: MockerFixture): from controllers.console.feature import FeatureApi mocker.patch( @@ -32,7 +33,7 @@ class TestFeatureApi: class TestSystemFeatureApi: - def test_get_system_features_authenticated(self, mocker): + def test_get_system_features_authenticated(self, mocker: MockerFixture): """ current_user.is_authenticated == True """ @@ -56,7 +57,7 @@ class TestSystemFeatureApi: assert result == {"features": {"sys_feature": True}} - def test_get_system_features_unauthenticated(self, mocker): + def test_get_system_features_unauthenticated(self, mocker: MockerFixture): """ current_user.is_authenticated raises Unauthorized """ diff --git a/api/tests/unit_tests/controllers/console/test_files.py b/api/tests/unit_tests/controllers/console/test_files.py index 5df9daa7f8..9274f6cf61 100644 --- a/api/tests/unit_tests/controllers/console/test_files.py +++ b/api/tests/unit_tests/controllers/console/test_files.py @@ -82,7 +82,7 @@ def mock_file_service(mock_db): class TestFileApiGet: - def test_get_upload_config(self, app): + def test_get_upload_config(self, app: Flask): api = FileApi() get_method = unwrap(api.get) @@ -95,7 +95,7 @@ class TestFileApiGet: class TestFileApiPost: - def test_no_file_uploaded(self, app, mock_account_context): + def test_no_file_uploaded(self, app: Flask, mock_account_context): api = FileApi() post_method = unwrap(api.post) @@ -103,7 +103,7 @@ class TestFileApiPost: with pytest.raises(NoFileUploadedError): post_method(api) - def test_too_many_files(self, app, mock_account_context): + def test_too_many_files(self, app: Flask, mock_account_context): api = FileApi() post_method = unwrap(api.post) @@ -120,7 +120,7 @@ class TestFileApiPost: with pytest.raises(TooManyFilesError): post_method(api) - def test_filename_missing(self, app, mock_account_context): + def test_filename_missing(self, app: Flask, mock_account_context): api = FileApi() post_method = unwrap(api.post) @@ -132,7 +132,7 @@ class TestFileApiPost: with pytest.raises(FilenameNotExistsError): post_method(api) - def test_dataset_upload_without_permission(self, app, mock_current_user): + def test_dataset_upload_without_permission(self, app: Flask, mock_current_user): mock_current_user.is_dataset_editor = False with patch( @@ -151,7 +151,7 @@ class TestFileApiPost: with pytest.raises(Forbidden): post_method(api) - def test_successful_upload(self, app, mock_account_context, mock_file_service): + def test_successful_upload(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -185,7 +185,7 @@ class TestFileApiPost: assert response["id"] == "file-id-123" assert response["name"] == "test.txt" - def test_upload_with_invalid_source(self, app, mock_account_context, mock_file_service): + def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service): """Test that invalid source parameter gets normalized to None""" api = FileApi() post_method = unwrap(api.post) @@ -225,7 +225,7 @@ class TestFileApiPost: call_kwargs = mock_file_service.upload_file.call_args[1] assert call_kwargs["source"] is None - def test_file_too_large_error(self, app, mock_account_context, mock_file_service): + def test_file_too_large_error(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -242,7 +242,7 @@ class TestFileApiPost: with pytest.raises(FileTooLargeError): post_method(api) - def test_unsupported_file_type(self, app, mock_account_context, mock_file_service): + def test_unsupported_file_type(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -259,7 +259,7 @@ class TestFileApiPost: with pytest.raises(UnsupportedFileTypeError): post_method(api) - def test_blocked_extension(self, app, mock_account_context, mock_file_service): + def test_blocked_extension(self, app: Flask, mock_account_context, mock_file_service): api = FileApi() post_method = unwrap(api.post) @@ -278,7 +278,7 @@ class TestFileApiPost: class TestFilePreviewApi: - def test_get_preview(self, app, mock_file_service): + def test_get_preview(self, app: Flask, mock_account_context, mock_file_service): api = FilePreviewApi() get_method = unwrap(api.get) mock_file_service.get_file_preview.return_value = "preview text" @@ -290,7 +290,7 @@ class TestFilePreviewApi: class TestFileSupportTypeApi: - def test_get_supported_types(self, app): + def test_get_supported_types(self, app: Flask): api = FileSupportTypeApi() get_method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index 0b1a32581a..4b4f968c8f 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -58,7 +58,7 @@ class TestChangeEmailSend: mock_get_change_data, mock_current_account, mock_db, - app, + app: Flask, ): mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("current@example.com", "acc1") @@ -107,7 +107,7 @@ class TestChangeEmailSend: mock_get_change_data, mock_current_account, mock_db, - app, + app: Flask, ): """GHSA-4q3w-q5mc-45rq: a phase-1 token must not unlock the new-email send step.""" from controllers.console.auth.error import InvalidTokenError @@ -155,7 +155,7 @@ class TestChangeEmailValidity: mock_reset_rate, mock_current_account, mock_db, - app, + app: Flask, ): mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("user@example.com", "acc2") @@ -214,7 +214,7 @@ class TestChangeEmailValidity: mock_reset_rate, mock_current_account, mock_db, - app, + app: Flask, ): mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) @@ -267,7 +267,7 @@ class TestChangeEmailValidity: mock_reset_rate, mock_current_account, mock_db, - app, + app: Flask, ): """A token whose phase marker is a string but not a known transition must be rejected.""" from controllers.console.auth.error import InvalidTokenError @@ -316,7 +316,7 @@ class TestChangeEmailValidity: mock_reset_rate, mock_current_account, mock_db, - app, + app: Flask, ): """A token minted without a phase marker (e.g. a hand-crafted token) must not validate.""" from controllers.console.auth.error import InvalidTokenError @@ -366,7 +366,7 @@ class TestChangeEmailReset: mock_send_notify, mock_current_account, mock_db, - app, + app: Flask, ): mock_features.return_value = SimpleNamespace(enable_change_email=True) current_user = _build_account("old@example.com", "acc3") @@ -418,7 +418,7 @@ class TestChangeEmailReset: mock_send_notify, mock_current_account, mock_db, - app, + app: Flask, ): """GHSA-4q3w-q5mc-45rq PoC: phase-1 token must not be usable against /reset.""" from controllers.console.auth.error import InvalidTokenError @@ -471,7 +471,7 @@ class TestChangeEmailReset: mock_send_notify, mock_current_account, mock_db, - app, + app: Flask, ): """A verified token for address A must not be replayed to change to address B.""" from controllers.console.auth.error import InvalidTokenError @@ -547,7 +547,7 @@ class TestAccountServiceSendChangeEmailEmail: class TestAccountDeletionFeedback: @patch("controllers.console.wraps.db") @patch("controllers.console.workspace.account.BillingService.update_account_deletion_feedback") - def test_should_normalize_feedback_email(self, mock_update, mock_db, app): + def test_should_normalize_feedback_email(self, mock_update, mock_db, app: Flask): with app.test_request_context( "/account/delete/feedback", method="POST", @@ -563,7 +563,7 @@ class TestCheckEmailUnique: @patch("controllers.console.wraps.db") @patch("controllers.console.workspace.account.AccountService.check_email_unique") @patch("controllers.console.workspace.account.AccountService.is_account_in_freeze") - def test_should_normalize_email(self, mock_is_freeze, mock_check_unique, mock_db, app): + def test_should_normalize_email(self, mock_is_freeze, mock_check_unique, mock_db, app: Flask): mock_is_freeze.return_value = False mock_check_unique.return_value = True diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index 811bf5b1e7..412d6a6c52 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -43,7 +43,7 @@ class TestMemberInviteEmailApi: mock_current_account, mock_invite_member, mock_get_features, - app, + app: Flask, ): mock_get_features.return_value = _build_feature_flags() mock_invite_member.return_value = "token-abc" diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index bbe9d09521..df0d2bda49 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.console import console_ns @@ -41,7 +42,7 @@ def unwrap(func): class TestAccountInitApi: - def test_init_success(self, app): + def test_init_success(self, app: Flask): api = AccountInitApi() method = unwrap(api.post) @@ -64,7 +65,7 @@ class TestAccountInitApi: assert resp["result"] == "success" - def test_init_already_initialized(self, app): + def test_init_already_initialized(self, app: Flask): api = AccountInitApi() method = unwrap(api.post) @@ -79,7 +80,7 @@ class TestAccountInitApi: class TestAccountProfileApi: - def test_get_profile_success(self, app): + def test_get_profile_success(self, app: Flask): api = AccountProfileApi() method = unwrap(api.get) @@ -113,7 +114,7 @@ class TestAccountUpdateApis: (AccountTimezoneApi, {"timezone": "UTC"}), ], ) - def test_update_success(self, app, api_cls, payload): + def test_update_success(self, app: Flask, api_cls, payload): api = api_cls() method = unwrap(api.post) @@ -140,7 +141,7 @@ class TestAccountUpdateApis: class TestAccountAvatarApiGet: """GET /account/avatar must not sign arbitrary upload_file IDs (IDOR).""" - def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app): + def test_get_avatar_signed_url_when_upload_owned_by_current_account(self, app: Flask): api = AccountAvatarApi() method = unwrap(api.get) @@ -172,7 +173,7 @@ class TestAccountAvatarApiGet: assert result == {"avatar_url": "https://signed/example"} sign_mock.assert_called_once_with(upload_file_id=file_id) - def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app): + def test_get_avatar_not_found_when_upload_created_by_other_account_same_tenant(self, app: Flask): api = AccountAvatarApi() method = unwrap(api.get) @@ -204,7 +205,7 @@ class TestAccountAvatarApiGet: sign_mock.assert_not_called() - def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app): + def test_get_avatar_not_found_when_upload_belongs_to_other_tenant(self, app: Flask): api = AccountAvatarApi() method = unwrap(api.get) @@ -236,7 +237,7 @@ class TestAccountAvatarApiGet: sign_mock.assert_not_called() - def test_get_avatar_https_pass_through_without_signing(self, app): + def test_get_avatar_https_pass_through_without_signing(self, app: Flask): api = AccountAvatarApi() method = unwrap(api.get) @@ -263,7 +264,7 @@ class TestAccountAvatarApiGet: class TestAccountPasswordApi: - def test_password_success(self, app): + def test_password_success(self, app: Flask): api = AccountPasswordApi() method = unwrap(api.post) @@ -292,7 +293,7 @@ class TestAccountPasswordApi: assert result["id"] == "u1" - def test_password_wrong_current(self, app): + def test_password_wrong_current(self, app: Flask): api = AccountPasswordApi() method = unwrap(api.post) @@ -317,7 +318,7 @@ class TestAccountPasswordApi: class TestAccountIntegrateApi: - def test_get_integrates(self, app): + def test_get_integrates(self, app: Flask): api = AccountIntegrateApi() method = unwrap(api.get) @@ -336,7 +337,7 @@ class TestAccountIntegrateApi: class TestAccountDeleteApi: - def test_delete_verify_success(self, app): + def test_delete_verify_success(self, app: Flask): api = AccountDeleteVerifyApi() method = unwrap(api.get) @@ -358,7 +359,7 @@ class TestAccountDeleteApi: assert result["result"] == "success" - def test_delete_invalid_code(self, app): + def test_delete_invalid_code(self, app: Flask): api = AccountDeleteApi() method = unwrap(api.post) @@ -379,7 +380,7 @@ class TestAccountDeleteApi: class TestChangeEmailApis: - def test_check_email_code_invalid(self, app): + def test_check_email_code_invalid(self, app: Flask): api = ChangeEmailCheckApi() method = unwrap(api.post) @@ -405,7 +406,7 @@ class TestChangeEmailApis: with pytest.raises(EmailCodeError): method(api) - def test_reset_email_already_used(self, app): + def test_reset_email_already_used(self, app: Flask): api = ChangeEmailResetApi() method = unwrap(api.post) @@ -427,7 +428,7 @@ class TestChangeEmailApis: class TestCheckEmailUniqueApi: - def test_email_unique_success(self, app): + def test_email_unique_success(self, app: Flask): api = CheckEmailUnique() method = unwrap(api.post) @@ -448,7 +449,7 @@ class TestCheckEmailUniqueApi: assert result["result"] == "success" - def test_email_in_freeze(self, app): + def test_email_in_freeze(self, app: Flask): api = CheckEmailUnique() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py index b4e03f681d..eb0ca15d2e 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_agent_providers.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.console.error import AccountNotFound from controllers.console.workspace.agent_providers import ( @@ -16,7 +17,7 @@ def unwrap(func): class TestAgentProviderListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = AgentProviderListApi() method = unwrap(api.get) @@ -39,7 +40,7 @@ class TestAgentProviderListApi: assert result == providers - def test_get_empty_list(self, app): + def test_get_empty_list(self, app: Flask): api = AgentProviderListApi() method = unwrap(api.get) @@ -61,7 +62,7 @@ class TestAgentProviderListApi: assert result == [] - def test_get_account_not_found(self, app): + def test_get_account_not_found(self, app: Flask): api = AgentProviderListApi() method = unwrap(api.get) @@ -77,7 +78,7 @@ class TestAgentProviderListApi: class TestAgentProviderApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = AgentProviderApi() method = unwrap(api.get) @@ -101,7 +102,7 @@ class TestAgentProviderApi: assert result == provider_data - def test_get_provider_not_found(self, app): + def test_get_provider_not_found(self, app: Flask): api = AgentProviderApi() method = unwrap(api.get) @@ -124,7 +125,7 @@ class TestAgentProviderApi: assert result is None - def test_get_account_not_found(self, app): + def test_get_account_not_found(self, app: Flask): api = AgentProviderApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py index 0b3d7ef6d7..ed7b2d606f 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_endpoint.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.console import console_ns from controllers.console.workspace.endpoint import ( @@ -39,7 +40,7 @@ def patch_current_account(user_and_tenant): @pytest.mark.usefixtures("patch_current_account") class TestEndpointCollectionApi: - def test_create_success(self, app): + def test_create_success(self, app: Flask): api = EndpointCollectionApi() method = unwrap(api.post) @@ -57,7 +58,7 @@ class TestEndpointCollectionApi: assert result["success"] is True - def test_create_permission_denied(self, app): + def test_create_permission_denied(self, app: Flask): api = EndpointCollectionApi() method = unwrap(api.post) @@ -77,7 +78,7 @@ class TestEndpointCollectionApi: with pytest.raises(ValueError): method(api) - def test_create_validation_error(self, app): + def test_create_validation_error(self, app: Flask): api = EndpointCollectionApi() method = unwrap(api.post) @@ -96,7 +97,7 @@ class TestEndpointCollectionApi: @pytest.mark.usefixtures("patch_current_account") class TestDeprecatedEndpointCreateApi: - def test_create_success(self, app): + def test_create_success(self, app: Flask): api = DeprecatedEndpointCreateApi() method = unwrap(api.post) @@ -117,7 +118,7 @@ class TestDeprecatedEndpointCreateApi: @pytest.mark.usefixtures("patch_current_account") class TestEndpointListApi: - def test_list_success(self, app): + def test_list_success(self, app: Flask): api = EndpointListApi() method = unwrap(api.get) @@ -130,7 +131,7 @@ class TestEndpointListApi: assert "endpoints" in result assert len(result["endpoints"]) == 1 - def test_list_invalid_query(self, app): + def test_list_invalid_query(self, app: Flask): api = EndpointListApi() method = unwrap(api.get) @@ -143,7 +144,7 @@ class TestEndpointListApi: @pytest.mark.usefixtures("patch_current_account") class TestEndpointListForSinglePluginApi: - def test_list_for_plugin_success(self, app): + def test_list_for_plugin_success(self, app: Flask): api = EndpointListForSinglePluginApi() method = unwrap(api.get) @@ -158,7 +159,7 @@ class TestEndpointListForSinglePluginApi: assert "endpoints" in result - def test_list_for_plugin_missing_param(self, app): + def test_list_for_plugin_missing_param(self, app: Flask): api = EndpointListForSinglePluginApi() method = unwrap(api.get) @@ -171,7 +172,7 @@ class TestEndpointListForSinglePluginApi: @pytest.mark.usefixtures("patch_current_account") class TestEndpointItemApi: - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = EndpointItemApi() method = unwrap(api.delete) @@ -187,7 +188,7 @@ class TestEndpointItemApi: assert result["success"] is True mock_delete.assert_called_once_with(tenant_id="t1", user_id="u1", endpoint_id="e1") - def test_delete_service_failure(self, app): + def test_delete_service_failure(self, app: Flask): api = EndpointItemApi() method = unwrap(api.delete) @@ -199,7 +200,7 @@ class TestEndpointItemApi: assert result["success"] is False - def test_update_success(self, app): + def test_update_success(self, app: Flask): api = EndpointItemApi() method = unwrap(api.patch) @@ -226,7 +227,7 @@ class TestEndpointItemApi: settings={"x": 1}, ) - def test_update_validation_error(self, app): + def test_update_validation_error(self, app: Flask): api = EndpointItemApi() method = unwrap(api.patch) @@ -238,7 +239,7 @@ class TestEndpointItemApi: with pytest.raises(ValueError): method(api, "e1") - def test_update_service_failure(self, app): + def test_update_service_failure(self, app: Flask): api = EndpointItemApi() method = unwrap(api.patch) @@ -258,7 +259,7 @@ class TestEndpointItemApi: @pytest.mark.usefixtures("patch_current_account") class TestDeprecatedEndpointDeleteApi: - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = DeprecatedEndpointDeleteApi() method = unwrap(api.post) @@ -272,7 +273,7 @@ class TestDeprecatedEndpointDeleteApi: assert result["success"] is True - def test_delete_invalid_payload(self, app): + def test_delete_invalid_payload(self, app: Flask): api = DeprecatedEndpointDeleteApi() method = unwrap(api.post) @@ -282,7 +283,7 @@ class TestDeprecatedEndpointDeleteApi: with pytest.raises(ValueError): method(api) - def test_delete_service_failure(self, app): + def test_delete_service_failure(self, app: Flask): api = DeprecatedEndpointDeleteApi() method = unwrap(api.post) @@ -299,7 +300,7 @@ class TestDeprecatedEndpointDeleteApi: @pytest.mark.usefixtures("patch_current_account") class TestDeprecatedEndpointUpdateApi: - def test_update_success(self, app): + def test_update_success(self, app: Flask): api = DeprecatedEndpointUpdateApi() method = unwrap(api.post) @@ -317,7 +318,7 @@ class TestDeprecatedEndpointUpdateApi: assert result["success"] is True - def test_update_validation_error(self, app): + def test_update_validation_error(self, app: Flask): api = DeprecatedEndpointUpdateApi() method = unwrap(api.post) @@ -329,7 +330,7 @@ class TestDeprecatedEndpointUpdateApi: with pytest.raises(ValueError): method(api) - def test_update_service_failure(self, app): + def test_update_service_failure(self, app: Flask): api = DeprecatedEndpointUpdateApi() method = unwrap(api.post) @@ -380,7 +381,7 @@ class TestEndpointRouteMetadata: @pytest.mark.usefixtures("patch_current_account") class TestEndpointEnableApi: - def test_enable_success(self, app): + def test_enable_success(self, app: Flask): api = EndpointEnableApi() method = unwrap(api.post) @@ -394,7 +395,7 @@ class TestEndpointEnableApi: assert result["success"] is True - def test_enable_invalid_payload(self, app): + def test_enable_invalid_payload(self, app: Flask): api = EndpointEnableApi() method = unwrap(api.post) @@ -404,7 +405,7 @@ class TestEndpointEnableApi: with pytest.raises(ValueError): method(api) - def test_enable_service_failure(self, app): + def test_enable_service_failure(self, app: Flask): api = EndpointEnableApi() method = unwrap(api.post) @@ -421,7 +422,7 @@ class TestEndpointEnableApi: @pytest.mark.usefixtures("patch_current_account") class TestEndpointDisableApi: - def test_disable_success(self, app): + def test_disable_success(self, app: Flask): api = EndpointDisableApi() method = unwrap(api.post) @@ -435,7 +436,7 @@ class TestEndpointDisableApi: assert result["success"] is True - def test_disable_invalid_payload(self, app): + def test_disable_invalid_payload(self, app: Flask): api = EndpointDisableApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_members.py b/api/tests/unit_tests/controllers/console/workspace/test_members.py index 718b57ba6b..0788ff603c 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_members.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.exceptions import HTTPException import services @@ -34,7 +35,7 @@ def unwrap(func): class TestMemberListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = MemberListApi() method = unwrap(api.get) @@ -59,7 +60,7 @@ class TestMemberListApi: assert status == 200 assert len(result["accounts"]) == 1 - def test_get_no_tenant(self, app): + def test_get_no_tenant(self, app: Flask): api = MemberListApi() method = unwrap(api.get) @@ -74,7 +75,7 @@ class TestMemberListApi: class TestMemberInviteEmailApi: - def test_invite_success(self, app): + def test_invite_success(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) @@ -101,7 +102,7 @@ class TestMemberInviteEmailApi: assert status == 201 assert result["result"] == "success" - def test_invite_limit_exceeded(self, app): + def test_invite_limit_exceeded(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) @@ -123,7 +124,7 @@ class TestMemberInviteEmailApi: with pytest.raises(WorkspaceMembersLimitExceeded): method(api) - def test_invite_already_member(self, app): + def test_invite_already_member(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) @@ -151,7 +152,7 @@ class TestMemberInviteEmailApi: assert result["invitation_results"][0]["status"] == "success" - def test_invite_invalid_role(self, app): + def test_invite_invalid_role(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) @@ -166,7 +167,7 @@ class TestMemberInviteEmailApi: assert status == 400 assert result["code"] == "invalid-role" - def test_invite_generic_exception(self, app): + def test_invite_generic_exception(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) @@ -196,7 +197,7 @@ class TestMemberInviteEmailApi: class TestMemberCancelInviteApi: - def test_cancel_success(self, app): + def test_cancel_success(self, app: Flask): api = MemberCancelInviteApi() method = unwrap(api.delete) @@ -216,7 +217,7 @@ class TestMemberCancelInviteApi: assert status == 200 assert result["result"] == "success" - def test_cancel_not_found(self, app): + def test_cancel_not_found(self, app: Flask): api = MemberCancelInviteApi() method = unwrap(api.delete) @@ -233,7 +234,7 @@ class TestMemberCancelInviteApi: with pytest.raises(HTTPException): method(api, "x") - def test_cancel_cannot_operate_self(self, app): + def test_cancel_cannot_operate_self(self, app: Flask): api = MemberCancelInviteApi() method = unwrap(api.delete) @@ -255,7 +256,7 @@ class TestMemberCancelInviteApi: assert status == 400 - def test_cancel_no_permission(self, app): + def test_cancel_no_permission(self, app: Flask): api = MemberCancelInviteApi() method = unwrap(api.delete) @@ -277,7 +278,7 @@ class TestMemberCancelInviteApi: assert status == 403 - def test_cancel_member_not_in_tenant(self, app): + def test_cancel_member_not_in_tenant(self, app: Flask): api = MemberCancelInviteApi() method = unwrap(api.delete) @@ -301,7 +302,7 @@ class TestMemberCancelInviteApi: class TestMemberUpdateRoleApi: - def test_update_success(self, app): + def test_update_success(self, app: Flask): api = MemberUpdateRoleApi() method = unwrap(api.put) @@ -324,7 +325,7 @@ class TestMemberUpdateRoleApi: assert result["result"] == "success" - def test_update_invalid_role(self, app): + def test_update_invalid_role(self, app: Flask): api = MemberUpdateRoleApi() method = unwrap(api.put) @@ -335,7 +336,7 @@ class TestMemberUpdateRoleApi: assert status == 400 - def test_update_member_not_found(self, app): + def test_update_member_not_found(self, app: Flask): api = MemberUpdateRoleApi() method = unwrap(api.put) @@ -354,7 +355,7 @@ class TestMemberUpdateRoleApi: class TestDatasetOperatorMemberListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = DatasetOperatorMemberListApi() method = unwrap(api.get) @@ -381,7 +382,7 @@ class TestDatasetOperatorMemberListApi: assert status == 200 assert len(result["accounts"]) == 1 - def test_get_no_tenant(self, app): + def test_get_no_tenant(self, app: Flask): api = DatasetOperatorMemberListApi() method = unwrap(api.get) @@ -396,7 +397,7 @@ class TestDatasetOperatorMemberListApi: class TestSendOwnerTransferEmailApi: - def test_send_success(self, app): + def test_send_success(self, app: Flask): api = SendOwnerTransferEmailApi() method = unwrap(api.post) @@ -419,7 +420,7 @@ class TestSendOwnerTransferEmailApi: assert result["result"] == "success" - def test_send_ip_limit(self, app): + def test_send_ip_limit(self, app: Flask): api = SendOwnerTransferEmailApi() method = unwrap(api.post) @@ -433,7 +434,7 @@ class TestSendOwnerTransferEmailApi: with pytest.raises(EmailSendIpLimitError): method(api) - def test_send_not_owner(self, app): + def test_send_not_owner(self, app: Flask): api = SendOwnerTransferEmailApi() method = unwrap(api.post) @@ -452,7 +453,7 @@ class TestSendOwnerTransferEmailApi: class TestOwnerTransferCheckApi: - def test_check_invalid_code(self, app): + def test_check_invalid_code(self, app: Flask): api = OwnerTransferCheckApi() method = unwrap(api.post) @@ -477,7 +478,7 @@ class TestOwnerTransferCheckApi: with pytest.raises(EmailCodeError): method(api) - def test_rate_limited(self, app): + def test_rate_limited(self, app: Flask): api = OwnerTransferCheckApi() method = unwrap(api.post) @@ -498,7 +499,7 @@ class TestOwnerTransferCheckApi: with pytest.raises(OwnerTransferLimitError): method(api) - def test_invalid_token(self, app): + def test_invalid_token(self, app: Flask): api = OwnerTransferCheckApi() method = unwrap(api.post) @@ -520,7 +521,7 @@ class TestOwnerTransferCheckApi: with pytest.raises(InvalidTokenError): method(api) - def test_invalid_email(self, app): + def test_invalid_email(self, app: Flask): api = OwnerTransferCheckApi() method = unwrap(api.post) @@ -547,7 +548,7 @@ class TestOwnerTransferCheckApi: class TestOwnerTransferApi: - def test_transfer_self(self, app): + def test_transfer_self(self, app: Flask): api = OwnerTransfer() method = unwrap(api.post) @@ -564,7 +565,7 @@ class TestOwnerTransferApi: with pytest.raises(CannotTransferOwnerToSelfError): method(api, "1") - def test_invalid_token(self, app): + def test_invalid_token(self, app: Flask): api = OwnerTransfer() method = unwrap(api.post) @@ -582,7 +583,7 @@ class TestOwnerTransferApi: with pytest.raises(InvalidTokenError): method(api, "2") - def test_member_not_in_tenant(self, app): + def test_member_not_in_tenant(self, app: Flask): api = OwnerTransfer() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py index 168479af1e..e836a3cc55 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_model_providers.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from flask import Flask from pydantic_core import ValidationError from werkzeug.exceptions import Forbidden @@ -26,7 +27,7 @@ def unwrap(func): class TestModelProviderListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = ModelProviderListApi() method = unwrap(api.get) @@ -47,7 +48,7 @@ class TestModelProviderListApi: class TestModelProviderCredentialApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.get) @@ -66,7 +67,7 @@ class TestModelProviderCredentialApi: assert "credentials" in result - def test_get_invalid_uuid(self, app): + def test_get_invalid_uuid(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.get) @@ -80,7 +81,7 @@ class TestModelProviderCredentialApi: with pytest.raises(ValidationError): method(api, provider="openai") - def test_post_create_success(self, app): + def test_post_create_success(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.post) @@ -102,7 +103,7 @@ class TestModelProviderCredentialApi: assert result["result"] == "success" assert status == 201 - def test_post_create_validation_error(self, app): + def test_post_create_validation_error(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.post) @@ -122,7 +123,7 @@ class TestModelProviderCredentialApi: with pytest.raises(ValueError): method(api, provider="openai") - def test_put_update_success(self, app): + def test_put_update_success(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.put) @@ -143,7 +144,7 @@ class TestModelProviderCredentialApi: assert result["result"] == "success" - def test_put_invalid_uuid(self, app): + def test_put_invalid_uuid(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.put) @@ -159,7 +160,7 @@ class TestModelProviderCredentialApi: with pytest.raises(ValidationError): method(api, provider="openai") - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = ModelProviderCredentialApi() method = unwrap(api.delete) @@ -183,7 +184,7 @@ class TestModelProviderCredentialApi: class TestModelProviderCredentialSwitchApi: - def test_switch_success(self, app): + def test_switch_success(self, app: Flask): api = ModelProviderCredentialSwitchApi() method = unwrap(api.post) @@ -204,7 +205,7 @@ class TestModelProviderCredentialSwitchApi: assert result["result"] == "success" - def test_switch_invalid_uuid(self, app): + def test_switch_invalid_uuid(self, app: Flask): api = ModelProviderCredentialSwitchApi() method = unwrap(api.post) @@ -222,7 +223,7 @@ class TestModelProviderCredentialSwitchApi: class TestModelProviderValidateApi: - def test_validate_success(self, app): + def test_validate_success(self, app: Flask): api = ModelProviderValidateApi() method = unwrap(api.post) @@ -243,7 +244,7 @@ class TestModelProviderValidateApi: assert result["result"] == "success" - def test_validate_failure(self, app): + def test_validate_failure(self, app: Flask): api = ModelProviderValidateApi() method = unwrap(api.post) @@ -266,7 +267,7 @@ class TestModelProviderValidateApi: class TestModelProviderIconApi: - def test_icon_success(self, app): + def test_icon_success(self, app: Flask): api = ModelProviderIconApi() with ( @@ -280,7 +281,7 @@ class TestModelProviderIconApi: assert response.mimetype == "image/png" - def test_icon_not_found(self, app): + def test_icon_not_found(self, app: Flask): api = ModelProviderIconApi() with ( @@ -295,7 +296,7 @@ class TestModelProviderIconApi: class TestPreferredProviderTypeUpdateApi: - def test_update_success(self, app): + def test_update_success(self, app: Flask): api = PreferredProviderTypeUpdateApi() method = unwrap(api.post) @@ -316,7 +317,7 @@ class TestPreferredProviderTypeUpdateApi: assert result["result"] == "success" - def test_invalid_enum(self, app): + def test_invalid_enum(self, app: Flask): api = PreferredProviderTypeUpdateApi() method = unwrap(api.post) @@ -334,7 +335,7 @@ class TestPreferredProviderTypeUpdateApi: class TestModelProviderPaymentCheckoutUrlApi: - def test_checkout_success(self, app): + def test_checkout_success(self, app: Flask): api = ModelProviderPaymentCheckoutUrlApi() method = unwrap(api.get) @@ -359,7 +360,7 @@ class TestModelProviderPaymentCheckoutUrlApi: assert "url" in result - def test_invalid_provider(self, app): + def test_invalid_provider(self, app: Flask): api = ModelProviderPaymentCheckoutUrlApi() method = unwrap(api.get) @@ -367,7 +368,7 @@ class TestModelProviderPaymentCheckoutUrlApi: with pytest.raises(ValueError): method(api, provider="openai") - def test_permission_denied(self, app): + def test_permission_denied(self, app: Flask): api = ModelProviderPaymentCheckoutUrlApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_models.py b/api/tests/unit_tests/controllers/console/workspace/test_models.py index f0d32f81fb..3c4acbab44 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_models.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_models.py @@ -32,7 +32,7 @@ class TestDefaultModelApi: with ( app.test_request_context( "/", - query_string={"model_type": ModelType.LLM.value}, + query_string={"model_type": ModelType.LLM}, ), patch( "controllers.console.workspace.models.current_account_with_tenant", @@ -53,7 +53,7 @@ class TestDefaultModelApi: payload = { "model_settings": [ { - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "provider": "openai", "model": "gpt-4", } @@ -72,12 +72,12 @@ class TestDefaultModelApi: assert result["result"] == "success" - def test_get_returns_empty_when_no_default(self, app): + def test_get_returns_empty_when_no_default(self, app: Flask): api = DefaultModelApi() method = unwrap(api.get) with ( - app.test_request_context("/", query_string={"model_type": ModelType.LLM.value}), + app.test_request_context("/", query_string={"model_type": ModelType.LLM}), patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")), patch("controllers.console.workspace.models.ModelProviderService") as service, ): @@ -113,7 +113,7 @@ class TestModelProviderModelApi: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "load_balancing": { "configs": [{"weight": 1}], "enabled": True, @@ -139,7 +139,7 @@ class TestModelProviderModelApi: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, } with ( @@ -154,7 +154,7 @@ class TestModelProviderModelApi: assert status == 204 - def test_get_models_returns_empty(self, app): + def test_get_models_returns_empty(self, app: Flask): api = ModelProviderModelApi() method = unwrap(api.get) @@ -180,7 +180,7 @@ class TestModelProviderModelCredentialApi: "/", query_string={ "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, }, ), patch( @@ -208,7 +208,7 @@ class TestModelProviderModelCredentialApi: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "credentials": {"key": "val"}, } @@ -224,12 +224,12 @@ class TestModelProviderModelCredentialApi: assert status == 201 - def test_get_empty_credentials(self, app): + def test_get_empty_credentials(self, app: Flask): api = ModelProviderModelCredentialApi() method = unwrap(api.get) with ( - app.test_request_context("/", query_string={"model": "gpt", "model_type": ModelType.LLM.value}), + app.test_request_context("/", query_string={"model": "gpt", "model_type": ModelType.LLM}), patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")), patch("controllers.console.workspace.models.ModelProviderService") as service, patch("controllers.console.workspace.models.ModelLoadBalancingService") as lb, @@ -242,13 +242,13 @@ class TestModelProviderModelCredentialApi: assert result["credentials"] == {} - def test_delete_success(self, app): + def test_delete_success(self, app: Flask): api = ModelProviderModelCredentialApi() method = unwrap(api.delete) payload = { "model": "gpt", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "credential_id": "123e4567-e89b-12d3-a456-426614174000", } @@ -269,7 +269,7 @@ class TestModelProviderModelCredentialSwitchApi: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "credential_id": "abc", } @@ -293,7 +293,7 @@ class TestModelEnableDisableApis: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, } with ( @@ -314,7 +314,7 @@ class TestModelEnableDisableApis: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, } with ( @@ -337,7 +337,7 @@ class TestModelProviderModelValidateApi: payload = { "model": "gpt-4", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "credentials": {"key": "val"}, } @@ -360,7 +360,7 @@ class TestModelProviderModelValidateApi: payload = { "model": model_name, - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "credentials": {}, } @@ -412,11 +412,11 @@ class TestParameterAndAvailableModels: ): service_mock.return_value.get_models_by_model_type.return_value = [] - result = method(api, ModelType.LLM.value) + result = method(api, ModelType.LLM) assert "data" in result - def test_empty_rules(self, app): + def test_empty_rules(self, app: Flask): api = ModelProviderModelParameterRuleApi() method = unwrap(api.get) @@ -431,7 +431,7 @@ class TestParameterAndAvailableModels: assert result["data"] == [] - def test_no_models(self, app): + def test_no_models(self, app: Flask): api = ModelProviderAvailableModelApi() method = unwrap(api.get) @@ -442,6 +442,6 @@ class TestParameterAndAvailableModels: ): service.return_value.get_models_by_model_type.return_value = [] - result = method(api, ModelType.LLM.value) + result = method(api, ModelType.LLM) assert result["data"] == [] diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index ce5fd1c466..83915a0b74 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -2,6 +2,7 @@ import io from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden @@ -61,7 +62,7 @@ def tenant(): class TestPluginListLatestVersionsApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginListLatestVersionsApi() method = unwrap(api.post) @@ -77,7 +78,7 @@ class TestPluginListLatestVersionsApi: assert "versions" in result - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginListLatestVersionsApi() method = unwrap(api.post) @@ -95,7 +96,7 @@ class TestPluginListLatestVersionsApi: class TestPluginDebuggingKeyApi: - def test_debugging_key_success(self, app): + def test_debugging_key_success(self, app: Flask): api = PluginDebuggingKeyApi() method = unwrap(api.get) @@ -108,7 +109,7 @@ class TestPluginDebuggingKeyApi: assert result["key"] == "k" - def test_debugging_key_error(self, app): + def test_debugging_key_error(self, app: Flask): api = PluginDebuggingKeyApi() method = unwrap(api.get) @@ -125,7 +126,7 @@ class TestPluginDebuggingKeyApi: class TestPluginListApi: - def test_plugin_list(self, app): + def test_plugin_list(self, app: Flask): api = PluginListApi() method = unwrap(api.get) @@ -142,7 +143,7 @@ class TestPluginListApi: class TestPluginIconApi: - def test_plugin_icon(self, app): + def test_plugin_icon(self, app: Flask): api = PluginIconApi() method = unwrap(api.get) @@ -156,7 +157,7 @@ class TestPluginIconApi: class TestPluginAssetApi: - def test_plugin_asset(self, app): + def test_plugin_asset(self, app: Flask): api = PluginAssetApi() method = unwrap(api.get) @@ -171,7 +172,7 @@ class TestPluginAssetApi: class TestPluginUploadFromPkgApi: - def test_upload_pkg_success(self, app): + def test_upload_pkg_success(self, app: Flask): api = PluginUploadFromPkgApi() method = unwrap(api.post) @@ -188,7 +189,7 @@ class TestPluginUploadFromPkgApi: assert result["ok"] is True - def test_upload_pkg_too_large(self, app): + def test_upload_pkg_too_large(self, app: Flask): api = PluginUploadFromPkgApi() method = unwrap(api.post) @@ -210,7 +211,7 @@ class TestPluginUploadFromPkgApi: class TestPluginInstallFromPkgApi: - def test_install_from_pkg(self, app): + def test_install_from_pkg(self, app: Flask): api = PluginInstallFromPkgApi() method = unwrap(api.post) @@ -229,7 +230,7 @@ class TestPluginInstallFromPkgApi: class TestPluginUninstallApi: - def test_uninstall(self, app): + def test_uninstall(self, app: Flask): api = PluginUninstallApi() method = unwrap(api.post) @@ -246,7 +247,7 @@ class TestPluginUninstallApi: class TestPluginChangePermissionApi: - def test_change_permission_forbidden(self, app): + def test_change_permission_forbidden(self, app: Flask): api = PluginChangePermissionApi() method = unwrap(api.post) @@ -264,7 +265,7 @@ class TestPluginChangePermissionApi: with pytest.raises(Forbidden): method(api) - def test_change_permission_success(self, app): + def test_change_permission_success(self, app: Flask): api = PluginChangePermissionApi() method = unwrap(api.post) @@ -286,7 +287,7 @@ class TestPluginChangePermissionApi: class TestPluginFetchPermissionApi: - def test_fetch_permission_default(self, app): + def test_fetch_permission_default(self, app: Flask): api = PluginFetchPermissionApi() method = unwrap(api.get) @@ -301,7 +302,7 @@ class TestPluginFetchPermissionApi: class TestPluginFetchDynamicSelectOptionsApi: - def test_fetch_dynamic_options(self, app, user): + def test_fetch_dynamic_options(self, app: Flask, user): api = PluginFetchDynamicSelectOptionsApi() method = unwrap(api.get) @@ -319,7 +320,7 @@ class TestPluginFetchDynamicSelectOptionsApi: class TestPluginReadmeApi: - def test_fetch_readme(self, app): + def test_fetch_readme(self, app: Flask): api = PluginReadmeApi() method = unwrap(api.get) @@ -334,7 +335,7 @@ class TestPluginReadmeApi: class TestPluginListInstallationsFromIdsApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginListInstallationsFromIdsApi() method = unwrap(api.post) @@ -352,7 +353,7 @@ class TestPluginListInstallationsFromIdsApi: assert "plugins" in result - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginListInstallationsFromIdsApi() method = unwrap(api.post) @@ -371,7 +372,7 @@ class TestPluginListInstallationsFromIdsApi: class TestPluginUploadFromGithubApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginUploadFromGithubApi() method = unwrap(api.post) @@ -388,7 +389,7 @@ class TestPluginUploadFromGithubApi: assert result["ok"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginUploadFromGithubApi() method = unwrap(api.post) @@ -407,7 +408,7 @@ class TestPluginUploadFromGithubApi: class TestPluginUploadFromBundleApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginUploadFromBundleApi() method = unwrap(api.post) @@ -430,7 +431,7 @@ class TestPluginUploadFromBundleApi: assert result["ok"] is True - def test_too_large(self, app): + def test_too_large(self, app: Flask): api = PluginUploadFromBundleApi() method = unwrap(api.post) @@ -458,7 +459,7 @@ class TestPluginUploadFromBundleApi: class TestPluginInstallFromGithubApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginInstallFromGithubApi() method = unwrap(api.post) @@ -478,7 +479,7 @@ class TestPluginInstallFromGithubApi: assert result["ok"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginInstallFromGithubApi() method = unwrap(api.post) @@ -502,7 +503,7 @@ class TestPluginInstallFromGithubApi: class TestPluginInstallFromMarketplaceApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginInstallFromMarketplaceApi() method = unwrap(api.post) @@ -520,7 +521,7 @@ class TestPluginInstallFromMarketplaceApi: assert result["ok"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginInstallFromMarketplaceApi() method = unwrap(api.post) @@ -539,7 +540,7 @@ class TestPluginInstallFromMarketplaceApi: class TestPluginFetchMarketplacePkgApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginFetchMarketplacePkgApi() method = unwrap(api.get) @@ -552,7 +553,7 @@ class TestPluginFetchMarketplacePkgApi: assert "manifest" in result - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginFetchMarketplacePkgApi() method = unwrap(api.get) @@ -569,7 +570,7 @@ class TestPluginFetchMarketplacePkgApi: class TestPluginFetchManifestApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginFetchManifestApi() method = unwrap(api.get) @@ -585,7 +586,7 @@ class TestPluginFetchManifestApi: assert "manifest" in result - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginFetchManifestApi() method = unwrap(api.get) @@ -602,7 +603,7 @@ class TestPluginFetchManifestApi: class TestPluginFetchInstallTasksApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginFetchInstallTasksApi() method = unwrap(api.get) @@ -615,7 +616,7 @@ class TestPluginFetchInstallTasksApi: assert "tasks" in result - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginFetchInstallTasksApi() method = unwrap(api.get) @@ -632,7 +633,7 @@ class TestPluginFetchInstallTasksApi: class TestPluginFetchInstallTaskApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginFetchInstallTaskApi() method = unwrap(api.get) @@ -645,7 +646,7 @@ class TestPluginFetchInstallTaskApi: assert "task" in result - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginFetchInstallTaskApi() method = unwrap(api.get) @@ -662,7 +663,7 @@ class TestPluginFetchInstallTaskApi: class TestPluginDeleteInstallTaskApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginDeleteInstallTaskApi() method = unwrap(api.post) @@ -675,7 +676,7 @@ class TestPluginDeleteInstallTaskApi: assert result["success"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginDeleteInstallTaskApi() method = unwrap(api.post) @@ -692,7 +693,7 @@ class TestPluginDeleteInstallTaskApi: class TestPluginDeleteAllInstallTaskItemsApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginDeleteAllInstallTaskItemsApi() method = unwrap(api.post) @@ -707,7 +708,7 @@ class TestPluginDeleteAllInstallTaskItemsApi: assert result["success"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginDeleteAllInstallTaskItemsApi() method = unwrap(api.post) @@ -724,7 +725,7 @@ class TestPluginDeleteAllInstallTaskItemsApi: class TestPluginDeleteInstallTaskItemApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginDeleteInstallTaskItemApi() method = unwrap(api.post) @@ -737,7 +738,7 @@ class TestPluginDeleteInstallTaskItemApi: assert result["success"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginDeleteInstallTaskItemApi() method = unwrap(api.post) @@ -754,7 +755,7 @@ class TestPluginDeleteInstallTaskItemApi: class TestPluginUpgradeFromMarketplaceApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginUpgradeFromMarketplaceApi() method = unwrap(api.post) @@ -775,7 +776,7 @@ class TestPluginUpgradeFromMarketplaceApi: assert result["ok"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginUpgradeFromMarketplaceApi() method = unwrap(api.post) @@ -797,7 +798,7 @@ class TestPluginUpgradeFromMarketplaceApi: class TestPluginUpgradeFromGithubApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginUpgradeFromGithubApi() method = unwrap(api.post) @@ -821,7 +822,7 @@ class TestPluginUpgradeFromGithubApi: assert result["ok"] is True - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginUpgradeFromGithubApi() method = unwrap(api.post) @@ -846,7 +847,7 @@ class TestPluginUpgradeFromGithubApi: class TestPluginFetchDynamicSelectOptionsWithCredentialsApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginFetchDynamicSelectOptionsWithCredentialsApi() method = unwrap(api.post) @@ -873,7 +874,7 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi: assert result["options"] == [1] - def test_daemon_error(self, app): + def test_daemon_error(self, app: Flask): api = PluginFetchDynamicSelectOptionsWithCredentialsApi() method = unwrap(api.post) @@ -901,7 +902,7 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi: class TestPluginChangePreferencesApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginChangePreferencesApi() method = unwrap(api.post) @@ -931,7 +932,7 @@ class TestPluginChangePreferencesApi: assert result["success"] is True - def test_permission_fail(self, app): + def test_permission_fail(self, app: Flask): api = PluginChangePreferencesApi() method = unwrap(api.post) @@ -962,7 +963,7 @@ class TestPluginChangePreferencesApi: class TestPluginFetchPreferencesApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginFetchPreferencesApi() method = unwrap(api.get) @@ -996,7 +997,7 @@ class TestPluginFetchPreferencesApi: class TestPluginAutoUpgradeExcludePluginApi: - def test_success(self, app): + def test_success(self, app: Flask): api = PluginAutoUpgradeExcludePluginApi() method = unwrap(api.post) @@ -1011,7 +1012,7 @@ class TestPluginAutoUpgradeExcludePluginApi: assert result["success"] is True - def test_fail(self, app): + def test_fail(self, app: Flask): api = PluginAutoUpgradeExcludePluginApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index e82a29f045..0ee1877630 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -2,6 +2,7 @@ from io import BytesIO from unittest.mock import MagicMock, patch import pytest +from flask import Flask from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Unauthorized @@ -37,7 +38,7 @@ def unwrap(func): class TestTenantListApi: - def test_get_success_saas_path(self, app): + def test_get_success_saas_path(self, app: Flask): api = TenantListApi() method = unwrap(api.get) @@ -85,7 +86,7 @@ class TestTenantListApi: get_plan_bulk_mock.assert_called_once_with(["t1", "t2"]) get_features_mock.assert_not_called() - def test_get_saas_path_partial_fallback_does_not_gate_plan_on_billing_enabled(self, app): + def test_get_saas_path_partial_fallback_does_not_gate_plan_on_billing_enabled(self, app: Flask): """Bulk omits a tenant: resolve plan via subscription.plan only; billing.enabled is not used. billing.enabled is mocked False to prove the endpoint does not gate on it for this path @@ -140,7 +141,7 @@ class TestTenantListApi: get_plan_bulk_mock.assert_called_once_with(["t1", "t2"]) get_features_mock.assert_called_once_with("t2") - def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app): + def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app: Flask): """Test fallback to FeatureService when bulk billing returns empty result. BillingService.get_plan_bulk catches exceptions internally and returns empty dict, @@ -197,7 +198,7 @@ class TestTenantListApi: assert get_features_mock.call_count == 2 logger_warning_mock.assert_called_once() - def test_get_billing_disabled_community_path(self, app): + def test_get_billing_disabled_community_path(self, app: Flask): api = TenantListApi() method = unwrap(api.get) @@ -236,7 +237,7 @@ class TestTenantListApi: assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX get_features_mock.assert_called_once_with("t1") - def test_get_enterprise_only_skips_feature_service(self, app): + def test_get_enterprise_only_skips_feature_service(self, app: Flask): api = TenantListApi() method = unwrap(api.get) @@ -276,7 +277,7 @@ class TestTenantListApi: assert result["workspaces"][1]["current"] is True get_features_mock.assert_not_called() - def test_get_enterprise_only_with_empty_tenants(self, app): + def test_get_enterprise_only_with_empty_tenants(self, app: Flask): api = TenantListApi() method = unwrap(api.get) @@ -302,17 +303,12 @@ class TestTenantListApi: class TestWorkspaceListApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = WorkspaceListApi() method = unwrap(api.get) tenant = MagicMock(id="t1", name="T", status="active", created_at=naive_utc_now()) - - paginate_result = MagicMock( - items=[tenant], - has_next=False, - total=1, - ) + paginate_result = MagicMock(items=[tenant], has_next=False, total=1) with ( app.test_request_context("/all-workspaces", query_string={"page": 1, "limit": 20}), @@ -324,29 +320,16 @@ class TestWorkspaceListApi: assert result["total"] == 1 assert result["has_more"] is False - def test_get_has_next_true(self, app): + def test_get_has_next_true(self, app: Flask): api = WorkspaceListApi() method = unwrap(api.get) - tenant = MagicMock( - id="t1", - name="T", - status="active", - created_at=naive_utc_now(), - ) - - paginate_result = MagicMock( - items=[tenant], - has_next=True, - total=10, - ) + tenant = MagicMock(id="t1", name="T", status="active", created_at=naive_utc_now()) + paginate_result = MagicMock(items=[tenant], has_next=True, total=10) with ( app.test_request_context("/all-workspaces", query_string={"page": 1, "limit": 1}), - patch( - "controllers.console.workspace.workspace.db.paginate", - return_value=paginate_result, - ), + patch("controllers.console.workspace.workspace.db.paginate", return_value=paginate_result), ): result, status = method(api) @@ -355,7 +338,7 @@ class TestWorkspaceListApi: class TestTenantApi: - def test_post_active_tenant(self, app): + def test_post_active_tenant(self, app: Flask): api = TenantApi() method = unwrap(api.post) @@ -375,7 +358,7 @@ class TestTenantApi: assert status == 200 assert result["id"] == "t1" - def test_post_archived_with_switch(self, app): + def test_post_archived_with_switch(self, app: Flask): api = TenantApi() method = unwrap(api.post) @@ -397,7 +380,7 @@ class TestTenantApi: assert result["id"] == "new" - def test_post_archived_no_tenant(self, app): + def test_post_archived_no_tenant(self, app: Flask): api = TenantApi() method = unwrap(api.post) @@ -411,7 +394,7 @@ class TestTenantApi: with pytest.raises(Unauthorized): method(api) - def test_post_info_path(self, app): + def test_post_info_path(self, app: Flask): api = TenantApi() method = unwrap(api.post) @@ -454,7 +437,7 @@ class TestTenantInfoResponse: class TestSwitchWorkspaceApi: - def test_switch_success(self, app): + def test_switch_success(self, app: Flask): api = SwitchWorkspaceApi() method = unwrap(api.post) @@ -477,7 +460,7 @@ class TestSwitchWorkspaceApi: assert result["result"] == "success" - def test_switch_not_linked(self, app): + def test_switch_not_linked(self, app: Flask): api = SwitchWorkspaceApi() method = unwrap(api.post) @@ -493,7 +476,7 @@ class TestSwitchWorkspaceApi: with pytest.raises(AccountNotLinkTenantError): method(api) - def test_switch_tenant_not_found(self, app): + def test_switch_tenant_not_found(self, app: Flask): api = SwitchWorkspaceApi() method = unwrap(api.post) @@ -515,7 +498,7 @@ class TestSwitchWorkspaceApi: class TestCustomConfigWorkspaceApi: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = CustomConfigWorkspaceApi() method = unwrap(api.post) @@ -538,7 +521,7 @@ class TestCustomConfigWorkspaceApi: assert result["result"] == "success" - def test_logo_fallback(self, app): + def test_logo_fallback(self, app: Flask): api = CustomConfigWorkspaceApi() method = unwrap(api.post) @@ -569,7 +552,7 @@ class TestCustomConfigWorkspaceApi: class TestWebappLogoWorkspaceApi: - def test_no_file(self, app): + def test_no_file(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -582,7 +565,7 @@ class TestWebappLogoWorkspaceApi: with pytest.raises(NoFileUploadedError): method(api) - def test_too_many_files(self, app): + def test_too_many_files(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -601,7 +584,7 @@ class TestWebappLogoWorkspaceApi: with pytest.raises(TooManyFilesError): method(api) - def test_invalid_extension(self, app): + def test_invalid_extension(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -616,7 +599,7 @@ class TestWebappLogoWorkspaceApi: with pytest.raises(UnsupportedFileTypeError): method(api) - def test_upload_success(self, app): + def test_upload_success(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -648,7 +631,7 @@ class TestWebappLogoWorkspaceApi: assert status == 201 assert result["id"] == "file1" - def test_filename_missing(self, app): + def test_filename_missing(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -672,7 +655,7 @@ class TestWebappLogoWorkspaceApi: with pytest.raises(FilenameNotExistsError): method(api) - def test_file_too_large(self, app): + def test_file_too_large(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -701,7 +684,7 @@ class TestWebappLogoWorkspaceApi: with pytest.raises(FileTooLargeError): method(api) - def test_service_unsupported_file(self, app): + def test_service_unsupported_file(self, app: Flask): api = WebappLogoWorkspaceApi() method = unwrap(api.post) @@ -732,7 +715,7 @@ class TestWebappLogoWorkspaceApi: class TestWorkspaceInfoApi: - def test_post_success(self, app): + def test_post_success(self, app: Flask): api = WorkspaceInfoApi() method = unwrap(api.post) @@ -756,7 +739,7 @@ class TestWorkspaceInfoApi: assert result["result"] == "success" - def test_no_current_tenant(self, app): + def test_no_current_tenant(self, app: Flask): api = WorkspaceInfoApi() method = unwrap(api.post) @@ -774,7 +757,7 @@ class TestWorkspaceInfoApi: class TestWorkspacePermissionApi: - def test_get_success(self, app): + def test_get_success(self, app: Flask): api = WorkspacePermissionApi() method = unwrap(api.get) @@ -799,7 +782,7 @@ class TestWorkspacePermissionApi: assert status == 200 assert result["workspace_id"] == "t1" - def test_no_current_tenant(self, app): + def test_no_current_tenant(self, app: Flask): api = WorkspacePermissionApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/files/test_upload.py b/api/tests/unit_tests/controllers/files/test_upload.py index e8f3cd4b66..ff6ba0e9a1 100644 --- a/api/tests/unit_tests/controllers/files/test_upload.py +++ b/api/tests/unit_tests/controllers/files/test_upload.py @@ -1,3 +1,4 @@ +import io import types from unittest.mock import patch @@ -30,9 +31,10 @@ class DummyFile: self.filename = filename self.mimetype = mimetype self._content = content + self.stream = io.BytesIO(content) def read(self): - return self._content + return self.stream.read() class DummyToolFile: diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index d1b09c3a58..598677faff 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -189,7 +189,7 @@ class TestGetUserTenant: """Test get_user_tenant decorator""" @patch("controllers.inner_api.plugin.wraps.Tenant") - def test_should_inject_tenant_and_user_models(self, mock_tenant_class, app: Flask, monkeypatch): + def test_should_inject_tenant_and_user_models(self, mock_tenant_class, app: Flask, monkeypatch: pytest.MonkeyPatch): """Test that decorator injects tenant_model and user_model into kwargs""" # Arrange @@ -244,7 +244,9 @@ class TestGetUserTenant: protected_view() @patch("controllers.inner_api.plugin.wraps.Tenant") - def test_should_use_default_session_id_when_user_id_empty(self, mock_tenant_class, app: Flask, monkeypatch): + def test_should_use_default_session_id_when_user_id_empty( + self, mock_tenant_class, app: Flask, monkeypatch: pytest.MonkeyPatch + ): """Test that default session ID is used when user_id is empty string""" # Arrange diff --git a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py index b16ad38c7c..2ab5547cd4 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_annotation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_annotation.py @@ -17,6 +17,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from flask import Flask from flask_restx.api import HTTPStatus from controllers.service_api.app.annotation import ( @@ -163,7 +164,7 @@ class TestAnnotationErrorPatterns: class TestAnnotationReplyActionApi: - def test_enable(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_enable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: enable_mock = Mock() monkeypatch.setattr(AppAnnotationService, "enable_app_annotation", enable_mock) @@ -181,7 +182,7 @@ class TestAnnotationReplyActionApi: assert status == 200 enable_mock.assert_called_once() - def test_disable(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_disable(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: disable_mock = Mock() monkeypatch.setattr(AppAnnotationService, "disable_app_annotation", disable_mock) @@ -231,7 +232,7 @@ class TestAnnotationReplyActionStatusApi: class TestAnnotationListApi: - def test_get(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0) monkeypatch.setattr( AppAnnotationService, @@ -248,7 +249,7 @@ class TestAnnotationListApi: assert response["total"] == 1 - def test_create(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_create(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0) monkeypatch.setattr( AppAnnotationService, @@ -268,7 +269,7 @@ class TestAnnotationListApi: class TestAnnotationUpdateDeleteApi: - def test_update_delete(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_update_delete(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0) monkeypatch.setattr( AppAnnotationService, diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index f5d93b5ac3..ae0edcf382 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -41,7 +41,7 @@ class TestAppParameterApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") def test_get_parameters_for_chat_app( - self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, mock_app_model + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask, mock_app_model ): """Test retrieving parameters for a chat app.""" # Arrange @@ -91,7 +91,7 @@ class TestAppParameterApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") def test_get_parameters_for_workflow_app( - self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, mock_app_model + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask, mock_app_model ): """Test retrieving parameters for a workflow app.""" # Arrange @@ -136,7 +136,7 @@ class TestAppParameterApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") def test_get_parameters_raises_error_when_chat_config_missing( - self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, mock_app_model + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask, mock_app_model ): """Test that AppUnavailableError is raised when chat app has no config.""" # Arrange @@ -174,7 +174,7 @@ class TestAppParameterApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") def test_get_parameters_raises_error_when_workflow_missing( - self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, mock_app_model + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask, mock_app_model ): """Test that AppUnavailableError is raised when workflow app has no workflow.""" # Arrange @@ -234,7 +234,14 @@ class TestAppMetaApi: @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.app.app.AppService") def test_get_app_meta( - self, mock_app_service, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, mock_app_model + self, + mock_app_service, + mock_db, + mock_validate_token, + mock_current_app, + mock_user_logged_in, + app: Flask, + mock_app_model, ): """Test retrieving app metadata via AppService.""" # Arrange @@ -310,7 +317,7 @@ class TestAppInfoApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") def test_get_app_info( - self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, mock_app_model + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask, mock_app_model ): """Test retrieving basic app information.""" mock_current_app.login_manager = Mock() @@ -402,7 +409,9 @@ class TestAppInfoApi: @patch("controllers.service_api.wraps.current_app") @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") - def test_get_app_info_with_no_tags(self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app): + def test_get_app_info_with_no_tags( + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask + ): """Test retrieving app info when app has no tags.""" # Arrange mock_current_app.login_manager = Mock() @@ -453,7 +462,7 @@ class TestAppInfoApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.db") def test_get_app_info_returns_correct_mode( - self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app, app_mode + self, mock_db, mock_validate_token, mock_current_app, mock_user_logged_in, app: Flask, app_mode ): """Test that all app modes are correctly returned.""" # Arrange diff --git a/api/tests/unit_tests/controllers/service_api/app/test_audio.py b/api/tests/unit_tests/controllers/service_api/app/test_audio.py index c16ebad739..4741481ef6 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_audio.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_audio.py @@ -13,6 +13,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.datastructures import FileStorage from werkzeug.exceptions import InternalServerError @@ -190,7 +191,7 @@ class TestAudioServiceMockedBehavior: class TestAudioApi: - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "ok"}) api = AudioApi() handler = _unwrap(api.post) @@ -216,7 +217,7 @@ class TestAudioApi: (InvokeError("invoke"), CompletionRequestError), ], ) - def test_error_mapping(self, app, monkeypatch: pytest.MonkeyPatch, exc, expected) -> None: + def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch, exc, expected) -> None: monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(exc)) api = AudioApi() handler = _unwrap(api.post) @@ -227,7 +228,7 @@ class TestAudioApi: with pytest.raises(expected): handler(api, app_model=app_model, end_user=end_user) - def test_unhandled_error(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_unhandled_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")) ) @@ -242,7 +243,7 @@ class TestAudioApi: class TestTextApi: - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"}) api = TextApi() @@ -259,7 +260,7 @@ class TestTextApi: assert response == {"audio": "ok"} - def test_error_mapping(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError()) ) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index 3364c07e62..a60b3b18bd 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -16,6 +16,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from flask import Flask from pydantic import ValidationError from werkzeug.exceptions import BadRequest, NotFound @@ -295,7 +296,7 @@ class TestCompletionControllerLogic: @patch("controllers.service_api.app.completion.service_api_ns") @patch("controllers.service_api.app.completion.AppGenerateService") - def test_completion_api_post_success(self, mock_generate_service, mock_service_api_ns, app): + def test_completion_api_post_success(self, mock_generate_service, mock_service_api_ns, app: Flask): """Test CompletionApi.post success path.""" from controllers.service_api.app.completion import CompletionApi @@ -320,7 +321,7 @@ class TestCompletionControllerLogic: mock_generate_service.generate.assert_called_once() @patch("controllers.service_api.app.completion.service_api_ns") - def test_completion_api_post_wrong_app_mode(self, mock_service_api_ns, app): + def test_completion_api_post_wrong_app_mode(self, mock_service_api_ns, app: Flask): """Test CompletionApi.post with wrong app mode.""" from controllers.service_api.app.completion import CompletionApi @@ -334,7 +335,7 @@ class TestCompletionControllerLogic: @patch("controllers.service_api.app.completion.service_api_ns") @patch("controllers.service_api.app.completion.AppGenerateService") - def test_chat_api_post_success(self, mock_generate_service, mock_service_api_ns, app): + def test_chat_api_post_success(self, mock_generate_service, mock_service_api_ns, app: Flask): """Test ChatApi.post success path.""" from controllers.service_api.app.completion import ChatApi @@ -355,7 +356,7 @@ class TestCompletionControllerLogic: assert response == {"text": "compacted"} @patch("controllers.service_api.app.completion.service_api_ns") - def test_chat_api_post_wrong_app_mode(self, mock_service_api_ns, app): + def test_chat_api_post_wrong_app_mode(self, mock_service_api_ns, app: Flask): """Test ChatApi.post with wrong app mode.""" from controllers.service_api.app.completion import ChatApi @@ -368,7 +369,7 @@ class TestCompletionControllerLogic: ChatApi().post.__wrapped__(ChatApi(), mock_app_model, mock_end_user) @patch("controllers.service_api.app.completion.AppTaskService") - def test_completion_stop_api_success(self, mock_task_service, app): + def test_completion_stop_api_success(self, mock_task_service, app: Flask): """Test CompletionStopApi.post success.""" from controllers.service_api.app.completion import CompletionStopApi @@ -385,7 +386,7 @@ class TestCompletionControllerLogic: mock_task_service.stop_task.assert_called_once() @patch("controllers.service_api.app.completion.AppTaskService") - def test_chat_stop_api_success(self, mock_task_service, app): + def test_chat_stop_api_success(self, mock_task_service, app: Flask): """Test ChatStopApi.post success.""" from controllers.service_api.app.completion import ChatStopApi @@ -414,7 +415,7 @@ class TestChatRequestPayloadController: class TestCompletionApiController: - def test_wrong_mode(self, app) -> None: + def test_wrong_mode(self, app: Flask) -> None: api = CompletionApi() handler = _unwrap(api.post) app_model = SimpleNamespace(mode=AppMode.CHAT.value) @@ -424,7 +425,7 @@ class TestCompletionApiController: with pytest.raises(AppUnavailableError): handler(api, app_model=app_model, end_user=end_user) - def test_conversation_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_conversation_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -442,7 +443,7 @@ class TestCompletionApiController: class TestCompletionStopApiController: - def test_wrong_mode(self, app) -> None: + def test_wrong_mode(self, app: Flask) -> None: api = CompletionStopApi() handler = _unwrap(api.post) app_model = SimpleNamespace(mode=AppMode.CHAT.value) @@ -452,7 +453,7 @@ class TestCompletionStopApiController: with pytest.raises(AppUnavailableError): handler(api, app_model=app_model, end_user=end_user, task_id="t1") - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: stop_mock = Mock() monkeypatch.setattr(AppTaskService, "stop_task", stop_mock) @@ -469,7 +470,7 @@ class TestCompletionStopApiController: class TestChatApiController: - def test_wrong_mode(self, app) -> None: + def test_wrong_mode(self, app: Flask) -> None: api = ChatApi() handler = _unwrap(api.post) app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) @@ -479,7 +480,7 @@ class TestChatApiController: with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user) - def test_workflow_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_workflow_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -495,7 +496,7 @@ class TestChatApiController: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user) - def test_draft_workflow(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -513,10 +514,10 @@ class TestChatApiController: class TestChatStopApiController: - def test_wrong_mode(self, app) -> None: + def test_wrong_mode(self, app: Flask) -> None: api = ChatStopApi() handler = _unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) + app_model = SimpleNamespace(mode=AppMode.COMPLETION) end_user = SimpleNamespace(id="u1") with app.test_request_context("/chat-messages/1/stop", method="POST"): diff --git a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py index 4fb8ecf784..52a7aa2189 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_conversation.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_conversation.py @@ -20,6 +20,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import BadRequest, NotFound import services @@ -339,7 +340,7 @@ class TestConversationAppModeValidation: @pytest.mark.parametrize( "mode", [ - AppMode.CHAT.value, + AppMode.CHAT, AppMode.AGENT_CHAT.value, AppMode.ADVANCED_CHAT.value, ], @@ -364,7 +365,7 @@ class TestConversationAppModeValidation: app raises NotChatAppError. """ app = Mock(spec=App) - app.mode = AppMode.COMPLETION.value + app.mode = AppMode.COMPLETION app_mode = AppMode.value_of(app.mode) assert app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT} @@ -494,17 +495,17 @@ class TestConversationPayloadsController: class TestConversationApiController: - def test_list_not_chat(self, app) -> None: + def test_list_not_chat(self, app: Flask) -> None: api = ConversationApi() handler = _unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) + app_model = SimpleNamespace(mode=AppMode.COMPLETION) end_user = SimpleNamespace() with app.test_request_context("/conversations", method="GET"): with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user) - def test_list_last_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_list_last_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: class _BeginStub: def __enter__(self): return SimpleNamespace() @@ -530,7 +531,7 @@ class TestConversationApiController: api = ConversationApi() handler = _unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( @@ -542,17 +543,17 @@ class TestConversationApiController: class TestConversationDetailApiController: - def test_delete_not_chat(self, app) -> None: + def test_delete_not_chat(self, app: Flask) -> None: api = ConversationDetailApi() handler = _unwrap(api.delete) - app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) + app_model = SimpleNamespace(mode=AppMode.COMPLETION) end_user = SimpleNamespace() with app.test_request_context("/conversations/1", method="DELETE"): with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user, c_id="00000000-0000-0000-0000-000000000001") - def test_delete_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_delete_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( ConversationService, "delete", @@ -561,7 +562,7 @@ class TestConversationDetailApiController: api = ConversationDetailApi() handler = _unwrap(api.delete) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context("/conversations/1", method="DELETE"): @@ -570,7 +571,7 @@ class TestConversationDetailApiController: class TestConversationRenameApiController: - def test_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( ConversationService, "rename", @@ -579,7 +580,7 @@ class TestConversationRenameApiController: api = ConversationRenameApi() handler = _unwrap(api.post) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( @@ -592,17 +593,17 @@ class TestConversationRenameApiController: class TestConversationVariablesApiController: - def test_not_chat(self, app) -> None: + def test_not_chat(self, app: Flask) -> None: api = ConversationVariablesApi() handler = _unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) + app_model = SimpleNamespace(mode=AppMode.COMPLETION) end_user = SimpleNamespace() with app.test_request_context("/conversations/1/variables", method="GET"): with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user, c_id="00000000-0000-0000-0000-000000000001") - def test_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( ConversationService, "get_conversational_variable", @@ -611,7 +612,7 @@ class TestConversationVariablesApiController: api = ConversationVariablesApi() handler = _unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( @@ -621,7 +622,7 @@ class TestConversationVariablesApiController: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user, c_id="00000000-0000-0000-0000-000000000001") - def test_success_serializes_response(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success_serializes_response(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) monkeypatch.setattr( ConversationService, @@ -644,7 +645,7 @@ class TestConversationVariablesApiController: api = ConversationVariablesApi() handler = _unwrap(api.get) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( @@ -661,7 +662,7 @@ class TestConversationVariablesApiController: class TestConversationVariableDetailApiController: - def test_update_type_mismatch(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_update_type_mismatch(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( ConversationService, "update_conversation_variable", @@ -670,7 +671,7 @@ class TestConversationVariableDetailApiController: api = ConversationVariableDetailApi() handler = _unwrap(api.put) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( @@ -687,7 +688,7 @@ class TestConversationVariableDetailApiController: variable_id="00000000-0000-0000-0000-000000000002", ) - def test_update_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_update_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( ConversationService, "update_conversation_variable", @@ -696,7 +697,7 @@ class TestConversationVariableDetailApiController: api = ConversationVariableDetailApi() handler = _unwrap(api.put) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( @@ -713,7 +714,7 @@ class TestConversationVariableDetailApiController: variable_id="00000000-0000-0000-0000-000000000002", ) - def test_update_success_serializes_response(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_update_success_serializes_response(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) monkeypatch.setattr( ConversationService, @@ -730,7 +731,7 @@ class TestConversationVariableDetailApiController: api = ConversationVariableDetailApi() handler = _unwrap(api.put) - app_model = SimpleNamespace(mode=AppMode.CHAT.value) + app_model = SimpleNamespace(mode=AppMode.CHAT) end_user = SimpleNamespace() with app.test_request_context( diff --git a/api/tests/unit_tests/controllers/service_api/app/test_file.py b/api/tests/unit_tests/controllers/service_api/app/test_file.py index 7060bd79df..88ebe955a8 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_file.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_file.py @@ -16,6 +16,7 @@ import uuid from unittest.mock import Mock, patch import pytest +from flask import Flask from controllers.common.errors import ( FilenameNotExistsError, @@ -237,7 +238,7 @@ class TestFileApiPost: self, mock_db, mock_file_svc_cls, - app, + app: Flask, mock_app_model, mock_end_user, ): @@ -282,7 +283,7 @@ class TestFileApiPost: assert status == 201 mock_file_svc_cls.return_value.upload_file.assert_called_once() - def test_upload_no_file(self, app, mock_app_model, mock_end_user): + def test_upload_no_file(self, app: Flask, mock_app_model, mock_end_user): """Test NoFileUploadedError when no file in request.""" from controllers.service_api.app.file import FileApi @@ -296,7 +297,7 @@ class TestFileApiPost: with pytest.raises(NoFileUploadedError): _unwrap(api.post)(api, app_model=mock_app_model, end_user=mock_end_user) - def test_upload_too_many_files(self, app, mock_app_model, mock_end_user): + def test_upload_too_many_files(self, app: Flask, mock_app_model, mock_end_user): """Test TooManyFilesError when multiple files uploaded.""" from io import BytesIO @@ -317,7 +318,7 @@ class TestFileApiPost: with pytest.raises(TooManyFilesError): _unwrap(api.post)(api, app_model=mock_app_model, end_user=mock_end_user) - def test_upload_no_mimetype(self, app, mock_app_model, mock_end_user): + def test_upload_no_mimetype(self, app: Flask, mock_app_model, mock_end_user): """Test UnsupportedFileTypeError when file has no mimetype.""" from io import BytesIO @@ -341,7 +342,7 @@ class TestFileApiPost: self, mock_db, mock_file_svc_cls, - app, + app: Flask, mock_app_model, mock_end_user, ): @@ -373,7 +374,7 @@ class TestFileApiPost: self, mock_db, mock_file_svc_cls, - app, + app: Flask, mock_app_model, mock_end_user, ): diff --git a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py index d83c22f2cf..f5e8453c5c 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_file_preview.py @@ -66,7 +66,7 @@ class TestFilePreviewApi: return message def test_validate_file_ownership_success( - self, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message + self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message ): """Test successful file ownership validation""" file_id = str(uuid.uuid4()) @@ -97,7 +97,7 @@ class TestFilePreviewApi: assert result_message_file == mock_message_file assert result_upload_file == mock_upload_file - def test_validate_file_ownership_file_not_found(self, file_preview_api): + def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi): """Test file ownership validation when MessageFile not found""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) @@ -112,7 +112,7 @@ class TestFilePreviewApi: assert "File not found in message context" in str(exc_info.value) - def test_validate_file_ownership_access_denied(self, file_preview_api, mock_message_file): + def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, mock_message_file): """Test file ownership validation when Message not owned by app""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) @@ -130,7 +130,9 @@ class TestFilePreviewApi: assert "not owned by requesting app" in str(exc_info.value) - def test_validate_file_ownership_upload_file_not_found(self, file_preview_api, mock_message_file, mock_message): + def test_validate_file_ownership_upload_file_not_found( + self, file_preview_api: FilePreviewApi, mock_message_file, mock_message + ): """Test file ownership validation when UploadFile not found""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) @@ -151,7 +153,7 @@ class TestFilePreviewApi: assert "Upload file record not found" in str(exc_info.value) def test_validate_file_ownership_tenant_mismatch( - self, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message + self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message ): """Test file ownership validation with tenant mismatch""" file_id = str(uuid.uuid4()) @@ -182,7 +184,7 @@ class TestFilePreviewApi: assert "tenant mismatch" in str(exc_info.value) - def test_validate_file_ownership_invalid_input(self, file_preview_api): + def test_validate_file_ownership_invalid_input(self, file_preview_api: FilePreviewApi): """Test file ownership validation with invalid input""" # Test with empty file_id @@ -195,7 +197,7 @@ class TestFilePreviewApi: file_preview_api._validate_file_ownership("file_id", "") assert "Invalid file or app identifier" in str(exc_info.value) - def test_build_file_response_basic(self, file_preview_api, mock_upload_file): + def test_build_file_response_basic(self, file_preview_api: FilePreviewApi, mock_upload_file): """Test basic file response building""" mock_generator = Mock() @@ -207,7 +209,7 @@ class TestFilePreviewApi: assert response.headers["Content-Length"] == str(mock_upload_file.size) assert "Cache-Control" in response.headers - def test_build_file_response_as_attachment(self, file_preview_api, mock_upload_file): + def test_build_file_response_as_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file): """Test file response building with attachment flag""" mock_generator = Mock() @@ -218,7 +220,7 @@ class TestFilePreviewApi: assert mock_upload_file.name in response.headers["Content-Disposition"] assert response.headers["Content-Type"] == "application/octet-stream" - def test_build_file_response_html_forces_attachment(self, file_preview_api, mock_upload_file): + def test_build_file_response_html_forces_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file): """Test HTML files are forced to download""" mock_generator = Mock() mock_upload_file.mime_type = "text/html" @@ -231,7 +233,7 @@ class TestFilePreviewApi: assert response.headers["Content-Type"] == "application/octet-stream" assert response.headers["X-Content-Type-Options"] == "nosniff" - def test_build_file_response_audio_video(self, file_preview_api, mock_upload_file): + def test_build_file_response_audio_video(self, file_preview_api: FilePreviewApi, mock_upload_file): """Test file response building for audio/video files""" mock_generator = Mock() mock_upload_file.mime_type = "video/mp4" @@ -241,7 +243,7 @@ class TestFilePreviewApi: # Check Range support for media files assert response.headers["Accept-Ranges"] == "bytes" - def test_build_file_response_no_size(self, file_preview_api, mock_upload_file): + def test_build_file_response_no_size(self, file_preview_api: FilePreviewApi, mock_upload_file): """Test file response building when size is unknown""" mock_generator = Mock() mock_upload_file.size = 0 # Unknown size @@ -253,7 +255,14 @@ class TestFilePreviewApi: @patch("controllers.service_api.app.file_preview.storage") def test_get_method_integration( - self, mock_storage, file_preview_api, mock_app, mock_end_user, mock_upload_file, mock_message_file, mock_message + self, + mock_storage, + file_preview_api: FilePreviewApi, + mock_app, + mock_end_user, + mock_upload_file, + mock_message_file, + mock_message, ): """Test the full GET method integration (without decorator)""" file_id = str(uuid.uuid4()) @@ -295,7 +304,13 @@ class TestFilePreviewApi: @patch("controllers.service_api.app.file_preview.storage") def test_storage_error_handling( - self, mock_storage, file_preview_api, mock_app, mock_upload_file, mock_message_file, mock_message + self, + mock_storage, + file_preview_api: FilePreviewApi, + mock_app, + mock_upload_file, + mock_message_file, + mock_message, ): """Test storage error handling in the core logic""" file_id = str(uuid.uuid4()) @@ -334,7 +349,7 @@ class TestFilePreviewApi: assert "Storage error" in str(exc_info.value) @patch("controllers.service_api.app.file_preview.logger") - def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api): + def test_validate_file_ownership_unexpected_error_logging(self, mock_logger, file_preview_api: FilePreviewApi): """Test that unexpected errors are logged properly""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index 846d5368f3..ff668ac60a 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -11,6 +11,7 @@ from types import SimpleNamespace from unittest.mock import ANY, MagicMock, Mock import pytest +from flask import Flask import services.app_generate_service as ags_module from controllers.service_api.app.workflow_events import WorkflowEventsApi @@ -31,7 +32,7 @@ from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus -from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig from graphon.nodes.human_input.enums import FormInputType from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account @@ -248,7 +249,9 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: class TestHitlServiceApi: # Service API event-stream continuation - def test_workflow_events_continue_on_pause_keeps_stream_open(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_workflow_events_continue_on_pause_keeps_stream_open( + self, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: workflow_run = SimpleNamespace( id="run-1", app_id="app-1", @@ -281,7 +284,7 @@ class TestHitlServiceApi: workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) def test_workflow_events_snapshot_continue_on_pause_keeps_pause_open( - self, app, monkeypatch: pytest.MonkeyPatch + self, app: Flask, monkeypatch: pytest.MonkeyPatch ) -> None: workflow_run = SimpleNamespace( id="run-1", @@ -449,7 +452,7 @@ class TestHitlServiceApi: node_title="Approval", form_content="Need approval", inputs=[], - actions=[UserAction(id="approve", title="Approve")], + actions=[UserActionConfig(id="approve", title="Approve")], display_in_ui=True, form_token="token-1", resolved_default_values={}, @@ -590,9 +593,9 @@ class TestHitlServiceApi: form_id="form-1", form_content="Rendered", inputs=[ - FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="field", default=None), + ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None), ], - actions=[UserAction(id="approve", title="Approve")], + actions=[UserActionConfig(id="approve", title="Approve")], display_in_ui=True, node_id="node-id", node_title="Human Step", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py index 531f722ceb..5d1c4b4e26 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi @@ -17,7 +18,7 @@ from tests.unit_tests.controllers.service_api.conftest import _unwrap class TestWorkflowHumanInputFormApi: - def test_get_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: definition = SimpleNamespace( model_dump=lambda: { "rendered_content": "Rendered form content", @@ -57,7 +58,7 @@ class TestWorkflowHumanInputFormApi: service_mock.get_form_by_token.assert_called_once_with("token-1") service_mock.ensure_form_active.assert_called_once_with(form) - def test_get_form_not_in_app(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_get_form_not_in_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: form = SimpleNamespace( app_id="another-app", tenant_id="tenant-1", @@ -87,7 +88,7 @@ class TestWorkflowHumanInputFormApi: ], ) def test_get_rejects_non_service_api_recipient_types( - self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType + self, app: Flask, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType ) -> None: form = SimpleNamespace( app_id="app-1", @@ -111,7 +112,7 @@ class TestWorkflowHumanInputFormApi: service_mock.ensure_form_active.assert_not_called() - def test_post_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_post_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: form = SimpleNamespace( app_id="app-1", tenant_id="tenant-1", @@ -155,7 +156,7 @@ class TestWorkflowHumanInputFormApi: ], ) def test_post_rejects_non_service_api_recipient_types( - self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType + self, app: Flask, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType ) -> None: form = SimpleNamespace( app_id="app-1", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_message.py b/api/tests/unit_tests/controllers/service_api/app/test_message.py index c2b8aed1ae..d44e92ce66 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_message.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_message.py @@ -19,6 +19,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from controllers.service_api.app.error import NotChatAppError @@ -380,7 +381,7 @@ class TestMessageService: class TestMessageListApi: - def test_not_chat_app(self, app) -> None: + def test_not_chat_app(self, app: Flask) -> None: api = MessageListApi() handler = _unwrap(api.get) app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) @@ -390,7 +391,7 @@ class TestMessageListApi: with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user) - def test_conversation_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_conversation_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "pagination_by_first_id", @@ -409,7 +410,7 @@ class TestMessageListApi: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user) - def test_first_message_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_first_message_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "pagination_by_first_id", @@ -430,7 +431,7 @@ class TestMessageListApi: class TestMessageFeedbackApi: - def test_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "create_feedback", @@ -452,7 +453,7 @@ class TestMessageFeedbackApi: class TestAppGetFeedbacksApi: - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(MessageService, "get_all_messages_feedbacks", lambda *_args, **_kwargs: ["f1"]) api = AppGetFeedbacksApi() @@ -466,7 +467,7 @@ class TestAppGetFeedbacksApi: class TestMessageSuggestedApi: - def test_not_chat(self, app) -> None: + def test_not_chat(self, app: Flask) -> None: api = MessageSuggestedApi() handler = _unwrap(api.get) app_model = SimpleNamespace(mode=AppMode.COMPLETION.value) @@ -476,7 +477,7 @@ class TestMessageSuggestedApi: with pytest.raises(NotChatAppError): handler(api, app_model=app_model, end_user=end_user, message_id="m1") - def test_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "get_suggested_questions_after_answer", @@ -492,7 +493,7 @@ class TestMessageSuggestedApi: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user, message_id="m1") - def test_disabled(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_disabled(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "get_suggested_questions_after_answer", @@ -508,7 +509,7 @@ class TestMessageSuggestedApi: with pytest.raises(BadRequest): handler(api, app_model=app_model, end_user=end_user, message_id="m1") - def test_internal_error(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_internal_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "get_suggested_questions_after_answer", @@ -524,7 +525,7 @@ class TestMessageSuggestedApi: with pytest.raises(InternalServerError): handler(api, app_model=app_model, end_user=end_user, message_id="m1") - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( MessageService, "get_suggested_questions_after_answer", diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index da09ec13ce..7115ea1e12 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -20,6 +20,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import BadRequest, NotFound from controllers.service_api.app.error import NotWorkflowAppError @@ -366,7 +367,7 @@ class TestWorkflowRunRepository: class TestWorkflowRunDetailApi: - def test_not_workflow_app(self, app) -> None: + def test_not_workflow_app(self, app: Flask) -> None: api = WorkflowRunDetailApi() handler = _unwrap(api.get) app_model = SimpleNamespace(mode=AppMode.CHAT.value) @@ -397,7 +398,7 @@ class TestWorkflowRunDetailApi: class TestWorkflowRunApi: - def test_not_workflow_app(self, app) -> None: + def test_not_workflow_app(self, app: Flask) -> None: api = WorkflowRunApi() handler = _unwrap(api.post) app_model = SimpleNamespace(mode=AppMode.CHAT.value) @@ -407,7 +408,7 @@ class TestWorkflowRunApi: with pytest.raises(NotWorkflowAppError): handler(api, app_model=app_model, end_user=end_user) - def test_rate_limit(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_rate_limit(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -425,7 +426,7 @@ class TestWorkflowRunApi: class TestWorkflowRunByIdApi: - def test_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -441,7 +442,7 @@ class TestWorkflowRunByIdApi: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user, workflow_id="w1") - def test_draft_workflow(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AppGenerateService, "generate", @@ -459,7 +460,7 @@ class TestWorkflowRunByIdApi: class TestWorkflowTaskStopApi: - def test_wrong_mode(self, app) -> None: + def test_wrong_mode(self, app: Flask) -> None: api = WorkflowTaskStopApi() handler = _unwrap(api.post) app_model = SimpleNamespace(mode=AppMode.CHAT.value) @@ -469,7 +470,7 @@ class TestWorkflowTaskStopApi: with pytest.raises(NotWorkflowAppError): handler(api, app_model=app_model, end_user=end_user, task_id="t1") - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: stop_mock = Mock() send_mock = Mock() monkeypatch.setattr(AppQueueManager, "set_stop_flag_no_user_check", stop_mock) @@ -489,7 +490,7 @@ class TestWorkflowTaskStopApi: class TestWorkflowAppLogApi: - def test_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: class _BeginStub: def __enter__(self): return SimpleNamespace() @@ -557,7 +558,7 @@ class TestWorkflowRunDetailApiGet: self, mock_db, mock_repo_factory, - app, + app: Flask, mock_workflow_app, ): """Test successful workflow run detail retrieval.""" @@ -579,7 +580,7 @@ class TestWorkflowRunDetailApiGet: assert result["status"] == "succeeded" @patch("controllers.service_api.app.workflow.db") - def test_get_workflow_run_wrong_app_mode(self, mock_db, app): + def test_get_workflow_run_wrong_app_mode(self, mock_db, app: Flask): """Test NotWorkflowAppError when app mode is not workflow or advanced_chat.""" from controllers.service_api.app.workflow import WorkflowRunDetailApi @@ -604,7 +605,7 @@ class TestWorkflowTaskStopApiPost: self, mock_queue_mgr, mock_graph_mgr, - app, + app: Flask, mock_workflow_app, ): """Test successful workflow task stop.""" @@ -624,7 +625,7 @@ class TestWorkflowTaskStopApiPost: mock_graph_mgr.assert_called_once() mock_graph_mgr.return_value.send_stop_command.assert_called_once_with("task-1") - def test_stop_workflow_task_wrong_app_mode(self, app): + def test_stop_workflow_task_wrong_app_mode(self, app: Flask): """Test NotWorkflowAppError when app mode is not workflow.""" from controllers.service_api.app.workflow import WorkflowTaskStopApi @@ -649,7 +650,7 @@ class TestWorkflowAppLogApiGet: self, mock_db, mock_wf_svc_cls, - app, + app: Flask, mock_workflow_app, ): """Test successful workflow log retrieval.""" diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py index f45a7f9632..a1aca06570 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.service_api.app.error import NotWorkflowAppError @@ -31,7 +32,7 @@ def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): class TestWorkflowEventsApi: - def test_wrong_app_mode(self, app) -> None: + def test_wrong_app_mode(self, app: Flask) -> None: api = WorkflowEventsApi() handler = _unwrap(api.get) app_model = SimpleNamespace(mode=AppMode.CHAT.value) @@ -41,7 +42,7 @@ class TestWorkflowEventsApi: with pytest.raises(NotWorkflowAppError): handler(api, app_model=app_model, end_user=end_user, task_id="run-1") - def test_workflow_run_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_workflow_run_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: _mock_repo_for_run(monkeypatch, workflow_run=None) api = WorkflowEventsApi() handler = _unwrap(api.get) @@ -52,7 +53,7 @@ class TestWorkflowEventsApi: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user, task_id="run-1") - def test_workflow_run_permission_denied(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_workflow_run_permission_denied(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: workflow_run = SimpleNamespace( id="run-1", app_id="app-1", @@ -70,7 +71,7 @@ class TestWorkflowEventsApi: with pytest.raises(NotFound): handler(api, app_model=app_model, end_user=end_user, task_id="run-1") - def test_finished_run_returns_sse(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_finished_run_returns_sse(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: workflow_run = SimpleNamespace( id="run-1", app_id="app-1", @@ -103,7 +104,7 @@ class TestWorkflowEventsApi: assert payload["task_id"] == "run-1" assert payload["event"] == "workflow_finished" - def test_running_run_streams_events(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_running_run_streams_events(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: workflow_run = SimpleNamespace( id="run-1", app_id="app-1", @@ -135,7 +136,7 @@ class TestWorkflowEventsApi: ) workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) - def test_running_run_with_snapshot(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + def test_running_run_with_snapshot(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: workflow_run = SimpleNamespace( id="run-1", app_id="app-1", diff --git a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py index f33c482d04..362af883ed 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py @@ -23,6 +23,7 @@ from datetime import UTC, datetime from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden, NotFound @@ -373,7 +374,7 @@ class TestDatasourcePluginsApiGet: @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") - def test_get_plugins_success(self, mock_svc_cls, mock_db, app): + def test_get_plugins_success(self, mock_svc_cls, mock_db, app: Flask): """Test successful retrieval of datasource plugins.""" tenant_id = str(uuid.uuid4()) dataset_id = str(uuid.uuid4()) @@ -396,7 +397,7 @@ class TestDatasourcePluginsApiGet: ) @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") - def test_get_plugins_not_found(self, mock_db, app): + def test_get_plugins_not_found(self, mock_db, app: Flask): """Test NotFound when dataset check fails.""" mock_db.session.scalar.return_value = None @@ -407,7 +408,7 @@ class TestDatasourcePluginsApiGet: @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") - def test_get_plugins_empty_list(self, mock_svc_cls, mock_db, app): + def test_get_plugins_empty_list(self, mock_svc_cls, mock_db, app: Flask): """Test empty plugin list.""" mock_db.session.scalar.return_value = Mock() mock_svc_instance = Mock() @@ -439,7 +440,7 @@ class TestDatasourceNodeRunApiPost: @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_success(self, mock_ns, mock_db, mock_svc_cls, mock_current_user, mock_gen, mock_helper, app): + def test_post_success(self, mock_ns, mock_db, mock_svc_cls, mock_current_user, mock_gen, mock_helper, app: Flask): """Test successful datasource node run.""" tenant_id = str(uuid.uuid4()) dataset_id = str(uuid.uuid4()) @@ -473,7 +474,7 @@ class TestDatasourceNodeRunApiPost: mock_svc_instance.run_datasource_workflow_node.assert_called_once() @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") - def test_post_not_found(self, mock_db, app): + def test_post_not_found(self, mock_db, app: Flask): """Test NotFound when dataset check fails.""" mock_db.session.scalar.return_value = None @@ -488,7 +489,7 @@ class TestDatasourceNodeRunApiPost: ) @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_fails_when_current_user_not_account(self, mock_ns, mock_db, app): + def test_post_fails_when_current_user_not_account(self, mock_ns, mock_db, app: Flask): """Test AssertionError when current_user is not an Account instance.""" mock_db.session.scalar.return_value = Mock() mock_ns.payload = { @@ -549,7 +550,7 @@ class TestPipelineRunApiPost: mock_gen_svc.generate.assert_called_once() @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") - def test_post_not_found(self, mock_db, app): + def test_post_not_found(self, mock_db, app: Flask): """Test NotFound when dataset check fails.""" mock_db.session.scalar.return_value = None @@ -561,7 +562,7 @@ class TestPipelineRunApiPost: @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_forbidden_non_account_user(self, mock_ns, mock_db, app): + def test_post_forbidden_non_account_user(self, mock_ns, mock_db, app: Flask): """Test Forbidden when current_user is not an Account.""" mock_db.session.scalar.return_value = Mock() mock_ns.payload = { @@ -585,7 +586,7 @@ class TestFileUploadApiPost: @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.FileService") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.db") - def test_upload_success(self, mock_db, mock_current_user, mock_file_svc_cls, app): + def test_upload_success(self, mock_db, mock_current_user, mock_file_svc_cls, app: Flask): """Test successful file upload.""" mock_current_user.__bool__ = Mock(return_value=True) @@ -621,7 +622,7 @@ class TestFileUploadApiPost: assert response["name"] == "doc.pdf" assert response["extension"] == "pdf" - def test_upload_no_file(self, app): + def test_upload_no_file(self, app: Flask): """Test error when no file is uploaded.""" with app.test_request_context( "/datasets/pipeline/file-upload", diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py index e9c3e6d376..fe8fc02548 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_dataset_segment.py @@ -18,6 +18,7 @@ import uuid from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.service_api.dataset.segment import ( @@ -782,7 +783,7 @@ class TestSegmentApiGet: mock_seg_svc, mock_marshal, mock_summary_svc, - app, + app: Flask, mock_tenant, mock_dataset, mock_segment, @@ -893,7 +894,7 @@ class TestSegmentApiPost: mock_seg_svc, mock_marshal, mock_summary_svc, - app, + app: Flask, mock_tenant, mock_dataset, mock_segment, @@ -946,7 +947,7 @@ class TestSegmentApiPost: mock_db, mock_account_fn, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -989,7 +990,7 @@ class TestSegmentApiPost: mock_db, mock_account_fn, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1041,7 +1042,7 @@ class TestDatasetSegmentApiDelete: mock_doc_svc, mock_dataset_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, mock_segment, @@ -1086,7 +1087,7 @@ class TestDatasetSegmentApiDelete: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1128,7 +1129,7 @@ class TestDatasetSegmentApiDelete: mock_account_fn, mock_doc_svc, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1162,7 +1163,7 @@ class TestDatasetSegmentApiDelete: mock_account_fn, mock_dataset_svc, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1232,7 +1233,7 @@ class TestDatasetSegmentApiUpdate: mock_seg_svc, mock_marshal, mock_summary_svc, - app, + app: Flask, mock_tenant, mock_dataset, mock_segment, @@ -1282,7 +1283,7 @@ class TestDatasetSegmentApiUpdate: mock_account_fn, mock_dataset_svc, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1322,7 +1323,7 @@ class TestDatasetSegmentApiUpdate: mock_dataset_svc, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1374,7 +1375,7 @@ class TestDatasetSegmentApiGetSingle: mock_seg_svc, mock_marshal, mock_summary_svc, - app, + app: Flask, mock_tenant, mock_dataset, mock_segment, @@ -1421,7 +1422,7 @@ class TestDatasetSegmentApiGetSingle: mock_seg_svc, mock_marshal, mock_summary_svc, - app, + app: Flask, mock_tenant, mock_dataset, mock_segment, @@ -1460,7 +1461,7 @@ class TestDatasetSegmentApiGetSingle: self, mock_db, mock_account_fn, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1491,7 +1492,7 @@ class TestDatasetSegmentApiGetSingle: mock_account_fn, mock_dataset_svc, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1526,7 +1527,7 @@ class TestDatasetSegmentApiGetSingle: mock_dataset_svc, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1570,7 +1571,7 @@ class TestChildChunkApiGet: mock_doc_svc, mock_seg_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1609,7 +1610,7 @@ class TestChildChunkApiGet: self, mock_db, mock_account_fn, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1638,7 +1639,7 @@ class TestChildChunkApiGet: mock_db, mock_account_fn, mock_doc_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1670,7 +1671,7 @@ class TestChildChunkApiGet: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1729,7 +1730,7 @@ class TestChildChunkApiPost: mock_doc_svc, mock_seg_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1771,7 +1772,7 @@ class TestChildChunkApiPost: mock_feature_svc, mock_db, mock_account_fn, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1809,7 +1810,7 @@ class TestChildChunkApiPost: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1863,7 +1864,7 @@ class TestDatasetChildChunkApiDelete: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1913,7 +1914,7 @@ class TestDatasetChildChunkApiDelete: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1954,7 +1955,7 @@ class TestDatasetChildChunkApiDelete: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1994,7 +1995,7 @@ class TestDatasetChildChunkApiDelete: mock_account_fn, mock_doc_svc, mock_seg_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 230c51161f..61ec397193 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -19,6 +19,7 @@ import uuid from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden, NotFound from controllers.service_api.dataset.document import ( @@ -550,7 +551,7 @@ class TestDocumentApiGet: @patch("controllers.service_api.dataset.document.DatasetService") @patch("controllers.service_api.dataset.document.DocumentService") def test_get_document_success_with_all_metadata( - self, mock_doc_svc, mock_dataset_svc, app, mock_tenant, mock_doc_detail + self, mock_doc_svc, mock_dataset_svc, app: Flask, mock_tenant, mock_doc_detail ): """Test successful document retrieval with metadata='all'.""" # Arrange @@ -579,7 +580,7 @@ class TestDocumentApiGet: assert "doc_metadata" in response @patch("controllers.service_api.dataset.document.DocumentService") - def test_get_document_not_found(self, mock_doc_svc, app, mock_tenant): + def test_get_document_not_found(self, mock_doc_svc, app: Flask, mock_tenant): """Test 404 when document is not found.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -599,7 +600,7 @@ class TestDocumentApiGet: api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id="nonexistent") @patch("controllers.service_api.dataset.document.DocumentService") - def test_get_document_forbidden_wrong_tenant(self, mock_doc_svc, app, mock_tenant, mock_doc_detail): + def test_get_document_forbidden_wrong_tenant(self, mock_doc_svc, app: Flask, mock_tenant, mock_doc_detail): """Test 403 when document tenant doesn't match request tenant.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -620,7 +621,7 @@ class TestDocumentApiGet: api.get(tenant_id=mock_tenant.id, dataset_id=dataset_id, document_id=mock_doc_detail.id) @patch("controllers.service_api.dataset.document.DocumentService") - def test_get_document_metadata_only(self, mock_doc_svc, app, mock_tenant, mock_doc_detail): + def test_get_document_metadata_only(self, mock_doc_svc, app: Flask, mock_tenant, mock_doc_detail): """Test document retrieval with metadata='only'.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -647,7 +648,9 @@ class TestDocumentApiGet: @patch("controllers.service_api.dataset.document.DatasetService") @patch("controllers.service_api.dataset.document.DocumentService") - def test_get_document_metadata_without(self, mock_doc_svc, mock_dataset_svc, app, mock_tenant, mock_doc_detail): + def test_get_document_metadata_without( + self, mock_doc_svc, mock_dataset_svc, app: Flask, mock_tenant, mock_doc_detail + ): """Test document retrieval with metadata='without'.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -674,7 +677,7 @@ class TestDocumentApiGet: assert "name" in response @patch("controllers.service_api.dataset.document.DocumentService") - def test_get_document_invalid_metadata_value(self, mock_doc_svc, app, mock_tenant, mock_doc_detail): + def test_get_document_invalid_metadata_value(self, mock_doc_svc, app: Flask, mock_tenant, mock_doc_detail): """Test error when metadata parameter has invalid value.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -713,7 +716,7 @@ class TestDocumentApiDelete: @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_delete_document_success(self, mock_db, mock_doc_svc, app, mock_tenant, mock_document): + def test_delete_document_success(self, mock_db, mock_doc_svc, app: Flask, mock_tenant, mock_document): """Test successful document deletion.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -741,7 +744,7 @@ class TestDocumentApiDelete: @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_delete_document_not_found(self, mock_db, mock_doc_svc, app, mock_tenant): + def test_delete_document_not_found(self, mock_db, mock_doc_svc, app: Flask, mock_tenant): """Test 404 when document not found.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -763,7 +766,7 @@ class TestDocumentApiDelete: @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_delete_document_archived_forbidden(self, mock_db, mock_doc_svc, app, mock_tenant, mock_document): + def test_delete_document_archived_forbidden(self, mock_db, mock_doc_svc, app: Flask, mock_tenant, mock_document): """Test ArchivedDocumentImmutableError when deleting archived document.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -785,7 +788,7 @@ class TestDocumentApiDelete: @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_delete_document_dataset_not_found(self, mock_db, mock_doc_svc, app, mock_tenant): + def test_delete_document_dataset_not_found(self, mock_db, mock_doc_svc, app: Flask, mock_tenant): """Test ValueError when dataset not found.""" # Arrange dataset_id = str(uuid.uuid4()) @@ -808,7 +811,7 @@ class TestDocumentListApi: @patch("controllers.service_api.dataset.document.marshal") @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_list_documents_success(self, mock_db, mock_doc_svc, mock_marshal, app, mock_tenant, mock_dataset): + def test_list_documents_success(self, mock_db, mock_doc_svc, mock_marshal, app: Flask, mock_tenant, mock_dataset): """Test successful document list retrieval.""" # Arrange mock_db.session.scalar.return_value = mock_dataset @@ -837,7 +840,7 @@ class TestDocumentListApi: assert response["total"] == 2 @patch("controllers.service_api.dataset.document.db") - def test_list_documents_dataset_not_found(self, mock_db, app, mock_tenant, mock_dataset): + def test_list_documents_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset): """Test 404 when dataset not found.""" # Arrange mock_db.session.scalar.return_value = None @@ -858,7 +861,9 @@ class TestDocumentIndexingStatusApi: @patch("controllers.service_api.dataset.document.marshal") @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_get_indexing_status_success(self, mock_db, mock_doc_svc, mock_marshal, app, mock_tenant, mock_dataset): + def test_get_indexing_status_success( + self, mock_db, mock_doc_svc, mock_marshal, app: Flask, mock_tenant, mock_dataset + ): """Test successful indexing status retrieval.""" # Arrange batch_id = "batch_123" @@ -894,7 +899,7 @@ class TestDocumentIndexingStatusApi: assert len(response["data"]) == 1 @patch("controllers.service_api.dataset.document.db") - def test_get_indexing_status_dataset_not_found(self, mock_db, app, mock_tenant, mock_dataset): + def test_get_indexing_status_dataset_not_found(self, mock_db, app: Flask, mock_tenant, mock_dataset): """Test 404 when dataset not found.""" # Arrange batch_id = "batch_123" @@ -911,7 +916,9 @@ class TestDocumentIndexingStatusApi: @patch("controllers.service_api.dataset.document.DocumentService") @patch("controllers.service_api.dataset.document.db") - def test_get_indexing_status_documents_not_found(self, mock_db, mock_doc_svc, app, mock_tenant, mock_dataset): + def test_get_indexing_status_documents_not_found( + self, mock_db, mock_doc_svc, app: Flask, mock_tenant, mock_dataset + ): """Test 404 when no documents found for batch.""" # Arrange batch_id = "batch_empty" @@ -978,7 +985,7 @@ class TestDocumentAddByTextApi: mock_knowledge_config, mock_doc_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1029,7 +1036,7 @@ class TestDocumentAddByTextApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.dataset.document.db") def test_create_document_dataset_not_found( - self, mock_db, mock_validate_token, mock_feature_svc, app, mock_tenant, mock_dataset + self, mock_db, mock_validate_token, mock_feature_svc, app: Flask, mock_tenant, mock_dataset ): """Test ValueError when dataset not found.""" # Arrange — neutralise billing decorators @@ -1052,13 +1059,13 @@ class TestDocumentAddByTextApi: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.dataset.document.db") def test_create_document_missing_indexing_technique( - self, mock_db, mock_validate_token, mock_feature_svc, app, mock_tenant, mock_dataset + self, mock_db, mock_validate_token, mock_feature_svc, app: Flask, mock_tenant, mock_dataset ): """Test error when both dataset and payload lack indexing_technique. When ``indexing_technique`` is ``None`` in the payload, ``model_dump(exclude_none=True)`` - omits the key. The production code accesses ``args["indexing_technique"]`` which raises - ``KeyError`` before the ``ValueError`` guard can fire. + omits the key. The service API should still raise the same validation error as other + document creation paths instead of leaking a ``KeyError`` from the dumped payload dict. """ # Arrange — neutralise billing decorators self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) @@ -1074,7 +1081,7 @@ class TestDocumentAddByTextApi: headers={"Authorization": "Bearer test_token"}, ): api = DocumentAddByTextApi() - with pytest.raises(KeyError): + with pytest.raises(ValueError, match="indexing_technique is required."): api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) @@ -1161,7 +1168,7 @@ class TestDocumentUpdateByTextApiPost: mock_file_svc_cls, mock_doc_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1206,7 +1213,7 @@ class TestDocumentUpdateByTextApiPost: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1245,7 +1252,7 @@ class TestDocumentAddByFileApiPost: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1275,7 +1282,7 @@ class TestDocumentAddByFileApiPost: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1306,7 +1313,7 @@ class TestDocumentAddByFileApiPost: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1338,7 +1345,7 @@ class TestDocumentAddByFileApiPost: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1381,7 +1388,7 @@ class TestDocumentUpdateByFileApiPatch: mock_feature_svc, mock_update_document_by_file, route_name, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1418,7 +1425,7 @@ class TestDocumentUpdateByFileApiPatch: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1453,7 +1460,7 @@ class TestDocumentUpdateByFileApiPatch: mock_validate_token, mock_feature_svc, mock_db, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -1497,7 +1504,7 @@ class TestDocumentUpdateByFileApiPatch: mock_file_svc_cls, mock_doc_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py index a26cdf6563..38fcb55fc0 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py @@ -18,6 +18,7 @@ import uuid from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import Forbidden, NotFound import services @@ -91,7 +92,7 @@ class TestHitTestingApiPost: mock_hit_svc, mock_marshal, mock_ns, - app, + app: Flask, ): """Test successful hit testing request.""" dataset_id = str(uuid.uuid4()) @@ -103,7 +104,7 @@ class TestHitTestingApiPost: mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None - mock_hit_svc.retrieve.return_value = {"query": "test query", "records": []} + mock_hit_svc.retrieve.return_value = {"query": {"content": "test query"}, "records": []} mock_hit_svc.hit_testing_args_check.return_value = None mock_marshal.return_value = [] @@ -129,7 +130,7 @@ class TestHitTestingApiPost: mock_hit_svc, mock_marshal, mock_ns, - app, + app: Flask, ): """Test hit testing with custom retrieval model.""" dataset_id = str(uuid.uuid4()) @@ -149,7 +150,7 @@ class TestHitTestingApiPost: "score_threshold": 0.8, } - mock_hit_svc.retrieve.return_value = {"query": "complex query", "records": []} + mock_hit_svc.retrieve.return_value = {"query": {"content": "complex query"}, "records": []} mock_hit_svc.hit_testing_args_check.return_value = None mock_marshal.return_value = [] @@ -183,7 +184,7 @@ class TestHitTestingApiPost: mock_hit_svc, mock_marshal, mock_ns, - app, + app: Flask, ): """Service API retrieval payload should not drop metadata filters.""" dataset_id = str(uuid.uuid4()) @@ -194,7 +195,7 @@ class TestHitTestingApiPost: mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None - mock_hit_svc.retrieve.return_value = {"query": "filtered query", "records": []} + mock_hit_svc.retrieve.return_value = {"query": {"content": "filtered query"}, "records": []} mock_hit_svc.hit_testing_args_check.return_value = None mock_marshal.return_value = [] @@ -232,16 +233,16 @@ class TestHitTestingApiPost: @patch("controllers.console.datasets.hit_testing_base.HitTestingService") @patch("controllers.console.datasets.hit_testing_base.DatasetService") @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account)) - def test_post_normalizes_legacy_query_and_nullable_list_fields( + def test_post_prepares_nullable_list_fields( self, mock_current_user, mock_dataset_svc, mock_hit_svc, mock_marshal, mock_ns, - app, + app: Flask, ): - """Test service API normalizes legacy query shape and nullable list fields.""" + """Test service API prepares nullable list fields from marshalled records.""" dataset_id = str(uuid.uuid4()) tenant_id = str(uuid.uuid4()) @@ -286,7 +287,7 @@ class TestHitTestingApiPost: mock_current_user, mock_dataset_svc, mock_ns, - app, + app: Flask, ): """Test hit testing with non-existent dataset.""" dataset_id = str(uuid.uuid4()) @@ -308,7 +309,7 @@ class TestHitTestingApiPost: mock_current_user, mock_dataset_svc, mock_ns, - app, + app: Flask, ): """Test hit testing when user lacks dataset permission.""" dataset_id = str(uuid.uuid4()) diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py index b93a1cf14b..b7e24f9201 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_metadata.py @@ -19,6 +19,7 @@ import uuid from unittest.mock import Mock, patch import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.service_api.dataset.metadata import ( @@ -76,7 +77,7 @@ class TestDatasetMetadataCreatePost: mock_dataset_svc, mock_meta_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -106,7 +107,7 @@ class TestDatasetMetadataCreatePost: def test_create_metadata_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -136,7 +137,7 @@ class TestDatasetMetadataCreateGet: self, mock_dataset_svc, mock_meta_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -160,7 +161,7 @@ class TestDatasetMetadataCreateGet: def test_get_metadata_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -201,7 +202,7 @@ class TestDatasetMetadataServiceApiPatch: mock_dataset_svc, mock_meta_svc, mock_marshal, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -232,7 +233,7 @@ class TestDatasetMetadataServiceApiPatch: def test_update_metadata_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -273,7 +274,7 @@ class TestDatasetMetadataServiceApiDelete: mock_current_user, mock_dataset_svc, mock_meta_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -302,7 +303,7 @@ class TestDatasetMetadataServiceApiDelete: def test_delete_metadata_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -336,7 +337,7 @@ class TestDatasetMetadataBuiltInFieldGet: def test_get_built_in_fields_success( self, mock_meta_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -382,7 +383,7 @@ class TestDatasetMetadataBuiltInFieldAction: mock_current_user, mock_dataset_svc, mock_meta_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -414,7 +415,7 @@ class TestDatasetMetadataBuiltInFieldAction: mock_current_user, mock_dataset_svc, mock_meta_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -441,7 +442,7 @@ class TestDatasetMetadataBuiltInFieldAction: def test_action_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -485,7 +486,7 @@ class TestDocumentMetadataEditPost: mock_current_user, mock_dataset_svc, mock_meta_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): @@ -513,7 +514,7 @@ class TestDocumentMetadataEditPost: def test_update_documents_metadata_dataset_not_found( self, mock_dataset_svc, - app, + app: Flask, mock_tenant, mock_dataset, ): diff --git a/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py b/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py index 3cc444e467..9c310a4f45 100644 --- a/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py +++ b/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from uuid import UUID, uuid4 import pytest +from pytest_mock import MockerFixture from controllers.service_api.end_user.end_user import EndUserApi from controllers.service_api.end_user.error import EndUserNotFoundError @@ -21,7 +22,9 @@ class TestEndUserApi: app.tenant_id = str(uuid4()) return app - def test_get_end_user_returns_all_attributes(self, mocker, resource: EndUserApi, app_model: App) -> None: + def test_get_end_user_returns_all_attributes( + self, mocker: MockerFixture, resource: EndUserApi, app_model: App + ) -> None: end_user = Mock(spec=EndUser) end_user.id = str(uuid4()) end_user.tenant_id = app_model.tenant_id @@ -54,7 +57,7 @@ class TestEndUserApi: assert result["created_at"].startswith("2024-01-01T00:00:00") assert result["updated_at"].startswith("2024-01-02T00:00:00") - def test_get_end_user_not_found(self, mocker, resource: EndUserApi, app_model: App) -> None: + def test_get_end_user_not_found(self, mocker: MockerFixture, resource: EndUserApi, app_model: App) -> None: mocker.patch("controllers.service_api.end_user.end_user.EndUserService.get_end_user_by_id", return_value=None) with pytest.raises(EndUserNotFoundError): diff --git a/api/tests/unit_tests/controllers/service_api/test_index.py b/api/tests/unit_tests/controllers/service_api/test_index.py index c560a3c698..c4074c14dd 100644 --- a/api/tests/unit_tests/controllers/service_api/test_index.py +++ b/api/tests/unit_tests/controllers/service_api/test_index.py @@ -5,6 +5,7 @@ Unit tests for Service API Index endpoint from unittest.mock import MagicMock, patch import pytest +from flask import Flask from controllers.service_api.index import IndexApi @@ -13,7 +14,7 @@ class TestIndexApi: """Test suite for IndexApi resource.""" @patch("controllers.service_api.index.dify_config", autospec=True) - def test_get_returns_api_info(self, mock_config, app): + def test_get_returns_api_info(self, mock_config, app: Flask): """Test that GET returns API metadata with correct structure.""" # Arrange mock_config.project.version = "1.0.0-test" @@ -32,7 +33,7 @@ class TestIndexApi: assert response["api_version"] == "v1" assert response["server_version"] == "1.0.0-test" - def test_get_response_has_required_fields(self, app): + def test_get_response_has_required_fields(self, app: Flask): """Test that response contains all required fields.""" # Arrange mock_config = MagicMock() @@ -53,7 +54,7 @@ class TestIndexApi: assert isinstance(response["server_version"], str) @pytest.mark.parametrize("version", ["0.0.1", "1.0.0", "2.0.0-beta", "1.11.4"]) - def test_get_returns_correct_version(self, app, version): + def test_get_returns_correct_version(self, app: Flask, version): """Test that server_version matches config version.""" # Arrange mock_config = MagicMock() diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 6dfbdcf98e..30d7b92913 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -39,7 +39,7 @@ class TestValidateAndGetApiToken: app.config["TESTING"] = True return app - def test_missing_authorization_header(self, app): + def test_missing_authorization_header(self, app: Flask): """Test that Unauthorized is raised when Authorization header is missing.""" # Arrange with app.test_request_context("/", method="GET"): @@ -50,7 +50,7 @@ class TestValidateAndGetApiToken: validate_and_get_api_token("app") assert "Authorization header must be provided" in str(exc_info.value) - def test_invalid_auth_scheme(self, app): + def test_invalid_auth_scheme(self, app: Flask): """Test that Unauthorized is raised when auth scheme is not Bearer.""" # Arrange with app.test_request_context("/", method="GET", headers={"Authorization": "Basic token123"}): @@ -62,7 +62,7 @@ class TestValidateAndGetApiToken: @patch("controllers.service_api.wraps.record_token_usage") @patch("controllers.service_api.wraps.ApiTokenCache") @patch("controllers.service_api.wraps.fetch_token_with_single_flight") - def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app): + def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask): """Test that valid token returns the ApiToken object.""" # Arrange mock_api_token = Mock(spec=ApiToken) @@ -84,7 +84,7 @@ class TestValidateAndGetApiToken: @patch("controllers.service_api.wraps.record_token_usage") @patch("controllers.service_api.wraps.ApiTokenCache") @patch("controllers.service_api.wraps.fetch_token_with_single_flight") - def test_invalid_token_raises_unauthorized(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app): + def test_invalid_token_raises_unauthorized(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask): """Test that invalid token raises Unauthorized.""" # Arrange from werkzeug.exceptions import Unauthorized @@ -161,7 +161,7 @@ class TestValidateAppToken: @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app): + def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask): """Test that Forbidden is raised when app no longer exists.""" # Arrange mock_api_token = Mock() @@ -182,7 +182,7 @@ class TestValidateAppToken: @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app): + def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask): """Test that Forbidden is raised when app status is abnormal.""" # Arrange mock_api_token = Mock() @@ -205,7 +205,7 @@ class TestValidateAppToken: @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app): + def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask): """Test that Forbidden is raised when app API is disabled.""" # Arrange mock_api_token = Mock() @@ -240,7 +240,7 @@ class TestCloudEditionBillingResourceCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") - def test_allows_when_under_limit(self, mock_get_features, mock_validate_token, app): + def test_allows_when_under_limit(self, mock_get_features, mock_validate_token, app: Flask): """Test that request is allowed when under resource limit.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -264,7 +264,7 @@ class TestCloudEditionBillingResourceCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") - def test_rejects_when_at_limit(self, mock_get_features, mock_validate_token, app): + def test_rejects_when_at_limit(self, mock_get_features, mock_validate_token, app: Flask): """Test that Forbidden is raised when at resource limit.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -287,7 +287,7 @@ class TestCloudEditionBillingResourceCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") - def test_allows_when_billing_disabled(self, mock_get_features, mock_validate_token, app): + def test_allows_when_billing_disabled(self, mock_get_features, mock_validate_token, app: Flask): """Test that request is allowed when billing is disabled.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -320,7 +320,7 @@ class TestCloudEditionBillingKnowledgeLimitCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") - def test_rejects_add_segment_in_sandbox(self, mock_get_features, mock_validate_token, app): + def test_rejects_add_segment_in_sandbox(self, mock_get_features, mock_validate_token, app: Flask): """Test that add_segment is rejected in SANDBOX plan.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -342,7 +342,7 @@ class TestCloudEditionBillingKnowledgeLimitCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_features") - def test_allows_other_operations_in_sandbox(self, mock_get_features, mock_validate_token, app): + def test_allows_other_operations_in_sandbox(self, mock_get_features, mock_validate_token, app: Flask): """Test that non-add_segment operations are allowed in SANDBOX.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -376,7 +376,7 @@ class TestCloudEditionBillingRateLimitCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit") - def test_allows_within_rate_limit(self, mock_get_rate_limit, mock_validate_token, app): + def test_allows_within_rate_limit(self, mock_get_rate_limit, mock_validate_token, app: Flask): """Test that request is allowed when within rate limit.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -406,7 +406,7 @@ class TestCloudEditionBillingRateLimitCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit") @patch("controllers.service_api.wraps.db") - def test_rejects_over_rate_limit(self, mock_db, mock_get_rate_limit, mock_validate_token, app): + def test_rejects_over_rate_limit(self, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask): """Test that Forbidden is raised when over rate limit.""" # Arrange mock_validate_token.return_value = Mock(tenant_id="tenant123") @@ -445,7 +445,7 @@ class TestValidateDatasetToken: @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.current_app") - def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app): + def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask): """Test that valid dataset token allows access.""" # Arrange # Use standard Mock for login_manager @@ -487,7 +487,7 @@ class TestValidateDatasetToken: @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app): + def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask): """Test that NotFound is raised when dataset doesn't exist.""" # Arrange mock_api_token = Mock() diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py new file mode 100644 index 0000000000..999f1ae78d --- /dev/null +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -0,0 +1,72 @@ +"""Swagger JSON rendering tests for Flask-RESTX API blueprints.""" + +import pytest +from flask import Flask + + +def _definition_refs(value: object) -> set[str]: + refs: set[str] = set() + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str) and ref.startswith("#/definitions/"): + refs.add(ref.removeprefix("#/definitions/")) + for item in value.values(): + refs.update(_definition_refs(item)) + elif isinstance(value, list): + for item in value: + refs.update(_definition_refs(item)) + return refs + + +@pytest.mark.parametrize( + ("first_kwargs", "second_kwargs"), + [ + ({"min_items": 1}, {"min_items": 2}), + ({"max_items": 1}, {"max_items": 2}), + ({"unique": True}, {"unique": False}), + ], +) +def test_inline_model_name_includes_list_constraints( + first_kwargs: dict[str, object], + second_kwargs: dict[str, object], +): + from flask_restx import fields + + from libs.flask_restx_compat import _inline_model_name + + first_inline_model: dict[object, object] = {"items": fields.List(fields.String, **first_kwargs)} + second_inline_model: dict[object, object] = {"items": fields.List(fields.String, **second_kwargs)} + + assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model) + + +def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + from controllers.service_api import bp as service_api_bp + from controllers.web import bp as web_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.config["RESTX_INCLUDE_ALL_MODELS"] = True + app.register_blueprint(console_bp) + app.register_blueprint(web_bp) + app.register_blueprint(service_api_bp) + + client = app.test_client() + + for route in ("/console/api/swagger.json", "/api/swagger.json", "/v1/swagger.json"): + response = client.get(route) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["swagger"] == "2.0" + assert "paths" in payload + assert "definitions" in payload + assert isinstance(payload["definitions"], dict) + missing_refs = _definition_refs(payload) - set(payload["definitions"]) + assert not sorted(ref for ref in missing_refs if ref.startswith("_AnonymousInlineModel")) + + assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index 13b953c04d..839939367c 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -44,7 +44,7 @@ class TestEmailCodeLoginSendEmailApi: self, mock_get_user, mock_send_email, - app, + app: Flask, ): mock_account = MagicMock() mock_get_user.return_value = mock_account @@ -75,7 +75,7 @@ class TestEmailCodeLoginApi: mock_get_user, mock_login, mock_reset_login_rate, - app, + app: Flask, ): mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} mock_get_user.return_value = MagicMock() diff --git a/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py b/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py index 9073ae1044..c1a4da8cd3 100644 --- a/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py +++ b/api/tests/unit_tests/core/agent/output_parser/test_cot_output_parser.py @@ -12,12 +12,13 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.agent.output_parser.cot_output_parser import CotAgentOutputParser @pytest.fixture -def mock_action_class(mocker): +def mock_action_class(mocker: MockerFixture): mock_action = MagicMock() mocker.patch( "core.agent.output_parser.cot_output_parser.AgentScratchpadUnit.Action", diff --git a/api/tests/unit_tests/core/agent/strategy/test_plugin.py b/api/tests/unit_tests/core/agent/strategy/test_plugin.py index e0894f1e90..0fea04845d 100644 --- a/api/tests/unit_tests/core/agent/strategy/test_plugin.py +++ b/api/tests/unit_tests/core/agent/strategy/test_plugin.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.agent.strategy.plugin import PluginAgentStrategy @@ -213,7 +214,9 @@ class TestInvoke: (None, None, "msg"), ], ) - def test_invoke_optional_arguments(self, strategy, mocker, conversation_id, app_id, message_id) -> None: + def test_invoke_optional_arguments( + self, strategy, mocker: MockerFixture, conversation_id, app_id, message_id + ) -> None: mock_manager = MagicMock() mock_manager.invoke = MagicMock(return_value=iter([])) diff --git a/api/tests/unit_tests/core/agent/test_base_agent_runner.py b/api/tests/unit_tests/core/agent/test_base_agent_runner.py index db4b293b16..d5fb853ee3 100644 --- a/api/tests/unit_tests/core/agent/test_base_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_base_agent_runner.py @@ -3,6 +3,7 @@ from decimal import Decimal from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture import core.agent.base_agent_runner as module from core.agent.base_agent_runner import BaseAgentRunner @@ -13,7 +14,7 @@ from core.agent.base_agent_runner import BaseAgentRunner @pytest.fixture -def mock_db_session(mocker): +def mock_db_session(mocker: MockerFixture): session = mocker.MagicMock() mocker.patch.object(module.db, "session", session) return session @@ -41,13 +42,13 @@ def runner(mocker, mock_db_session): class TestRepack: - def test_sets_empty_if_none(self, runner, mocker): + def test_sets_empty_if_none(self, runner, mocker: MockerFixture): entity = mocker.MagicMock() entity.app_config.prompt_template.simple_prompt_template = None result = runner._repack_app_generate_entity(entity) assert result.app_config.prompt_template.simple_prompt_template == "" - def test_keeps_existing(self, runner, mocker): + def test_keeps_existing(self, runner, mocker: MockerFixture): entity = mocker.MagicMock() entity.app_config.prompt_template.simple_prompt_template = "abc" result = runner._repack_app_generate_entity(entity) @@ -60,7 +61,7 @@ class TestRepack: class TestUpdatePromptTool: - def build_param(self, mocker, **kwargs): + def build_param(self, mocker: MockerFixture, **kwargs): p = mocker.MagicMock() p.form = kwargs.get("form") @@ -75,7 +76,7 @@ class TestUpdatePromptTool: p.required = kwargs.get("required", False) return p - def test_skip_non_llm(self, runner, mocker): + def test_skip_non_llm(self, runner, mocker: MockerFixture): tool = mocker.MagicMock() param = self.build_param(mocker, form="NOT_LLM") tool.get_runtime_parameters.return_value = [param] @@ -86,7 +87,7 @@ class TestUpdatePromptTool: result = runner.update_prompt_message_tool(tool, prompt_tool) assert result.parameters["properties"] == {} - def test_enum_and_required(self, runner, mocker): + def test_enum_and_required(self, runner, mocker: MockerFixture): option = mocker.MagicMock(value="opt1") param = self.build_param( mocker, @@ -104,7 +105,7 @@ class TestUpdatePromptTool: result = runner.update_prompt_message_tool(tool, prompt_tool) assert "p1" in result.parameters["required"] - def test_skip_file_type_param(self, runner, mocker): + def test_skip_file_type_param(self, runner, mocker: MockerFixture): tool = mocker.MagicMock() param = self.build_param(mocker, form=module.ToolParameter.ToolParameterForm.LLM) param.type = module.ToolParameter.ToolParameterType.FILE @@ -116,7 +117,7 @@ class TestUpdatePromptTool: result = runner.update_prompt_message_tool(tool, prompt_tool) assert result.parameters["properties"] == {} - def test_duplicate_required_not_duplicated(self, runner, mocker): + def test_duplicate_required_not_duplicated(self, runner, mocker: MockerFixture): tool = mocker.MagicMock() param = self.build_param( @@ -141,7 +142,7 @@ class TestUpdatePromptTool: class TestCreateAgentThought: - def test_with_files(self, runner, mock_db_session, mocker): + def test_with_files(self, runner, mock_db_session, mocker: MockerFixture): mock_thought = mocker.MagicMock(id=10) mocker.patch.object(module, "MessageAgentThought", return_value=mock_thought) @@ -149,7 +150,7 @@ class TestCreateAgentThought: assert result == "10" assert runner.agent_thought_count == 1 - def test_without_files(self, runner, mock_db_session, mocker): + def test_without_files(self, runner, mock_db_session, mocker: MockerFixture): mock_thought = mocker.MagicMock(id=11) mocker.patch.object(module, "MessageAgentThought", return_value=mock_thought) @@ -163,7 +164,7 @@ class TestCreateAgentThought: class TestSaveAgentThought: - def setup_agent(self, mocker): + def setup_agent(self, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1;tool2" agent.tool_labels = {} @@ -175,7 +176,7 @@ class TestSaveAgentThought: with pytest.raises(ValueError): runner.save_agent_thought("id", None, None, None, None, None, None, [], None) - def test_full_update(self, runner, mock_db_session, mocker): + def test_full_update(self, runner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent @@ -210,7 +211,7 @@ class TestSaveAgentThought: assert agent.tokens == 3 assert "tool1" in json.loads(agent.tool_labels_str) - def test_label_fallback_when_none(self, runner, mock_db_session, mocker): + def test_label_fallback_when_none(self, runner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) agent.tool = "unknown_tool" mock_db_session.scalar.return_value = agent @@ -220,7 +221,7 @@ class TestSaveAgentThought: labels = json.loads(agent.tool_labels_str) assert "unknown_tool" in labels - def test_json_failure_paths(self, runner, mock_db_session, mocker): + def test_json_failure_paths(self, runner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent @@ -241,13 +242,13 @@ class TestSaveAgentThought: assert mock_db_session.commit.called - def test_messages_ids_none(self, runner, mock_db_session, mocker): + def test_messages_ids_none(self, runner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent runner.save_agent_thought("id", None, None, None, None, None, None, None, None) assert mock_db_session.commit.called - def test_success_dict_serialization(self, runner, mock_db_session, mocker): + def test_success_dict_serialization(self, runner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent @@ -273,19 +274,19 @@ class TestSaveAgentThought: class TestOrganizeUserPrompt: - def test_no_files(self, runner, mock_db_session, mocker): + def test_no_files(self, runner, mock_db_session, mocker: MockerFixture): mock_db_session.scalars.return_value.all.return_value = [] msg = mocker.MagicMock(id="1", query="hello", app_model_config=None) result = runner.organize_agent_user_prompt(msg) assert result.content == "hello" - def test_with_files_no_config(self, runner, mock_db_session, mocker): + def test_with_files_no_config(self, runner, mock_db_session, mocker: MockerFixture): mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()] msg = mocker.MagicMock(id="1", query="hello", app_model_config=None) result = runner.organize_agent_user_prompt(msg) assert result.content == "hello" - def test_image_detail_low_fallback(self, runner, mock_db_session, mocker): + def test_image_detail_low_fallback(self, runner, mock_db_session, mocker: MockerFixture): mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()] file_config = mocker.MagicMock() file_config.image_config = mocker.MagicMock(detail=None) @@ -305,27 +306,27 @@ class TestOrganizeUserPrompt: class TestOrganizeHistory: - def test_empty(self, runner, mock_db_session, mocker): + def test_empty(self, runner, mock_db_session, mocker: MockerFixture): mock_db_session.execute.return_value.scalars.return_value.all.return_value = [] mocker.patch.object(module, "extract_thread_messages", return_value=[]) result = runner.organize_agent_history([]) assert result == [] - def test_with_answer_only(self, runner, mock_db_session, mocker): + def test_with_answer_only(self, runner, mock_db_session, mocker: MockerFixture): msg = mocker.MagicMock(id="m1", answer="ans", agent_thoughts=[], app_model_config=None) mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg] mocker.patch.object(module, "extract_thread_messages", return_value=[msg]) result = runner.organize_agent_history([]) assert any(isinstance(x, module.AssistantPromptMessage) for x in result) - def test_skip_current_message(self, runner, mock_db_session, mocker): + def test_skip_current_message(self, runner, mock_db_session, mocker: MockerFixture): msg = mocker.MagicMock(id="msg_current", agent_thoughts=[], answer="ans", app_model_config=None) mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg] mocker.patch.object(module, "extract_thread_messages", return_value=[msg]) result = runner.organize_agent_history([]) assert result == [] - def test_with_tool_calls_invalid_json(self, runner, mock_db_session, mocker): + def test_with_tool_calls_invalid_json(self, runner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock( tool="tool1", tool_input="invalid", @@ -341,7 +342,7 @@ class TestOrganizeHistory: result = runner.organize_agent_history([]) assert isinstance(result, list) - def test_empty_tool_name_split(self, runner, mock_db_session, mocker): + def test_empty_tool_name_split(self, runner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock(tool=";", thought="thinking") msg = mocker.MagicMock(id="m5", agent_thoughts=[thought], answer=None, app_model_config=None) @@ -350,7 +351,7 @@ class TestOrganizeHistory: result = runner.organize_agent_history([]) assert isinstance(result, list) - def test_valid_json_tool_flow(self, runner, mock_db_session, mocker): + def test_valid_json_tool_flow(self, runner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock( tool="tool1", tool_input=json.dumps({"tool1": {"x": 1}}), @@ -379,7 +380,7 @@ class TestOrganizeHistory: class TestConvertToolToPromptMessageTool: - def test_basic_conversion(self, runner, mocker): + def test_basic_conversion(self, runner, mocker: MockerFixture): tool = mocker.MagicMock(tool_name="tool1") runtime_param = mocker.MagicMock() @@ -404,7 +405,7 @@ class TestConvertToolToPromptMessageTool: prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool) assert entity == tool_entity - def test_full_conversion_multiple_params(self, runner, mocker): + def test_full_conversion_multiple_params(self, runner, mocker: MockerFixture): tool = mocker.MagicMock(tool_name="tool1") # LLM param with input_schema override @@ -441,7 +442,7 @@ class TestConvertToolToPromptMessageTool: class TestInitPromptToolsExtended: - def test_agent_tool_branch(self, runner, mocker): + def test_agent_tool_branch(self, runner, mocker: MockerFixture): agent_tool = mocker.MagicMock(tool_name="agent_tool") runner.app_config.agent = mocker.MagicMock(tools=[agent_tool]) mocker.patch.object(runner, "_convert_tool_to_prompt_message_tool", return_value=(MagicMock(), "entity")) @@ -449,7 +450,7 @@ class TestInitPromptToolsExtended: tools, prompts = runner._init_prompt_tools() assert "agent_tool" in tools - def test_exception_in_conversion(self, runner, mocker): + def test_exception_in_conversion(self, runner, mocker: MockerFixture): agent_tool = mocker.MagicMock(tool_name="bad_tool") runner.app_config.agent = mocker.MagicMock(tools=[agent_tool]) mocker.patch.object(runner, "_convert_tool_to_prompt_message_tool", side_effect=Exception) @@ -464,7 +465,7 @@ class TestInitPromptToolsExtended: class TestAdditionalCoverage: - def test_update_prompt_with_input_schema(self, runner, mocker): + def test_update_prompt_with_input_schema(self, runner, mocker: MockerFixture): tool = mocker.MagicMock() param = mocker.MagicMock() @@ -487,7 +488,7 @@ class TestAdditionalCoverage: result = runner.update_prompt_message_tool(tool, prompt_tool) assert result.parameters["properties"]["p1"]["type"] == "number" - def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker): + def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" agent.tool_labels = {"tool1": {"en_US": "existing"}} @@ -498,7 +499,7 @@ class TestAdditionalCoverage: labels = json.loads(agent.tool_labels_str) assert labels["tool1"]["en_US"] == "existing" - def test_save_agent_thought_tool_meta_string(self, runner, mock_db_session, mocker): + def test_save_agent_thought_tool_meta_string(self, runner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" agent.tool_labels = {} @@ -508,7 +509,7 @@ class TestAdditionalCoverage: runner.save_agent_thought("id", None, None, None, None, "meta_string", None, [], None) assert agent.tool_meta_str == "meta_string" - def test_convert_dataset_retriever_tool(self, runner, mocker): + def test_convert_dataset_retriever_tool(self, runner, mocker: MockerFixture): ds_tool = mocker.MagicMock() ds_tool.entity.identity.name = "ds" ds_tool.entity.description.llm = "desc" @@ -525,7 +526,7 @@ class TestAdditionalCoverage: prompt = runner._convert_dataset_retriever_tool_to_prompt_message_tool(ds_tool) assert prompt is not None - def test_organize_user_prompt_with_file_objects(self, runner, mock_db_session, mocker): + def test_organize_user_prompt_with_file_objects(self, runner, mock_db_session, mocker: MockerFixture): mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()] file_config = mocker.MagicMock() @@ -544,7 +545,7 @@ class TestAdditionalCoverage: result = runner.organize_agent_user_prompt(msg) assert result is not None - def test_organize_history_without_tool_names(self, runner, mock_db_session, mocker): + def test_organize_history_without_tool_names(self, runner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock(tool=None, thought="thinking") msg = mocker.MagicMock(id="m3", agent_thoughts=[thought], answer=None, app_model_config=None) @@ -554,7 +555,7 @@ class TestAdditionalCoverage: result = runner.organize_agent_history([]) assert isinstance(result, list) - def test_organize_history_multiple_tools_split(self, runner, mock_db_session, mocker): + def test_organize_history_multiple_tools_split(self, runner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock( tool="tool1;tool2", tool_input=json.dumps({"tool1": {}, "tool2": {}}), @@ -572,7 +573,7 @@ class TestAdditionalCoverage: # ================= Additional Surgical Coverage ================= - def test_convert_tool_select_enum_branch(self, runner, mocker): + def test_convert_tool_select_enum_branch(self, runner, mocker: MockerFixture): tool = mocker.MagicMock(tool_name="tool1") param = mocker.MagicMock() @@ -599,7 +600,7 @@ class TestAdditionalCoverage: class TestConvertDatasetRetrieverTool: - def test_required_param_added(self, runner, mocker): + def test_required_param_added(self, runner, mocker: MockerFixture): ds_tool = mocker.MagicMock() ds_tool.entity.identity.name = "ds" ds_tool.entity.description.llm = "desc" @@ -619,7 +620,7 @@ class TestConvertDatasetRetrieverTool: class TestBaseAgentRunnerInit: - def test_init_sets_stream_tool_call_and_files(self, mocker): + def test_init_sets_stream_tool_call_and_files(self, mocker: MockerFixture): session = mocker.MagicMock() session.scalar.return_value = 2 mocker.patch.object(module.db, "session", session) @@ -662,7 +663,7 @@ class TestBaseAgentRunnerInit: class TestBaseAgentRunnerCoverage: - def test_convert_tool_skips_non_llm_param(self, runner, mocker): + def test_convert_tool_skips_non_llm_param(self, runner, mocker: MockerFixture): tool = mocker.MagicMock(tool_name="tool1") param = mocker.MagicMock() @@ -680,7 +681,7 @@ class TestBaseAgentRunnerCoverage: assert prompt_tool.parameters["properties"] == {} - def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker): + def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker: MockerFixture): dataset_tool = mocker.MagicMock() dataset_tool.entity.identity.name = "ds" runner.dataset_tools = [dataset_tool] @@ -692,7 +693,7 @@ class TestBaseAgentRunnerCoverage: assert tools["ds"] == dataset_tool assert len(prompt_tools) == 1 - def test_update_prompt_message_tool_select_enum(self, runner, mocker): + def test_update_prompt_message_tool_select_enum(self, runner, mocker: MockerFixture): tool = mocker.MagicMock() option1 = mocker.MagicMock(value="A") @@ -716,7 +717,7 @@ class TestBaseAgentRunnerCoverage: assert result.parameters["properties"]["select_param"]["enum"] == ["A", "B"] - def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker): + def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" agent.tool_labels = {} @@ -754,7 +755,7 @@ class TestBaseAgentRunnerCoverage: assert isinstance(agent.observation, str) assert isinstance(agent.tool_meta_str, str) - def test_save_agent_thought_skips_empty_tool_name(self, runner, mock_db_session, mocker): + def test_save_agent_thought_skips_empty_tool_name(self, runner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1;;" agent.tool_labels = {} @@ -768,7 +769,7 @@ class TestBaseAgentRunnerCoverage: labels = json.loads(agent.tool_labels_str) assert "" not in labels - def test_organize_history_includes_system_prompt(self, runner, mock_db_session, mocker): + def test_organize_history_includes_system_prompt(self, runner, mock_db_session, mocker: MockerFixture): mock_db_session.execute.return_value.scalars.return_value.all.return_value = [] mocker.patch.object(module, "extract_thread_messages", return_value=[]) @@ -778,7 +779,7 @@ class TestBaseAgentRunnerCoverage: assert system_message in result - def test_organize_history_tool_inputs_and_observation_none(self, runner, mock_db_session, mocker): + def test_organize_history_tool_inputs_and_observation_none(self, runner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock( tool="tool1", tool_input=None, diff --git a/api/tests/unit_tests/core/agent/test_cot_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_agent_runner.py index cde8820e00..314305d371 100644 --- a/api/tests/unit_tests/core/agent/test_cot_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_agent_runner.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.agent.cot_agent_runner import CotAgentRunner from core.agent.entities import AgentScratchpadUnit @@ -25,7 +26,7 @@ class DummyRunner(CotAgentRunner): @pytest.fixture -def runner(mocker): +def runner(mocker: MockerFixture): # Prevent BaseAgentRunner __init__ from hitting database mocker.patch( "core.agent.base_agent_runner.BaseAgentRunner.organize_agent_history", @@ -165,7 +166,7 @@ class TestHandleInvokeAction: response, meta = runner._handle_invoke_action(action, {}, []) assert "there is not a tool named" in response - def test_tool_with_json_string_args(self, runner, mocker): + def test_tool_with_json_string_args(self, runner, mocker: MockerFixture): action = AgentScratchpadUnit.Action(action_name="tool", action_input=json.dumps({"a": 1})) tool_instance = MagicMock() tool_instances = {"tool": tool_instance} @@ -180,7 +181,7 @@ class TestHandleInvokeAction: class TestOrganizeHistoricPromptMessages: - def test_empty_history(self, runner, mocker): + def test_empty_history(self, runner, mocker: MockerFixture): mocker.patch( "core.agent.cot_agent_runner.AgentHistoryPromptTransform.get_prompt", return_value=[], @@ -190,7 +191,7 @@ class TestOrganizeHistoricPromptMessages: class TestRun: - def test_run_handles_empty_parser_output(self, runner, mocker): + def test_run_handles_empty_parser_output(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -202,7 +203,7 @@ class TestRun: results = list(runner.run(message, "query", {})) assert isinstance(results, list) - def test_run_with_action_and_tool_invocation(self, runner, mocker): + def test_run_with_action_and_tool_invocation(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -223,7 +224,7 @@ class TestRun: with pytest.raises(AgentMaxIterationError): list(runner.run(message, "query", {"tool": MagicMock()})) - def test_run_respects_max_iteration_boundary(self, runner, mocker): + def test_run_respects_max_iteration_boundary(self, runner, mocker: MockerFixture): runner.app_config.agent.max_iteration = 1 message = MagicMock() message.id = "msg-id" @@ -245,7 +246,7 @@ class TestRun: with pytest.raises(AgentMaxIterationError): list(runner.run(message, "query", {"tool": MagicMock()})) - def test_run_basic_flow(self, runner, mocker): + def test_run_basic_flow(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -257,7 +258,7 @@ class TestRun: results = list(runner.run(message, "query", {"name": "John"})) assert results - def test_run_max_iteration_error(self, runner, mocker): + def test_run_max_iteration_error(self, runner, mocker: MockerFixture): runner.app_config.agent.max_iteration = 0 message = MagicMock() message.id = "msg-id" @@ -272,7 +273,7 @@ class TestRun: with pytest.raises(AgentMaxIterationError): list(runner.run(message, "query", {})) - def test_run_increase_usage_aggregation(self, runner, mocker): + def test_run_increase_usage_aggregation(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" runner.app_config.agent.max_iteration = 2 @@ -329,7 +330,7 @@ class TestRun: assert final_usage.completion_price == 2 assert final_usage.total_price == 4 - def test_run_when_no_action_branch(self, runner, mocker): + def test_run_when_no_action_branch(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -341,7 +342,7 @@ class TestRun: results = list(runner.run(message, "query", {})) assert results[-1].delta.message.content == "" - def test_run_usage_missing_key_branch(self, runner, mocker): + def test_run_usage_missing_key_branch(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -354,7 +355,7 @@ class TestRun: list(runner.run(message, "query", {})) - def test_run_prompt_tool_update_branch(self, runner, mocker): + def test_run_prompt_tool_update_branch(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -410,7 +411,7 @@ class TestRun: class TestInitReactState: - def test_init_react_state_resets_state(self, runner, mocker): + def test_init_react_state_resets_state(self, runner, mocker: MockerFixture): mocker.patch.object(runner, "_organize_historic_prompt_messages", return_value=["historic"]) runner._agent_scratchpad = ["old"] runner._query = "old" @@ -423,7 +424,7 @@ class TestInitReactState: class TestHandleInvokeActionExtended: - def test_tool_with_invalid_json_string_args(self, runner, mocker): + def test_tool_with_invalid_json_string_args(self, runner, mocker: MockerFixture): action = AgentScratchpadUnit.Action(action_name="tool", action_input="not-json") tool_instance = MagicMock() tool_instances = {"tool": tool_instance} @@ -457,7 +458,7 @@ class TestFillInputsEdgeCases: class TestOrganizeHistoricPromptMessagesExtended: - def test_user_message_flushes_scratchpad(self, runner, mocker): + def test_user_message_flushes_scratchpad(self, runner, mocker: MockerFixture): from graphon.model_runtime.entities.message_entities import UserPromptMessage user_message = UserPromptMessage(content="Hi") @@ -480,7 +481,7 @@ class TestOrganizeHistoricPromptMessagesExtended: with pytest.raises(NotImplementedError): runner._organize_historic_prompt_messages([]) - def test_agent_history_transform_invocation(self, runner, mocker): + def test_agent_history_transform_invocation(self, runner, mocker: MockerFixture): mock_transform = MagicMock() mock_transform.get_prompt.return_value = [] @@ -495,7 +496,7 @@ class TestOrganizeHistoricPromptMessagesExtended: class TestRunAdditionalBranches: - def test_run_with_no_action_final_answer_empty(self, runner, mocker): + def test_run_with_no_action_final_answer_empty(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -507,7 +508,7 @@ class TestRunAdditionalBranches: results = list(runner.run(message, "query", {})) assert any(hasattr(r, "delta") for r in results) - def test_run_with_final_answer_action_string(self, runner, mocker): + def test_run_with_final_answer_action_string(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -521,7 +522,7 @@ class TestRunAdditionalBranches: results = list(runner.run(message, "query", {})) assert results[-1].delta.message.content == "done" - def test_run_with_final_answer_action_dict(self, runner, mocker): + def test_run_with_final_answer_action_dict(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -535,7 +536,7 @@ class TestRunAdditionalBranches: results = list(runner.run(message, "query", {})) assert json.loads(results[-1].delta.message.content) == {"a": 1} - def test_run_with_string_final_answer(self, runner, mocker): + def test_run_with_string_final_answer(self, runner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" diff --git a/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py index ea8cc8aa86..8e7093fd12 100644 --- a/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from pytest_mock import MockerFixture from core.agent.cot_chat_agent_runner import CotChatAgentRunner from graphon.model_runtime.entities.message_entities import TextPromptMessageContent @@ -55,7 +56,7 @@ def runner(): class TestOrganizeSystemPrompt: - def test_organize_system_prompt_success(self, runner, mocker): + def test_organize_system_prompt_success(self, runner, mocker: MockerFixture): first_prompt = "Instruction: {{instruction}}, Tools: {{tools}}, Names: {{tool_names}}" runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt(first_prompt))) @@ -154,7 +155,7 @@ class TestOrganizeUserQuery: class TestOrganizePromptMessages: - def test_no_scratchpad(self, runner, mocker): + def test_no_scratchpad(self, runner, mocker: MockerFixture): runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}"))) runner._organize_system_prompt = MagicMock(return_value="system") runner._organize_user_query = MagicMock(return_value=["query"]) @@ -164,7 +165,7 @@ class TestOrganizePromptMessages: assert "query" in result runner._organize_historic_prompt_messages.assert_called_once() - def test_with_final_scratchpad(self, runner, mocker): + def test_with_final_scratchpad(self, runner, mocker: MockerFixture): runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}"))) runner._organize_system_prompt = MagicMock(return_value="system") runner._organize_user_query = MagicMock(return_value=["query"]) @@ -177,7 +178,7 @@ class TestOrganizePromptMessages: combined = "".join([m.content for m in assistant_msgs if isinstance(m.content, str)]) assert "Final Answer: done" in combined - def test_with_thought_action_observation(self, runner, mocker): + def test_with_thought_action_observation(self, runner, mocker: MockerFixture): runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}"))) runner._organize_system_prompt = MagicMock(return_value="system") runner._organize_user_query = MagicMock(return_value=["query"]) @@ -197,7 +198,7 @@ class TestOrganizePromptMessages: assert "Action: action" in combined assert "Observation: observe" in combined - def test_multiple_units_mixed(self, runner, mocker): + def test_multiple_units_mixed(self, runner, mocker: MockerFixture): runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}"))) runner._organize_system_prompt = MagicMock(return_value="system") runner._organize_user_query = MagicMock(return_value=["query"]) diff --git a/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py index 2f5873d865..0d949c357d 100644 --- a/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py @@ -1,6 +1,7 @@ import json import pytest +from pytest_mock import MockerFixture from core.agent.cot_completion_agent_runner import CotCompletionAgentRunner from graphon.model_runtime.entities.message_entities import ( @@ -74,7 +75,7 @@ class TestOrganizeInstructionPrompt: class TestOrganizeHistoricPrompt: - def test_with_user_and_assistant_string(self, runner, mocker): + def test_with_user_and_assistant_string(self, runner, mocker: MockerFixture): user_msg = UserPromptMessage(content="Hello") assistant_msg = AssistantPromptMessage(content="Hi there") @@ -89,7 +90,7 @@ class TestOrganizeHistoricPrompt: assert "Question: Hello" in result assert "Hi there" in result - def test_assistant_list_with_text_content(self, runner, mocker): + def test_assistant_list_with_text_content(self, runner, mocker: MockerFixture): text_content = TextPromptMessageContent(data="Partial answer") assistant_msg = AssistantPromptMessage(content=[text_content]) @@ -103,7 +104,7 @@ class TestOrganizeHistoricPrompt: assert "Partial answer" in result - def test_assistant_list_with_non_text_content_ignored(self, runner, mocker): + def test_assistant_list_with_non_text_content_ignored(self, runner, mocker: MockerFixture): non_text_content = ImagePromptMessageContent(format="url", mime_type="image/png") assistant_msg = AssistantPromptMessage(content=[non_text_content]) @@ -116,7 +117,7 @@ class TestOrganizeHistoricPrompt: result = runner._organize_historic_prompt() assert result == "" - def test_empty_history(self, runner, mocker): + def test_empty_history(self, runner, mocker: MockerFixture): mocker.patch.object( runner, "_organize_historic_prompt_messages", @@ -136,7 +137,7 @@ class TestOrganizePromptMessages: def test_full_flow_with_scratchpad( self, runner, - mocker, + mocker: MockerFixture, dummy_app_config_factory, dummy_agent_config_factory, dummy_prompt_entity_factory, @@ -171,7 +172,12 @@ class TestOrganizePromptMessages: assert "Question: What is Python?" in content def test_no_scratchpad( - self, runner, mocker, dummy_app_config_factory, dummy_agent_config_factory, dummy_prompt_entity_factory + self, + runner, + mocker: MockerFixture, + dummy_app_config_factory, + dummy_agent_config_factory, + dummy_prompt_entity_factory, ): template = "SYS {{historic_messages}} {{agent_scratchpad}} {{query}}" @@ -198,7 +204,7 @@ class TestOrganizePromptMessages: def test_partial_scratchpad_units( self, runner, - mocker, + mocker: MockerFixture, thought, action, observation, diff --git a/api/tests/unit_tests/core/agent/test_fc_agent_runner.py b/api/tests/unit_tests/core/agent/test_fc_agent_runner.py index 17ab5babcb..3a4347e723 100644 --- a/api/tests/unit_tests/core/agent/test_fc_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_fc_agent_runner.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.agent.errors import AgentMaxIterationError from core.agent.fc_agent_runner import FunctionCallAgentRunner @@ -68,7 +69,7 @@ class DummyResult: @pytest.fixture -def runner(mocker): +def runner(mocker: MockerFixture): # Completely bypass BaseAgentRunner __init__ to avoid DB / Flask context mocker.patch( "core.agent.base_agent_runner.BaseAgentRunner.__init__", @@ -230,7 +231,7 @@ class TestOrganizeUserQuery: result = runner._organize_user_query(None, []) assert len(result) == 1 - def test_with_files_uses_image_detail_config(self, runner, mocker): + def test_with_files_uses_image_detail_config(self, runner, mocker: MockerFixture): file_content = TextPromptMessageContent(data="file-content") mock_to_prompt = mocker.patch( "core.agent.fc_agent_runner.file_manager.to_prompt_message_content", @@ -352,7 +353,7 @@ class TestRunMethod: assert len(outputs) == 1 assert runner.save_agent_thought.call_args.kwargs["thought"] == "hi" - def test_run_streaming_tool_call_inputs_type_error(self, runner, mocker): + def test_run_streaming_tool_call_inputs_type_error(self, runner, mocker: MockerFixture): message = MagicMock(id="m1") runner.stream_tool_call = True @@ -398,7 +399,7 @@ class TestRunMethod: outputs = list(runner.run(message, "query")) assert len(outputs) >= 1 - def test_run_with_tool_instance_and_files(self, runner, mocker): + def test_run_with_tool_instance_and_files(self, runner, mocker: MockerFixture): message = MagicMock(id="m1") tool_call = MagicMock() diff --git a/api/tests/unit_tests/core/agent/test_plugin_entities.py b/api/tests/unit_tests/core/agent/test_plugin_entities.py index 9955190aca..aa3098a2a1 100644 --- a/api/tests/unit_tests/core/agent/test_plugin_entities.py +++ b/api/tests/unit_tests/core/agent/test_plugin_entities.py @@ -9,6 +9,7 @@ mocking; ensure entity invariants and validation rules remain stable. import pytest from pydantic import ValidationError +from pytest_mock import MockerFixture from core.agent.plugin_entities import ( AgentFeature, @@ -28,12 +29,12 @@ from core.tools.entities.tool_entities import ToolIdentity, ToolProviderIdentity @pytest.fixture -def mock_identity(mocker): +def mock_identity(mocker: MockerFixture): return mocker.MagicMock(spec=AgentStrategyIdentity) @pytest.fixture -def mock_provider_identity(mocker): +def mock_provider_identity(mocker: MockerFixture): return mocker.MagicMock(spec=AgentStrategyProviderIdentity) @@ -47,7 +48,7 @@ class TestAgentStrategyParameterType: "enum_member", list(AgentStrategyParameter.AgentStrategyParameterType), ) - def test_as_normal_type_calls_external_function(self, mocker, enum_member) -> None: + def test_as_normal_type_calls_external_function(self, mocker: MockerFixture, enum_member) -> None: mock_func = mocker.patch( "core.agent.plugin_entities.as_normal_type", return_value="normalized", @@ -58,7 +59,7 @@ class TestAgentStrategyParameterType: mock_func.assert_called_once_with(enum_member) assert result == "normalized" - def test_as_normal_type_propagates_exception(self, mocker) -> None: + def test_as_normal_type_propagates_exception(self, mocker: MockerFixture) -> None: enum_member = AgentStrategyParameter.AgentStrategyParameterType.STRING mocker.patch( "core.agent.plugin_entities.as_normal_type", @@ -79,7 +80,7 @@ class TestAgentStrategyParameterType: (AgentStrategyParameter.AgentStrategyParameterType.FILES, []), ], ) - def test_cast_value_calls_external_function(self, mocker, enum_member, value) -> None: + def test_cast_value_calls_external_function(self, mocker: MockerFixture, enum_member, value) -> None: mock_func = mocker.patch( "core.agent.plugin_entities.cast_parameter_value", return_value="casted", @@ -90,7 +91,7 @@ class TestAgentStrategyParameterType: mock_func.assert_called_once_with(enum_member, value) assert result == "casted" - def test_cast_value_propagates_exception(self, mocker) -> None: + def test_cast_value_propagates_exception(self, mocker: MockerFixture) -> None: enum_member = AgentStrategyParameter.AgentStrategyParameterType.STRING mocker.patch( "core.agent.plugin_entities.cast_parameter_value", @@ -136,7 +137,7 @@ class TestAgentStrategyParameter: assert any(error["loc"] == ("type",) for error in exc_info.value.errors()) - def test_init_frontend_parameter_calls_external(self, mocker) -> None: + def test_init_frontend_parameter_calls_external(self, mocker: MockerFixture) -> None: mock_func = mocker.patch( "core.agent.plugin_entities.init_frontend_parameter", return_value="frontend", @@ -153,7 +154,7 @@ class TestAgentStrategyParameter: mock_func.assert_called_once_with(param, param.type, "value") assert result == "frontend" - def test_init_frontend_parameter_propagates_exception(self, mocker) -> None: + def test_init_frontend_parameter_propagates_exception(self, mocker: MockerFixture) -> None: mocker.patch( "core.agent.plugin_entities.init_frontend_parameter", side_effect=RuntimeError("error"), diff --git a/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py b/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py index 1c5b6ed944..6dbf301f65 100644 --- a/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py +++ b/api/tests/unit_tests/core/app/app_config/common/test_parameters_mapping.py @@ -10,7 +10,7 @@ class TestGetParametersFromFeatureDict: """Test suite for get_parameters_from_feature_dict""" @pytest.fixture - def mock_config(self, monkeypatch): + def mock_config(self, monkeypatch: pytest.MonkeyPatch): """Mock dify_config values""" mock = MagicMock() mock.UPLOAD_IMAGE_FILE_SIZE_LIMIT = 1 @@ -23,7 +23,7 @@ class TestGetParametersFromFeatureDict: return mock @pytest.fixture - def mock_default_file_limits(self, monkeypatch): + def mock_default_file_limits(self, monkeypatch: pytest.MonkeyPatch): """Mock DEFAULT_FILE_NUMBER_LIMITS constant""" monkeypatch.setattr(parameters_mapping, "DEFAULT_FILE_NUMBER_LIMITS", 99) return 99 diff --git a/api/tests/unit_tests/core/app/app_config/common/test_sensitive_word_avoidance_manager.py b/api/tests/unit_tests/core/app/app_config/common/test_sensitive_word_avoidance_manager.py index 013ed0cbc4..bd4ca5ff85 100644 --- a/api/tests/unit_tests/core/app/app_config/common/test_sensitive_word_avoidance_manager.py +++ b/api/tests/unit_tests/core/app/app_config/common/test_sensitive_word_avoidance_manager.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.app.app_config.common.sensitive_word_avoidance.manager import ( SensitiveWordAvoidanceConfigManager, @@ -26,7 +27,7 @@ class TestSensitiveWordAvoidanceConfigManagerConvert: # Assert assert result is None - def test_convert_returns_entity_when_enabled(self, mocker): + def test_convert_returns_entity_when_enabled(self, mocker: MockerFixture): # Arrange mock_entity = MagicMock() mocker.patch( @@ -48,7 +49,7 @@ class TestSensitiveWordAvoidanceConfigManagerConvert: # Assert assert result == mock_entity - def test_convert_enabled_without_type_or_config(self, mocker): + def test_convert_enabled_without_type_or_config(self, mocker: MockerFixture): # Arrange mock_entity = MagicMock() patched = mocker.patch( @@ -135,7 +136,7 @@ class TestSensitiveWordAvoidanceConfigManagerValidateAndSetDefaults: with pytest.raises(ValueError, match="must be a dict"): SensitiveWordAvoidanceConfigManager.validate_and_set_defaults(tenant_id="tenant1", config=config) - def test_validate_calls_moderation_factory(self, mocker): + def test_validate_calls_moderation_factory(self, mocker: MockerFixture): # Arrange mock_validate = mocker.patch( "core.app.app_config.common.sensitive_word_avoidance.manager.ModerationFactory.validate_config" @@ -159,7 +160,7 @@ class TestSensitiveWordAvoidanceConfigManagerValidateAndSetDefaults: assert result_config["sensitive_word_avoidance"]["enabled"] is True assert fields == ["sensitive_word_avoidance"] - def test_validate_sets_empty_dict_when_config_none(self, mocker): + def test_validate_sets_empty_dict_when_config_none(self, mocker: MockerFixture): # Arrange mock_validate = mocker.patch( "core.app.app_config.common.sensitive_word_avoidance.manager.ModerationFactory.validate_config" @@ -179,7 +180,7 @@ class TestSensitiveWordAvoidanceConfigManagerValidateAndSetDefaults: # Assert mock_validate.assert_called_once_with(name="mock_type", tenant_id="tenant1", config={}) - def test_validate_only_structure_validate_skips_factory(self, mocker): + def test_validate_only_structure_validate_skips_factory(self, mocker: MockerFixture): # Arrange mock_validate = mocker.patch( "core.app.app_config.common.sensitive_word_avoidance.manager.ModerationFactory.validate_config" diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_agent_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_agent_manager.py index 992b580376..359b04070b 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_agent_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_agent_manager.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager @@ -84,7 +85,7 @@ class TestAgentConfigManagerConvert: assert result.strategy.name == "CHAIN_OF_THOUGHT" - def test_convert_skips_disabled_tools(self, mocker, base_config): + def test_convert_skips_disabled_tools(self, mocker: MockerFixture, base_config): # Patch AgentEntity to bypass pydantic validation mock_agent_entity = mocker.patch( "core.app.app_config.easy_ui_based_app.agent.manager.AgentEntity", @@ -128,7 +129,7 @@ class TestAgentConfigManagerConvert: mock_validate.assert_called_once() mock_agent_entity.assert_called_once() - def test_convert_tool_requires_minimum_keys(self, mocker, base_config): + def test_convert_tool_requires_minimum_keys(self, mocker: MockerFixture, base_config): mock_validate = mocker.patch( "core.app.app_config.easy_ui_based_app.agent.manager.AgentToolEntity.model_validate", return_value=MagicMock(), diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py index a688e2a5c5..3a239eac0e 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py @@ -2,6 +2,7 @@ import uuid from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.app.app_config.easy_ui_based_app.dataset.manager import DatasetConfigManager from core.entities.agent_entities import PlanningStrategy @@ -69,7 +70,7 @@ class TestDatasetConfigManagerConvert: assert result.dataset_ids == [valid_uuid] assert result.retrieve_config.query_variable == "query" - def test_convert_single_with_metadata_configs(self, valid_uuid, mocker): + def test_convert_single_with_metadata_configs(self, valid_uuid, mocker: MockerFixture): mock_retrieve_config = MagicMock() mock_entity = MagicMock() mock_entity.dataset_ids = [valid_uuid] @@ -258,7 +259,7 @@ class TestExtractDatasetConfig: with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility("tenant1", AppMode.CHAT, config) - def test_extract_invalid_uuid(self, mocker): + def test_extract_invalid_uuid(self, mocker: MockerFixture): invalid_uuid = "not-a-uuid" config = { "agent_mode": { @@ -270,7 +271,7 @@ class TestExtractDatasetConfig: with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility("tenant1", AppMode.CHAT, config) - def test_extract_dataset_not_exists(self, valid_uuid, mocker): + def test_extract_dataset_not_exists(self, valid_uuid, mocker: MockerFixture): mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", return_value=None, @@ -292,7 +293,7 @@ class TestExtractDatasetConfig: class TestIsDatasetExists: - def test_dataset_exists_true(self, mocker, valid_uuid): + def test_dataset_exists_true(self, mocker: MockerFixture, valid_uuid): mock_dataset = MagicMock() mock_dataset.tenant_id = "tenant1" mocker.patch( @@ -302,14 +303,14 @@ class TestIsDatasetExists: assert DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid) - def test_dataset_exists_false_when_not_found(self, mocker, valid_uuid): + def test_dataset_exists_false_when_not_found(self, mocker: MockerFixture, valid_uuid): mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", return_value=None, ) assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid) - def test_dataset_exists_false_when_tenant_mismatch(self, mocker, valid_uuid): + def test_dataset_exists_false_when_tenant_mismatch(self, mocker: MockerFixture, valid_uuid): mock_dataset = MagicMock() mock_dataset.tenant_id = "other" mocker.patch( diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_converter.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_converter.py index 186b4a501d..e5b581b6a0 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_converter.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_converter.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.entities.model_entities import ModelStatus @@ -16,7 +17,7 @@ from graphon.model_runtime.entities.model_entities import ModelPropertyKey class TestModelConfigConverter: @pytest.fixture(autouse=True) - def patch_response_entity(self, mocker): + def patch_response_entity(self, mocker: MockerFixture): """ Patch ModelConfigWithCredentialsEntity to bypass Pydantic validation and return a simple namespace object instead. @@ -69,7 +70,7 @@ class TestModelConfigConverter: return bundle @pytest.fixture - def patch_provider_manager(self, mocker, mock_provider_bundle): + def patch_provider_manager(self, mocker: MockerFixture, mock_provider_bundle): mock_manager = MagicMock() mock_manager.get_provider_model_bundle.return_value = mock_provider_bundle mocker.patch( @@ -99,7 +100,7 @@ class TestModelConfigConverter: assert result.parameters == {"temperature": 0.7} assert result.stop == ["\n"] - def test_convert_mode_from_schema_valid(self, mock_app_config, mock_provider_bundle, mocker): + def test_convert_mode_from_schema_valid(self, mock_app_config, mock_provider_bundle, mocker: MockerFixture): mock_app_config.model.mode = None mock_provider_bundle.model_type_instance.get_model_schema.return_value.model_properties = { @@ -116,7 +117,9 @@ class TestModelConfigConverter: result = ModelConfigConverter.convert(mock_app_config) assert result.mode == LLMMode.COMPLETION - def test_convert_mode_from_schema_invalid_fallback(self, mock_app_config, mock_provider_bundle, mocker): + def test_convert_mode_from_schema_invalid_fallback( + self, mock_app_config, mock_provider_bundle, mocker: MockerFixture + ): mock_provider_bundle.model_type_instance.get_model_schema.return_value.model_properties = { ModelPropertyKey.MODE: "invalid" } @@ -135,7 +138,7 @@ class TestModelConfigConverter: # Credential Errors # ============================= - def test_convert_credentials_none_raises(self, mock_app_config, mock_provider_bundle, mocker): + def test_convert_credentials_none_raises(self, mock_app_config, mock_provider_bundle, mocker: MockerFixture): mock_provider_bundle.configuration.get_current_credentials.return_value = None mock_manager = MagicMock() @@ -152,7 +155,7 @@ class TestModelConfigConverter: # Provider Model Errors # ============================= - def test_convert_provider_model_none_raises(self, mock_app_config, mock_provider_bundle, mocker): + def test_convert_provider_model_none_raises(self, mock_app_config, mock_provider_bundle, mocker: MockerFixture): mock_provider_bundle.configuration.get_provider_model.return_value = None mock_manager = MagicMock() @@ -174,7 +177,7 @@ class TestModelConfigConverter: ], ) def test_convert_provider_model_status_errors( - self, mock_app_config, mock_provider_bundle, mocker, status, expected_exception + self, mock_app_config, mock_provider_bundle, mocker: MockerFixture, status, expected_exception ): mock_provider = MagicMock() mock_provider.status = status @@ -194,7 +197,7 @@ class TestModelConfigConverter: # Schema Errors # ============================= - def test_convert_model_schema_none_raises(self, mock_app_config, mock_provider_bundle, mocker): + def test_convert_model_schema_none_raises(self, mock_app_config, mock_provider_bundle, mocker: MockerFixture): mock_provider_bundle.model_type_instance.get_model_schema.return_value = None mock_manager = MagicMock() diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_manager.py index 68bca485bb..72e334004e 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_model_config_manager.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture # Target from core.app.app_config.easy_ui_based_app.model_config.manager import ModelConfigManager @@ -107,7 +108,9 @@ class TestModelConfigManager: # validate_and_set_defaults # ========================================================== - def test_validate_and_set_defaults_success(self, mocker, valid_config, provider_entities, valid_model_list): + def test_validate_and_set_defaults_success( + self, mocker: MockerFixture, valid_config, provider_entities, valid_model_list + ): self._patch_model_assembly( mocker, provider_entities=provider_entities, @@ -127,35 +130,37 @@ class TestModelConfigManager: with pytest.raises(ValueError, match="object type"): ModelConfigManager.validate_and_set_defaults("tenant1", {"model": "invalid"}) - def test_validate_and_set_defaults_missing_provider(self, mocker, provider_entities): + def test_validate_and_set_defaults_missing_provider(self, mocker: MockerFixture, provider_entities): config = {"model": {"name": "gpt-4", "completion_params": {}}} self._patch_model_assembly(mocker, provider_entities=provider_entities, model_list=[]) with pytest.raises(ValueError, match="model.provider is required"): ModelConfigManager.validate_and_set_defaults("tenant1", config) - def test_validate_and_set_defaults_invalid_provider(self, mocker, provider_entities): + def test_validate_and_set_defaults_invalid_provider(self, mocker: MockerFixture, provider_entities): config = {"model": {"provider": "invalid/provider", "name": "gpt-4", "completion_params": {}}} self._patch_model_assembly(mocker, provider_entities=provider_entities, model_list=[]) with pytest.raises(ValueError, match="model.provider is required"): ModelConfigManager.validate_and_set_defaults("tenant1", config) - def test_validate_and_set_defaults_missing_name(self, mocker, provider_entities): + def test_validate_and_set_defaults_missing_name(self, mocker: MockerFixture, provider_entities): config = {"model": {"provider": "openai/gpt", "completion_params": {}}} self._patch_model_assembly(mocker, provider_entities=provider_entities, model_list=[]) with pytest.raises(ValueError, match="model.name is required"): ModelConfigManager.validate_and_set_defaults("tenant1", config) - def test_validate_and_set_defaults_empty_models(self, mocker, provider_entities): + def test_validate_and_set_defaults_empty_models(self, mocker: MockerFixture, provider_entities): config = {"model": {"provider": "openai/gpt", "name": "gpt-4", "completion_params": {}}} self._patch_model_assembly(mocker, provider_entities=provider_entities, model_list=[]) with pytest.raises(ValueError, match="must be in the specified model list"): ModelConfigManager.validate_and_set_defaults("tenant1", config) - def test_validate_and_set_defaults_invalid_model_name(self, mocker, provider_entities, valid_model_list): + def test_validate_and_set_defaults_invalid_model_name( + self, mocker: MockerFixture, provider_entities, valid_model_list + ): config = {"model": {"provider": "openai/gpt", "name": "invalid", "completion_params": {}}} self._patch_model_assembly( mocker, @@ -166,7 +171,7 @@ class TestModelConfigManager: with pytest.raises(ValueError, match="must be in the specified model list"): ModelConfigManager.validate_and_set_defaults("tenant1", config) - def test_validate_and_set_defaults_default_mode_when_missing(self, mocker, provider_entities): + def test_validate_and_set_defaults_default_mode_when_missing(self, mocker: MockerFixture, provider_entities): model = MagicMock() model.model = "gpt-4" model.model_properties = {} @@ -178,7 +183,9 @@ class TestModelConfigManager: assert updated_config["model"]["mode"] == "completion" - def test_validate_and_set_defaults_missing_completion_params(self, mocker, provider_entities, valid_model_list): + def test_validate_and_set_defaults_missing_completion_params( + self, mocker: MockerFixture, provider_entities, valid_model_list + ): config = {"model": {"provider": "openai/gpt", "name": "gpt-4"}} self._patch_model_assembly( mocker, @@ -189,7 +196,7 @@ class TestModelConfigManager: with pytest.raises(ValueError, match="completion_params is required"): ModelConfigManager.validate_and_set_defaults("tenant1", config) - def test_validate_and_set_defaults_provider_without_slash_converted(self, mocker, valid_model_list): + def test_validate_and_set_defaults_provider_without_slash_converted(self, mocker: MockerFixture, valid_model_list): """ Covers branch where provider does not contain '/' and ModelProviderID conversion is triggered (line 64). diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py index fd49072cd5..62e1d22129 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py @@ -1,6 +1,8 @@ +from collections import UserString from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.app.app_config.easy_ui_based_app.prompt_template.manager import ( PromptTemplateConfigManager, @@ -11,21 +13,25 @@ from core.app.app_config.easy_ui_based_app.prompt_template.manager import ( # ----------------------------- -class DummyEnumValue: +class DummyEnumValue(UserString): def __init__(self, value): + super().__init__(value) self.value = value class DummyPromptType: def __init__(self): - self.SIMPLE = "simple" - self.ADVANCED = "advanced" + self.SIMPLE = DummyEnumValue("simple") + self.ADVANCED = DummyEnumValue("advanced") def value_of(self, value): - return value + for enum_value in self: + if enum_value.value == value: + return enum_value + raise ValueError(f"invalid prompt type value {value}") def __iter__(self): - return iter([DummyEnumValue("simple"), DummyEnumValue("advanced")]) + return iter([self.SIMPLE, self.ADVANCED]) # ----------------------------- @@ -38,7 +44,7 @@ class TestPromptTemplateConfigManagerConvert: with pytest.raises(ValueError, match="prompt_type is required"): PromptTemplateConfigManager.convert({}) - def test_convert_simple_prompt(self, mocker): + def test_convert_simple_prompt(self, mocker: MockerFixture): mock_prompt_entity_cls = MagicMock() mock_prompt_entity_cls.PromptType = DummyPromptType() @@ -56,7 +62,7 @@ class TestPromptTemplateConfigManagerConvert: assert result == "simple_entity" mock_prompt_entity_cls.assert_called_once_with(prompt_type="simple", simple_prompt_template="hello") - def test_convert_advanced_chat_valid(self, mocker): + def test_convert_advanced_chat_valid(self, mocker: MockerFixture): mock_prompt_entity_cls = MagicMock() mock_prompt_entity_cls.PromptType = DummyPromptType() mock_prompt_entity_cls.return_value = "advanced_entity" @@ -97,7 +103,7 @@ class TestPromptTemplateConfigManagerConvert: {"text": "hi", "role": 123}, ], ) - def test_convert_advanced_invalid_message_fields(self, mocker, message): + def test_convert_advanced_invalid_message_fields(self, mocker: MockerFixture, message): mock_prompt_entity_cls = MagicMock() mock_prompt_entity_cls.PromptType = DummyPromptType() @@ -114,7 +120,7 @@ class TestPromptTemplateConfigManagerConvert: with pytest.raises(ValueError): PromptTemplateConfigManager.convert(config) - def test_convert_advanced_completion_with_roles(self, mocker): + def test_convert_advanced_completion_with_roles(self, mocker: MockerFixture): mock_prompt_entity_cls = MagicMock() mock_prompt_entity_cls.PromptType = DummyPromptType() mock_prompt_entity_cls.return_value = "advanced_entity" @@ -154,7 +160,7 @@ class TestValidateAndSetDefaults: def setup_method(self): self.valid_model = {"mode": "chat"} - def _patch_prompt_type(self, mocker): + def _patch_prompt_type(self, mocker: MockerFixture): mock_prompt_entity_cls = MagicMock() mock_prompt_entity_cls.PromptType = DummyPromptType() mocker.patch( @@ -163,7 +169,7 @@ class TestValidateAndSetDefaults: ) return mock_prompt_entity_cls - def test_default_prompt_type_set(self, mocker): + def test_default_prompt_type_set(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = {"model": self.valid_model} @@ -173,7 +179,7 @@ class TestValidateAndSetDefaults: assert result["prompt_type"] == "simple" assert isinstance(keys, list) - def test_invalid_prompt_type_raises(self, mocker): + def test_invalid_prompt_type_raises(self, mocker: MockerFixture): class InvalidEnum(DummyPromptType): def __iter__(self): return iter([DummyEnumValue("valid")]) @@ -191,7 +197,7 @@ class TestValidateAndSetDefaults: with pytest.raises(ValueError): PromptTemplateConfigManager.validate_and_set_defaults("chat_app", config) - def test_invalid_chat_prompt_config_type(self, mocker): + def test_invalid_chat_prompt_config_type(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = { @@ -203,7 +209,7 @@ class TestValidateAndSetDefaults: with pytest.raises(ValueError): PromptTemplateConfigManager.validate_and_set_defaults("chat_app", config) - def test_simple_mode_invalid_pre_prompt_type(self, mocker): + def test_simple_mode_invalid_pre_prompt_type(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = { @@ -215,7 +221,7 @@ class TestValidateAndSetDefaults: with pytest.raises(ValueError): PromptTemplateConfigManager.validate_and_set_defaults("chat_app", config) - def test_advanced_requires_one_config(self, mocker): + def test_advanced_requires_one_config(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = { @@ -228,7 +234,7 @@ class TestValidateAndSetDefaults: with pytest.raises(ValueError): PromptTemplateConfigManager.validate_and_set_defaults("chat_app", config) - def test_advanced_invalid_model_mode(self, mocker): + def test_advanced_invalid_model_mode(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = { @@ -240,7 +246,7 @@ class TestValidateAndSetDefaults: with pytest.raises(ValueError): PromptTemplateConfigManager.validate_and_set_defaults("chat_app", config) - def test_advanced_chat_prompt_length_exceeds(self, mocker): + def test_advanced_chat_prompt_length_exceeds(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = { @@ -252,7 +258,7 @@ class TestValidateAndSetDefaults: with pytest.raises(ValueError): PromptTemplateConfigManager.validate_and_set_defaults("chat_app", config) - def test_completion_prefix_defaults_set_when_empty(self, mocker): + def test_completion_prefix_defaults_set_when_empty(self, mocker: MockerFixture): self._patch_prompt_type(mocker) config = { diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_variables_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_variables_manager.py index d9fe7004ff..b82417cfed 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_variables_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_variables_manager.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture from core.app.app_config.easy_ui_based_app.variables.manager import ( BasicVariablesConfigManager, @@ -15,7 +16,7 @@ class TestBasicVariablesConfigManagerConvert: assert variables == [] assert external == [] - def test_convert_external_data_tools_enabled_and_disabled(self, mocker): + def test_convert_external_data_tools_enabled_and_disabled(self, mocker: MockerFixture): config = { "external_data_tools": [ {"enabled": False}, @@ -232,7 +233,7 @@ class TestValidateExternalDataToolsAndSetDefaults: with pytest.raises(ValueError): BasicVariablesConfigManager.validate_external_data_tools_and_set_defaults("tenant", config) - def test_validate_disabled_tool_skipped(self, mocker): + def test_validate_disabled_tool_skipped(self, mocker: MockerFixture): config = {"external_data_tools": [{"enabled": False}]} spy = mocker.patch( @@ -250,7 +251,7 @@ class TestValidateExternalDataToolsAndSetDefaults: with pytest.raises(ValueError): BasicVariablesConfigManager.validate_external_data_tools_and_set_defaults("tenant", config) - def test_validate_enabled_tool_calls_factory(self, mocker): + def test_validate_enabled_tool_calls_factory(self, mocker: MockerFixture): config = {"external_data_tools": [{"enabled": True, "type": "tool", "config": {"a": 1}}]} spy = mocker.patch( @@ -263,7 +264,7 @@ class TestValidateExternalDataToolsAndSetDefaults: class TestValidateAndSetDefaultsIntegration: - def test_validate_and_set_defaults_calls_both(self, mocker): + def test_validate_and_set_defaults_calls_both(self, mocker: MockerFixture): config = {} spy_var = mocker.patch.object( diff --git a/api/tests/unit_tests/core/app/app_config/test_base_app_config_manager.py b/api/tests/unit_tests/core/app/app_config/test_base_app_config_manager.py index e99852cf76..e2ab3e2192 100644 --- a/api/tests/unit_tests/core/app/app_config/test_base_app_config_manager.py +++ b/api/tests/unit_tests/core/app/app_config/test_base_app_config_manager.py @@ -2,6 +2,7 @@ from collections import UserDict from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.app.app_config.base_app_config_manager import BaseAppConfigManager @@ -12,7 +13,7 @@ class TestBaseAppConfigManager: return {"key": "value", "another": 123} @pytest.fixture - def mock_app_additional_features(self, mocker): + def mock_app_additional_features(self, mocker: MockerFixture): mock_instance = MagicMock() mocker.patch( "core.app.app_config.base_app_config_manager.AppAdditionalFeatures", @@ -21,7 +22,7 @@ class TestBaseAppConfigManager: return mock_instance @pytest.fixture - def mock_managers(self, mocker): + def mock_managers(self, mocker: MockerFixture): retrieval = mocker.patch( "core.app.app_config.base_app_config_manager.RetrievalResourceConfigManager.convert", return_value="retrieval_result", @@ -72,7 +73,7 @@ class TestBaseAppConfigManager: ) def test_convert_features_all_modes( self, - mocker, + mocker: MockerFixture, mock_config_dict, mock_app_additional_features, mock_managers, @@ -107,7 +108,7 @@ class TestBaseAppConfigManager: mock_managers["speech_to_text"].assert_called_once_with(config=dict(mock_config_dict.items())) mock_managers["text_to_speech"].assert_called_once_with(config=dict(mock_config_dict.items())) - def test_convert_features_empty_config(self, mocker, mock_app_additional_features, mock_managers): + def test_convert_features_empty_config(self, mocker: MockerFixture, mock_app_additional_features, mock_managers): # Arrange empty_config = {} mock_app_mode = MagicMock() @@ -143,7 +144,7 @@ class TestBaseAppConfigManager: with pytest.raises((TypeError, AttributeError)): BaseAppConfigManager.convert_features(invalid_config, "CHAT") - def test_convert_features_manager_exception_propagates(self, mocker, mock_config_dict): + def test_convert_features_manager_exception_propagates(self, mocker: MockerFixture, mock_config_dict): # Arrange mocker.patch( "core.app.app_config.base_app_config_manager.RetrievalResourceConfigManager.convert", @@ -154,7 +155,9 @@ class TestBaseAppConfigManager: with pytest.raises(RuntimeError): BaseAppConfigManager.convert_features(mock_config_dict, "CHAT") - def test_convert_features_mapping_subclass(self, mocker, mock_app_additional_features, mock_managers): + def test_convert_features_mapping_subclass( + self, mocker: MockerFixture, mock_app_additional_features, mock_managers + ): # Arrange class CustomMapping(UserDict): pass diff --git a/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py b/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py index fa128aca87..dacd69a578 100644 --- a/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py +++ b/api/tests/unit_tests/core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture from core.app.app_config.workflow_ui_based_app.variables.manager import ( WorkflowVariablesConfigManager, @@ -10,19 +11,19 @@ from core.app.app_config.workflow_ui_based_app.variables.manager import ( @pytest.fixture -def mock_workflow(mocker): +def mock_workflow(mocker: MockerFixture): workflow = mocker.MagicMock() workflow.graph_dict = {"nodes": []} return workflow @pytest.fixture -def mock_variable_entity(mocker): +def mock_variable_entity(mocker: MockerFixture): return mocker.patch("core.app.app_config.workflow_ui_based_app.variables.manager.VariableEntity") @pytest.fixture -def mock_rag_entity(mocker): +def mock_rag_entity(mocker: MockerFixture): return mocker.patch("core.app.app_config.workflow_ui_based_app.variables.manager.RagPipelineVariableEntity") diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index af5d203f12..5df064030b 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -111,7 +111,7 @@ class TestAdvancedChatAppGeneratorInternals: workflow_id="workflow-id", ) - def test_generate_loads_conversation_and_files(self, monkeypatch): + def test_generate_loads_conversation_and_files(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() @@ -195,8 +195,9 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["application_generate_entity"].files == built_files assert build_files_called["called"] is True - def test_resume_delegates_to_generate(self, monkeypatch): + def test_resume_delegates_to_generate(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() + existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user") application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( task_id="task", app_config=self._build_app_config(), @@ -207,22 +208,25 @@ class TestAdvancedChatAppGeneratorInternals: stream=True, invoke_from=InvokeFrom.WEB_APP, extras={}, - trace_manager=None, + trace_manager=existing_trace_manager, workflow_run_id="run-id", ) - captured: dict[str, object] = {} + captured_entity: AdvancedChatAppGenerateEntity | None = None + captured_graph_runtime_state: object | None = None def _fake_generate(**kwargs): - captured.update(kwargs) - return {"resumed": True} + nonlocal captured_entity, captured_graph_runtime_state + captured_entity = kwargs["application_generate_entity"] + captured_graph_runtime_state = kwargs["graph_runtime_state"] + return SimpleNamespace(resumed=True) monkeypatch.setattr(generator, "_generate", _fake_generate) result = generator.resume( - app_model=SimpleNamespace(), + app_model=SimpleNamespace(id="app-id"), workflow=SimpleNamespace(), - user=SimpleNamespace(), + user=SimpleNamespace(id="end-user-id", session_id="session-id"), conversation=SimpleNamespace(id="conversation-id"), message=SimpleNamespace(id="message-id"), application_generate_entity=application_generate_entity, @@ -232,10 +236,12 @@ class TestAdvancedChatAppGeneratorInternals: pause_state_config=None, ) - assert result == {"resumed": True} - assert captured["graph_runtime_state"] is not None + assert result.resumed is True + assert captured_entity is not None + assert captured_entity.trace_manager is existing_trace_manager + assert captured_graph_runtime_state is not None - def test_single_iteration_generate_builds_debug_task(self, monkeypatch): + def test_single_iteration_generate_builds_debug_task(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() captured: dict[str, object] = {} @@ -293,7 +299,7 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["variable_loader"] is var_loader assert captured["application_generate_entity"].single_iteration_run.node_id == "node-1" - def test_single_loop_generate_builds_debug_task(self, monkeypatch): + def test_single_loop_generate_builds_debug_task(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() app_config = self._build_app_config() captured: dict[str, object] = {} @@ -351,7 +357,7 @@ class TestAdvancedChatAppGeneratorInternals: assert captured["variable_loader"] is var_loader assert captured["application_generate_entity"].single_loop_run.node_id == "node-2" - def test_generate_internal_flow_initial_conversation_with_pause_layer(self, monkeypatch): + def test_generate_internal_flow_initial_conversation_with_pause_layer(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 0 app_config = self._build_app_config() @@ -449,7 +455,7 @@ class TestAdvancedChatAppGeneratorInternals: assert isinstance(captured["conversation"], ConversationSnapshot) assert isinstance(captured["message"], MessageSnapshot) - def test_generate_internal_flow_with_existing_records_skips_init(self, monkeypatch): + def test_generate_internal_flow_with_existing_records_skips_init(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 0 app_config = self._build_app_config() @@ -535,7 +541,7 @@ class TestAdvancedChatAppGeneratorInternals: db_session.refresh.assert_not_called() db_session.close.assert_called_once() - def test_generate_worker_raises_when_workflow_not_found(self, monkeypatch): + def test_generate_worker_raises_when_workflow_not_found(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -594,7 +600,7 @@ class TestAdvancedChatAppGeneratorInternals: graph_runtime_state=None, ) - def test_generate_worker_raises_when_app_not_found_for_internal_call(self, monkeypatch): + def test_generate_worker_raises_when_app_not_found_for_internal_call(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -658,7 +664,7 @@ class TestAdvancedChatAppGeneratorInternals: graph_runtime_state=None, ) - def test_generate_worker_handles_stopped_error(self, monkeypatch): + def test_generate_worker_handles_stopped_error(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -732,7 +738,7 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager.publish_error.assert_not_called() - def test_generate_worker_handles_validation_error(self, monkeypatch): + def test_generate_worker_handles_validation_error(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -816,7 +822,7 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager.publish_error.assert_called_once() - def test_generate_worker_handles_value_and_unknown_errors(self, monkeypatch): + def test_generate_worker_handles_value_and_unknown_errors(self, monkeypatch: pytest.MonkeyPatch): app_config = self._build_app_config() @contextmanager @@ -897,7 +903,7 @@ class TestAdvancedChatAppGeneratorInternals: queue_manager.publish_error.assert_called_once() - def test_handle_response_closed_file_raises_stopped(self, monkeypatch): + def test_handle_response_closed_file_raises_stopped(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 @@ -953,7 +959,7 @@ class TestAdvancedChatAppGeneratorInternals: stream=False, ) - def test_handle_response_re_raises_value_error(self, monkeypatch): + def test_handle_response_re_raises_value_error(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 app_config = self._build_app_config() @@ -1002,7 +1008,7 @@ class TestAdvancedChatAppGeneratorInternals: logger_exception.assert_called_once() - def test_generate_worker_handles_invoke_auth_error(self, monkeypatch): + def test_generate_worker_handles_invoke_auth_error(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() generator._dialogue_count = 1 @@ -1088,7 +1094,7 @@ class TestAdvancedChatAppGeneratorInternals: assert queue_manager.publish_error.called - def test_generate_debugger_enables_retrieve_source(self, monkeypatch): + def test_generate_debugger_enables_retrieve_source(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() app_config = WorkflowUIBasedAppConfig( @@ -1167,7 +1173,7 @@ class TestAdvancedChatAppGeneratorInternals: assert app_config.additional_features.show_retrieve_source is True assert captured["application_generate_entity"].query == "hello" - def test_generate_service_api_sets_parent_message_id(self, monkeypatch): + def test_generate_service_api_sets_parent_message_id(self, monkeypatch: pytest.MonkeyPatch): generator = AdvancedChatAppGenerator() app_config = WorkflowUIBasedAppConfig( @@ -1243,3 +1249,119 @@ class TestAdvancedChatAppGeneratorInternals: ) assert captured["application_generate_entity"].parent_message_id == UUID_NIL + + +class TestAdvancedChatAppGeneratorResume: + @staticmethod + def _build_app_config() -> WorkflowUIBasedAppConfig: + return WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.ADVANCED_CHAT, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + + def test_resume_restores_trace_manager_when_missing(self, monkeypatch: pytest.MonkeyPatch): + generator = AdvancedChatAppGenerator() + application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( + task_id="task", + app_config=self._build_app_config(), + file_upload_config=None, + conversation_id="conversation-id", + inputs={}, + query="hello", + files=[], + parent_message_id="parent-message-id", + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=None, + workflow_run_id="run-id", + ) + DummyTraceQueueManager = type( + "_DummyTraceQueueManager", + (TraceQueueManager,), + { + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) + }, + ) + monkeypatch.setattr( + "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + DummyTraceQueueManager, + ) + captured_entity: AdvancedChatAppGenerateEntity | None = None + + def _fake_generate(**kwargs): + nonlocal captured_entity + captured_entity = kwargs["application_generate_entity"] + return SimpleNamespace(ok=True) + + monkeypatch.setattr(generator, "_generate", _fake_generate) + + result = generator.resume( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(), + user=SimpleNamespace(id="end-user-id", session_id="session-id"), + conversation=SimpleNamespace(id="conversation-id"), + message=SimpleNamespace(id="message-id"), + application_generate_entity=application_generate_entity, + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + graph_runtime_state=SimpleNamespace(), + ) + + assert result.ok is True + assert captured_entity is not None + trace_manager = captured_entity.trace_manager + assert isinstance(trace_manager, DummyTraceQueueManager) + assert trace_manager.app_id == "app-id" + assert trace_manager.user_id == "session-id" + + def test_resume_preserves_existing_trace_manager(self, monkeypatch: pytest.MonkeyPatch): + generator = AdvancedChatAppGenerator() + existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user") + application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( + task_id="task", + app_config=self._build_app_config(), + file_upload_config=None, + conversation_id="conversation-id", + inputs={}, + query="hello", + files=[], + parent_message_id="parent-message-id", + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=existing_trace_manager, + workflow_run_id="run-id", + ) + captured_entity: AdvancedChatAppGenerateEntity | None = None + + def _fake_generate(**kwargs): + nonlocal captured_entity + captured_entity = kwargs["application_generate_entity"] + return SimpleNamespace(ok=True) + + monkeypatch.setattr(generator, "_generate", _fake_generate) + + result = generator.resume( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(), + user=SimpleNamespace(id="end-user-id", session_id="session-id"), + conversation=SimpleNamespace(id="conversation-id"), + message=SimpleNamespace(id="message-id"), + application_generate_entity=application_generate_entity, + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + graph_runtime_state=SimpleNamespace(), + ) + + assert result.ok is True + assert captured_entity is not None + assert captured_entity.trace_manager is existing_trace_manager diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 64bcfa9a18..9726c939e9 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -51,7 +51,7 @@ from core.base.tts.app_generator_tts_publisher import AudioTrunk from core.workflow.system_variables import build_system_variables from graphon.entities.pause_reason import PauseReasonType from graphon.enums import BuiltinNodeTypes -from graphon.nodes.human_input.entities import UserAction +from graphon.nodes.human_input.entities import UserActionConfig from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.enums import MessageStatus @@ -132,7 +132,9 @@ class TestAdvancedChatGenerateTaskPipeline: pipeline._task_state.answer = "partial answer" pipeline._workflow_run_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=build_test_variable_pool( + variables=build_system_variables(workflow_execution_id="run-id"), + ), start_at=0.0, total_tokens=7, node_run_steps=3, @@ -148,7 +150,7 @@ class TestAdvancedChatGenerateTaskPipeline: node_title="Approval", form_content="Need approval", inputs=[], - actions=[UserAction(id="approve", title="Approve")], + actions=[UserActionConfig(id="approve", title="Approve")], display_in_ui=True, form_token="token-1", resolved_default_values={}, @@ -224,7 +226,7 @@ class TestAdvancedChatGenerateTaskPipeline: assert isinstance(responses[0], ValueError) - def test_handle_workflow_started_event_sets_run_id(self, monkeypatch): + def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")), @@ -232,9 +234,19 @@ class TestAdvancedChatGenerateTaskPipeline: ) pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started" + # Track database operations for verification + executed_statements = [] + @contextmanager def _fake_session(): - yield SimpleNamespace() + sess = SimpleNamespace() + + def _execute(stmt): + executed_statements.append(stmt) + return SimpleNamespace() + + sess.execute = _execute + yield sess monkeypatch.setattr(pipeline, "_database_session", _fake_session) monkeypatch.setattr(pipeline, "_get_message", lambda **kwargs: SimpleNamespace()) @@ -244,6 +256,14 @@ class TestAdvancedChatGenerateTaskPipeline: assert pipeline._workflow_run_id == "run-id" assert responses == ["started"] + # Verify database operation was executed + assert len(executed_statements) == 1 + # Verify the UPDATE statement targets the correct message and sets workflow_run_id + update_stmt = executed_statements[0] + stmt_str = str(update_stmt) + assert "UPDATE messages" in stmt_str + assert "WHERE messages.id" in stmt_str + def test_message_end_to_stream_response_strips_annotation_reply(self): pipeline = _make_pipeline() pipeline._task_state.metadata.annotation_reply = AnnotationReply( @@ -368,11 +388,13 @@ class TestAdvancedChatGenerateTaskPipeline: assert list(pipeline._handle_loop_next_event(loop_next)) == ["loop_next"] assert list(pipeline._handle_loop_completed_event(loop_done)) == ["loop_done"] - def test_workflow_finish_handlers(self, monkeypatch): + def test_workflow_finish_handlers(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._workflow_run_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish" @@ -583,7 +605,9 @@ class TestAdvancedChatGenerateTaskPipeline: self.items = items graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) @@ -593,7 +617,7 @@ class TestAdvancedChatGenerateTaskPipeline: assert message.answer == "hello" assert message.message_metadata - def test_handle_stop_event_saves_message_for_moderation(self, monkeypatch): + def test_handle_stop_event_saves_message_for_moderation(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._message_end_to_stream_response = lambda: "end" saved: list[str] = [] @@ -614,10 +638,12 @@ class TestAdvancedChatGenerateTaskPipeline: assert responses == ["end"] assert saved == ["saved"] - def test_handle_message_end_event_applies_output_moderation(self, monkeypatch): + def test_handle_message_end_event_applies_output_moderation(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._base_task_pipeline.handle_output_moderation_when_task_finished = lambda answer: "safe" diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_config_manager.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_config_manager.py index a871e8d93b..d47b70e950 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_config_manager.py @@ -2,6 +2,7 @@ import uuid from types import SimpleNamespace import pytest +from pytest_mock import MockerFixture from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom from core.app.apps.agent_chat.app_config_manager import ( @@ -11,7 +12,7 @@ from core.entities.agent_entities import PlanningStrategy class TestAgentChatAppConfigManagerGetAppConfig: - def test_get_app_config_override_config(self, mocker): + def test_get_app_config_override_config(self, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat") app_model_config = mocker.MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"ignored": True} @@ -45,7 +46,7 @@ class TestAgentChatAppConfigManagerGetAppConfig: assert result.variables == "variables" assert result.external_data_variables == "external" - def test_get_app_config_conversation_specific(self, mocker): + def test_get_app_config_conversation_specific(self, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat") app_model_config = mocker.MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"model": {"provider": "p"}} @@ -76,7 +77,7 @@ class TestAgentChatAppConfigManagerGetAppConfig: assert result.app_model_config_dict == app_model_config.to_dict.return_value assert result.app_model_config_from.value == "conversation-specific-config" - def test_get_app_config_latest_config(self, mocker): + def test_get_app_config_latest_config(self, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat") app_model_config = mocker.MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"model": {"provider": "p"}} @@ -107,7 +108,7 @@ class TestAgentChatAppConfigManagerGetAppConfig: class TestAgentChatAppConfigManagerConfigValidate: - def test_config_validate_filters_related_keys(self, mocker): + def test_config_validate_filters_related_keys(self, mocker: MockerFixture): config = { "model": {}, "user_input_form": {}, @@ -247,7 +248,7 @@ class TestValidateAgentModeAndSetDefaults: {"agent_mode": {"enabled": True, "tools": [{"dataset": {"enabled": True, "id": "bad"}}]}}, ) - def test_old_tool_dataset_id_not_exists(self, mocker): + def test_old_tool_dataset_id_not_exists(self, mocker: MockerFixture): mocker.patch( "core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.is_dataset_exists", return_value=False, @@ -275,7 +276,7 @@ class TestValidateAgentModeAndSetDefaults: "tenant", {"agent_mode": {"enabled": True, "tools": [tool]}} ) - def test_valid_old_and_new_style_tools(self, mocker): + def test_valid_old_and_new_style_tools(self, mocker: MockerFixture): mocker.patch( "core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.is_dataset_exists", return_value=True, diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py index 80f7f94b1a..6cd62c933a 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py @@ -2,6 +2,7 @@ import contextlib import pytest from pydantic import ValidationError +from pytest_mock import MockerFixture from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator from core.app.apps.exc import GenerateTaskStoppedError @@ -16,7 +17,7 @@ class DummyAccount: @pytest.fixture -def generator(mocker): +def generator(mocker: MockerFixture): gen = AgentChatAppGenerator() mocker.patch( "core.app.apps.agent_chat.app_generator.current_app", @@ -27,19 +28,19 @@ def generator(mocker): class TestAgentChatAppGeneratorGenerate: - def test_generate_rejects_blocking_mode(self, generator, mocker): + def test_generate_rejects_blocking_mode(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock() user = DummyAccount("user") with pytest.raises(ValueError): generator.generate(app_model=app_model, user=user, args={}, invoke_from=mocker.MagicMock(), streaming=False) - def test_generate_requires_query(self, generator, mocker): + def test_generate_requires_query(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock() user = DummyAccount("user") with pytest.raises(ValueError): generator.generate(app_model=app_model, user=user, args={"inputs": {}}, invoke_from=mocker.MagicMock()) - def test_generate_rejects_non_string_query(self, generator, mocker): + def test_generate_rejects_non_string_query(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock() user = DummyAccount("user") with pytest.raises(ValueError): @@ -50,7 +51,7 @@ class TestAgentChatAppGeneratorGenerate: invoke_from=mocker.MagicMock(), ) - def test_generate_override_requires_debugger(self, generator, mocker): + def test_generate_override_requires_debugger(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock() user = DummyAccount("user") @@ -62,7 +63,7 @@ class TestAgentChatAppGeneratorGenerate: invoke_from=InvokeFrom.WEB_APP, ) - def test_generate_success_with_debugger_override(self, generator, mocker): + def test_generate_success_with_debugger_override(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat") app_model_config = mocker.MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"model": {"provider": "p"}} @@ -142,7 +143,7 @@ class TestAgentChatAppGeneratorGenerate: assert result == {"result": "ok"} thread_obj.start.assert_called_once() - def test_generate_without_file_config(self, generator, mocker): + def test_generate_without_file_config(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat") app_model_config = mocker.MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"model": {"provider": "p"}} @@ -213,14 +214,14 @@ class TestAgentChatAppGeneratorGenerate: class TestAgentChatAppGeneratorWorker: @pytest.fixture(autouse=True) - def patch_context(self, mocker): + def patch_context(self, mocker: MockerFixture): @contextlib.contextmanager def ctx_manager(*args, **kwargs): yield mocker.patch("core.app.apps.agent_chat.app_generator.preserve_flask_contexts", ctx_manager) - def test_generate_worker_handles_generate_task_stopped(self, generator, mocker): + def test_generate_worker_handles_generate_task_stopped(self, generator, mocker: MockerFixture): queue_manager = mocker.MagicMock() generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock()) generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock()) @@ -250,7 +251,7 @@ class TestAgentChatAppGeneratorWorker: Exception("bad"), ], ) - def test_generate_worker_publishes_errors(self, generator, mocker, error): + def test_generate_worker_publishes_errors(self, generator, mocker: MockerFixture, error): queue_manager = mocker.MagicMock() generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock()) generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock()) @@ -271,7 +272,7 @@ class TestAgentChatAppGeneratorWorker: assert queue_manager.publish_error.called - def test_generate_worker_logs_value_error_when_debug(self, generator, mocker): + def test_generate_worker_logs_value_error_when_debug(self, generator, mocker: MockerFixture): queue_manager = mocker.MagicMock() generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock()) generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock()) diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py index 4567b35480..0260235b03 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture from core.agent.entities import AgentEntity from core.app.apps.agent_chat.app_runner import AgentChatAppRunner @@ -13,7 +14,7 @@ def runner(): class TestAgentChatAppRunnerRun: - def test_run_app_not_found(self, runner, mocker): + def test_run_app_not_found(self, runner, mocker: MockerFixture): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", agent=mocker.MagicMock()) generate_entity = mocker.MagicMock(app_config=app_config, inputs={}, query="q", files=[], stream=True) @@ -22,7 +23,7 @@ class TestAgentChatAppRunnerRun: with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) - def test_run_moderation_error_direct_output(self, runner, mocker): + def test_run_moderation_error_direct_output(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock() @@ -45,7 +46,7 @@ class TestAgentChatAppRunnerRun: runner.direct_output.assert_called_once() - def test_run_annotation_reply_short_circuits(self, runner, mocker): + def test_run_annotation_reply_short_circuits(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock() @@ -74,7 +75,7 @@ class TestAgentChatAppRunnerRun: queue_manager.publish.assert_called_once() runner.direct_output.assert_called_once() - def test_run_hosting_moderation_short_circuits(self, runner, mocker): + def test_run_hosting_moderation_short_circuits(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock() @@ -98,7 +99,7 @@ class TestAgentChatAppRunnerRun: runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) - def test_run_model_schema_missing(self, runner, mocker): + def test_run_model_schema_missing(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -140,7 +141,7 @@ class TestAgentChatAppRunnerRun: (LLMMode.COMPLETION, "CotCompletionAgentRunner"), ], ) - def test_run_chain_of_thought_modes(self, runner, mocker, mode, expected_runner): + def test_run_chain_of_thought_modes(self, runner, mocker: MockerFixture, mode, expected_runner): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -196,7 +197,7 @@ class TestAgentChatAppRunnerRun: runner_instance.run.assert_called_once() runner._handle_invoke_result.assert_called_once() - def test_run_invalid_llm_mode_raises(self, runner, mocker): + def test_run_invalid_llm_mode_raises(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -242,7 +243,7 @@ class TestAgentChatAppRunnerRun: with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), conversation, message) - def test_run_function_calling_strategy_selected_by_features(self, runner, mocker): + def test_run_function_calling_strategy_selected_by_features(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -298,7 +299,7 @@ class TestAgentChatAppRunnerRun: assert app_config.agent.strategy == AgentEntity.Strategy.FUNCTION_CALLING runner_instance.run.assert_called_once() - def test_run_conversation_not_found(self, runner, mocker): + def test_run_conversation_not_found(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.FUNCTION_CALLING) @@ -332,7 +333,7 @@ class TestAgentChatAppRunnerRun: with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(id="conv"), mocker.MagicMock(id="msg")) - def test_run_message_not_found(self, runner, mocker): + def test_run_message_not_found(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.FUNCTION_CALLING) @@ -366,7 +367,7 @@ class TestAgentChatAppRunnerRun: with pytest.raises(ValueError): runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(id="conv"), mocker.MagicMock(id="msg")) - def test_run_invalid_agent_strategy_raises(self, runner, mocker): + def test_run_invalid_agent_strategy_raises(self, runner, mocker: MockerFixture): app_record = mocker.MagicMock(id="app1", tenant_id="tenant") app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock(strategy="invalid", provider="p", model="m") diff --git a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py index aa2085177e..8dcf6e9193 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture import core.app.apps.completion.app_runner as module from core.app.apps.completion.app_runner import CompletionAppRunner @@ -47,7 +48,7 @@ def _build_generate_entity(app_config, file_upload_config=None): class TestCompletionAppRunner: - def test_run_app_not_found(self, runner, mocker): + def test_run_app_not_found(self, runner, mocker: MockerFixture): session = mocker.MagicMock() session.scalar.return_value = None mocker.patch.object(module.db, "session", session) @@ -58,7 +59,7 @@ class TestCompletionAppRunner: with pytest.raises(ValueError): runner.run(app_generate_entity, MagicMock(), MagicMock()) - def test_run_moderation_error_outputs_direct(self, runner, mocker): + def test_run_moderation_error_outputs_direct(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") session = mocker.MagicMock() @@ -78,7 +79,7 @@ class TestCompletionAppRunner: runner.direct_output.assert_called_once() runner._handle_invoke_result.assert_not_called() - def test_run_hosting_moderation_stops(self, runner, mocker): + def test_run_hosting_moderation_stops(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") session = mocker.MagicMock() @@ -97,7 +98,7 @@ class TestCompletionAppRunner: runner._handle_invoke_result.assert_not_called() - def test_run_dataset_and_external_tools_flow(self, runner, mocker): + def test_run_dataset_and_external_tools_flow(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") session = mocker.MagicMock() @@ -140,7 +141,7 @@ class TestCompletionAppRunner: assert dataset_retrieval.retrieve.call_args.kwargs["query"] == "query_from_input" runner._handle_invoke_result.assert_called_once() - def test_run_uses_low_image_detail_default(self, runner, mocker): + def test_run_uses_low_image_detail_default(self, runner, mocker: MockerFixture): app_record = MagicMock(id="app1", tenant_id="tenant") session = mocker.MagicMock() diff --git a/api/tests/unit_tests/core/app/apps/completion/test_completion_app_config_manager.py b/api/tests/unit_tests/core/app/apps/completion/test_completion_app_config_manager.py index 024bd8f302..353162be8c 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_completion_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_completion_app_config_manager.py @@ -1,6 +1,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock +from pytest_mock import MockerFixture + import core.app.apps.completion.app_config_manager as module from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom from core.app.apps.completion.app_config_manager import CompletionAppConfigManager @@ -8,7 +10,7 @@ from models.model import AppMode class TestCompletionAppConfigManager: - def test_get_app_config_with_override(self, mocker): + def test_get_app_config_with_override(self, mocker: MockerFixture): app_model = MagicMock(tenant_id="tenant", id="app1", mode=AppMode.COMPLETION.value) app_model_config = MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"model": {"provider": "x"}} @@ -35,8 +37,8 @@ class TestCompletionAppConfigManager: assert result.external_data_variables == ["ext1"] assert result.app_mode == AppMode.COMPLETION - def test_get_app_config_without_override_uses_model_config(self, mocker): - app_model = MagicMock(tenant_id="tenant", id="app1", mode=AppMode.COMPLETION.value) + def test_get_app_config_without_override_uses_model_config(self, mocker: MockerFixture): + app_model = MagicMock(tenant_id="tenant", id="app1", mode=AppMode.COMPLETION) app_model_config = MagicMock(id="cfg1") app_model_config.to_dict.return_value = {"model": {"provider": "x"}} @@ -53,7 +55,7 @@ class TestCompletionAppConfigManager: assert result.app_model_config_from == EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG assert result.app_model_config_dict == {"model": {"provider": "x"}} - def test_config_validate_filters_related_keys(self, mocker): + def test_config_validate_filters_related_keys(self, mocker: MockerFixture): config = { "model": {"provider": "x"}, "variables": ["v"], diff --git a/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py b/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py index f2e35f9900..de20dde677 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from pydantic import ValidationError +from pytest_mock import MockerFixture import core.app.apps.completion.app_generator as module from core.app.apps.completion.app_generator import CompletionAppGenerator @@ -15,7 +16,7 @@ from services.errors.message import MessageNotExistsError @pytest.fixture -def generator(mocker): +def generator(mocker: MockerFixture): gen = CompletionAppGenerator() mocker.patch.object(module, "copy_current_request_context", side_effect=lambda fn: fn) @@ -69,7 +70,7 @@ class TestCompletionAppGenerator: streaming=False, ) - def test_generate_success_no_file_config(self, generator, mocker): + def test_generate_success_no_file_config(self, generator, mocker: MockerFixture): app_model_config = _build_app_model_config() mocker.patch.object(generator, "_get_app_model_config", return_value=app_model_config) mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=None) @@ -99,7 +100,7 @@ class TestCompletionAppGenerator: assert result == "converted" module.file_factory.build_from_mappings.assert_not_called() - def test_generate_success_with_files(self, generator, mocker): + def test_generate_success_with_files(self, generator, mocker: MockerFixture): app_model_config = _build_app_model_config() mocker.patch.object(generator, "_get_app_model_config", return_value=app_model_config) @@ -131,7 +132,7 @@ class TestCompletionAppGenerator: assert result == "converted" module.file_factory.build_from_mappings.assert_called_once() - def test_generate_override_model_config_debugger(self, generator, mocker): + def test_generate_override_model_config_debugger(self, generator, mocker: MockerFixture): app_model_config = _build_app_model_config() mocker.patch.object(generator, "_get_app_model_config", return_value=app_model_config) @@ -165,7 +166,7 @@ class TestCompletionAppGenerator: assert get_app_config.call_args.kwargs["override_config_dict"] == override_config - def test_generate_more_like_this_message_not_found(self, generator, mocker): + def test_generate_more_like_this_message_not_found(self, generator, mocker: MockerFixture): session = mocker.MagicMock() session.scalar.return_value = None mocker.patch.object(module.db, "session", session) @@ -178,7 +179,7 @@ class TestCompletionAppGenerator: invoke_from=InvokeFrom.WEB_APP, ) - def test_generate_more_like_this_disabled(self, generator, mocker): + def test_generate_more_like_this_disabled(self, generator, mocker: MockerFixture): app_model = _build_app_model() app_model.app_model_config = MagicMock(more_like_this=False, more_like_this_dict={"enabled": False}) @@ -195,7 +196,7 @@ class TestCompletionAppGenerator: invoke_from=InvokeFrom.WEB_APP, ) - def test_generate_more_like_this_app_model_config_missing(self, generator, mocker): + def test_generate_more_like_this_app_model_config_missing(self, generator, mocker: MockerFixture): app_model = _build_app_model() app_model.app_model_config = None @@ -212,7 +213,7 @@ class TestCompletionAppGenerator: invoke_from=InvokeFrom.WEB_APP, ) - def test_generate_more_like_this_message_config_none(self, generator, mocker): + def test_generate_more_like_this_message_config_none(self, generator, mocker: MockerFixture): app_model = _build_app_model() app_model.app_model_config = MagicMock(more_like_this=True, more_like_this_dict={"enabled": True}) @@ -229,7 +230,7 @@ class TestCompletionAppGenerator: invoke_from=InvokeFrom.WEB_APP, ) - def test_generate_more_like_this_success(self, generator, mocker): + def test_generate_more_like_this_success(self, generator, mocker: MockerFixture): app_model = _build_app_model() app_model.app_model_config = MagicMock(more_like_this=True, more_like_this_dict={"enabled": True}) @@ -297,7 +298,7 @@ class TestCompletionAppGenerator: (RuntimeError("boom"), True), ], ) - def test_generate_worker_error_handling(self, generator, mocker, error, should_publish): + def test_generate_worker_error_handling(self, generator, mocker: MockerFixture, error, should_publish): flask_app = MagicMock() flask_app.app_context.return_value = contextlib.nullcontext() diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py index 5d4c9bcde0..6c1ee20ffb 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py @@ -1,12 +1,14 @@ from types import SimpleNamespace from unittest.mock import MagicMock +from pytest_mock import MockerFixture + import core.app.apps.pipeline.pipeline_config_manager as module from core.app.apps.pipeline.pipeline_config_manager import PipelineConfigManager from models.model import AppMode -def test_get_pipeline_config(mocker): +def test_get_pipeline_config(mocker: MockerFixture): pipeline = MagicMock(tenant_id="tenant", id="pipe1") workflow = MagicMock(id="wf1") @@ -26,7 +28,7 @@ def test_get_pipeline_config(mocker): assert result.rag_pipeline_variables == ["var1"] -def test_config_validate_filters_related_keys(mocker): +def test_config_validate_filters_related_keys(mocker: MockerFixture): config = { "file_upload": {"enabled": True}, "tts": {"enabled": True}, diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py index c36edf48fc..dd91243a37 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock import pytest +from pytest_mock import MockerFixture import core.app.apps.pipeline.pipeline_generator as module from core.app.apps.exc import GenerateTaskStoppedError @@ -23,7 +24,7 @@ class FakeRagPipelineGenerateEntity(SimpleNamespace): @pytest.fixture -def generator(mocker): +def generator(mocker: MockerFixture): gen = module.PipelineGenerator() mocker.patch.object(module, "RagPipelineGenerateEntity", FakeRagPipelineGenerateEntity) @@ -88,7 +89,7 @@ class DummySession: return False -def test_generate_dataset_missing(generator, mocker): +def test_generate_dataset_missing(generator, mocker: MockerFixture): pipeline = _build_pipeline() pipeline.retrieve_dataset.return_value = None @@ -106,7 +107,7 @@ def test_generate_dataset_missing(generator, mocker): ) -def test_generate_debugger_calls_generate(generator, mocker): +def test_generate_debugger_calls_generate(generator, mocker: MockerFixture): pipeline = _build_pipeline() workflow = _build_workflow() @@ -150,7 +151,7 @@ def test_generate_debugger_calls_generate(generator, mocker): assert result == {"result": "ok"} -def test_generate_published_pipeline_creates_documents_and_delay(generator, mocker): +def test_generate_published_pipeline_creates_documents_and_delay(generator, mocker: MockerFixture): pipeline = _build_pipeline() workflow = _build_workflow() @@ -228,7 +229,7 @@ def test_generate_published_pipeline_creates_documents_and_delay(generator, mock task_proxy.delay.assert_called_once() -def test_generate_is_retry_calls_generate(generator, mocker): +def test_generate_is_retry_calls_generate(generator, mocker: MockerFixture): pipeline = _build_pipeline() workflow = _build_workflow() @@ -273,7 +274,7 @@ def test_generate_is_retry_calls_generate(generator, mocker): assert result == {"result": "ok"} -def test_generate_worker_handles_errors(generator, mocker): +def test_generate_worker_handles_errors(generator, mocker: MockerFixture): flask_app = MagicMock() flask_app.app_context.return_value = contextlib.nullcontext() mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve) @@ -308,7 +309,7 @@ def test_generate_worker_handles_errors(generator, mocker): queue_manager.publish_error.assert_called_once() -def test_generate_worker_sets_system_user_id_for_external_call(generator, mocker): +def test_generate_worker_sets_system_user_id_for_external_call(generator, mocker: MockerFixture): flask_app = MagicMock() flask_app.app_context.return_value = contextlib.nullcontext() mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve) @@ -341,7 +342,7 @@ def test_generate_worker_sets_system_user_id_for_external_call(generator, mocker assert module.PipelineRunner.call_args.kwargs["system_user_id"] == "session" -def test_generate_raises_when_workflow_not_found(generator, mocker): +def test_generate_raises_when_workflow_not_found(generator, mocker: MockerFixture): flask_app = MagicMock() mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve) @@ -369,7 +370,7 @@ def test_generate_raises_when_workflow_not_found(generator, mocker): ) -def test_generate_success_returns_converted(generator, mocker): +def test_generate_success_returns_converted(generator, mocker: MockerFixture): flask_app = MagicMock() mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve) @@ -409,7 +410,7 @@ def test_generate_success_returns_converted(generator, mocker): assert result == "converted" -def test_single_iteration_generate_validates_inputs(generator, mocker): +def test_single_iteration_generate_validates_inputs(generator, mocker: MockerFixture): with pytest.raises(ValueError): generator.single_iteration_generate(_build_pipeline(), _build_workflow(), "", _build_user(), {}) @@ -419,7 +420,7 @@ def test_single_iteration_generate_validates_inputs(generator, mocker): ) -def test_single_iteration_generate_dataset_required(generator, mocker): +def test_single_iteration_generate_dataset_required(generator, mocker: MockerFixture): pipeline = _build_pipeline() pipeline.retrieve_dataset.return_value = None @@ -436,7 +437,7 @@ def test_single_iteration_generate_dataset_required(generator, mocker): ) -def test_single_iteration_generate_success(generator, mocker): +def test_single_iteration_generate_success(generator, mocker: MockerFixture): pipeline = _build_pipeline() session = DummySession() @@ -476,7 +477,7 @@ def test_single_iteration_generate_success(generator, mocker): assert result == {"ok": True} -def test_single_loop_generate_success(generator, mocker): +def test_single_loop_generate_success(generator, mocker: MockerFixture): pipeline = _build_pipeline() session = DummySession() @@ -516,7 +517,7 @@ def test_single_loop_generate_success(generator, mocker): assert result == {"ok": True} -def test_handle_response_value_error_triggers_generate_task_stopped(generator, mocker): +def test_handle_response_value_error_triggers_generate_task_stopped(generator, mocker: MockerFixture): pipeline = _build_pipeline() workflow = _build_workflow() app_entity = FakeRagPipelineGenerateEntity(task_id="t") @@ -536,7 +537,7 @@ def test_handle_response_value_error_triggers_generate_task_stopped(generator, m ) -def test_build_document_sets_metadata_for_builtin_fields(generator, mocker): +def test_build_document_sets_metadata_for_builtin_fields(generator, mocker: MockerFixture): class DummyDocument(SimpleNamespace): pass @@ -620,7 +621,7 @@ def test_format_datasource_info_list_missing_node_data(generator): ) -def test_format_datasource_info_list_online_drive_folder(generator, mocker): +def test_format_datasource_info_list_online_drive_folder(generator, mocker: MockerFixture): workflow = MagicMock( graph_dict={ "nodes": [ diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py index 9db83f5531..abfc76afa0 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture import core.app.apps.pipeline.pipeline_queue_manager as module from core.app.apps.base_app_queue_manager import PublishFrom @@ -16,7 +17,7 @@ from core.app.entities.queue_entities import ( from graphon.model_runtime.entities.llm_entities import LLMResult -def test_publish_sets_stop_listen_and_raises_on_stopped(mocker): +def test_publish_sets_stop_listen_and_raises_on_stopped(mocker: MockerFixture): manager = PipelineQueueManager(task_id="t", user_id="u", invoke_from=InvokeFrom.WEB_APP, app_mode="rag") manager._q = mocker.MagicMock() manager.stop_listen = mocker.MagicMock() @@ -28,7 +29,7 @@ def test_publish_sets_stop_listen_and_raises_on_stopped(mocker): manager.stop_listen.assert_called_once() -def test_publish_stop_events_trigger_stop_listen(mocker): +def test_publish_stop_events_trigger_stop_listen(mocker: MockerFixture): manager = PipelineQueueManager(task_id="t", user_id="u", invoke_from=InvokeFrom.WEB_APP, app_mode="rag") manager._q = mocker.MagicMock() manager.stop_listen = mocker.MagicMock() @@ -46,7 +47,7 @@ def test_publish_stop_events_trigger_stop_listen(mocker): manager.stop_listen.assert_called_once() -def test_publish_non_stop_event_no_stop_listen(mocker): +def test_publish_non_stop_event_no_stop_listen(mocker: MockerFixture): manager = PipelineQueueManager(task_id="t", user_id="u", invoke_from=InvokeFrom.WEB_APP, app_mode="rag") manager._q = mocker.MagicMock() manager.stop_listen = mocker.MagicMock() diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index 603062a51c..1eed76cf84 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -22,6 +22,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture import core.app.apps.pipeline.pipeline_runner as module from core.app.apps.pipeline.pipeline_runner import PipelineRunner @@ -126,7 +127,7 @@ def test_update_document_status_on_failure(mocker, runner): session.commit.assert_called_once() -def test_run_pipeline_not_found(mocker): +def test_run_pipeline_not_found(mocker: MockerFixture): app_generate_entity = _build_app_generate_entity() app_generate_entity.invoke_from = InvokeFrom.WEB_APP app_generate_entity.single_iteration_run = None @@ -150,7 +151,7 @@ def test_run_pipeline_not_found(mocker): runner.run() -def test_run_workflow_not_initialized(mocker): +def test_run_workflow_not_initialized(mocker: MockerFixture): app_generate_entity = _build_app_generate_entity() pipeline = MagicMock(id="pipe") @@ -174,7 +175,7 @@ def test_run_workflow_not_initialized(mocker): runner.run() -def test_run_single_iteration_path(mocker): +def test_run_single_iteration_path(mocker: MockerFixture): app_generate_entity = _build_app_generate_entity() app_generate_entity.single_iteration_run = MagicMock() @@ -223,7 +224,7 @@ def test_run_single_iteration_path(mocker): runner._handle_event.assert_called() -def test_run_normal_path_builds_graph(mocker): +def test_run_normal_path_builds_graph(mocker: MockerFixture): app_generate_entity = _build_app_generate_entity() pipeline = MagicMock(id="pipe") diff --git a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py index f48a7fb38e..835c9a8576 100644 --- a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py @@ -45,7 +45,7 @@ def _make_generate_entity(app_config: WorkflowUIBasedAppConfig) -> AdvancedChatA @pytest.fixture(autouse=True) -def _mock_db_session(monkeypatch): +def _mock_db_session(monkeypatch: pytest.MonkeyPatch): session = MagicMock() def refresh_side_effect(obj): @@ -108,7 +108,7 @@ def test_init_generate_records_marks_existing_conversation(): assert entity.is_new_conversation is False -def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch): +def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch: pytest.MonkeyPatch): app_config = _make_app_config() entity = _make_generate_entity(app_config) entity.conversation_id = "existing-conversation-id" diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py index b0f8b423e1..f2a1700664 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py @@ -369,7 +369,7 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default(): class TestBaseAppGeneratorExtras: - def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch): + def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest.MonkeyPatch): base_app_generator = BaseAppGenerator() variables = [ diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py index 17de39ca99..c6eedf7be7 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py @@ -42,7 +42,7 @@ class _QueueRecorder: class TestAppRunner: - def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch): + def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() model_schema = SimpleNamespace( @@ -65,7 +65,7 @@ class TestAppRunner: assert model_config.parameters["max_tokens"] == 20 - def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypatch): + def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() model_schema = SimpleNamespace( @@ -86,7 +86,7 @@ class TestAppRunner: assert runner.recalc_llm_max_tokens(model_config, prompt_messages=[]) == -1 - def test_direct_output_streaming_publishes_chunks_and_end(self, monkeypatch): + def test_direct_output_streaming_publishes_chunks_and_end(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() queue = _QueueRecorder() app_generate_entity = SimpleNamespace(model_conf=SimpleNamespace(model="mock"), stream=True) @@ -133,7 +133,7 @@ class TestAppRunner: stream=True, ) - def test_organize_prompt_messages_simple_template(self, monkeypatch): + def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() model_config = SimpleNamespace(mode="chat", stop=["STOP"]) prompt_template_entity = PromptTemplateEntity( @@ -158,7 +158,7 @@ class TestAppRunner: assert prompt_messages == ["simple-message"] assert stop == ["simple-stop"] - def test_organize_prompt_messages_advanced_completion_template(self, monkeypatch): + def test_organize_prompt_messages_advanced_completion_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() model_config = SimpleNamespace(mode="completion", stop=[""]) captured: dict[str, object] = {} @@ -191,7 +191,7 @@ class TestAppRunner: assert memory_config.role_prefix.user == "U" assert memory_config.role_prefix.assistant == "A" - def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch): + def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() model_config = SimpleNamespace(mode="chat", stop=[""]) captured: dict[str, object] = {} @@ -245,7 +245,7 @@ class TestAppRunner: files=[], ) - def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, monkeypatch): + def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() queue = _QueueRecorder() warning_logger = MagicMock() @@ -284,7 +284,7 @@ class TestAppRunner: assert queue.events[-1].llm_result.message.content == "abc" warning_logger.assert_called_once() - def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors(self, monkeypatch): + def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() queue = _QueueRecorder() exception_logger = MagicMock() @@ -331,7 +331,7 @@ class TestAppRunner: assert queue.events[-1].llm_result.usage == usage exception_logger.assert_called_once() - def test_handle_multimodal_image_content_fallback_return_branch(self, monkeypatch): + def test_handle_multimodal_image_content_fallback_return_branch(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() class _ToggleBool: @@ -367,7 +367,7 @@ class TestAppRunner: db_session.add.assert_not_called() queue_manager.publish.assert_not_called() - def test_check_hosting_moderation_direct_output_called(self, monkeypatch): + def test_check_hosting_moderation_direct_output_called(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() queue = _QueueRecorder() app_generate_entity = SimpleNamespace(stream=False) @@ -388,7 +388,7 @@ class TestAppRunner: assert result is True assert direct_output.called - def test_fill_in_inputs_from_external_data_tools(self, monkeypatch): + def test_fill_in_inputs_from_external_data_tools(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() monkeypatch.setattr( "core.app.apps.base_app_runner.ExternalDataFetch.fetch", @@ -405,7 +405,7 @@ class TestAppRunner: assert result == {"foo": "bar"} - def test_moderation_for_inputs_returns_result(self, monkeypatch): + def test_moderation_for_inputs_returns_result(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() monkeypatch.setattr( "core.app.apps.base_app_runner.InputModeration.check", @@ -424,7 +424,7 @@ class TestAppRunner: assert result == (True, {}, "") - def test_query_app_annotations_to_reply(self, monkeypatch): + def test_query_app_annotations_to_reply(self, monkeypatch: pytest.MonkeyPatch): runner = AppRunner() monkeypatch.setattr( "core.app.apps.base_app_runner.AnnotationReplyFeature.query", diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py index 1250ac5ecf..6a9b5e7619 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py @@ -85,7 +85,7 @@ def _make_chat_generate_entity(app_config: EasyUIBasedAppConfig) -> ChatAppGener @pytest.fixture(autouse=True) -def _mock_db_session(monkeypatch): +def _mock_db_session(monkeypatch: pytest.MonkeyPatch): session = MagicMock() def refresh_side_effect(obj): @@ -130,7 +130,7 @@ def test_init_generate_records_sets_conversation_fields_for_chat_entity(): class TestMessageBasedAppGeneratorExtras: - def test_handle_response_closed_file_raises_stopped(self, monkeypatch): + def test_handle_response_closed_file_raises_stopped(self, monkeypatch: pytest.MonkeyPatch): generator = MessageBasedAppGenerator() class _Pipeline: @@ -155,7 +155,7 @@ class TestMessageBasedAppGeneratorExtras: stream=False, ) - def test_get_app_model_config_requires_valid_config(self, monkeypatch): + def test_get_app_model_config_requires_valid_config(self, monkeypatch: pytest.MonkeyPatch): generator = MessageBasedAppGenerator() app_model = SimpleNamespace(id="app", app_model_config_id=None, app_model_config=None) diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py index 6104b8d6ca..5f13c6aff6 100644 --- a/api/tests/unit_tests/core/app/apps/test_pause_resume.py +++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py @@ -3,6 +3,8 @@ import time from types import ModuleType, SimpleNamespace from typing import Any +from pytest_mock import MockerFixture + import graphon.nodes.human_input.entities # noqa: F401 from core.app.apps.advanced_chat import app_generator as adv_app_gen_module from core.app.apps.workflow import app_generator as wf_app_gen_module @@ -58,7 +60,7 @@ class _StubToolNode(Node[_StubToolNodeData]): def __init__( self, node_id: str, - config: _StubToolNodeData, + data: _StubToolNodeData, *, graph_init_params, graph_runtime_state, @@ -66,7 +68,7 @@ class _StubToolNode(Node[_StubToolNodeData]): ) -> None: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) @@ -101,7 +103,7 @@ class _StubToolNode(Node[_StubToolNodeData]): yield self._convert_node_run_result_to_graph_node_event(result) -def _patch_tool_node(mocker): +def _patch_tool_node(mocker: MockerFixture): original_resolve_node_class = node_factory_module.resolve_workflow_node_class def _patched_resolve_node_class(*, node_type: NodeType, node_version: str) -> type[Node]: @@ -167,7 +169,7 @@ def _build_graph(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> G def _build_runtime_state(run_id: str) -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="user", app_id="app", workflow_id="workflow"), user_inputs={}, conversation_variables=[], @@ -196,7 +198,7 @@ def _node_successes(events: list[GraphEngineEvent]) -> list[str]: return [evt.node_id for evt in events if isinstance(evt, NodeRunSucceededEvent)] -def test_workflow_app_pause_resume_matches_baseline(mocker): +def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): _patch_tool_node(mocker) baseline_state = _build_runtime_state("baseline") @@ -226,7 +228,11 @@ def test_workflow_app_pause_resume_matches_baseline(mocker): app_model=SimpleNamespace(mode="workflow"), workflow=SimpleNamespace(), user=SimpleNamespace(), - application_generate_entity=SimpleNamespace(stream=False, invoke_from=InvokeFrom.SERVICE_API), + application_generate_entity=SimpleNamespace( + stream=False, + invoke_from=InvokeFrom.SERVICE_API, + trace_manager=SimpleNamespace(), + ), graph_runtime_state=resumed_state, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), @@ -236,7 +242,7 @@ def test_workflow_app_pause_resume_matches_baseline(mocker): assert resumed_state.outputs == baseline_outputs -def test_advanced_chat_pause_resume_matches_baseline(mocker): +def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture): _patch_tool_node(mocker) baseline_state = _build_runtime_state("adv-baseline") @@ -268,7 +274,11 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker): user=SimpleNamespace(), conversation=SimpleNamespace(id="conv"), message=SimpleNamespace(id="msg"), - application_generate_entity=SimpleNamespace(stream=False, invoke_from=InvokeFrom.SERVICE_API), + application_generate_entity=SimpleNamespace( + stream=False, + invoke_from=InvokeFrom.SERVICE_API, + trace_manager=SimpleNamespace(), + ), workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), graph_runtime_state=resumed_state, diff --git a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py index 58f0e47a4b..12f3ed9f07 100644 --- a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py +++ b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py @@ -54,7 +54,7 @@ class FakeTopic: return self._state["subscribed"] -def test_retrieve_events_calls_on_subscribe_after_subscription(monkeypatch): +def test_retrieve_events_calls_on_subscribe_after_subscription(monkeypatch: pytest.MonkeyPatch): topic = FakeTopic() def fake_get_response_topic(cls, app_mode, workflow_run_id): @@ -92,7 +92,7 @@ def test_normalize_terminal_events_empty_values(): assert _normalize_terminal_events([]) == set({}) -def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch): +def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch: pytest.MonkeyPatch): topic = FakeTopic() times = [1000.0, 1000.0, 1001.0, 1001.0, 1002.0] diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 7e8367c6c4..44c34a0142 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -1,6 +1,9 @@ +import contextlib from types import SimpleNamespace from unittest.mock import MagicMock +from pytest_mock import MockerFixture + from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator @@ -22,11 +25,81 @@ def test_should_prepare_user_inputs_keeps_validation_when_flag_false(): assert WorkflowAppGenerator()._should_prepare_user_inputs(args) -def test_resume_delegates_to_generate(mocker): +def test_generate_includes_parent_trace_context_in_extras(monkeypatch): + generator = WorkflowAppGenerator() + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerator._bind_file_access_scope", + lambda *args, **kwargs: contextlib.nullcontext(), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config", + lambda *args, **kwargs: SimpleNamespace( + app_id="app-1", tenant_id="tenant-1", workflow_id="workflow-1", variables=[] + ), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.file_factory.build_from_mappings", lambda *args, **kwargs: [] + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.TraceQueueManager", MagicMock()) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", + MagicMock(return_value=MagicMock()), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + MagicMock(return_value=MagicMock()), + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.db", SimpleNamespace(engine=MagicMock())) + monkeypatch.setattr(generator, "_prepare_user_inputs", lambda *, user_inputs, **kwargs: user_inputs) + + captured = {} + + def fake_workflow_app_generate_entity(**kwargs): + captured["workflow_app_generate_entity_kwargs"] = kwargs + return SimpleNamespace(**kwargs) + + def fake_generate(**kwargs): + captured["application_generate_entity"] = kwargs["application_generate_entity"] + return {"data": {}} + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerateEntity", fake_workflow_app_generate_entity + ) + monkeypatch.setattr(generator, "_generate", fake_generate) + + result = generator.generate( + app_model=SimpleNamespace(tenant_id="tenant-1", id="app-1"), + workflow=SimpleNamespace(features_dict={}), + user=SimpleNamespace(id="user-1", session_id="session-1"), + args={ + "inputs": {"query": "hello"}, + "files": [], + "external_trace_id": "trace-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + invoke_from="service-api", + streaming=False, + call_depth=0, + ) + + assert result == {"data": {}} + extras = captured["workflow_app_generate_entity_kwargs"]["extras"] + assert extras["external_trace_id"] == "trace-1" + assert extras["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_resume_delegates_to_generate(mocker: MockerFixture): generator = WorkflowAppGenerator() mock_generate = mocker.patch.object(generator, "_generate", return_value="ok") - application_generate_entity = SimpleNamespace(stream=False, invoke_from="debugger") + application_generate_entity = SimpleNamespace(stream=False, invoke_from="debugger", trace_manager=MagicMock()) runtime_state = MagicMock(name="runtime-state") pause_config = MagicMock(name="pause-config") @@ -52,7 +125,7 @@ def test_resume_delegates_to_generate(mocker): assert kwargs["invoke_from"] == "debugger" -def test_generate_appends_pause_layer_and_forwards_state(mocker): +def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture): generator = WorkflowAppGenerator() mock_queue_manager = MagicMock() @@ -124,7 +197,7 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker): assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state -def test_resume_path_runs_worker_with_runtime_state(mocker): +def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture): generator = WorkflowAppGenerator() runtime_state = MagicMock(name="runtime-state") diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 58c7bfa4bc..3949c41eae 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -54,7 +54,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -90,10 +90,10 @@ class TestWorkflowBasedAppRunner: with pytest.raises(ValueError, match="Neither single_iteration_run nor single_loop_run"): runner._prepare_single_node_execution(workflow, None, None, user_id="00000000-0000-0000-0000-000000000001") - def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch): + def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch: pytest.MonkeyPatch): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -142,7 +142,9 @@ class TestWorkflowBasedAppRunner: assert graph is not None assert variable_pool is graph_runtime_state.variable_pool - def test_get_graph_and_variable_pool_preloads_constructor_variables_before_graph_init(self, monkeypatch): + def test_get_graph_and_variable_pool_preloads_constructor_variables_before_graph_init( + self, monkeypatch: pytest.MonkeyPatch + ): variable_loader = SimpleNamespace( load_variables=lambda selectors: ( [ @@ -162,7 +164,7 @@ class TestWorkflowBasedAppRunner: app_id="app", ) graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) @@ -232,7 +234,7 @@ class TestWorkflowBasedAppRunner: assert graph is not None assert variable_pool.get(["sys", "conversation_id"]).value == "conv-1" - def test_handle_graph_run_events_and_pause_notifications(self, monkeypatch): + def test_handle_graph_run_events_and_pause_notifications(self, monkeypatch: pytest.MonkeyPatch): published: list[object] = [] class _QueueManager: @@ -241,7 +243,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) graph_runtime_state.register_paused_node("node-1") @@ -284,7 +286,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) @@ -423,7 +425,7 @@ class TestWorkflowBasedAppRunner: runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index 620a153204..248fed5388 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -16,7 +16,7 @@ from models.workflow import Workflow def _make_graph_state(): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, environment_variables=[], diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index a3ab379b66..72a46a74c9 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -14,7 +14,7 @@ from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.entities.pause_reason import HumanInputRequired from graphon.graph_events import GraphRunPausedEvent -from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig from graphon.nodes.human_input.enums import FormInputType from models.account import Account from models.human_input import RecipientType @@ -157,9 +157,9 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon form_id="form-1", form_content="Rendered", inputs=[ - FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="field", default=None), + ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None), ], - actions=[UserAction(id="approve", title="Approve")], + actions=[UserActionConfig(id="approve", title="Approve")], node_id="node-id", node_title="Human Step", ) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index 09ad078a70..941a47b572 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -67,7 +67,7 @@ class TestWorkflowAppGeneratorValidation: class TestWorkflowAppGeneratorHandleResponse: - def test_handle_response_closed_file_raises_stopped(self, monkeypatch): + def test_handle_response_closed_file_raises_stopped(self, monkeypatch: pytest.MonkeyPatch): generator = WorkflowAppGenerator() app_config = WorkflowUIBasedAppConfig( @@ -116,7 +116,7 @@ class TestWorkflowAppGeneratorHandleResponse: class TestWorkflowAppGeneratorGenerate: - def test_generate_skips_prepare_inputs_when_flag_set(self, monkeypatch): + def test_generate_skips_prepare_inputs_when_flag_set(self, monkeypatch: pytest.MonkeyPatch): generator = WorkflowAppGenerator() app_config = WorkflowUIBasedAppConfig( @@ -186,3 +186,114 @@ class TestWorkflowAppGeneratorGenerate: ) assert result == {"ok": True} + + +class TestWorkflowAppGeneratorResume: + def test_resume_restores_trace_manager_when_missing(self, monkeypatch: pytest.MonkeyPatch): + generator = WorkflowAppGenerator() + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.WORKFLOW, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = WorkflowAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=None, + workflow_execution_id="run-id", + call_depth=0, + ) + DummyTraceQueueManager = type( + "_DummyTraceQueueManager", + (TraceQueueManager,), + { + "__init__": lambda self, app_id=None, user_id=None: ( + setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id) + ) + }, + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.TraceQueueManager", + DummyTraceQueueManager, + ) + captured_entity: WorkflowAppGenerateEntity | None = None + + def _fake_generate(**kwargs): + nonlocal captured_entity + captured_entity = kwargs["application_generate_entity"] + return SimpleNamespace(ok=True) + + monkeypatch.setattr(generator, "_generate", _fake_generate) + + result = generator.resume( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(), + user=SimpleNamespace(id="end-user-id", session_id="session-id"), + application_generate_entity=application_generate_entity, + graph_runtime_state=SimpleNamespace(), + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + ) + + assert result.ok is True + assert captured_entity is not None + trace_manager = captured_entity.trace_manager + assert isinstance(trace_manager, DummyTraceQueueManager) + assert trace_manager.app_id == "app-id" + assert trace_manager.user_id == "session-id" + + def test_resume_preserves_existing_trace_manager(self, monkeypatch: pytest.MonkeyPatch): + generator = WorkflowAppGenerator() + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.WORKFLOW, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user") + application_generate_entity = WorkflowAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=existing_trace_manager, + workflow_execution_id="run-id", + call_depth=0, + ) + captured_entity: WorkflowAppGenerateEntity | None = None + + def _fake_generate(**kwargs): + nonlocal captured_entity + captured_entity = kwargs["application_generate_entity"] + return SimpleNamespace(ok=True) + + monkeypatch.setattr(generator, "_generate", _fake_generate) + + result = generator.resume( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(), + user=SimpleNamespace(id="end-user-id", session_id="session-id"), + application_generate_entity=application_generate_entity, + graph_runtime_state=SimpleNamespace(), + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + ) + + assert result.ok is True + assert captured_entity is not None + assert captured_entity.trace_manager is existing_trace_manager diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 0bcc1029b0..ea21a1cc1a 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -95,7 +95,9 @@ class TestWorkflowGenerateTaskPipeline: def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=build_test_variable_pool( + variables=build_system_variables(workflow_execution_id="run-id"), + ), start_at=0.0, total_tokens=5, node_run_steps=2, @@ -187,7 +189,7 @@ class TestWorkflowGenerateTaskPipeline: assert isinstance(responses[0], ValueError) - def test_handle_workflow_started_event_sets_run_id(self, monkeypatch): + def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")), @@ -283,7 +285,9 @@ class TestWorkflowGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish" @@ -408,7 +412,7 @@ class TestWorkflowGenerateTaskPipeline: assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"] assert list(pipeline._handle_agent_log_event(agent_event)) == ["log"] - def test_wrapper_process_stream_response_emits_audio_end(self, monkeypatch): + def test_wrapper_process_stream_response_emits_audio_end(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._workflow_features_dict = { "text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"} @@ -560,7 +564,7 @@ class TestWorkflowGenerateTaskPipeline: responses = list(pipeline._wrapper_process_stream_response()) assert responses == [PingStreamResponse(task_id="task")] - def test_wrapper_process_stream_response_final_audio_none_then_finish(self, monkeypatch): + def test_wrapper_process_stream_response_final_audio_none_then_finish(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._workflow_features_dict = { "text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"} @@ -597,7 +601,7 @@ class TestWorkflowGenerateTaskPipeline: assert sleep_spy assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses) - def test_wrapper_process_stream_response_handles_audio_exception(self, monkeypatch): + def test_wrapper_process_stream_response_handles_audio_exception(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() pipeline._workflow_features_dict = { "text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"} @@ -633,7 +637,7 @@ class TestWorkflowGenerateTaskPipeline: assert logger_exception assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses) - def test_database_session_rolls_back_on_error(self, monkeypatch): + def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch): pipeline = _make_pipeline() calls = {"enter": 0, "exit_exc": None} @@ -725,7 +729,9 @@ class TestWorkflowGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) @@ -753,7 +759,9 @@ class TestWorkflowGenerateTaskPipeline: pipeline = _make_pipeline() pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._handle_ping_event = lambda event, **kwargs: iter(["ping"]) @@ -769,7 +777,9 @@ class TestWorkflowGenerateTaskPipeline: def test_process_stream_response_main_match_paths_and_cleanup(self): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-id") + ), start_at=0.0, ) pipeline._base_task_pipeline.queue_manager.listen = lambda: iter( diff --git a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py index d3bd15b6f3..320a3bc42c 100644 --- a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py @@ -21,7 +21,9 @@ class TestTriggerPostLayer: ) runtime_state = SimpleNamespace( outputs={"answer": "ok"}, - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-1")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), total_tokens=12, ) @@ -60,7 +62,9 @@ class TestTriggerPostLayer: def test_on_event_handles_missing_trigger_log(self): runtime_state = SimpleNamespace( outputs={}, - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-1")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), total_tokens=0, ) @@ -91,7 +95,9 @@ class TestTriggerPostLayer: def test_on_event_ignores_non_status_events(self): runtime_state = SimpleNamespace( outputs={}, - variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-1")), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), total_tokens=0, ) diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py index a20d89d807..f10e0084d0 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py @@ -143,7 +143,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert pipeline._listen_audio_msg(publisher=None, task_id="task") is None - def test_process_stream_response_handles_chunks_and_end(self, monkeypatch): + def test_process_stream_response_handles_chunks_and_end(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) @@ -245,7 +245,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert any(isinstance(event, QueueLLMChunkEvent) for event in events) assert any(isinstance(event, QueueStopEvent) for event in events) - def test_handle_stop_updates_usage(self, monkeypatch): + def test_handle_stop_updates_usage(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) @@ -313,7 +313,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert pipeline._task_state.llm_result.usage.prompt_tokens == 10 assert pipeline._task_state.llm_result.usage.completion_tokens == 5 - def test_record_files_builds_file_payloads(self, monkeypatch): + def test_record_files_builds_file_payloads(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) @@ -405,7 +405,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert files assert len(files) == 3 - def test_process_stream_response_handles_annotation_and_error(self, monkeypatch): + def test_process_stream_response_handles_annotation_and_error(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) @@ -472,7 +472,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert isinstance(responses[-1], ValueError) assert pipeline._task_state.llm_result.message.content == "annotatedagent" - def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch): + def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) @@ -681,7 +681,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert responses == ["payload"] - def test_wrapper_process_stream_response_with_tts_publisher(self, monkeypatch): + def test_wrapper_process_stream_response_with_tts_publisher(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) @@ -715,7 +715,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert responses[1] == "payload" assert isinstance(responses[-1], MessageAudioEndStreamResponse) - def test_wrapper_process_stream_response_timeout_yields_audio_chunk(self, monkeypatch): + def test_wrapper_process_stream_response_timeout_yields_audio_chunk(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) @@ -756,7 +756,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert any(isinstance(item, MessageAudioStreamResponse) for item in responses) assert isinstance(responses[-1], MessageAudioEndStreamResponse) - def test_process_stream_response_handles_stop_event_and_output_replacement(self, monkeypatch): + def test_process_stream_response_handles_stop_event_and_output_replacement(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) pipeline = EasyUIBasedGenerateTaskPipeline( @@ -896,7 +896,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert list(pipeline._process_stream_response(publisher=None)) == [] - def test_save_message_persists_fields_and_emits_trace(self, monkeypatch): + def test_save_message_persists_fields_and_emits_trace(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) pipeline = EasyUIBasedGenerateTaskPipeline( @@ -981,7 +981,7 @@ class TestEasyUiBasedGenerateTaskPipeline: with pytest.raises(ValueError, match="Conversation conv not found"): pipeline._save_message(session=session) - def test_message_end_to_stream_response_includes_usage_metadata(self, monkeypatch): + def test_message_end_to_stream_response_includes_usage_metadata(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) pipeline = EasyUIBasedGenerateTaskPipeline( @@ -1021,7 +1021,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.id == "msg" assert response.metadata["usage"]["prompt_tokens"] == 1 - def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch): + def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) pipeline = EasyUIBasedGenerateTaskPipeline( @@ -1059,7 +1059,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.files is None - def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch): + def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) pipeline = EasyUIBasedGenerateTaskPipeline( @@ -1155,7 +1155,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response.id == "msg" assert response.answer == "hello" - def test_agent_thought_to_stream_response_returns_none_when_not_found(self, monkeypatch): + def test_agent_thought_to_stream_response_returns_none_when_not_found(self, monkeypatch: pytest.MonkeyPatch): conversation = SimpleNamespace(id="conv", mode=AppMode.CHAT) message = SimpleNamespace(id="msg", created_at=datetime.now(UTC)) pipeline = EasyUIBasedGenerateTaskPipeline( diff --git a/api/tests/unit_tests/core/app/test_llm_quota.py b/api/tests/unit_tests/core/app/test_llm_quota.py new file mode 100644 index 0000000000..13bdf76535 --- /dev/null +++ b/api/tests/unit_tests/core/app/test_llm_quota.py @@ -0,0 +1,628 @@ +from collections.abc import Generator +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +from configs import dify_config +from core.app.llm.quota import ( + deduct_llm_quota, + deduct_llm_quota_for_model, + ensure_llm_quota_available, + ensure_llm_quota_available_for_model, +) +from core.entities.model_entities import ModelStatus +from core.entities.provider_entities import ProviderQuotaType, QuotaUnit +from core.errors.error import QuotaExceededError +from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.model_runtime.entities.model_entities import ModelType +from models import TenantCreditPool +from models.enums import ProviderQuotaType as ModelProviderQuotaType +from models.provider import Provider, ProviderType + + +@contextmanager +def _patched_credit_pool_session_factory(engine: Engine) -> Generator[None, None, None]: + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker): + yield + + +def test_ensure_llm_quota_available_for_model_raises_when_system_model_is_exhausted() -> None: + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + get_provider_model=MagicMock(return_value=SimpleNamespace(status=ModelStatus.QUOTA_EXCEEDED)), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + pytest.raises(QuotaExceededError, match="Model provider openai quota exceeded."), + ): + ensure_llm_quota_available_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + provider_configuration.get_provider_model.assert_called_once_with( + model_type=ModelType.LLM, + model="gpt-4o", + ) + + +def test_ensure_llm_quota_available_for_model_raises_when_provider_is_missing() -> None: + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = None + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + pytest.raises(ValueError, match="Provider openai does not exist."), + ): + ensure_llm_quota_available_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + +def test_ensure_llm_quota_available_for_model_ignores_custom_provider_configuration() -> None: + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.CUSTOM, + get_provider_model=MagicMock(), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager): + ensure_llm_quota_available_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + provider_configuration.get_provider_model.assert_not_called() + + +def test_deduct_llm_quota_for_model_uses_identity_based_trial_billing() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 42 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=42, + ) + + +def test_deduct_llm_quota_for_model_caps_trial_pool_when_usage_exceeds_remaining() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 3 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": "trial-pool", + "tenant_id": "tenant-id", + "pool_type": ModelProviderQuotaType.TRIAL, + "quota_limit": 10, + "quota_used": 9, + }, + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + _patched_credit_pool_session_factory(engine), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + quota_used = connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == "trial-pool")) + + assert quota_used == 10 + + +def test_deduct_llm_quota_for_model_returns_for_unbounded_quota() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 42 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=-1, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_not_called() + + +def test_deduct_llm_quota_for_model_uses_credit_configuration() -> None: + usage = LLMUsage.empty_usage() + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.CREDITS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch.object(type(dify_config), "get_model_credits", return_value=9) as mock_get_model_credits, + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_get_model_credits.assert_called_once_with("gpt-4o") + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=9, + ) + + +def test_deduct_llm_quota_for_model_uses_single_charge_for_times_quota() -> None: + usage = LLMUsage.empty_usage() + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TIMES, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=1, + ) + + +def test_deduct_llm_quota_for_model_uses_paid_billing_pool() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 5 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.PAID, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.PAID, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_called_once_with( + tenant_id="tenant-id", + credits_required=5, + pool_type="paid", + ) + + +def test_deduct_llm_quota_for_model_updates_free_quota_usage() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 3 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.FREE, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.FREE, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + engine = create_engine("sqlite:///:memory:") + Provider.__table__.create(engine) + with engine.begin() as connection: + connection.execute( + Provider.__table__.insert(), + [ + { + "id": "matching-provider", + "tenant_id": "tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 10, + "is_valid": True, + }, + { + "id": "other-tenant", + "tenant_id": "other-tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 20, + "is_valid": True, + }, + { + "id": "other-provider", + "tenant_id": "tenant-id", + "provider_name": "anthropic", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 30, + "is_valid": True, + }, + { + "id": "custom-provider", + "tenant_id": "tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.CUSTOM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 100, + "quota_used": 40, + "is_valid": True, + }, + ], + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("core.app.llm.quota.db", SimpleNamespace(engine=engine)), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + quota_used_by_id = dict(connection.execute(select(Provider.id, Provider.quota_used)).all()) + + assert quota_used_by_id == { + "matching-provider": 13, + "other-tenant": 20, + "other-provider": 30, + "custom-provider": 40, + } + + with engine.begin() as connection: + connection.execute( + Provider.__table__.update().where(Provider.id == "matching-provider").values(quota_limit=13, quota_used=13) + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("core.app.llm.quota.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="Model provider openai quota exceeded."), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + exhausted_quota_used = connection.scalar(select(Provider.quota_used).where(Provider.id == "matching-provider")) + + assert exhausted_quota_used == 13 + + +def test_deduct_llm_quota_for_model_caps_free_quota_and_raises_when_usage_exceeds_remaining() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 3 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.FREE, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.FREE, + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + engine = create_engine("sqlite:///:memory:") + Provider.__table__.create(engine) + with engine.begin() as connection: + connection.execute( + Provider.__table__.insert(), + { + "id": "matching-provider", + "tenant_id": "tenant-id", + "provider_name": "openai", + "provider_type": ProviderType.SYSTEM, + "quota_type": ProviderQuotaType.FREE, + "quota_limit": 15, + "quota_used": 13, + "is_valid": True, + }, + ) + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("core.app.llm.quota.db", SimpleNamespace(engine=engine)), + pytest.raises(QuotaExceededError, match="Model provider openai quota exceeded."), + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + with engine.connect() as connection: + quota_used = connection.scalar(select(Provider.quota_used).where(Provider.id == "matching-provider")) + + assert quota_used == 15 + + +def test_deduct_llm_quota_for_model_ignores_unknown_quota_type() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 2 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=SimpleNamespace( + current_quota_type="unexpected", + quota_configurations=[ + SimpleNamespace( + quota_type="unexpected", + quota_unit=QuotaUnit.TOKENS, + quota_limit=100, + ) + ], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + patch("core.app.llm.quota.sessionmaker") as mock_sessionmaker, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_not_called() + mock_sessionmaker.assert_not_called() + + +def test_deduct_llm_quota_for_model_ignores_custom_provider_configuration() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 2 + provider_configuration = SimpleNamespace( + using_provider_type=ProviderType.CUSTOM, + system_configuration=SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[], + ), + ) + provider_manager = MagicMock() + provider_manager.get_configurations.return_value.get.return_value = provider_configuration + + with ( + patch("core.app.llm.quota.create_plugin_provider_manager", return_value=provider_manager), + patch("services.credit_pool_service.CreditPoolService.deduct_credits_capped") as mock_deduct_credits, + patch("core.app.llm.quota.sessionmaker") as mock_sessionmaker, + ): + deduct_llm_quota_for_model( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + mock_deduct_credits.assert_not_called() + mock_sessionmaker.assert_not_called() + + +def test_ensure_llm_quota_available_wrapper_warns_and_delegates() -> None: + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace(tenant_id="tenant-id")), + model_type_instance=SimpleNamespace(model_type=ModelType.LLM), + ) + + with ( + pytest.deprecated_call(match="ensure_llm_quota_available\\(model_instance=.*deprecated"), + patch("core.app.llm.quota.ensure_llm_quota_available_for_model") as mock_ensure, + ): + ensure_llm_quota_available(model_instance=model_instance) + + mock_ensure.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) + + +def test_ensure_llm_quota_available_wrapper_rejects_non_llm_model_instances() -> None: + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace(tenant_id="tenant-id")), + model_type_instance=SimpleNamespace(model_type=ModelType.TEXT_EMBEDDING), + ) + + with ( + pytest.deprecated_call(match="ensure_llm_quota_available\\(model_instance=.*deprecated"), + pytest.raises(ValueError, match="only support LLM model instances"), + ): + ensure_llm_quota_available(model_instance=model_instance) + + +def test_deduct_llm_quota_wrapper_warns_and_delegates() -> None: + usage = LLMUsage.empty_usage() + usage.total_tokens = 7 + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + model_type_instance=SimpleNamespace(model_type=ModelType.LLM), + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace()), + ) + + with ( + pytest.deprecated_call(match="deduct_llm_quota\\(tenant_id=.*deprecated"), + patch("core.app.llm.quota.deduct_llm_quota_for_model") as mock_deduct, + ): + deduct_llm_quota( + tenant_id="tenant-id", + model_instance=model_instance, + usage=usage, + ) + + mock_deduct.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=usage, + ) + + +def test_deduct_llm_quota_wrapper_rejects_non_llm_model_instances() -> None: + usage = LLMUsage.empty_usage() + model_instance = SimpleNamespace( + provider="openai", + model_name="gpt-4o", + model_type_instance=SimpleNamespace(model_type=ModelType.TEXT_EMBEDDING), + provider_model_bundle=SimpleNamespace(configuration=SimpleNamespace()), + ) + + with ( + pytest.deprecated_call(match="deduct_llm_quota\\(tenant_id=.*deprecated"), + pytest.raises(ValueError, match="only support LLM model instances"), + ): + deduct_llm_quota( + tenant_id="tenant-id", + model_instance=model_instance, + usage=usage, + ) diff --git a/api/tests/unit_tests/core/app/workflow/test_node_factory.py b/api/tests/unit_tests/core/app/workflow/test_node_factory.py index 30a068f4c5..addce649d5 100644 --- a/api/tests/unit_tests/core/app/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/app/workflow/test_node_factory.py @@ -8,9 +8,9 @@ from graphon.enums import BuiltinNodeTypes class DummyNode: - def __init__(self, *, node_id, config, graph_init_params, graph_runtime_state, **kwargs): + def __init__(self, *, node_id, data, graph_init_params, graph_runtime_state, **kwargs): self.id = node_id - self.config = config + self.data = data self.graph_init_params = graph_init_params self.graph_runtime_state = graph_runtime_state self.kwargs = kwargs @@ -46,7 +46,7 @@ class TestDifyNodeFactory: lambda **_kwargs: node_class, ) - def _factory(self, monkeypatch): + def _factory(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_STRING_LENGTH", 10) monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MAX_NUMBER", 10) monkeypatch.setattr("core.workflow.node_factory.dify_config.CODE_MIN_NUMBER", -10) @@ -72,20 +72,20 @@ class TestDifyNodeFactory: graph_runtime_state=SimpleNamespace(), ) - def test_create_node_unknown_type(self, monkeypatch): + def test_create_node_unknown_type(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": "unknown"}}) - def test_create_node_missing_mapping(self, monkeypatch): + def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) monkeypatch.setattr("core.workflow.node_factory.get_node_type_classes_mapping", lambda: {}) with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}}) - def test_create_node_missing_latest_class(self, monkeypatch): + def test_create_node_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) monkeypatch.setattr( "core.workflow.node_factory.get_node_type_classes_mapping", @@ -96,7 +96,7 @@ class TestDifyNodeFactory: with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}}) - def test_create_node_selects_versioned_class(self, monkeypatch): + def test_create_node_selects_versioned_class(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) selected_versions: list[tuple[str, str]] = [] @@ -115,7 +115,7 @@ class TestDifyNodeFactory: assert node.id == "node-1" assert selected_versions == [("snapshot", "called")] - def test_create_node_code_branch(self, monkeypatch): + def test_create_node_code_branch(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) self._stub_node_resolution(monkeypatch, DummyCodeNode) @@ -124,7 +124,7 @@ class TestDifyNodeFactory: assert isinstance(node, DummyCodeNode) assert node.id == "node-1" - def test_create_node_template_transform_branch(self, monkeypatch): + def test_create_node_template_transform_branch(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) self._stub_node_resolution(monkeypatch, DummyTemplateTransformNode) @@ -133,7 +133,7 @@ class TestDifyNodeFactory: assert isinstance(node, DummyTemplateTransformNode) assert "jinja2_template_renderer" in node.kwargs - def test_create_node_http_request_branch(self, monkeypatch): + def test_create_node_http_request_branch(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) self._stub_node_resolution(monkeypatch, DummyHttpRequestNode) @@ -142,7 +142,7 @@ class TestDifyNodeFactory: assert isinstance(node, DummyHttpRequestNode) assert "http_request_config" in node.kwargs - def test_create_node_knowledge_retrieval_branch(self, monkeypatch): + def test_create_node_knowledge_retrieval_branch(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) self._stub_node_resolution(monkeypatch, DummyKnowledgeRetrievalNode) @@ -151,7 +151,7 @@ class TestDifyNodeFactory: assert isinstance(node, DummyKnowledgeRetrievalNode) assert node.kwargs == {} - def test_create_node_document_extractor_branch(self, monkeypatch): + def test_create_node_document_extractor_branch(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory(monkeypatch) self._stub_node_resolution(monkeypatch, DummyDocumentExtractorNode) diff --git a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py index 82552470a9..04ce524904 100644 --- a/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py +++ b/api/tests/unit_tests/core/app/workflow/test_observability_layer_extra.py @@ -2,12 +2,14 @@ from __future__ import annotations from types import SimpleNamespace +import pytest + from core.app.workflow.layers.observability import ObservabilityLayer from graphon.enums import BuiltinNodeTypes class TestObservabilityLayerExtras: - def test_init_tracer_enabled_sets_tracer(self, monkeypatch): + def test_init_tracer_enabled_sets_tracer(self, monkeypatch: pytest.MonkeyPatch): tracer = object() monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) @@ -18,7 +20,7 @@ class TestObservabilityLayerExtras: assert layer._is_disabled is False assert layer._tracer is tracer - def test_init_tracer_disables_when_get_tracer_fails(self, monkeypatch, caplog): + def test_init_tracer_disables_when_get_tracer_fails(self, monkeypatch: pytest.MonkeyPatch, caplog): monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", True) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) @@ -33,7 +35,7 @@ class TestObservabilityLayerExtras: assert layer._tracer is None assert "Failed to get OpenTelemetry tracer" in caplog.text - def test_init_tracer_disables_when_otel_disabled(self, monkeypatch): + def test_init_tracer_disables_when_otel_disabled(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.app.workflow.layers.observability.dify_config.ENABLE_OTEL", False) monkeypatch.setattr("core.app.workflow.layers.observability.is_instrument_flag_enabled", lambda: False) @@ -143,7 +145,7 @@ class TestObservabilityLayerExtras: assert layer._node_contexts == {} - def test_on_node_run_end_calls_span_end(self, monkeypatch): + def test_on_node_run_end_calls_span_end(self, monkeypatch: pytest.MonkeyPatch): layer = ObservabilityLayer() layer._is_disabled = False ended: list[str] = [] @@ -164,7 +166,7 @@ class TestObservabilityLayerExtras: assert ended == ["ended"] assert "exec" not in layer._node_contexts - def test_on_node_run_end_logs_detach_failure(self, monkeypatch, caplog): + def test_on_node_run_end_logs_detach_failure(self, monkeypatch: pytest.MonkeyPatch, caplog): layer = ObservabilityLayer() layer._is_disabled = False @@ -186,7 +188,7 @@ class TestObservabilityLayerExtras: assert "Failed to detach OpenTelemetry token" in caplog.text assert "exec" not in layer._node_contexts - def test_on_node_run_start_and_end_creates_span(self, monkeypatch): + def test_on_node_run_start_and_end_creates_span(self, monkeypatch: pytest.MonkeyPatch): layer = ObservabilityLayer() layer._is_disabled = False diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py index cacb4dd4fa..9cefa97bef 100644 --- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py +++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py @@ -7,6 +7,7 @@ import pytest from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer +from core.ops.ops_trace_manager import TraceTask, TraceTaskName from core.workflow.system_variables import SystemVariableKey, build_system_variables from graphon.entities import WorkflowNodeExecution from graphon.entities.pause_reason import SchedulingPause @@ -60,7 +61,10 @@ def _make_layer( workflow_execution_id="run-id", conversation_id="conv-id", ) - runtime_state = GraphRuntimeState(variable_pool=VariablePool(system_variables=system_variables), start_at=0.0) + runtime_state = GraphRuntimeState( + variable_pool=VariablePool.from_bootstrap(system_variables=system_variables), + start_at=0.0, + ) read_only_state = ReadOnlyGraphRuntimeStateWrapper(runtime_state) application_generate_entity = WorkflowAppGenerateEntity.model_construct( @@ -120,7 +124,7 @@ class TestWorkflowPersistenceLayer: with pytest.raises(ValueError, match="workflow_execution_id must be provided"): layer._get_execution_id() - def test_prepare_workflow_inputs_excludes_conversation_id(self, monkeypatch): + def test_prepare_workflow_inputs_excludes_conversation_id(self, monkeypatch: pytest.MonkeyPatch): layer, _, _, _ = _make_layer() monkeypatch.setattr( @@ -214,6 +218,59 @@ class TestWorkflowPersistenceLayer: assert exec_repo.saved[-1].status == WorkflowExecutionStatus.FAILED assert trace_tasks + def test_handle_graph_run_succeeded_enqueues_parent_trace_context(self, monkeypatch): + trace_tasks: list[TraceTask] = [] + trace_manager = SimpleNamespace(user_id="user", add_trace_task=lambda task: trace_tasks.append(task)) + layer, _, _, _ = _make_layer( + extras={ + "external_trace_id": "trace", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + trace_manager=trace_manager, + ) + layer._handle_graph_run_started() + + captured: dict[str, object] = {} + + def fake_workflow_trace( + self: TraceTask, + *, + workflow_run_id: str | None, + conversation_id: str | None, + user_id: str | None, + total_tokens_override: int | None = None, + ): + captured["trace_type"] = self.trace_type + captured["external_trace_id"] = self.kwargs.get("external_trace_id") + captured["parent_trace_context"] = self.kwargs.get("parent_trace_context") + captured["workflow_run_id"] = workflow_run_id + return {"ok": True} + + monkeypatch.setattr(TraceTask, "workflow_trace", fake_workflow_trace) + + layer._handle_graph_run_succeeded(GraphRunSucceededEvent(outputs={"ok": True})) + + assert trace_tasks + trace_task = trace_tasks[0] + assert trace_task.trace_type == TraceTaskName.WORKFLOW_TRACE + assert trace_task.kwargs["external_trace_id"] == "trace" + assert trace_task.kwargs["parent_trace_context"] == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + trace_task.execute() + + assert captured["trace_type"] == TraceTaskName.WORKFLOW_TRACE + assert captured["external_trace_id"] == "trace" + assert captured["parent_trace_context"] == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + def test_handle_graph_run_aborted_sets_status(self): layer, exec_repo, _, _ = _make_layer() layer._handle_graph_run_started() diff --git a/api/tests/unit_tests/core/base/test_app_generator_tts_publisher.py b/api/tests/unit_tests/core/base/test_app_generator_tts_publisher.py index 7b433ab57b..1125ce6dbc 100644 --- a/api/tests/unit_tests/core/base/test_app_generator_tts_publisher.py +++ b/api/tests/unit_tests/core/base/test_app_generator_tts_publisher.py @@ -3,6 +3,7 @@ import queue from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.base.tts.app_generator_tts_publisher import ( AppGeneratorTTSPublisher, @@ -17,7 +18,7 @@ from core.base.tts.app_generator_tts_publisher import ( @pytest.fixture -def mock_model_instance(mocker): +def mock_model_instance(mocker: MockerFixture): model = mocker.MagicMock() model.invoke_tts.return_value = [b"audio1", b"audio2"] model.get_tts_voices.return_value = [{"value": "voice1"}, {"value": "voice2"}] @@ -33,7 +34,7 @@ def mock_model_manager(mocker, mock_model_instance): @pytest.fixture(autouse=True) -def patch_threads(mocker): +def patch_threads(mocker: MockerFixture): """Prevent real threads from starting during tests""" mocker.patch("threading.Thread.start", return_value=None) @@ -114,7 +115,7 @@ class TestProcessFuture: finish = audio_queue.get() assert finish.status == "finish" - def test_process_future_exception(self, mocker): + def test_process_future_exception(self, mocker: MockerFixture): future_queue = queue.Queue() audio_queue = queue.Queue() @@ -222,7 +223,7 @@ class TestAppGeneratorTTSPublisher: publisher.executor.submit.assert_not_called() - def test_runtime_sentence_threshold_triggers_submit(self, mock_model_manager, mocker): + def test_runtime_sentence_threshold_triggers_submit(self, mock_model_manager, mocker: MockerFixture): publisher = AppGeneratorTTSPublisher("tenant", "voice1") publisher.executor = MagicMock() @@ -297,7 +298,7 @@ class TestAppGeneratorTTSPublisher: publisher.executor.submit.assert_not_called() - def test_runtime_handles_agent_message_event_list_content(self, mock_model_manager, mocker): + def test_runtime_handles_agent_message_event_list_content(self, mock_model_manager, mocker: MockerFixture): publisher = AppGeneratorTTSPublisher("tenant", "voice1") publisher.executor = MagicMock() @@ -332,7 +333,7 @@ class TestAppGeneratorTTSPublisher: assert publisher.msg_text == "Hello " - def test_runtime_handles_agent_message_event_empty_content(self, mock_model_manager, mocker): + def test_runtime_handles_agent_message_event_empty_content(self, mock_model_manager, mocker: MockerFixture): publisher = AppGeneratorTTSPublisher("tenant", "voice1") publisher.executor = MagicMock() @@ -358,7 +359,7 @@ class TestAppGeneratorTTSPublisher: assert publisher.msg_text == "" - def test_runtime_resets_msg_text_when_text_tmp_not_str(self, mock_model_manager, mocker): + def test_runtime_resets_msg_text_when_text_tmp_not_str(self, mock_model_manager, mocker: MockerFixture): publisher = AppGeneratorTTSPublisher("tenant", "voice1") publisher.executor = MagicMock() diff --git a/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py index 4c1aa33540..f9b3b1864e 100644 --- a/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_agent_tool_callback_handler.py @@ -1,8 +1,10 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture import core.callback_handler.agent_tool_callback_handler as module +from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler # ----------------------------- # Fixtures @@ -10,17 +12,17 @@ import core.callback_handler.agent_tool_callback_handler as module @pytest.fixture -def enable_debug(mocker): +def enable_debug(mocker: MockerFixture): mocker.patch.object(module.dify_config, "DEBUG", True) @pytest.fixture -def disable_debug(mocker): +def disable_debug(mocker: MockerFixture): mocker.patch.object(module.dify_config, "DEBUG", False) @pytest.fixture -def mock_print(mocker): +def mock_print(mocker: MockerFixture): return mocker.patch("builtins.print") @@ -71,7 +73,7 @@ class TestPrintText: module.print_text("hello") mock_print.assert_called_once_with("hello", end="", file=None) - def test_print_text_with_color(self, mocker, mock_print): + def test_print_text_with_color(self, mocker: MockerFixture, mock_print): mock_get_color = mocker.patch( "core.callback_handler.agent_tool_callback_handler.get_colored_text", return_value="colored_text", @@ -82,7 +84,7 @@ class TestPrintText: mock_get_color.assert_called_once_with("hello", "green") mock_print.assert_called_once_with("colored_text", end="", file=None) - def test_print_text_with_file_flush(self, mocker): + def test_print_text_with_file_flush(self, mocker: MockerFixture): mock_file = MagicMock() mock_print = mocker.patch("builtins.print") @@ -107,21 +109,25 @@ class TestDifyAgentCallbackHandler: assert handler.color == "green" assert handler.current_loop == 1 - def test_on_tool_start_debug_enabled(self, handler, enable_debug, mocker): + def test_on_tool_start_debug_enabled(self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_tool_start("tool1", {"a": 1}) mock_print_text.assert_called() - def test_on_tool_start_debug_disabled(self, handler, disable_debug, mocker): + def test_on_tool_start_debug_disabled( + self, handler: DifyAgentCallbackHandler, disable_debug, mocker: MockerFixture + ): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_tool_start("tool1", {"a": 1}) mock_print_text.assert_not_called() - def test_on_tool_end_debug_enabled_and_trace(self, handler, enable_debug, mocker): + def test_on_tool_end_debug_enabled_and_trace( + self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture + ): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") mock_trace_manager = MagicMock() @@ -137,7 +143,9 @@ class TestDifyAgentCallbackHandler: assert mock_print_text.call_count >= 1 mock_trace_manager.add_trace_task.assert_called_once() - def test_on_tool_end_without_trace_manager(self, handler, enable_debug, mocker): + def test_on_tool_end_without_trace_manager( + self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture + ): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_tool_end( @@ -148,14 +156,16 @@ class TestDifyAgentCallbackHandler: assert mock_print_text.call_count >= 1 - def test_on_tool_error_debug_enabled(self, handler, enable_debug, mocker): + def test_on_tool_error_debug_enabled(self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_tool_error(Exception("error")) mock_print_text.assert_called_once() - def test_on_tool_error_debug_disabled(self, handler, disable_debug, mocker): + def test_on_tool_error_debug_disabled( + self, handler: DifyAgentCallbackHandler, disable_debug, mocker: MockerFixture + ): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_tool_error(Exception("error")) @@ -163,14 +173,16 @@ class TestDifyAgentCallbackHandler: mock_print_text.assert_not_called() @pytest.mark.parametrize("thought", ["thinking", ""]) - def test_on_agent_start(self, handler, enable_debug, mocker, thought): + def test_on_agent_start(self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture, thought): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_agent_start(thought) mock_print_text.assert_called() - def test_on_agent_finish_increments_loop(self, handler, enable_debug, mocker): + def test_on_agent_finish_increments_loop( + self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture + ): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") current_loop = handler.current_loop @@ -179,19 +191,21 @@ class TestDifyAgentCallbackHandler: assert handler.current_loop == current_loop + 1 mock_print_text.assert_called() - def test_on_datasource_start_debug_enabled(self, handler, enable_debug, mocker): + def test_on_datasource_start_debug_enabled( + self, handler: DifyAgentCallbackHandler, enable_debug, mocker: MockerFixture + ): mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text") handler.on_datasource_start("ds1", {"x": 1}) mock_print_text.assert_called_once() - def test_ignore_agent_property(self, disable_debug, handler): + def test_ignore_agent_property(self, disable_debug, handler: DifyAgentCallbackHandler): assert handler.ignore_agent is True - def test_ignore_chat_model_property(self, disable_debug, handler): + def test_ignore_chat_model_property(self, disable_debug, handler: DifyAgentCallbackHandler): assert handler.ignore_chat_model is True - def test_ignore_properties_when_debug_enabled(self, enable_debug, handler): + def test_ignore_properties_when_debug_enabled(self, enable_debug, handler: DifyAgentCallbackHandler): assert handler.ignore_agent is False assert handler.ignore_chat_model is False diff --git a/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py index 8e5670e9be..f23669c3c7 100644 --- a/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_index_tool_callback_handler.py @@ -1,4 +1,5 @@ import pytest +from pytest_mock import MockerFixture from core.app.entities.app_invoke_entities import InvokeFrom from core.callback_handler.index_tool_callback_handler import ( @@ -7,12 +8,12 @@ from core.callback_handler.index_tool_callback_handler import ( @pytest.fixture -def mock_queue_manager(mocker): +def mock_queue_manager(mocker: MockerFixture): return mocker.Mock() @pytest.fixture -def handler(mock_queue_manager, mocker): +def handler(mock_queue_manager, mocker: MockerFixture): mocker.patch( "core.callback_handler.index_tool_callback_handler.db", ) @@ -34,7 +35,7 @@ class TestOnQuery: (InvokeFrom.WEB_APP, "end_user"), ], ) - def test_on_query_success_roles(self, mocker, mock_queue_manager, invoke_from, expected_role): + def test_on_query_success_roles(self, mocker: MockerFixture, mock_queue_manager, invoke_from, expected_role): # Arrange mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db") @@ -57,7 +58,7 @@ class TestOnQuery: assert dataset_query.created_by_role == expected_role mock_db.session.commit.assert_called_once() - def test_on_query_none_values(self, mocker, mock_queue_manager): + def test_on_query_none_values(self, mocker: MockerFixture, mock_queue_manager): mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db") handler = DatasetIndexToolCallbackHandler( @@ -75,7 +76,7 @@ class TestOnQuery: class TestOnToolEnd: - def test_on_tool_end_no_metadata(self, handler, mocker): + def test_on_tool_end_no_metadata(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture): mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db") document = mocker.Mock() @@ -85,7 +86,9 @@ class TestOnToolEnd: mock_db.session.commit.assert_not_called() - def test_on_tool_end_dataset_document_not_found(self, handler, mocker): + def test_on_tool_end_dataset_document_not_found( + self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture + ): mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db") mock_db.session.scalar.return_value = None @@ -96,7 +99,9 @@ class TestOnToolEnd: mock_db.session.scalar.assert_called_once() - def test_on_tool_end_parent_child_index_with_child(self, handler, mocker): + def test_on_tool_end_parent_child_index_with_child( + self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture + ): mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db") mock_dataset_doc = mocker.Mock() @@ -119,7 +124,7 @@ class TestOnToolEnd: mock_db.session.execute.assert_called_once() mock_db.session.commit.assert_called_once() - def test_on_tool_end_non_parent_child_index(self, handler, mocker): + def test_on_tool_end_non_parent_child_index(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture): mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db") mock_dataset_doc = mocker.Mock() @@ -139,12 +144,12 @@ class TestOnToolEnd: mock_db.session.execute.assert_called_once() mock_db.session.commit.assert_called_once() - def test_on_tool_end_empty_documents(self, handler): + def test_on_tool_end_empty_documents(self, handler: DatasetIndexToolCallbackHandler): handler.on_tool_end([]) class TestReturnRetrieverResourceInfo: - def test_publish_called(self, handler, mock_queue_manager, mocker): + def test_publish_called(self, handler: DatasetIndexToolCallbackHandler, mock_queue_manager, mocker: MockerFixture): mock_event = mocker.patch("core.callback_handler.index_tool_callback_handler.QueueRetrieverResourcesEvent") resources = [mocker.Mock()] diff --git a/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py b/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py index 131fb006ed..5b53c5965c 100644 --- a/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py +++ b/api/tests/unit_tests/core/callback_handler/test_workflow_tool_callback_handler.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, call import pytest +from pytest_mock import MockerFixture from core.callback_handler.workflow_tool_callback_handler import ( DifyWorkflowCallbackHandler, @@ -26,13 +27,13 @@ def handler(): @pytest.fixture -def mock_print_text(mocker): +def mock_print_text(mocker: MockerFixture): """Mock print_text to avoid real stdout printing.""" return mocker.patch("core.callback_handler.workflow_tool_callback_handler.print_text") class TestDifyWorkflowCallbackHandler: - def test_on_tool_execution_single_output_success(self, handler, mock_print_text): + def test_on_tool_execution_single_output_success(self, handler: DifyWorkflowCallbackHandler, mock_print_text): # Arrange tool_name = "test_tool" tool_inputs = {"a": 1} @@ -62,7 +63,7 @@ class TestDifyWorkflowCallbackHandler: ] ) - def test_on_tool_execution_multiple_outputs(self, handler, mock_print_text): + def test_on_tool_execution_multiple_outputs(self, handler: DifyWorkflowCallbackHandler, mock_print_text): # Arrange tool_name = "multi_tool" outputs = [ @@ -83,7 +84,7 @@ class TestDifyWorkflowCallbackHandler: assert results == outputs assert mock_print_text.call_count == 4 * len(outputs) - def test_on_tool_execution_empty_iterable(self, handler, mock_print_text): + def test_on_tool_execution_empty_iterable(self, handler: DifyWorkflowCallbackHandler, mock_print_text): # Arrange tool_name = "empty_tool" @@ -108,7 +109,9 @@ class TestDifyWorkflowCallbackHandler: ("not_iterable", AttributeError), ], ) - def test_on_tool_execution_invalid_outputs_type(self, handler, invalid_outputs, expected_exception): + def test_on_tool_execution_invalid_outputs_type( + self, handler: DifyWorkflowCallbackHandler, invalid_outputs, expected_exception + ): # Arrange tool_name = "invalid_tool" @@ -122,7 +125,7 @@ class TestDifyWorkflowCallbackHandler: ) ) - def test_on_tool_execution_long_json_truncation(self, handler, mock_print_text): + def test_on_tool_execution_long_json_truncation(self, handler: DifyWorkflowCallbackHandler, mock_print_text): # Arrange tool_name = "long_json_tool" long_json = "x" * 1500 @@ -144,7 +147,7 @@ class TestDifyWorkflowCallbackHandler: color="blue", ) - def test_on_tool_execution_model_dump_json_exception(self, handler, mock_print_text): + def test_on_tool_execution_model_dump_json_exception(self, handler: DifyWorkflowCallbackHandler, mock_print_text): # Arrange tool_name = "exception_tool" bad_message = MagicMock() @@ -163,7 +166,9 @@ class TestDifyWorkflowCallbackHandler: # Ensure first two prints happened before failure assert mock_print_text.call_count >= 2 - def test_on_tool_execution_none_message_id_and_trace_manager(self, handler, mock_print_text): + def test_on_tool_execution_none_message_id_and_trace_manager( + self, handler: DifyWorkflowCallbackHandler, mock_print_text + ): # Arrange tool_name = "optional_params_tool" message = DummyToolInvokeMessage('{"data": "ok"}') diff --git a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py index 4f39d38831..cee7d46083 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py @@ -34,20 +34,6 @@ class TestDatasourceFileManager: assert f"nonce={mock_urandom.return_value.hex()}" in signed_url assert "sign=" in signed_url - @patch("core.datasource.datasource_file_manager.time.time") - @patch("core.datasource.datasource_file_manager.os.urandom") - @patch("core.datasource.datasource_file_manager.dify_config") - def test_sign_file_empty_secret(self, mock_config, mock_urandom, mock_time): - # Setup - mock_config.FILES_URL = "http://localhost:5001" - mock_config.SECRET_KEY = None # Empty secret - mock_time.return_value = 1700000000 - mock_urandom.return_value = b"1234567890abcdef" - - # Execute - signed_url = DatasourceFileManager.sign_file("file_id", ".png") - assert "sign=" in signed_url - @patch("core.datasource.datasource_file_manager.time.time") @patch("core.datasource.datasource_file_manager.dify_config") def test_verify_file(self, mock_config, mock_time): @@ -76,25 +62,6 @@ class TestDatasourceFileManager: mock_time.return_value = 1700000500 # 700 seconds after timestamp (300 is timeout) assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is False - @patch("core.datasource.datasource_file_manager.time.time") - @patch("core.datasource.datasource_file_manager.dify_config") - def test_verify_file_empty_secret(self, mock_config, mock_time): - # Setup - mock_config.SECRET_KEY = "" # Empty string secret - mock_config.FILES_ACCESS_TIMEOUT = 300 - mock_time.return_value = 1700000000 - - datasource_file_id = "file_id_123" - timestamp = "1699999800" - nonce = "some_nonce" - - # Calculate with empty secret - data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - sign = hmac.new(b"", data_to_sign.encode(), hashlib.sha256).digest() - encoded_sign = base64.urlsafe_b64encode(sign).decode() - - assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is True - @patch("core.datasource.datasource_file_manager.db") @patch("core.datasource.datasource_file_manager.storage") @patch("core.datasource.datasource_file_manager.uuid4") diff --git a/api/tests/unit_tests/core/datasource/test_datasource_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_manager.py index deeac49bbc..8842d678c7 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_manager.py @@ -2,6 +2,7 @@ import types from collections.abc import Generator import pytest +from pytest_mock import MockerFixture from contexts.wrapper import RecyclableContextVar from core.datasource.datasource_manager import DatasourceManager @@ -37,7 +38,7 @@ def _invalidate_recyclable_contextvars() -> None: RecyclableContextVar.increment_thread_recycles() -def test_get_icon_url_calls_runtime(mocker): +def test_get_icon_url_calls_runtime(mocker: MockerFixture): fake_runtime = mocker.Mock() fake_runtime.get_icon_url.return_value = "https://icon" mocker.patch.object(DatasourceManager, "get_datasource_runtime", return_value=fake_runtime) @@ -52,7 +53,7 @@ def test_get_icon_url_calls_runtime(mocker): DatasourceManager.get_datasource_runtime.assert_called_once() -def test_get_datasource_runtime_delegates_to_provider_controller(mocker): +def test_get_datasource_runtime_delegates_to_provider_controller(mocker: MockerFixture): provider_controller = mocker.Mock() provider_controller.get_datasource.return_value = object() mocker.patch.object(DatasourceManager, "get_datasource_plugin_provider", return_value=provider_controller) @@ -114,7 +115,7 @@ def test_get_datasource_plugin_provider_creates_controller_and_caches(mocker, da assert ctrl_cls.call_count == 1 -def test_get_datasource_plugin_provider_raises_when_provider_entity_missing(mocker): +def test_get_datasource_plugin_provider_raises_when_provider_entity_missing(mocker: MockerFixture): _invalidate_recyclable_contextvars() mocker.patch( "core.datasource.datasource_manager.PluginDatasourceManager.fetch_datasource_provider", @@ -129,7 +130,7 @@ def test_get_datasource_plugin_provider_raises_when_provider_entity_missing(mock ) -def test_get_datasource_plugin_provider_raises_for_unsupported_type(mocker): +def test_get_datasource_plugin_provider_raises_for_unsupported_type(mocker: MockerFixture): _invalidate_recyclable_contextvars() provider_entity = types.SimpleNamespace(declaration=object(), plugin_id="plugin", plugin_unique_identifier="uniq") mocker.patch( @@ -145,7 +146,7 @@ def test_get_datasource_plugin_provider_raises_for_unsupported_type(mocker): ) -def test_get_datasource_plugin_provider_raises_when_controller_none(mocker): +def test_get_datasource_plugin_provider_raises_when_controller_none(mocker: MockerFixture): _invalidate_recyclable_contextvars() provider_entity = types.SimpleNamespace(declaration=object(), plugin_id="plugin", plugin_unique_identifier="uniq") mocker.patch( @@ -165,7 +166,7 @@ def test_get_datasource_plugin_provider_raises_when_controller_none(mocker): ) -def test_stream_online_results_yields_messages_online_document(mocker): +def test_stream_online_results_yields_messages_online_document(mocker: MockerFixture): # stub runtime to yield a text message def _doc_messages(**_): yield from _gen_messages_text_only("hello") @@ -195,7 +196,7 @@ def test_stream_online_results_yields_messages_online_document(mocker): assert msgs[0].message.text == "hello" -def test_stream_online_results_sets_credentials_and_returns_empty_dict_online_document(mocker): +def test_stream_online_results_sets_credentials_and_returns_empty_dict_online_document(mocker: MockerFixture): class _Runtime: def __init__(self) -> None: self.runtime = types.SimpleNamespace(credentials=None) @@ -229,7 +230,7 @@ def test_stream_online_results_sets_credentials_and_returns_empty_dict_online_do assert final_value == {} -def test_stream_online_results_raises_when_missing_params(mocker): +def test_stream_online_results_raises_when_missing_params(mocker: MockerFixture): class _Runtime: def __init__(self) -> None: self.runtime = types.SimpleNamespace(credentials=None) @@ -279,7 +280,7 @@ def test_stream_online_results_raises_when_missing_params(mocker): ) -def test_stream_online_results_yields_messages_and_returns_empty_dict_online_drive(mocker): +def test_stream_online_results_yields_messages_and_returns_empty_dict_online_drive(mocker: MockerFixture): class _Runtime: def __init__(self) -> None: self.runtime = types.SimpleNamespace(credentials=None) @@ -313,7 +314,7 @@ def test_stream_online_results_yields_messages_and_returns_empty_dict_online_dri assert final_value == {} -def test_stream_online_results_raises_for_unsupported_stream_type(mocker): +def test_stream_online_results_raises_for_unsupported_stream_type(mocker: MockerFixture): mocker.patch.object(DatasourceManager, "get_datasource_runtime", return_value=mocker.Mock()) mocker.patch( "core.datasource.datasource_manager.DatasourceProviderService.get_datasource_credentials", @@ -337,7 +338,7 @@ def test_stream_online_results_raises_for_unsupported_stream_type(mocker): ) -def test_stream_node_events_emits_events_online_document(mocker): +def test_stream_node_events_emits_events_online_document(mocker: MockerFixture): # make manager's low-level stream produce TEXT only mocker.patch.object( DatasourceManager, @@ -370,7 +371,7 @@ def test_stream_node_events_emits_events_online_document(mocker): assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED -def test_stream_node_events_builds_file_and_variables_from_messages(mocker): +def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture): mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) def _transformed(**_kwargs): @@ -478,7 +479,7 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker): assert events[-1].node_run_result.outputs["x"] == 1 -def test_stream_node_events_raises_when_toolfile_missing(mocker): +def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture): mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) def _transformed(**_kwargs): @@ -526,7 +527,7 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker): ) -def test_stream_node_events_online_drive_sets_variable_pool_file_and_outputs(mocker): +def test_stream_node_events_online_drive_sets_variable_pool_file_and_outputs(mocker: MockerFixture): mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) file_in = File( @@ -580,7 +581,7 @@ def test_stream_node_events_online_drive_sets_variable_pool_file_and_outputs(moc assert completed.node_run_result.outputs["datasource_type"] == DatasourceProviderType.ONLINE_DRIVE -def test_stream_node_events_skips_file_build_for_non_online_types(mocker): +def test_stream_node_events_skips_file_build_for_non_online_types(mocker: MockerFixture): mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) def _transformed(**_kwargs): @@ -620,7 +621,7 @@ def test_stream_node_events_skips_file_build_for_non_online_types(mocker): assert events[-1].node_run_result.outputs["file"] is None -def test_get_upload_file_by_id_builds_file(mocker): +def test_get_upload_file_by_id_builds_file(mocker: MockerFixture): # fake UploadFile row fake_row = types.SimpleNamespace( id="fid", @@ -654,7 +655,7 @@ def test_get_upload_file_by_id_builds_file(mocker): assert f.storage_key == "k" -def test_get_upload_file_by_id_raises_when_missing(mocker): +def test_get_upload_file_by_id_raises_when_missing(mocker: MockerFixture): class _S: def __enter__(self): return self diff --git a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py index ef8f360dbf..2642179992 100644 --- a/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py +++ b/api/tests/unit_tests/core/entities/test_entities_execution_extra_content.py @@ -4,7 +4,7 @@ from core.entities.execution_extra_content import ( HumanInputFormDefinition, HumanInputFormSubmissionData, ) -from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig from graphon.nodes.human_input.enums import FormInputType from models.execution_extra_content import ExecutionContentType @@ -16,8 +16,8 @@ def test_human_input_content_defaults_and_domain_alias() -> None: node_id="node-1", node_title="Human Input", form_content="Please confirm", - inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="answer")], - actions=[UserAction(id="confirm", title="Confirm")], + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="answer")], + actions=[UserActionConfig(id="confirm", title="Confirm")], resolved_default_values={"answer": "yes"}, expiration_time=1_700_000_000, ) diff --git a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py index a28143026f..1b714d6830 100644 --- a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py +++ b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py @@ -354,7 +354,8 @@ def test_validate_provider_credentials_handles_hidden_secret_value() -> None: with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="restored-key"): with patch( @@ -379,7 +380,10 @@ def test_validate_provider_credentials_without_credential_id() -> None: mock_factory = Mock() mock_factory.provider_credentials_validate.return_value = {"region": "us"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), + ): validated = configuration.validate_provider_credentials(credentials={"region": "us"}) assert validated == {"region": "us"} @@ -426,23 +430,37 @@ def test_switch_preferred_provider_type_creates_record_when_missing() -> None: def test_get_model_type_instance_and_schema_delegate_to_factory() -> None: configuration = _build_provider_configuration() - mock_factory = Mock() mock_model_type_instance = Mock() mock_schema = _build_ai_model("gpt-4o") - mock_factory.get_model_type_instance.return_value = mock_model_type_instance + mock_factory = Mock() + mock_factory.get_provider_schema.return_value = configuration.provider mock_factory.get_model_schema.return_value = mock_schema + mock_assembly = Mock() + mock_assembly.model_runtime = Mock() + mock_assembly.model_provider_factory = mock_factory - with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", - return_value=mock_factory, - ) as mock_factory_builder: + with ( + patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=mock_assembly, + ) as mock_assembly_builder, + patch( + "core.entities.provider_configuration.create_model_type_instance", + return_value=mock_model_type_instance, + ) as mock_model_builder, + ): model_type_instance = configuration.get_model_type_instance(ModelType.LLM) model_schema = configuration.get_model_schema(ModelType.LLM, "gpt-4o", {"api_key": "x"}) assert model_type_instance is mock_model_type_instance assert model_schema is mock_schema - assert mock_factory_builder.call_count == 2 - mock_factory.get_model_type_instance.assert_called_once_with(provider="openai", model_type=ModelType.LLM) + assert mock_assembly_builder.call_count == 2 + mock_factory.get_provider_schema.assert_called_once_with(provider="openai") + mock_model_builder.assert_called_once_with( + runtime=mock_assembly.model_runtime, + provider_schema=configuration.provider, + model_type=ModelType.LLM, + ) mock_factory.get_model_schema.assert_called_once_with( provider="openai", model_type=ModelType.LLM, @@ -456,17 +474,21 @@ def test_get_model_type_instance_and_schema_reuse_bound_runtime_factory() -> Non bound_runtime = Mock() configuration.bind_model_runtime(bound_runtime) - mock_factory = Mock() mock_model_type_instance = Mock() mock_schema = _build_ai_model("gpt-4o") - mock_factory.get_model_type_instance.return_value = mock_model_type_instance + mock_factory = Mock() + mock_factory.get_provider_schema.return_value = configuration.provider mock_factory.get_model_schema.return_value = mock_schema with ( patch( "core.entities.provider_configuration.ModelProviderFactory", return_value=mock_factory ) as mock_factory_cls, - patch("core.entities.provider_configuration.create_plugin_model_provider_factory") as mock_factory_builder, + patch("core.entities.provider_configuration.create_plugin_model_assembly") as mock_assembly_builder, + patch( + "core.entities.provider_configuration.create_model_type_instance", + return_value=mock_model_type_instance, + ) as mock_model_builder, ): model_type_instance = configuration.get_model_type_instance(ModelType.LLM) model_schema = configuration.get_model_schema(ModelType.LLM, "gpt-4o", {"api_key": "x"}) @@ -474,8 +496,14 @@ def test_get_model_type_instance_and_schema_reuse_bound_runtime_factory() -> Non assert model_type_instance is mock_model_type_instance assert model_schema is mock_schema assert mock_factory_cls.call_count == 2 - mock_factory_cls.assert_called_with(model_runtime=bound_runtime) - mock_factory_builder.assert_not_called() + mock_factory_cls.assert_called_with(runtime=bound_runtime) + mock_assembly_builder.assert_not_called() + mock_factory.get_provider_schema.assert_called_once_with(provider="openai") + mock_model_builder.assert_called_once_with( + runtime=bound_runtime, + provider_schema=configuration.provider, + model_type=ModelType.LLM, + ) def test_get_provider_model_returns_none_when_model_not_found() -> None: @@ -504,7 +532,10 @@ def test_get_provider_models_system_deduplicates_sorts_and_filters_active() -> N mock_factory = Mock() mock_factory.get_provider_schema.return_value = provider_schema - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory): + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), + ): all_models = configuration.get_provider_models(model_type=ModelType.LLM, only_active=False) active_models = configuration.get_provider_models(model_type=ModelType.LLM, only_active=True) @@ -722,7 +753,8 @@ def test_validate_provider_credentials_handles_invalid_original_json() -> None: with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-key"): validated = configuration.validate_provider_credentials( @@ -1069,7 +1101,8 @@ def test_validate_custom_model_credentials_supports_hidden_reuse_and_sessionless with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.decrypt_token", return_value="raw"): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): @@ -1083,7 +1116,10 @@ def test_validate_custom_model_credentials_supports_hidden_reuse_and_sessionless mock_factory2 = Mock() mock_factory2.model_credentials_validate.return_value = {"region": "us"} - with patch("core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory2): + with patch( + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory2), + ): validated = configuration.validate_custom_model_credentials( model_type=ModelType.LLM, model="gpt-4o", @@ -1575,7 +1611,8 @@ def test_validate_provider_credentials_uses_empty_original_when_record_missing() with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): validated = configuration.validate_provider_credentials( @@ -1701,7 +1738,8 @@ def test_validate_custom_model_credentials_handles_invalid_original_json() -> No with _patched_session(mock_session): with patch( - "core.entities.provider_configuration.create_plugin_model_provider_factory", return_value=mock_factory + "core.entities.provider_configuration.create_plugin_model_assembly", + return_value=SimpleNamespace(model_runtime=Mock(), model_provider_factory=mock_factory), ): with patch("core.entities.provider_configuration.encrypter.encrypt_token", return_value="enc-new"): validated = configuration.validate_custom_model_credentials( diff --git a/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py b/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py index 399b531205..9c1cbe82a0 100644 --- a/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py +++ b/api/tests/unit_tests/core/extension/test_api_based_extension_requestor.py @@ -1,11 +1,12 @@ import httpx import pytest +from pytest_mock import MockerFixture from core.extension.api_based_extension_requestor import APIBasedExtensionRequestor from models.api_based_extension import APIBasedExtensionPoint -def test_request_success(mocker): +def test_request_success(mocker: MockerFixture): # Mock httpx.Client and its context manager mock_client = mocker.MagicMock() mock_client_instance = mock_client.__enter__.return_value @@ -28,7 +29,7 @@ def test_request_success(mocker): ) -def test_request_with_ssrf_proxy(mocker): +def test_request_with_ssrf_proxy(mocker: MockerFixture): # Mock dify_config mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080") mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", "https://proxy:8081") @@ -59,7 +60,7 @@ def test_request_with_ssrf_proxy(mocker): assert mock_transport.call_count == 2 -def test_request_with_only_one_proxy_config(mocker): +def test_request_with_only_one_proxy_config(mocker: MockerFixture): # Mock dify_config with only one proxy mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080") mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", None) @@ -84,7 +85,7 @@ def test_request_with_only_one_proxy_config(mocker): assert kwargs.get("mounts") is None -def test_request_timeout(mocker): +def test_request_timeout(mocker: MockerFixture): mock_client = mocker.MagicMock() mock_client_instance = mock_client.__enter__.return_value mocker.patch("httpx.Client", return_value=mock_client) @@ -95,7 +96,7 @@ def test_request_timeout(mocker): requestor.request(APIBasedExtensionPoint.PING, {}) -def test_request_connection_error(mocker): +def test_request_connection_error(mocker: MockerFixture): mock_client = mocker.MagicMock() mock_client_instance = mock_client.__enter__.return_value mocker.patch("httpx.Client", return_value=mock_client) @@ -106,7 +107,7 @@ def test_request_connection_error(mocker): requestor.request(APIBasedExtensionPoint.PING, {}) -def test_request_error_status_code(mocker): +def test_request_error_status_code(mocker: MockerFixture): mock_client = mocker.MagicMock() mock_client_instance = mock_client.__enter__.return_value mocker.patch("httpx.Client", return_value=mock_client) @@ -121,7 +122,7 @@ def test_request_error_status_code(mocker): requestor.request(APIBasedExtensionPoint.PING, {}) -def test_request_error_status_code_long_content(mocker): +def test_request_error_status_code_long_content(mocker: MockerFixture): mock_client = mocker.MagicMock() mock_client_instance = mock_client.__enter__.return_value mocker.patch("httpx.Client", return_value=mock_client) diff --git a/api/tests/unit_tests/core/external_data_tool/test_external_data_fetch.py b/api/tests/unit_tests/core/external_data_tool/test_external_data_fetch.py index 86b461cf04..c1c1291281 100644 --- a/api/tests/unit_tests/core/external_data_tool/test_external_data_fetch.py +++ b/api/tests/unit_tests/core/external_data_tool/test_external_data_fetch.py @@ -13,7 +13,7 @@ class TestExternalDataFetch: app = Flask(__name__) return app - def test_fetch_success(self, app): + def test_fetch_success(self, app: Flask): with app.app_context(): fetcher = ExternalDataFetch() @@ -79,7 +79,7 @@ class TestExternalDataFetch: assert result_inputs == inputs assert result_inputs is not inputs # Should be a copy - def test_fetch_with_none_variable(self, app): + def test_fetch_with_none_variable(self, app: Flask): with app.app_context(): fetcher = ExternalDataFetch() tool = ExternalDataVariableEntity(variable="var1", type="type1", config={}) @@ -95,7 +95,7 @@ class TestExternalDataFetch: assert "var1" not in result_inputs assert result_inputs == {"in": "val"} - def test_query_external_data_tool(self, app): + def test_query_external_data_tool(self, app: Flask): fetcher = ExternalDataFetch() tool = ExternalDataVariableEntity(variable="var1", type="type1", config={"k": "v"}) diff --git a/api/tests/unit_tests/core/helper/test_creators.py b/api/tests/unit_tests/core/helper/test_creators.py index df67d3f513..8750f6d907 100644 --- a/api/tests/unit_tests/core/helper/test_creators.py +++ b/api/tests/unit_tests/core/helper/test_creators.py @@ -8,7 +8,7 @@ from yarl import URL @pytest.fixture(autouse=True) -def _patch_creators_url(monkeypatch): +def _patch_creators_url(monkeypatch: pytest.MonkeyPatch): """Patch the module-level creators_platform_api_url for all tests.""" monkeypatch.setattr( "core.helper.creators.creators_platform_api_url", diff --git a/api/tests/unit_tests/core/helper/test_moderation.py b/api/tests/unit_tests/core/helper/test_moderation.py index a0dfa86d20..c33002329b 100644 --- a/api/tests/unit_tests/core/helper/test_moderation.py +++ b/api/tests/unit_tests/core/helper/test_moderation.py @@ -68,8 +68,8 @@ def test_check_moderation_returns_true_when_model_accepts_text(mocker: MockerFix mocker.patch("core.helper.moderation.secrets.choice", return_value="chunk") moderation_model = SimpleNamespace(invoke=lambda **invoke_kwargs: invoke_kwargs["text"] == "chunk") - factory = SimpleNamespace(get_model_type_instance=lambda **_factory_kwargs: moderation_model) - mocker.patch("core.helper.moderation.create_plugin_model_provider_factory", return_value=factory) + assembly = SimpleNamespace(create_model_type_instance=lambda **_factory_kwargs: moderation_model) + mocker.patch("core.helper.moderation.create_plugin_model_assembly", return_value=assembly) assert ( check_moderation( @@ -91,7 +91,7 @@ def test_check_moderation_returns_true_when_text_is_empty(mocker: MockerFixture) provider_map={openai_provider: hosting_openai}, ), ) - factory_mock = mocker.patch("core.helper.moderation.create_plugin_model_provider_factory") + factory_mock = mocker.patch("core.helper.moderation.create_plugin_model_assembly") choice_mock = mocker.patch("core.helper.moderation.secrets.choice") assert ( @@ -119,8 +119,8 @@ def test_check_moderation_returns_false_when_model_rejects_text(mocker: MockerFi mocker.patch("core.helper.moderation.secrets.choice", return_value="chunk") moderation_model = SimpleNamespace(invoke=lambda **_invoke_kwargs: False) - factory = SimpleNamespace(get_model_type_instance=lambda **_factory_kwargs: moderation_model) - mocker.patch("core.helper.moderation.create_plugin_model_provider_factory", return_value=factory) + assembly = SimpleNamespace(create_model_type_instance=lambda **_factory_kwargs: moderation_model) + mocker.patch("core.helper.moderation.create_plugin_model_assembly", return_value=assembly) assert ( check_moderation( @@ -147,8 +147,8 @@ def test_check_moderation_raises_bad_request_when_provider_call_fails(mocker: Mo failing_model = SimpleNamespace( invoke=lambda **_invoke_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), ) - factory = SimpleNamespace(get_model_type_instance=lambda **_factory_kwargs: failing_model) - mocker.patch("core.helper.moderation.create_plugin_model_provider_factory", return_value=factory) + assembly = SimpleNamespace(create_model_type_instance=lambda **_factory_kwargs: failing_model) + mocker.patch("core.helper.moderation.create_plugin_model_assembly", return_value=assembly) with pytest.raises(InvokeBadRequestError, match="Rate limit exceeded, please try again later."): check_moderation( diff --git a/api/tests/unit_tests/core/helper/test_trace_id_helper.py b/api/tests/unit_tests/core/helper/test_trace_id_helper.py index 27bfe1af05..96e2d44730 100644 --- a/api/tests/unit_tests/core/helper/test_trace_id_helper.py +++ b/api/tests/unit_tests/core/helper/test_trace_id_helper.py @@ -1,6 +1,12 @@ import pytest -from core.helper.trace_id_helper import extract_external_trace_id_from_args, get_external_trace_id, is_valid_trace_id +from core.helper.trace_id_helper import ( + ParentTraceContext, + extract_external_trace_id_from_args, + extract_parent_trace_context_from_args, + get_external_trace_id, + is_valid_trace_id, +) class DummyRequest: @@ -84,3 +90,92 @@ class TestTraceIdHelper: def test_extract_external_trace_id_from_args(self, args, expected): """Test extraction of external_trace_id from args mapping""" assert extract_external_trace_id_from_args(args) == expected + + @pytest.mark.parametrize( + ("args", "expected"), + [ + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": "node-execution-1", + } + }, + { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id="node-execution-1", + ) + }, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_node_execution_id": "node-execution-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": 123, + "parent_node_execution_id": "node-execution-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": None, + } + }, + {}, + ), + ({}, {}), + ], + ) + def test_extract_parent_trace_context_from_args(self, args, expected): + """Test extraction of parent_trace_context from args mapping""" + assert extract_parent_trace_context_from_args(args) == expected + + def test_extract_parent_trace_context_returns_typed_context(self): + """Parent trace context is parsed into a Pydantic value object.""" + result = extract_parent_trace_context_from_args( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": "node-execution-1", + } + } + ) + + assert result == { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id="node-execution-1", + ) + } + + def test_extract_parent_trace_context_rejects_incomplete_typed_context(self): + """Typed parent trace context follows the same completeness rule as raw mappings.""" + result = extract_parent_trace_context_from_args( + { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id=None, + ) + } + ) + + assert result == {} diff --git a/api/tests/unit_tests/core/mcp/client/test_sse.py b/api/tests/unit_tests/core/mcp/client/test_sse.py index e6eeb6cd59..ea0ff7395d 100644 --- a/api/tests/unit_tests/core/mcp/client/test_sse.py +++ b/api/tests/unit_tests/core/mcp/client/test_sse.py @@ -34,6 +34,17 @@ def test_sse_message_id_coercion(): assert msg.root.jsonrpc == expected.root.jsonrpc +def test_sse_message_without_id_stays_notification(): + """Test that method messages without an ID still parse as notifications.""" + json_message = '{"jsonrpc": "2.0", "method": "ping", "params": null}' + + msg = types.JSONRPCMessage.model_validate_json(json_message) + + assert isinstance(msg.root, types.JSONRPCNotification) + assert msg.root.method == "ping" + assert msg.root.jsonrpc == "2.0" + + class MockSSEClient: """Mock SSE client for testing.""" diff --git a/api/tests/unit_tests/core/memory/test_token_buffer_memory.py b/api/tests/unit_tests/core/memory/test_token_buffer_memory.py index f459250b8e..72c24bda96 100644 --- a/api/tests/unit_tests/core/memory/test_token_buffer_memory.py +++ b/api/tests/unit_tests/core/memory/test_token_buffer_memory.py @@ -198,6 +198,48 @@ class TestBuildPromptMessageWithFiles: assert isinstance(result.content[-1], TextPromptMessageContent) assert result.content[-1].data == "user text" + def test_replay_does_not_pass_config_to_file_factory(self): + """Replay contract: history files were validated on upload, so this + path must not forward a FileUploadConfig. The factory's signature + no longer accepts ``config``; this test guards against a future + regression that re-introduces it.""" + conv = _make_conversation(AppMode.CHAT) + mem = TokenBufferMemory(conversation=conv, model_instance=_make_model_instance()) + + mock_file_extra_config = MagicMock() + mock_file_extra_config.image_config = None + + real_image_content = ImagePromptMessageContent( + url="http://example.com/img.png", format="png", mime_type="image/png" + ) + mock_app_record = MagicMock() + mock_app_record.tenant_id = "tenant-1" + + with ( + patch( + "core.memory.token_buffer_memory.FileUploadConfigManager.convert", + return_value=mock_file_extra_config, + ), + patch( + "core.memory.token_buffer_memory.file_factory.build_from_message_file", + return_value=MagicMock(), + ) as mock_build, + patch( + "core.memory.token_buffer_memory.file_manager.to_prompt_message_content", + return_value=real_image_content, + ), + ): + mem._build_prompt_message_with_files( + message_files=[MagicMock()], + text_content="user text", + message=_make_message(), + app_record=mock_app_record, + is_user_message=True, + ) + + mock_build.assert_called_once() + assert "config" not in mock_build.call_args.kwargs + @pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION]) def test_chat_mode_with_files_assistant_message(self, mode): """When files are present, returns AssistantPromptMessage with list content.""" diff --git a/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py b/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py index c4fd970562..2b51dc8182 100644 --- a/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py +++ b/api/tests/unit_tests/core/model_runtime/test_model_provider_factory.py @@ -2,6 +2,7 @@ from unittest.mock import Mock import pytest +from core.plugin.impl.model_runtime_factory import create_model_type_instance from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ( @@ -73,7 +74,7 @@ def test_model_provider_factory_resolves_runtime_provider_name() -> None: supported_model_types=[ModelType.LLM], configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], ) - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime([provider])) + factory = ModelProviderFactory(runtime=_FakeModelRuntime([provider])) provider_schema = factory.get_model_provider("openai") @@ -98,7 +99,7 @@ def test_model_provider_factory_resolves_canonical_short_name_independent_of_pro configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL], ), ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) provider_schema = factory.get_model_provider("openai") @@ -107,8 +108,8 @@ def test_model_provider_factory_resolves_canonical_short_name_independent_of_pro def test_model_provider_factory_requires_runtime() -> None: - with pytest.raises(ValueError, match="model_runtime is required"): - ModelProviderFactory(model_runtime=None) # type: ignore[arg-type] + with pytest.raises(ValueError, match="runtime is required"): + ModelProviderFactory(runtime=None) # type: ignore[arg-type] def test_model_provider_factory_get_providers_returns_runtime_providers() -> None: @@ -119,7 +120,7 @@ def test_model_provider_factory_get_providers_returns_runtime_providers() -> Non supported_model_types=[ModelType.LLM], ) ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) result = factory.get_providers() @@ -133,7 +134,7 @@ def test_model_provider_factory_get_provider_schema_delegates_to_provider_lookup provider_name="openai", supported_model_types=[ModelType.LLM], ) - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime([provider])) + factory = ModelProviderFactory(runtime=_FakeModelRuntime([provider])) result = factory.get_provider_schema("openai") @@ -142,7 +143,7 @@ def test_model_provider_factory_get_provider_schema_delegates_to_provider_lookup def test_model_provider_factory_raises_for_unknown_provider() -> None: factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( + runtime=_FakeModelRuntime( [ _build_provider( provider="langgenius/openai/openai", @@ -172,7 +173,7 @@ def test_model_provider_factory_get_models_filters_provider_and_model_type() -> models=[_build_model("rerank-v3", ModelType.RERANK)], ), ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) results = factory.get_models(provider="openai", model_type=ModelType.LLM) @@ -196,7 +197,7 @@ def test_model_provider_factory_get_models_skips_providers_without_requested_mod models=[_build_model("eleven_multilingual_v2", ModelType.TTS)], ), ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) results = factory.get_models(model_type=ModelType.TTS) @@ -214,7 +215,7 @@ def test_model_provider_factory_get_models_without_model_type_keeps_all_provider models=[_build_model("gpt-4o-mini", ModelType.LLM), _build_model("tts-1", ModelType.TTS)], ) ] - factory = ModelProviderFactory(model_runtime=_FakeModelRuntime(providers)) + factory = ModelProviderFactory(runtime=_FakeModelRuntime(providers)) results = factory.get_models(provider="openai") @@ -242,7 +243,7 @@ def test_model_provider_factory_validates_provider_credentials() -> None: ) ] ) - factory = ModelProviderFactory(model_runtime=runtime) + factory = ModelProviderFactory(runtime=runtime) filtered = factory.provider_credentials_validate( provider="openai", @@ -258,7 +259,7 @@ def test_model_provider_factory_validates_provider_credentials() -> None: def test_model_provider_factory_provider_credentials_validate_requires_schema() -> None: factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( + runtime=_FakeModelRuntime( [ _build_provider( provider="langgenius/openai/openai", @@ -294,7 +295,7 @@ def test_model_provider_factory_validates_model_credentials() -> None: ) ] ) - factory = ModelProviderFactory(model_runtime=runtime) + factory = ModelProviderFactory(runtime=runtime) filtered = factory.model_credentials_validate( provider="openai", @@ -314,7 +315,7 @@ def test_model_provider_factory_validates_model_credentials() -> None: def test_model_provider_factory_model_credentials_validate_requires_schema() -> None: factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( + runtime=_FakeModelRuntime( [ _build_provider( provider="langgenius/openai/openai", @@ -346,7 +347,7 @@ def test_model_provider_factory_get_model_schema_and_icon_use_canonical_provider ) runtime.get_model_schema.return_value = "schema" runtime.get_provider_icon.return_value = (b"icon", "image/png") - factory = ModelProviderFactory(model_runtime=runtime) + factory = ModelProviderFactory(runtime=runtime) assert ( factory.get_model_schema( @@ -382,39 +383,43 @@ def test_model_provider_factory_get_model_schema_and_icon_use_canonical_provider (ModelType.TTS, TTSModel), ], ) -def test_model_provider_factory_builds_model_type_instances( +def test_create_model_type_instance_builds_model_wrappers( model_type: ModelType, expected_type: type[object], ) -> None: - factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( - [ - _build_provider( - provider="langgenius/openai/openai", - provider_name="openai", - supported_model_types=[model_type], - ) - ] - ) + runtime = _FakeModelRuntime( + [ + _build_provider( + provider="langgenius/openai/openai", + provider_name="openai", + supported_model_types=[model_type], + ) + ] ) - instance = factory.get_model_type_instance("openai", model_type) + instance = create_model_type_instance( + runtime=runtime, + provider_schema=runtime.fetch_model_providers()[0], + model_type=model_type, + ) assert isinstance(instance, expected_type) -def test_model_provider_factory_rejects_unsupported_model_type() -> None: - factory = ModelProviderFactory( - model_runtime=_FakeModelRuntime( - [ - _build_provider( - provider="langgenius/openai/openai", - provider_name="openai", - supported_model_types=[ModelType.LLM], - ) - ] - ) +def test_create_model_type_instance_rejects_unsupported_model_type() -> None: + runtime = _FakeModelRuntime( + [ + _build_provider( + provider="langgenius/openai/openai", + provider_name="openai", + supported_model_types=[ModelType.LLM], + ) + ] ) with pytest.raises(ValueError, match="Unsupported model type: unsupported"): - factory.get_model_type_instance("openai", "unsupported") # type: ignore[arg-type] + create_model_type_instance( + runtime=runtime, + provider_schema=runtime.fetch_model_providers()[0], + model_type="unsupported", # type: ignore[arg-type] + ) diff --git a/api/tests/unit_tests/core/ops/test_base_trace_instance.py b/api/tests/unit_tests/core/ops/test_base_trace_instance.py index ac65d13454..15a2af17ca 100644 --- a/api/tests/unit_tests/core/ops/test_base_trace_instance.py +++ b/api/tests/unit_tests/core/ops/test_base_trace_instance.py @@ -18,7 +18,7 @@ class ConcreteTraceInstance(BaseTraceInstance): @pytest.fixture -def mock_db_session(monkeypatch): +def mock_db_session(monkeypatch: pytest.MonkeyPatch): mock_session = MagicMock(spec=Session) mock_session.__enter__.return_value = mock_session mock_session.__exit__.return_value = None diff --git a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py index e47df0121e..33a3293682 100644 --- a/api/tests/unit_tests/core/ops/test_ops_trace_manager.py +++ b/api/tests/unit_tests/core/ops/test_ops_trace_manager.py @@ -203,7 +203,7 @@ class DummySessionContext: @pytest.fixture(autouse=True) -def patch_provider_map(monkeypatch): +def patch_provider_map(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.ops.ops_trace_manager.provider_config_map", FakeProviderMap({"dummy": FAKE_PROVIDER_ENTRY}) ) @@ -212,7 +212,7 @@ def patch_provider_map(monkeypatch): @pytest.fixture(autouse=True) -def patch_timer_and_current_app(monkeypatch): +def patch_timer_and_current_app(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.ops.ops_trace_manager.threading.Timer", DummyTimer) monkeypatch.setattr("core.ops.ops_trace_manager.trace_manager_queue", queue.Queue()) monkeypatch.setattr("core.ops.ops_trace_manager.trace_manager_timer", None) @@ -227,12 +227,12 @@ def patch_timer_and_current_app(monkeypatch): @pytest.fixture(autouse=True) -def patch_sqlalchemy_session(monkeypatch): +def patch_sqlalchemy_session(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.ops.ops_trace_manager.Session", DummySessionContext) @pytest.fixture -def encryption_mocks(monkeypatch): +def encryption_mocks(monkeypatch: pytest.MonkeyPatch): encrypt_mock = MagicMock(side_effect=lambda tenant, value: f"enc-{value}") batch_decrypt_mock = MagicMock(side_effect=lambda tenant, values: [f"dec-{value}" for value in values]) obfuscate_mock = MagicMock(side_effect=lambda value: f"ob-{value}") @@ -243,7 +243,7 @@ def encryption_mocks(monkeypatch): @pytest.fixture -def mock_db(monkeypatch): +def mock_db(monkeypatch: pytest.MonkeyPatch): session = MagicMock() session.scalars.return_value.all.return_value = ["chat"] db_mock = MagicMock() @@ -254,7 +254,7 @@ def mock_db(monkeypatch): @pytest.fixture -def workflow_repo_fixture(monkeypatch): +def workflow_repo_fixture(monkeypatch: pytest.MonkeyPatch): repo = MagicMock() repo.get_workflow_run_by_id_without_tenant.return_value = make_workflow_run() monkeypatch.setattr(TraceTask, "_get_workflow_run_repo", classmethod(lambda cls: repo)) @@ -340,13 +340,13 @@ def test_get_ops_trace_instance_handles_none_app(mock_db): assert OpsTraceManager.get_ops_trace_instance("app-id") is None -def test_get_ops_trace_instance_returns_none_when_disabled(mock_db, monkeypatch): +def test_get_ops_trace_instance_returns_none_when_disabled(mock_db, monkeypatch: pytest.MonkeyPatch): app = SimpleNamespace(id="app-id", tracing=json.dumps({"enabled": False})) mock_db.get.return_value = app assert OpsTraceManager.get_ops_trace_instance("app-id") is None -def test_get_ops_trace_instance_invalid_provider(mock_db, monkeypatch): +def test_get_ops_trace_instance_invalid_provider(mock_db, monkeypatch: pytest.MonkeyPatch): app = SimpleNamespace(id="app-id", tracing=json.dumps({"enabled": True, "tracing_provider": "missing"})) mock_db.get.return_value = app monkeypatch.setattr("core.ops.ops_trace_manager.provider_config_map", FakeProviderMap({})) @@ -388,7 +388,7 @@ def test_get_app_config_through_message_id_app_model_config(mock_db): assert result.id == "cfg" -def test_update_app_tracing_config_invalid_provider(mock_db, monkeypatch): +def test_update_app_tracing_config_invalid_provider(mock_db, monkeypatch: pytest.MonkeyPatch): mock_db.get.return_value = None with pytest.raises(ValueError, match="Invalid tracing provider"): OpsTraceManager.update_app_tracing_config("app", True, "bad") @@ -407,21 +407,21 @@ def test_update_app_tracing_config_success(mock_db): def test_get_app_tracing_config_errors_when_missing(mock_db): mock_db.get.return_value = None with pytest.raises(ValueError, match="App not found"): - OpsTraceManager.get_app_tracing_config("app") + OpsTraceManager.get_app_tracing_config("app", mock_db) def test_get_app_tracing_config_returns_defaults(mock_db): mock_db.get.return_value = SimpleNamespace(tracing=None) - assert OpsTraceManager.get_app_tracing_config("app-id") == {"enabled": False, "tracing_provider": None} + assert OpsTraceManager.get_app_tracing_config("app-id", mock_db) == {"enabled": False, "tracing_provider": None} def test_get_app_tracing_config_returns_payload(mock_db): payload = {"enabled": True, "tracing_provider": "dummy"} mock_db.get.return_value = SimpleNamespace(tracing=json.dumps(payload)) - assert OpsTraceManager.get_app_tracing_config("app-id") == payload + assert OpsTraceManager.get_app_tracing_config("app-id", mock_db) == payload -def test_check_and_project_helpers(monkeypatch): +def test_check_and_project_helpers(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.ops.ops_trace_manager.provider_config_map", FakeProviderMap( @@ -449,7 +449,7 @@ def test_check_and_project_helpers(monkeypatch): assert OpsTraceManager.get_trace_config_project_url({}, "dummy") == "url" -def test_trace_task_conversation_and_extract(monkeypatch): +def test_trace_task_conversation_and_extract(monkeypatch: pytest.MonkeyPatch): task = TraceTask(trace_type=TraceTaskName.CONVERSATION_TRACE, message_id="msg") assert task.conversation_trace(foo="bar") == {"foo": "bar"} assert task._extract_streaming_metrics(make_message_data(message_metadata="not json")) == {} @@ -525,7 +525,7 @@ def test_extract_streaming_metrics_invalid_json(): assert task._extract_streaming_metrics(fake_message) == {} -def test_trace_queue_manager_add_and_collect(monkeypatch): +def test_trace_queue_manager_add_and_collect(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.ops.ops_trace_manager.OpsTraceManager.get_ops_trace_instance", classmethod(lambda cls, aid: True) ) @@ -536,7 +536,7 @@ def test_trace_queue_manager_add_and_collect(monkeypatch): assert tasks == [task] -def test_trace_queue_manager_run_invokes_send(monkeypatch): +def test_trace_queue_manager_run_invokes_send(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.ops.ops_trace_manager.OpsTraceManager.get_ops_trace_instance", classmethod(lambda cls, aid: True) ) @@ -556,7 +556,7 @@ def test_trace_queue_manager_run_invokes_send(monkeypatch): assert called["tasks"] == [task] -def test_trace_queue_manager_send_to_celery(monkeypatch): +def test_trace_queue_manager_send_to_celery(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.ops.ops_trace_manager.OpsTraceManager.get_ops_trace_instance", classmethod(lambda cls, aid: True) ) diff --git a/api/tests/unit_tests/core/ops/test_trace_queue_manager.py b/api/tests/unit_tests/core/ops/test_trace_queue_manager.py index a4903054e0..13cf01651e 100644 --- a/api/tests/unit_tests/core/ops/test_trace_queue_manager.py +++ b/api/tests/unit_tests/core/ops/test_trace_queue_manager.py @@ -19,7 +19,7 @@ import pytest @pytest.fixture -def trace_queue_manager_and_task(monkeypatch): +def trace_queue_manager_and_task(monkeypatch: pytest.MonkeyPatch): """Fixture to provide TraceQueueManager and TraceTask with delayed imports.""" module_name = "core.ops.ops_trace_manager" if module_name not in sys.modules: diff --git a/api/tests/unit_tests/core/plugin/impl/test_agent_client.py b/api/tests/unit_tests/core/plugin/impl/test_agent_client.py index 1537ffacf5..d8843f0eeb 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_agent_client.py +++ b/api/tests/unit_tests/core/plugin/impl/test_agent_client.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from core.plugin.entities.request import PluginInvokeContext from core.plugin.impl.agent import PluginAgentClient @@ -15,7 +17,7 @@ def _agent_provider(name: str = "agent") -> SimpleNamespace: class TestPluginAgentClient: - def test_fetch_agent_strategy_providers(self, mocker): + def test_fetch_agent_strategy_providers(self, mocker: MockerFixture): client = PluginAgentClient() provider = _agent_provider("remote") @@ -43,7 +45,7 @@ class TestPluginAgentClient: assert result[0].declaration.identity.name == "org/plugin/remote" assert result[0].declaration.strategies[0].identity.provider == "org/plugin/remote" - def test_fetch_agent_strategy_provider(self, mocker): + def test_fetch_agent_strategy_provider(self, mocker: MockerFixture): client = PluginAgentClient() provider = _agent_provider("provider") @@ -63,7 +65,7 @@ class TestPluginAgentClient: assert result.declaration.identity.name == "org/plugin/provider" assert result.declaration.strategies[0].identity.provider == "org/plugin/provider" - def test_invoke_merges_chunks_and_passes_context(self, mocker): + def test_invoke_merges_chunks_and_passes_context(self, mocker: MockerFixture): client = PluginAgentClient() stream_mock = mocker.patch.object( client, "_request_with_plugin_daemon_response_stream", return_value=iter(["raw"]) diff --git a/api/tests/unit_tests/core/plugin/impl/test_asset_manager.py b/api/tests/unit_tests/core/plugin/impl/test_asset_manager.py index 5f564062d5..c2cce5d691 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_asset_manager.py +++ b/api/tests/unit_tests/core/plugin/impl/test_asset_manager.py @@ -1,12 +1,13 @@ from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from core.plugin.impl.asset import PluginAssetManager class TestPluginAssetManager: - def test_fetch_asset_success(self, mocker): + def test_fetch_asset_success(self, mocker: MockerFixture): manager = PluginAssetManager() response = MagicMock(status_code=200, content=b"asset-bytes") request_mock = mocker.patch.object(manager, "_request", return_value=response) @@ -16,14 +17,14 @@ class TestPluginAssetManager: assert result == b"asset-bytes" request_mock.assert_called_once_with(method="GET", path="plugin/tenant-1/asset/asset-1") - def test_fetch_asset_not_found_raises(self, mocker): + def test_fetch_asset_not_found_raises(self, mocker: MockerFixture): manager = PluginAssetManager() mocker.patch.object(manager, "_request", return_value=MagicMock(status_code=404, content=b"")) with pytest.raises(ValueError, match="can not found asset asset-1"): manager.fetch_asset("tenant-1", "asset-1") - def test_extract_asset_success(self, mocker): + def test_extract_asset_success(self, mocker: MockerFixture): manager = PluginAssetManager() response = MagicMock(status_code=200, content=b"file-content") request_mock = mocker.patch.object(manager, "_request", return_value=response) @@ -37,7 +38,7 @@ class TestPluginAssetManager: params={"plugin_unique_identifier": "org/plugin:1", "file_path": "README.md"}, ) - def test_extract_asset_not_found_raises(self, mocker): + def test_extract_asset_not_found_raises(self, mocker: MockerFixture): manager = PluginAssetManager() mocker.patch.object(manager, "_request", return_value=MagicMock(status_code=404, content=b"")) diff --git a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py index 23894bd417..b154f056ca 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_base_client_impl.py @@ -1,6 +1,7 @@ import json import pytest +from pytest_mock import MockerFixture from core.plugin.endpoint.exc import EndpointSetupFailedError from core.plugin.entities.plugin_daemon import PluginDaemonInnerError @@ -39,7 +40,7 @@ class _StreamContext: class TestBasePluginClientImpl: - def test_inject_trace_headers(self, mocker): + def test_inject_trace_headers(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch("core.plugin.impl.base.dify_config.ENABLE_OTEL", True) trace_header = "00-abc-xyz-01" @@ -54,7 +55,7 @@ class TestBasePluginClientImpl: client._inject_trace_headers(headers_with_existing) assert headers_with_existing["TraceParent"] == "exists" - def test_stream_request_handles_data_lines_and_dict_payload(self, mocker): + def test_stream_request_handles_data_lines_and_dict_payload(self, mocker: MockerFixture): client = BasePluginClient() stream_mock = mocker.patch( "httpx.Client.stream", @@ -66,14 +67,14 @@ class TestBasePluginClientImpl: assert result == ["hello", "world"] assert stream_mock.call_args.kwargs["data"] == {"k": "v"} - def test_request_with_plugin_daemon_response_handles_request_exception(self, mocker): + def test_request_with_plugin_daemon_response_handles_request_exception(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch.object(client, "_request", side_effect=RuntimeError("boom")) with pytest.raises(ValueError, match="Failed to request plugin daemon"): client._request_with_plugin_daemon_response("GET", "plugin/tenant/path", bool) - def test_request_with_plugin_daemon_response_applies_transformer(self, mocker): + def test_request_with_plugin_daemon_response_applies_transformer(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch.object(client, "_request", return_value=_ResponseStub({"code": 0, "message": "", "data": True})) @@ -88,14 +89,14 @@ class TestBasePluginClientImpl: assert result is True assert transformed == {"code": 0, "message": "", "data": True} - def test_request_with_plugin_daemon_response_stream_malformed_json_error(self, mocker): + def test_request_with_plugin_daemon_response_stream_malformed_json_error(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch.object(client, "_stream_request", return_value=iter(['{"error":"bad-line"}'])) with pytest.raises(ValueError, match="bad-line"): list(client._request_with_plugin_daemon_response_stream("GET", "p", bool)) - def test_request_with_plugin_daemon_response_stream_plugin_daemon_inner_error(self, mocker): + def test_request_with_plugin_daemon_response_stream_plugin_daemon_inner_error(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch.object( client, "_stream_request", return_value=iter(['{"code":-500,"message":"not-json","data":null}']) @@ -105,14 +106,14 @@ class TestBasePluginClientImpl: list(client._request_with_plugin_daemon_response_stream("GET", "p", bool)) assert exc_info.value.message == "not-json" - def test_request_with_plugin_daemon_response_stream_plugin_daemon_error(self, mocker): + def test_request_with_plugin_daemon_response_stream_plugin_daemon_error(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch.object(client, "_stream_request", return_value=iter(['{"code":-1,"message":"err","data":null}'])) with pytest.raises(ValueError, match="plugin daemon: err, code: -1"): list(client._request_with_plugin_daemon_response_stream("GET", "p", bool)) - def test_request_with_plugin_daemon_response_stream_empty_data_error(self, mocker): + def test_request_with_plugin_daemon_response_stream_empty_data_error(self, mocker: MockerFixture): client = BasePluginClient() mocker.patch.object(client, "_stream_request", return_value=iter(['{"code":0,"message":"","data":null}'])) diff --git a/api/tests/unit_tests/core/plugin/impl/test_datasource_manager.py b/api/tests/unit_tests/core/plugin/impl/test_datasource_manager.py index 4c5987d759..94723dcfe2 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_datasource_manager.py +++ b/api/tests/unit_tests/core/plugin/impl/test_datasource_manager.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from core.datasource.entities.datasource_entities import ( GetOnlineDocumentPageContentRequest, OnlineDriveBrowseFilesRequest, @@ -19,7 +21,7 @@ def _datasource_provider(name: str = "provider") -> SimpleNamespace: class TestPluginDatasourceManager: - def test_fetch_datasource_providers(self, mocker): + def test_fetch_datasource_providers(self, mocker: MockerFixture): manager = PluginDatasourceManager() provider = _datasource_provider("remote") repack = mocker.patch("core.plugin.impl.datasource.ToolTransformService.repack_provider") @@ -52,7 +54,7 @@ class TestPluginDatasourceManager: assert result[1].declaration.datasources[0].identity.provider == "org/plugin/remote" repack.assert_called_once_with(tenant_id="tenant-1", provider=provider) - def test_fetch_installed_datasource_providers(self, mocker): + def test_fetch_installed_datasource_providers(self, mocker: MockerFixture): manager = PluginDatasourceManager() provider = _datasource_provider("remote") repack = mocker.patch("core.plugin.impl.datasource.ToolTransformService.repack_provider") @@ -83,7 +85,7 @@ class TestPluginDatasourceManager: assert result[0].declaration.datasources[0].identity.provider == "org/plugin/remote" repack.assert_called_once_with(tenant_id="tenant-1", provider=provider) - def test_fetch_datasource_provider_local_and_remote(self, mocker): + def test_fetch_datasource_provider_local_and_remote(self, mocker: MockerFixture): manager = PluginDatasourceManager() local = manager.fetch_datasource_provider("tenant-1", "langgenius/file/file") @@ -113,7 +115,7 @@ class TestPluginDatasourceManager: assert result.declaration.identity.name == "org/plugin/provider" assert result.declaration.datasources[0].identity.provider == "org/plugin/provider" - def test_get_website_crawl_streaming(self, mocker): + def test_get_website_crawl_streaming(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter(["crawl"]) @@ -132,7 +134,7 @@ class TestPluginDatasourceManager: assert stream_mock.call_count == 1 - def test_get_online_document_pages_streaming(self, mocker): + def test_get_online_document_pages_streaming(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter(["pages"]) @@ -151,7 +153,7 @@ class TestPluginDatasourceManager: assert stream_mock.call_count == 1 - def test_get_online_document_page_content_streaming(self, mocker): + def test_get_online_document_page_content_streaming(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter(["content"]) @@ -170,7 +172,7 @@ class TestPluginDatasourceManager: assert stream_mock.call_count == 1 - def test_online_drive_browse_files_streaming(self, mocker): + def test_online_drive_browse_files_streaming(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter(["browse"]) @@ -189,7 +191,7 @@ class TestPluginDatasourceManager: assert stream_mock.call_count == 1 - def test_online_drive_download_file_streaming(self, mocker): + def test_online_drive_download_file_streaming(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter(["download"]) @@ -208,14 +210,14 @@ class TestPluginDatasourceManager: assert stream_mock.call_count == 1 - def test_validate_provider_credentials_returns_true_when_stream_yields_result(self, mocker): + def test_validate_provider_credentials_returns_true_when_stream_yields_result(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter([SimpleNamespace(result=True)]) assert manager.validate_provider_credentials("tenant-1", "user-1", "provider", "org/plugin", {"k": "v"}) is True - def test_validate_provider_credentials_returns_false_when_stream_empty(self, mocker): + def test_validate_provider_credentials_returns_false_when_stream_empty(self, mocker: MockerFixture): manager = PluginDatasourceManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") stream_mock.return_value = iter([]) diff --git a/api/tests/unit_tests/core/plugin/impl/test_debugging_client.py b/api/tests/unit_tests/core/plugin/impl/test_debugging_client.py index c80785aee0..05959207b1 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_debugging_client.py +++ b/api/tests/unit_tests/core/plugin/impl/test_debugging_client.py @@ -1,10 +1,12 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from core.plugin.impl.debugging import PluginDebuggingClient class TestPluginDebuggingClient: - def test_get_debugging_key(self, mocker): + def test_get_debugging_key(self, mocker: MockerFixture): client = PluginDebuggingClient() request_mock = mocker.patch.object( client, diff --git a/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py index 4cf657a050..7a24cc01d1 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py @@ -1,11 +1,12 @@ import pytest +from pytest_mock import MockerFixture from core.plugin.impl.endpoint import PluginEndpointClient from core.plugin.impl.exc import PluginDaemonInternalServerError class TestPluginEndpointClientImpl: - def test_create_endpoint(self, mocker): + def test_create_endpoint(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True) @@ -18,7 +19,7 @@ class TestPluginEndpointClientImpl: assert args[:3] == ("POST", "plugin/tenant-1/endpoint/setup", bool) assert kwargs["data"]["plugin_unique_identifier"] == "org/plugin:1" - def test_list_endpoints(self, mocker): + def test_list_endpoints(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["endpoint"]) @@ -28,7 +29,7 @@ class TestPluginEndpointClientImpl: assert request_mock.call_args.args[1] == "plugin/tenant-1/endpoint/list" assert request_mock.call_args.kwargs["params"] == {"page": 2, "page_size": 20} - def test_list_endpoints_for_single_plugin(self, mocker): + def test_list_endpoints_for_single_plugin(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["endpoint"]) @@ -38,7 +39,7 @@ class TestPluginEndpointClientImpl: assert request_mock.call_args.args[1] == "plugin/tenant-1/endpoint/list/plugin" assert request_mock.call_args.kwargs["params"] == {"plugin_id": "org/plugin", "page": 1, "page_size": 10} - def test_update_endpoint(self, mocker): + def test_update_endpoint(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True) @@ -47,7 +48,7 @@ class TestPluginEndpointClientImpl: assert result is True assert request_mock.call_args.args[:3] == ("POST", "plugin/tenant-1/endpoint/update", bool) - def test_enable_and_disable_endpoint(self, mocker): + def test_enable_and_disable_endpoint(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=True) @@ -58,7 +59,7 @@ class TestPluginEndpointClientImpl: assert calls[0].args[1] == "plugin/tenant-1/endpoint/enable" assert calls[1].args[1] == "plugin/tenant-1/endpoint/disable" - def test_delete_endpoint_idempotent_and_re_raise(self, mocker): + def test_delete_endpoint_idempotent_and_re_raise(self, mocker: MockerFixture): client = PluginEndpointClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response") diff --git a/api/tests/unit_tests/core/plugin/impl/test_exc_impl.py b/api/tests/unit_tests/core/plugin/impl/test_exc_impl.py index 8c6f1c6b7f..d99a8c114f 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_exc_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_exc_impl.py @@ -1,11 +1,13 @@ import json +from pytest_mock import MockerFixture + from core.plugin.impl import exc as exc_module from core.plugin.impl.exc import PluginDaemonError, PluginInvokeError class TestPluginImplExceptions: - def test_plugin_daemon_error_str_contains_request_id(self, mocker): + def test_plugin_daemon_error_str_contains_request_id(self, mocker: MockerFixture): mocker.patch("core.plugin.impl.exc.get_request_id", return_value="req-123") error = PluginDaemonError("bad") @@ -21,7 +23,7 @@ class TestPluginImplExceptions: assert "RateLimit" in friendly assert "too many" in friendly - def test_plugin_invoke_error_invalid_json_and_fallback(self, mocker): + def test_plugin_invoke_error_invalid_json_and_fallback(self, mocker: MockerFixture): err = PluginInvokeError("plain text") assert err._get_error_object() == {} @@ -32,7 +34,7 @@ class TestPluginImplExceptions: err2 = PluginInvokeError("plain text") assert err2.get_error_message() == "plain text" - def test_plugin_invoke_error_get_error_object_handles_adapter_exception(self, mocker): + def test_plugin_invoke_error_get_error_object_handles_adapter_exception(self, mocker: MockerFixture): adapter = mocker.patch.object(exc_module, "TypeAdapter") adapter.return_value.validate_json.side_effect = RuntimeError("invalid") diff --git a/api/tests/unit_tests/core/plugin/impl/test_model_client.py b/api/tests/unit_tests/core/plugin/impl/test_model_client.py index bcbebbb38b..6dc572310c 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_model_client.py +++ b/api/tests/unit_tests/core/plugin/impl/test_model_client.py @@ -4,13 +4,14 @@ import io from types import SimpleNamespace import pytest +from pytest_mock import MockerFixture from core.plugin.entities.plugin_daemon import PluginDaemonInnerError from core.plugin.impl.model import PluginModelClient class TestPluginModelClient: - def test_fetch_model_providers(self, mocker): + def test_fetch_model_providers(self, mocker: MockerFixture): client = PluginModelClient() request_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response", return_value=["provider-a"]) @@ -23,7 +24,7 @@ class TestPluginModelClient: ) assert request_mock.call_args.kwargs["params"] == {"page": 1, "page_size": 256} - def test_get_model_schema(self, mocker): + def test_get_model_schema(self, mocker: MockerFixture): client = PluginModelClient() schema = SimpleNamespace(name="schema") stream_mock = mocker.patch.object( @@ -45,7 +46,7 @@ class TestPluginModelClient: assert result is schema assert stream_mock.call_args.args[:2] == ("POST", "plugin/tenant-1/dispatch/model/schema") - def test_get_model_schema_empty_stream_returns_none(self, mocker): + def test_get_model_schema_empty_stream_returns_none(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -53,7 +54,7 @@ class TestPluginModelClient: assert result is None - def test_validate_provider_credentials(self, mocker): + def test_validate_provider_credentials(self, mocker: MockerFixture): client = PluginModelClient() stream_mock = mocker.patch.object( client, @@ -77,7 +78,7 @@ class TestPluginModelClient: "plugin/tenant-1/dispatch/model/validate_provider_credentials", ) - def test_validate_provider_credentials_without_dict_update(self, mocker): + def test_validate_provider_credentials_without_dict_update(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object( client, @@ -91,13 +92,13 @@ class TestPluginModelClient: assert result is False assert credentials == {"api_key": "same"} - def test_validate_provider_credentials_empty_returns_false(self, mocker): + def test_validate_provider_credentials_empty_returns_false(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) assert client.validate_provider_credentials("tenant-1", "user-1", "org/plugin:1", "provider-a", {}) is False - def test_validate_model_credentials(self, mocker): + def test_validate_model_credentials(self, mocker: MockerFixture): client = PluginModelClient() stream_mock = mocker.patch.object( client, @@ -123,7 +124,7 @@ class TestPluginModelClient: "plugin/tenant-1/dispatch/model/validate_model_credentials", ) - def test_validate_model_credentials_empty_returns_false(self, mocker): + def test_validate_model_credentials_empty_returns_false(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -132,7 +133,7 @@ class TestPluginModelClient: is False ) - def test_invoke_llm(self, mocker): + def test_invoke_llm(self, mocker: MockerFixture): client = PluginModelClient() stream_mock = mocker.patch.object( client, "_request_with_plugin_daemon_response_stream", return_value=iter(["chunk-1"]) @@ -160,7 +161,7 @@ class TestPluginModelClient: assert call_kwargs["data"]["data"]["stream"] is False assert call_kwargs["data"]["data"]["model_parameters"] == {"temperature": 0.1} - def test_invoke_llm_wraps_plugin_daemon_inner_error(self, mocker): + def test_invoke_llm_wraps_plugin_daemon_inner_error(self, mocker: MockerFixture): client = PluginModelClient() def _boom(): @@ -182,7 +183,7 @@ class TestPluginModelClient: ) ) - def test_get_llm_num_tokens(self, mocker): + def test_get_llm_num_tokens(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object( client, @@ -204,7 +205,7 @@ class TestPluginModelClient: assert result == 42 - def test_get_llm_num_tokens_empty_returns_zero(self, mocker): + def test_get_llm_num_tokens_empty_returns_zero(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -213,7 +214,7 @@ class TestPluginModelClient: == 0 ) - def test_invoke_text_embedding(self, mocker): + def test_invoke_text_embedding(self, mocker: MockerFixture): client = PluginModelClient() embedding_result = SimpleNamespace(data=[[0.1, 0.2]]) mocker.patch.object( @@ -233,7 +234,7 @@ class TestPluginModelClient: assert result is embedding_result - def test_invoke_text_embedding_empty_raises(self, mocker): + def test_invoke_text_embedding_empty_raises(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -242,7 +243,7 @@ class TestPluginModelClient: "tenant-1", "user-1", "org/plugin:1", "provider-a", "embedding-a", {}, ["hello"], "x" ) - def test_invoke_multimodal_embedding(self, mocker): + def test_invoke_multimodal_embedding(self, mocker: MockerFixture): client = PluginModelClient() embedding_result = SimpleNamespace(data=[[0.3, 0.4]]) mocker.patch.object( @@ -262,7 +263,7 @@ class TestPluginModelClient: assert result is embedding_result - def test_invoke_multimodal_embedding_empty_raises(self, mocker): + def test_invoke_multimodal_embedding_empty_raises(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -271,7 +272,7 @@ class TestPluginModelClient: "tenant-1", "user-1", "org/plugin:1", "provider-a", "embedding-a", {}, [{"type": "image"}], "x" ) - def test_get_text_embedding_num_tokens(self, mocker): + def test_get_text_embedding_num_tokens(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object( client, @@ -287,7 +288,7 @@ class TestPluginModelClient: 3, ] - def test_get_text_embedding_num_tokens_empty_returns_list(self, mocker): + def test_get_text_embedding_num_tokens_empty_returns_list(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -298,7 +299,7 @@ class TestPluginModelClient: == [] ) - def test_invoke_rerank(self, mocker): + def test_invoke_rerank(self, mocker: MockerFixture): client = PluginModelClient() rerank_result = SimpleNamespace(scores=[0.9]) mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([rerank_result])) @@ -318,14 +319,14 @@ class TestPluginModelClient: assert result is rerank_result - def test_invoke_rerank_empty_raises(self, mocker): + def test_invoke_rerank_empty_raises(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) with pytest.raises(ValueError, match="Failed to invoke rerank"): client.invoke_rerank("tenant-1", "user-1", "org/plugin:1", "provider-a", "rerank-a", {}, "q", ["doc-1"]) - def test_invoke_multimodal_rerank(self, mocker): + def test_invoke_multimodal_rerank(self, mocker: MockerFixture): client = PluginModelClient() rerank_result = SimpleNamespace(scores=[0.8]) mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([rerank_result])) @@ -345,7 +346,7 @@ class TestPluginModelClient: assert result is rerank_result - def test_invoke_multimodal_rerank_empty_raises(self, mocker): + def test_invoke_multimodal_rerank_empty_raises(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -361,7 +362,7 @@ class TestPluginModelClient: [{"type": "image"}], ) - def test_invoke_tts(self, mocker): + def test_invoke_tts(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object( client, @@ -384,7 +385,7 @@ class TestPluginModelClient: assert result == [b"hello", b"!"] - def test_invoke_tts_wraps_plugin_daemon_inner_error(self, mocker): + def test_invoke_tts_wraps_plugin_daemon_inner_error(self, mocker: MockerFixture): client = PluginModelClient() def _boom(): @@ -396,7 +397,7 @@ class TestPluginModelClient: with pytest.raises(ValueError, match="tts error-400"): list(client.invoke_tts("tenant-1", "user-1", "org/plugin:1", "provider-a", "tts-a", {}, "hello", "alloy")) - def test_get_tts_model_voices(self, mocker): + def test_get_tts_model_voices(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object( client, @@ -425,13 +426,13 @@ class TestPluginModelClient: assert result == [{"name": "Alloy", "value": "alloy"}, {"name": "Echo", "value": "echo"}] - def test_get_tts_model_voices_empty_returns_list(self, mocker): + def test_get_tts_model_voices_empty_returns_list(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) assert client.get_tts_model_voices("tenant-1", "user-1", "org/plugin:1", "provider-a", "tts-a", {}) == [] - def test_invoke_speech_to_text(self, mocker): + def test_invoke_speech_to_text(self, mocker: MockerFixture): client = PluginModelClient() stream_mock = mocker.patch.object( client, @@ -452,7 +453,7 @@ class TestPluginModelClient: assert result == "transcribed text" assert stream_mock.call_args.kwargs["data"]["data"]["file"] == "616263" - def test_invoke_speech_to_text_empty_raises(self, mocker): + def test_invoke_speech_to_text_empty_raises(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -461,7 +462,7 @@ class TestPluginModelClient: "tenant-1", "user-1", "org/plugin:1", "provider-a", "stt-a", {}, io.BytesIO(b"abc") ) - def test_invoke_moderation(self, mocker): + def test_invoke_moderation(self, mocker: MockerFixture): client = PluginModelClient() stream_mock = mocker.patch.object( client, @@ -482,7 +483,7 @@ class TestPluginModelClient: assert result is True assert stream_mock.call_args.kwargs["path"] == "plugin/tenant-1/dispatch/moderation/invoke" - def test_invoke_moderation_empty_raises(self, mocker): + def test_invoke_moderation_empty_raises(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) diff --git a/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py b/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py index 7491e79f30..52da674f06 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py +++ b/api/tests/unit_tests/core/plugin/impl/test_model_runtime_factory.py @@ -31,6 +31,6 @@ def test_plugin_model_assembly_reuses_single_runtime_across_views(): assert assembly.model_manager is model_manager mock_runtime_factory.assert_called_once_with(tenant_id="tenant-1", user_id="user-1") - mock_provider_factory_cls.assert_called_once_with(model_runtime=runtime) + mock_provider_factory_cls.assert_called_once_with(runtime=runtime) mock_provider_manager_cls.assert_called_once_with(model_runtime=runtime) mock_model_manager_cls.assert_called_once_with(provider_manager=provider_manager) diff --git a/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py b/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py index 6fb4c99432..f6c9b1c669 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py +++ b/api/tests/unit_tests/core/plugin/impl/test_oauth_handler.py @@ -2,6 +2,7 @@ from io import BytesIO from types import SimpleNamespace import pytest +from pytest_mock import MockerFixture from werkzeug import Request from core.plugin.impl.oauth import OAuthHandler @@ -25,7 +26,7 @@ def _build_request(body: bytes = b"payload") -> Request: class TestOAuthHandler: - def test_get_authorization_url(self, mocker): + def test_get_authorization_url(self, mocker: MockerFixture): handler = OAuthHandler() stream_mock = mocker.patch.object( handler, @@ -45,7 +46,7 @@ class TestOAuthHandler: assert response.authorization_url == "https://auth.example.com" assert stream_mock.call_count == 1 - def test_get_authorization_url_no_response_raises(self, mocker): + def test_get_authorization_url_no_response_raises(self, mocker: MockerFixture): handler = OAuthHandler() mocker.patch.object(handler, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -59,7 +60,7 @@ class TestOAuthHandler: system_credentials={}, ) - def test_get_credentials(self, mocker): + def test_get_credentials(self, mocker: MockerFixture): handler = OAuthHandler() captured_data = {} @@ -85,7 +86,7 @@ class TestOAuthHandler: assert "raw_http_request" in captured_data["data"] assert stream_mock.call_count == 1 - def test_get_credentials_no_response_raises(self, mocker): + def test_get_credentials_no_response_raises(self, mocker: MockerFixture): handler = OAuthHandler() mocker.patch.object(handler, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -100,7 +101,7 @@ class TestOAuthHandler: request=_build_request(), ) - def test_refresh_credentials(self, mocker): + def test_refresh_credentials(self, mocker: MockerFixture): handler = OAuthHandler() stream_mock = mocker.patch.object( handler, @@ -121,7 +122,7 @@ class TestOAuthHandler: assert response.credentials == {"token": "new"} assert stream_mock.call_count == 1 - def test_refresh_credentials_no_response_raises(self, mocker): + def test_refresh_credentials_no_response_raises(self, mocker: MockerFixture): handler = OAuthHandler() mocker.patch.object(handler, "_request_with_plugin_daemon_response_stream", return_value=iter([])) diff --git a/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py b/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py index 80cf46f9bb..3ae3cc18e4 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py +++ b/api/tests/unit_tests/core/plugin/impl/test_tool_manager.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.tool import PluginToolManager @@ -15,7 +17,7 @@ def _tool_provider(name: str = "provider") -> SimpleNamespace: class TestPluginToolManager: - def test_fetch_tool_providers(self, mocker): + def test_fetch_tool_providers(self, mocker: MockerFixture): manager = PluginToolManager() provider = _tool_provider("remote") mocker.patch("core.plugin.impl.tool.resolve_dify_schema_refs", return_value={"resolved": True}) @@ -44,7 +46,7 @@ class TestPluginToolManager: assert result[0].declaration.identity.name == "org/plugin/remote" assert result[0].declaration.tools[0].identity.provider == "org/plugin/remote" - def test_fetch_tool_provider(self, mocker): + def test_fetch_tool_provider(self, mocker: MockerFixture): manager = PluginToolManager() provider = _tool_provider("provider") mocker.patch("core.plugin.impl.tool.resolve_dify_schema_refs", return_value={"resolved": True}) @@ -68,7 +70,7 @@ class TestPluginToolManager: assert result.declaration.identity.name == "org/plugin/provider" assert result.declaration.tools[0].identity.provider == "org/plugin/provider" - def test_invoke_merges_chunks(self, mocker): + def test_invoke_merges_chunks(self, mocker: MockerFixture): manager = PluginToolManager() stream_mock = mocker.patch.object( manager, "_request_with_plugin_daemon_response_stream", return_value=iter(["chunk"]) @@ -92,7 +94,7 @@ class TestPluginToolManager: assert merge_mock.call_count == 1 assert stream_mock.call_args.kwargs["headers"]["X-Plugin-ID"] == "org/plugin" - def test_validate_credentials_paths(self, mocker): + def test_validate_credentials_paths(self, mocker: MockerFixture): manager = PluginToolManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") @@ -108,7 +110,7 @@ class TestPluginToolManager: stream_mock.return_value = iter([]) assert manager.validate_datasource_credentials("tenant-1", "user-1", "org/plugin/provider", {"k": "v"}) is False - def test_get_runtime_parameters_paths(self, mocker): + def test_get_runtime_parameters_paths(self, mocker: MockerFixture): manager = PluginToolManager() stream_mock = mocker.patch.object(manager, "_request_with_plugin_daemon_response_stream") diff --git a/api/tests/unit_tests/core/plugin/impl/test_trigger_client.py b/api/tests/unit_tests/core/plugin/impl/test_trigger_client.py index 76da51c2c8..811bb7e50d 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_trigger_client.py +++ b/api/tests/unit_tests/core/plugin/impl/test_trigger_client.py @@ -2,6 +2,7 @@ from io import BytesIO from types import SimpleNamespace import pytest +from pytest_mock import MockerFixture from werkzeug import Request from core.plugin.entities.plugin_daemon import CredentialType @@ -62,7 +63,7 @@ def _subscription_call_kwargs(method_name: str) -> dict: class TestPluginTriggerClient: - def test_fetch_trigger_providers(self, mocker): + def test_fetch_trigger_providers(self, mocker: MockerFixture): client = PluginTriggerClient() provider = _trigger_provider("remote") @@ -89,7 +90,7 @@ class TestPluginTriggerClient: assert result[0].declaration.identity.name == "org/plugin/remote" assert result[0].declaration.events[0].identity.provider == "org/plugin/remote" - def test_fetch_trigger_provider(self, mocker): + def test_fetch_trigger_provider(self, mocker: MockerFixture): client = PluginTriggerClient() provider = _trigger_provider("provider") @@ -108,7 +109,7 @@ class TestPluginTriggerClient: assert result.declaration.identity.name == "org/plugin/provider" assert result.declaration.events[0].identity.provider == "org/plugin/provider" - def test_invoke_trigger_event(self, mocker): + def test_invoke_trigger_event(self, mocker: MockerFixture): client = PluginTriggerClient() stream_mock = mocker.patch.object( client, @@ -132,7 +133,7 @@ class TestPluginTriggerClient: assert result.variables == {"ok": True} assert stream_mock.call_count == 1 - def test_invoke_trigger_event_no_response_raises(self, mocker): + def test_invoke_trigger_event_no_response_raises(self, mocker: MockerFixture): client = PluginTriggerClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) @@ -150,7 +151,7 @@ class TestPluginTriggerClient: payload={"payload": 1}, ) - def test_validate_provider_credentials(self, mocker): + def test_validate_provider_credentials(self, mocker: MockerFixture): client = PluginTriggerClient() stream_mock = mocker.patch.object(client, "_request_with_plugin_daemon_response_stream") @@ -163,7 +164,7 @@ class TestPluginTriggerClient: ): client.validate_provider_credentials("tenant-1", "user-1", "org/plugin/provider", {"k": "v"}) - def test_dispatch_event(self, mocker): + def test_dispatch_event(self, mocker: MockerFixture): client = PluginTriggerClient() stream_mock = mocker.patch.object( client, @@ -195,7 +196,7 @@ class TestPluginTriggerClient: ) @pytest.mark.parametrize("method_name", ["subscribe", "unsubscribe", "refresh"]) - def test_subscription_operations_success(self, mocker, method_name): + def test_subscription_operations_success(self, mocker: MockerFixture, method_name): client = PluginTriggerClient() stream_mock = mocker.patch.object( client, @@ -217,7 +218,7 @@ class TestPluginTriggerClient: ("refresh", "No response received from plugin daemon for refresh"), ], ) - def test_subscription_operations_no_response(self, mocker, method_name, expected): + def test_subscription_operations_no_response(self, mocker: MockerFixture, method_name, expected): client = PluginTriggerClient() mocker.patch.object(client, "_request_with_plugin_daemon_response_stream", return_value=iter([])) method = getattr(client, method_name) diff --git a/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py b/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py index 3feb4159ad..2ed7c70ed9 100644 --- a/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py +++ b/api/tests/unit_tests/core/plugin/test_backwards_invocation_app.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest from pydantic import BaseModel +from pytest_mock import MockerFixture from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.plugin.backwards_invocation.app import PluginAppBackwardsInvocation @@ -41,7 +42,7 @@ class TestBaseBackwardsInvocation: class TestPluginAppBackwardsInvocation: - def test_fetch_app_info_workflow_path(self, mocker): + def test_fetch_app_info_workflow_path(self, mocker: MockerFixture): workflow = MagicMock() workflow.features_dict = {"feature": "v"} workflow.user_input_form.return_value = [{"name": "foo"}] @@ -57,7 +58,7 @@ class TestPluginAppBackwardsInvocation: assert result == {"data": {"mapped": True}} mapper.assert_called_once_with(features_dict={"feature": "v"}, user_input_form=[{"name": "foo"}]) - def test_fetch_app_info_model_config_path(self, mocker): + def test_fetch_app_info_model_config_path(self, mocker: MockerFixture): model_config = MagicMock() model_config.to_dict.return_value = {"user_input_form": [{"name": "bar"}], "k": "v"} app = MagicMock(mode=AppMode.COMPLETION, app_model_config=model_config) @@ -81,7 +82,7 @@ class TestPluginAppBackwardsInvocation: (AppMode.COMPLETION, "invoke_completion_app"), ], ) - def test_invoke_app_routes_by_mode(self, mocker, mode, route_method): + def test_invoke_app_routes_by_mode(self, mocker: MockerFixture, mode, route_method): app = MagicMock(mode=mode) user = MagicMock() mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) @@ -102,7 +103,7 @@ class TestPluginAppBackwardsInvocation: assert result == {"routed": True} assert route.call_count == 1 - def test_invoke_app_uses_end_user_when_user_id_missing(self, mocker): + def test_invoke_app_uses_end_user_when_user_id_missing(self, mocker: MockerFixture): app = MagicMock(mode=AppMode.WORKFLOW) end_user = MagicMock() mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=app) @@ -127,7 +128,7 @@ class TestPluginAppBackwardsInvocation: get_or_create.assert_called_once_with(app) assert route.call_args.args[1] is end_user - def test_invoke_app_missing_query_for_chat_raises(self, mocker): + def test_invoke_app_missing_query_for_chat_raises(self, mocker: MockerFixture): mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=MagicMock(mode=AppMode.CHAT)) mocker.patch.object(PluginAppBackwardsInvocation, "_get_user", return_value=MagicMock()) @@ -143,7 +144,7 @@ class TestPluginAppBackwardsInvocation: files=[], ) - def test_invoke_app_unexpected_mode_raises(self, mocker): + def test_invoke_app_unexpected_mode_raises(self, mocker: MockerFixture): mocker.patch.object(PluginAppBackwardsInvocation, "_get_app", return_value=MagicMock(mode="other")) mocker.patch.object(PluginAppBackwardsInvocation, "_get_user", return_value=MagicMock()) @@ -166,7 +167,7 @@ class TestPluginAppBackwardsInvocation: (AppMode.CHAT, "core.plugin.backwards_invocation.app.ChatAppGenerator.generate"), ], ) - def test_invoke_chat_app_agent_and_chat(self, mocker, mode, generator_path): + def test_invoke_chat_app_agent_and_chat(self, mocker: MockerFixture, mode, generator_path): app = MagicMock(mode=mode, workflow=None) spy = mocker.patch(generator_path, return_value={"result": "ok"}) @@ -183,7 +184,7 @@ class TestPluginAppBackwardsInvocation: assert result == {"result": "ok"} assert spy.call_count == 1 - def test_invoke_chat_app_advanced_chat_injects_pause_state_config(self, mocker): + def test_invoke_chat_app_advanced_chat_injects_pause_state_config(self, mocker: MockerFixture): workflow = MagicMock() workflow.created_by = "owner-id" @@ -242,7 +243,7 @@ class TestPluginAppBackwardsInvocation: files=[], ) - def test_invoke_workflow_app_injects_pause_state_config(self, mocker): + def test_invoke_workflow_app_injects_pause_state_config(self, mocker: MockerFixture): workflow = MagicMock() workflow.created_by = "owner-id" @@ -284,7 +285,7 @@ class TestPluginAppBackwardsInvocation: files=[], ) - def test_invoke_completion_app(self, mocker): + def test_invoke_completion_app(self, mocker: MockerFixture): spy = mocker.patch( "core.plugin.backwards_invocation.app.CompletionAppGenerator.generate", return_value={"ok": 1} ) @@ -295,7 +296,7 @@ class TestPluginAppBackwardsInvocation: assert result == {"ok": 1} assert spy.call_count == 1 - def test_get_user_returns_end_user(self, mocker): + def test_get_user_returns_end_user(self, mocker: MockerFixture): session = MagicMock() session.scalar.side_effect = [MagicMock(id="end-user")] session_ctx = MagicMock() @@ -307,7 +308,7 @@ class TestPluginAppBackwardsInvocation: user = PluginAppBackwardsInvocation._get_user("uid") assert user.id == "end-user" - def test_get_user_falls_back_to_account_user(self, mocker): + def test_get_user_falls_back_to_account_user(self, mocker: MockerFixture): session = MagicMock() session.scalar.side_effect = [None, MagicMock(id="account-user")] session_ctx = MagicMock() @@ -319,7 +320,7 @@ class TestPluginAppBackwardsInvocation: user = PluginAppBackwardsInvocation._get_user("uid") assert user.id == "account-user" - def test_get_user_raises_when_user_not_found(self, mocker): + def test_get_user_raises_when_user_not_found(self, mocker: MockerFixture): session = MagicMock() session.scalar.side_effect = [None, None] session_ctx = MagicMock() @@ -331,21 +332,21 @@ class TestPluginAppBackwardsInvocation: with pytest.raises(ValueError, match="user not found"): PluginAppBackwardsInvocation._get_user("uid") - def test_get_app_returns_app(self, mocker): + def test_get_app_returns_app(self, mocker: MockerFixture): app_obj = MagicMock(id="app") db = SimpleNamespace(session=MagicMock(scalar=MagicMock(return_value=app_obj))) mocker.patch("core.plugin.backwards_invocation.app.db", db) assert PluginAppBackwardsInvocation._get_app("app", "tenant") is app_obj - def test_get_app_raises_when_missing(self, mocker): + def test_get_app_raises_when_missing(self, mocker: MockerFixture): db = SimpleNamespace(session=MagicMock(scalar=MagicMock(return_value=None))) mocker.patch("core.plugin.backwards_invocation.app.db", db) with pytest.raises(ValueError, match="app not found"): PluginAppBackwardsInvocation._get_app("app", "tenant") - def test_get_app_raises_when_query_fails(self, mocker): + def test_get_app_raises_when_query_fails(self, mocker: MockerFixture): db = SimpleNamespace(session=MagicMock(scalar=MagicMock(side_effect=RuntimeError("db down")))) mocker.patch("core.plugin.backwards_invocation.app.db", db) diff --git a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py index 88bf555594..b1ecaa4ead 100644 --- a/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py +++ b/api/tests/unit_tests/core/plugin/test_model_runtime_adapter.py @@ -3,7 +3,7 @@ import datetime import uuid from types import SimpleNamespace -from unittest.mock import Mock, sentinel +from unittest.mock import Mock, patch, sentinel import pytest @@ -13,6 +13,8 @@ from core.plugin.impl.model import PluginModelClient from core.plugin.impl.model_runtime import TENANT_SCOPE_SCHEMA_CACHE_USER_ID, PluginModelRuntime from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity @@ -146,7 +148,31 @@ class TestPluginModelRuntime: def test_invoke_llm_resolves_plugin_fields(self) -> None: client = Mock(spec=PluginModelClient) - client.invoke_llm.return_value = sentinel.result + usage = LLMUsage.empty_usage() + client.invoke_llm.return_value = iter( + [ + LLMResultChunk( + model="gpt-4o-mini", + prompt_messages=[], + system_fingerprint="fp-plugin", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content="plugin "), + ), + ), + LLMResultChunk( + model="gpt-4o-mini", + prompt_messages=[], + system_fingerprint="fp-plugin", + delta=LLMResultChunkDelta( + index=1, + message=AssistantPromptMessage(content="response"), + usage=usage, + finish_reason="stop", + ), + ), + ] + ) runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) result = runtime.invoke_llm( @@ -160,7 +186,11 @@ class TestPluginModelRuntime: stream=False, ) - assert result is sentinel.result + assert result.model == "gpt-4o-mini" + assert result.prompt_messages == [] + assert result.message.content == "plugin response" + assert result.usage == usage + assert result.system_fingerprint == "fp-plugin" client.invoke_llm.assert_called_once_with( tenant_id="tenant", user_id="user", @@ -175,6 +205,38 @@ class TestPluginModelRuntime: stream=False, ) + def test_invoke_llm_returns_plugin_stream_directly(self) -> None: + client = Mock(spec=PluginModelClient) + stream_result = iter([]) + client.invoke_llm.return_value = stream_result + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) + + result = runtime.invoke_llm( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0.3}, + prompt_messages=[], + tools=None, + stop=("END",), + stream=True, + ) + + assert result is stream_result + client.invoke_llm.assert_called_once_with( + tenant_id="tenant", + user_id="user", + plugin_id="langgenius/openai", + provider="openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0.3}, + prompt_messages=[], + tools=None, + stop=["END"], + stream=True, + ) + def test_invoke_llm_rejects_per_call_user_override(self) -> None: client = Mock(spec=PluginModelClient) client.invoke_llm.return_value = sentinel.result @@ -267,6 +329,129 @@ def test_get_model_schema_uses_cached_schema_without_hitting_client(monkeypatch: client.get_model_schema.assert_not_called() +def test_structured_output_adapter_invokes_bound_runtime_streaming() -> None: + runtime = Mock() + runtime.invoke_llm.return_value = sentinel.stream_result + adapter = model_runtime_module._PluginStructuredOutputModelInstance( + runtime=runtime, + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + ) + tool = Mock() + + result = adapter.invoke_llm( + prompt_messages=[], + model_parameters=None, + tools=[tool], + stop=["END"], + stream=True, + callbacks=sentinel.callbacks, + ) + + assert result is sentinel.stream_result + runtime.invoke_llm.assert_called_once_with( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={}, + prompt_messages=[], + tools=[tool], + stop=["END"], + stream=True, + ) + + +def test_structured_output_adapter_invokes_bound_runtime_non_streaming() -> None: + runtime = Mock() + runtime.invoke_llm.return_value = sentinel.result + adapter = model_runtime_module._PluginStructuredOutputModelInstance( + runtime=runtime, + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + ) + + result = adapter.invoke_llm( + prompt_messages=[], + model_parameters={"temperature": 0}, + tools=None, + stop=None, + stream=False, + ) + + assert result is sentinel.result + runtime.invoke_llm.assert_called_once_with( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0}, + prompt_messages=[], + tools=None, + stop=None, + stream=False, + ) + + +def test_invoke_llm_with_structured_output_delegates_with_bound_adapter() -> None: + client = Mock(spec=PluginModelClient) + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) + schema = _build_model_schema() + runtime.get_model_schema = Mock(return_value=schema) # type: ignore[method-assign] + + with patch.object( + model_runtime_module, + "invoke_llm_with_structured_output_helper", + return_value=sentinel.structured_result, + ) as mock_helper: + result = runtime.invoke_llm_with_structured_output( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + json_schema={"type": "object"}, + model_parameters={"temperature": 0}, + prompt_messages=[], + stop=("END",), + stream=False, + ) + + assert result is sentinel.structured_result + runtime.get_model_schema.assert_called_once_with( + provider="langgenius/openai/openai", + model_type=ModelType.LLM, + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + ) + helper_kwargs = mock_helper.call_args.kwargs + assert helper_kwargs["provider"] == "langgenius/openai/openai" + assert helper_kwargs["model_schema"] == schema + assert helper_kwargs["json_schema"] == {"type": "object"} + assert helper_kwargs["model_parameters"] == {"temperature": 0} + assert helper_kwargs["prompt_messages"] == [] + assert helper_kwargs["tools"] is None + assert helper_kwargs["stop"] == ["END"] + assert helper_kwargs["stream"] is False + assert isinstance(helper_kwargs["model_instance"], model_runtime_module._PluginStructuredOutputModelInstance) + + +def test_invoke_llm_with_structured_output_raises_when_model_schema_is_missing() -> None: + client = Mock(spec=PluginModelClient) + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client) + runtime.get_model_schema = Mock(return_value=None) # type: ignore[method-assign] + + with pytest.raises(ValueError, match="Model schema not found for gpt-4o-mini"): + runtime.invoke_llm_with_structured_output( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + json_schema={"type": "object"}, + model_parameters={}, + prompt_messages=[], + stop=None, + stream=False, + ) + + def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytest.MonkeyPatch) -> None: client = Mock(spec=PluginModelClient) schema = _build_model_schema() diff --git a/api/tests/unit_tests/core/plugin/test_plugin_entities.py b/api/tests/unit_tests/core/plugin/test_plugin_entities.py index f1c4c7e700..deac0ba1da 100644 --- a/api/tests/unit_tests/core/plugin/test_plugin_entities.py +++ b/api/tests/unit_tests/core/plugin/test_plugin_entities.py @@ -5,6 +5,7 @@ from enum import StrEnum import pytest from flask import Response from pydantic import ValidationError +from pytest_mock import MockerFixture from core.plugin.entities.endpoint import EndpointEntityWithInstance from core.plugin.entities.marketplace import MarketplacePluginDeclaration, MarketplacePluginSnapshot @@ -34,7 +35,7 @@ from graphon.model_runtime.entities.message_entities import ( class TestEndpointEntity: - def test_endpoint_entity_with_instance_renders_url(self, mocker): + def test_endpoint_entity_with_instance_renders_url(self, mocker: MockerFixture): mocker.patch("core.plugin.entities.endpoint.dify_config.ENDPOINT_URL_TEMPLATE", "https://dify.test/{hook_id}") now = datetime.datetime.now(datetime.UTC) diff --git a/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py b/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py index 1b114b369a..1f46634b89 100644 --- a/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py +++ b/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py @@ -1,5 +1,7 @@ from uuid import uuid4 +from pytest_mock import MockerFixture + from constants import UUID_NIL from core.prompt.utils.extract_thread_messages import extract_thread_messages from core.prompt.utils.get_thread_messages_length import get_thread_messages_length @@ -103,7 +105,7 @@ def test_extract_thread_messages_breaks_when_parent_is_none(): assert result[0].id == id2 -def test_get_thread_messages_length_excludes_newly_created_empty_answer(mocker): +def test_get_thread_messages_length_excludes_newly_created_empty_answer(mocker: MockerFixture): id1, id2 = str(uuid4()), str(uuid4()) messages = [ MockMessage(id2, id1, answer=""), # newest generated message should be excluded @@ -119,7 +121,7 @@ def test_get_thread_messages_length_excludes_newly_created_empty_answer(mocker): mock_scalars.assert_called_once() -def test_get_thread_messages_length_keeps_non_empty_latest_answer(mocker): +def test_get_thread_messages_length_keeps_non_empty_latest_answer(mocker: MockerFixture): id1, id2 = str(uuid4()), str(uuid4()) messages = [ MockMessage(id2, id1, answer="latest-answer"), diff --git a/api/tests/unit_tests/core/prompt/test_prompt_transform.py b/api/tests/unit_tests/core/prompt/test_prompt_transform.py index 5308c8e7b3..3d71e73496 100644 --- a/api/tests/unit_tests/core/prompt/test_prompt_transform.py +++ b/api/tests/unit_tests/core/prompt/test_prompt_transform.py @@ -209,7 +209,7 @@ class TestPromptTransform: assert result == ["only"] memory.get_history_prompt_messages.assert_called_with(max_token_limit=10, message_limit=None) - def test_append_chat_histories_extends_prompt_messages(self, monkeypatch): + def test_append_chat_histories_extends_prompt_messages(self, monkeypatch: pytest.MonkeyPatch): transform = PromptTransform() memory = MagicMock() memory_config = SimpleNamespace(window=SimpleNamespace(enabled=False, size=None)) diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py index 1e91c2dd88..e233bd2ef0 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py @@ -67,7 +67,7 @@ def _dataset(dataset_keyword_table=None, keyword_number=None): @pytest.fixture -def patched_runtime(monkeypatch): +def patched_runtime(monkeypatch: pytest.MonkeyPatch): session = MagicMock() db = SimpleNamespace(session=session) storage = MagicMock() @@ -151,7 +151,7 @@ def test_add_texts_without_keywords_list_always_uses_extractor(monkeypatch, patc assert set(keyword._update_segment_keywords.call_args.args[2]) == {"from-extractor"} -def test_text_exists_handles_missing_and_existing_keyword_table(monkeypatch): +def test_text_exists_handles_missing_and_existing_keyword_table(monkeypatch: pytest.MonkeyPatch): keyword = Jieba(_dataset(_dataset_keyword_table())) monkeypatch.setattr(keyword, "_get_dataset_keyword_table", MagicMock(return_value=None)) @@ -308,7 +308,7 @@ def test_add_and_delete_ids_from_keyword_table_helpers(): assert deleted["kw2"] == {"node-2"} -def test_retrieve_ids_by_query_ranks_by_keyword_frequency(monkeypatch): +def test_retrieve_ids_by_query_ranks_by_keyword_frequency(monkeypatch: pytest.MonkeyPatch): keyword = Jieba(_dataset(_dataset_keyword_table())) handler = MagicMock() handler.extract_keywords.return_value = ["kw-a", "kw-b"] @@ -350,7 +350,7 @@ def test_update_segment_keywords_updates_when_segment_exists(monkeypatch, patche patched_runtime.session.commit.assert_not_called() -def test_create_segment_keywords_and_update_segment_keywords_index(monkeypatch): +def test_create_segment_keywords_and_update_segment_keywords_index(monkeypatch: pytest.MonkeyPatch): keyword = Jieba(_dataset(_dataset_keyword_table())) monkeypatch.setattr(keyword, "_get_dataset_keyword_table", MagicMock(return_value={})) monkeypatch.setattr(keyword, "_update_segment_keywords", MagicMock()) @@ -365,7 +365,7 @@ def test_create_segment_keywords_and_update_segment_keywords_index(monkeypatch): keyword._save_dataset_keyword_table.assert_called_once() -def test_multi_create_segment_keywords_uses_provided_and_extracted_keywords(monkeypatch): +def test_multi_create_segment_keywords_uses_provided_and_extracted_keywords(monkeypatch: pytest.MonkeyPatch): keyword = Jieba(_dataset(_dataset_keyword_table(), keyword_number=2)) handler = MagicMock() handler.extract_keywords.return_value = {"auto"} diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py index a4586c141b..c8ee75bf43 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py @@ -2,6 +2,8 @@ import sys import types from types import SimpleNamespace +import pytest + from core.rag.datasource.keyword.jieba.jieba_keyword_table_handler import JiebaKeywordTableHandler from core.rag.datasource.keyword.jieba.stopwords import STOPWORDS @@ -38,7 +40,7 @@ def _install_fake_jieba_modules( monkeypatch.delitem(sys.modules, "jieba.analyse.tfidf", raising=False) -def test_init_uses_existing_default_tfidf(monkeypatch): +def test_init_uses_existing_default_tfidf(monkeypatch: pytest.MonkeyPatch): analyse_module = types.ModuleType("jieba.analyse") default_tfidf = _DummyTFIDF() analyse_module.default_tfidf = default_tfidf @@ -51,7 +53,7 @@ def test_init_uses_existing_default_tfidf(monkeypatch): assert handler._tfidf.stop_words == STOPWORDS -def test_load_tfidf_extractor_uses_tfidf_class_and_caches_default(monkeypatch): +def test_load_tfidf_extractor_uses_tfidf_class_and_caches_default(monkeypatch: pytest.MonkeyPatch): analyse_module = types.ModuleType("jieba.analyse") analyse_module.default_tfidf = None @@ -67,7 +69,7 @@ def test_load_tfidf_extractor_uses_tfidf_class_and_caches_default(monkeypatch): assert analyse_module.default_tfidf is handler._tfidf -def test_load_tfidf_extractor_imports_from_tfidf_submodule(monkeypatch): +def test_load_tfidf_extractor_imports_from_tfidf_submodule(monkeypatch: pytest.MonkeyPatch): analyse_module = types.ModuleType("jieba.analyse") analyse_module.default_tfidf = None @@ -85,7 +87,7 @@ def test_load_tfidf_extractor_imports_from_tfidf_submodule(monkeypatch): assert analyse_module.default_tfidf is handler._tfidf -def test_load_tfidf_extractor_falls_back_when_tfidf_unavailable(monkeypatch): +def test_load_tfidf_extractor_falls_back_when_tfidf_unavailable(monkeypatch: pytest.MonkeyPatch): analyse_module = types.ModuleType("jieba.analyse") analyse_module.default_tfidf = None _install_fake_jieba_modules(monkeypatch, analyse_module) @@ -96,7 +98,7 @@ def test_load_tfidf_extractor_falls_back_when_tfidf_unavailable(monkeypatch): assert fallback_keywords == ["two"] -def test_build_fallback_tfidf_uses_lcut_when_available(monkeypatch): +def test_build_fallback_tfidf_uses_lcut_when_available(monkeypatch: pytest.MonkeyPatch): analyse_module = types.ModuleType("jieba.analyse") _install_fake_jieba_modules(monkeypatch, analyse_module, jieba_attrs={"lcut": lambda _: ["x", "x", "y"]}) @@ -105,7 +107,7 @@ def test_build_fallback_tfidf_uses_lcut_when_available(monkeypatch): assert tfidf.extract_tags("ignored", topK=1) == ["x"] -def test_build_fallback_tfidf_uses_cut_when_lcut_is_missing(monkeypatch): +def test_build_fallback_tfidf_uses_cut_when_lcut_is_missing(monkeypatch: pytest.MonkeyPatch): analyse_module = types.ModuleType("jieba.analyse") _install_fake_jieba_modules( monkeypatch, diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py index 0d969a3270..e1765b17cb 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/test_keyword_factory.py @@ -10,7 +10,7 @@ from core.rag.datasource.keyword.keyword_type import KeyWordType from core.rag.models.document import Document -def test_get_keyword_factory_returns_jieba_factory(monkeypatch): +def test_get_keyword_factory_returns_jieba_factory(monkeypatch: pytest.MonkeyPatch): fake_module = types.ModuleType("core.rag.datasource.keyword.jieba.jieba") class FakeJieba: @@ -27,7 +27,7 @@ def test_get_keyword_factory_raises_for_unsupported_type(): Keyword.get_keyword_factory("unsupported") -def test_keyword_initialization_uses_configured_factory(monkeypatch): +def test_keyword_initialization_uses_configured_factory(monkeypatch: pytest.MonkeyPatch): dataset = SimpleNamespace(id="dataset-1") fake_processor = MagicMock() diff --git a/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py b/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py index b0ecad4d0c..f72351ffa2 100644 --- a/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py +++ b/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py @@ -182,7 +182,7 @@ class TestRetrievalServiceInternals: app.app_context.return_value.__exit__.return_value = False return app - def test_retrieve_with_attachment_ids_only(self, monkeypatch, internal_dataset): + def test_retrieve_with_attachment_ids_only(self, monkeypatch: pytest.MonkeyPatch, internal_dataset): with ( patch("core.rag.datasource.retrieval_service.RetrievalService._get_dataset", return_value=internal_dataset), patch("core.rag.datasource.retrieval_service.RetrievalService._retrieve") as mock_retrieve, @@ -699,7 +699,9 @@ class TestRetrievalServiceInternals: assert RetrievalService.format_retrieval_documents(documents) == [] - def test_format_retrieval_documents_with_parent_child_summary_and_attachments(self, monkeypatch): + def test_format_retrieval_documents_with_parent_child_summary_and_attachments( + self, monkeypatch: pytest.MonkeyPatch + ): dataset_doc_parent = SimpleNamespace( id="doc-parent", doc_form=IndexStructureType.PARENT_CHILD_INDEX, @@ -877,7 +879,7 @@ class TestRetrievalServiceInternals: assert result_by_segment_id["segment-parent-summary"].summary == "summary for parent" assert result_by_segment_id["segment-parent-summary"].child_chunks == [] - def test_format_retrieval_documents_rolls_back_and_raises_when_db_fails(self, monkeypatch): + def test_format_retrieval_documents_rolls_back_and_raises_when_db_fails(self, monkeypatch: pytest.MonkeyPatch): rollback = Mock() monkeypatch.setattr(retrieval_service_module.db.session, "rollback", rollback) monkeypatch.setattr(retrieval_service_module.db.session, "scalars", Mock(side_effect=RuntimeError("db error"))) @@ -936,7 +938,7 @@ class TestRetrievalServiceInternals: future_ok.cancel.assert_called() def test_retrieve_internal_raises_value_error_when_exceptions_exist( - self, monkeypatch, internal_dataset, internal_flask_app + self, monkeypatch: pytest.MonkeyPatch, internal_dataset, internal_flask_app ): executor = _ImmediateExecutor() monkeypatch.setattr(retrieval_service_module, "ThreadPoolExecutor", lambda *args, **kwargs: executor) @@ -958,7 +960,9 @@ class TestRetrievalServiceInternals: query="query", ) - def test_retrieve_internal_hybrid_weighted_attachment_flow(self, monkeypatch, internal_dataset, internal_flask_app): + def test_retrieve_internal_hybrid_weighted_attachment_flow( + self, monkeypatch: pytest.MonkeyPatch, internal_dataset, internal_flask_app + ): executor = _ImmediateExecutor() monkeypatch.setattr(retrieval_service_module, "ThreadPoolExecutor", lambda *args, **kwargs: executor) monkeypatch.setattr( @@ -1034,7 +1038,7 @@ class TestRetrievalServiceInternals: assert any(doc.metadata["doc_id"] == "processed-doc" for doc in all_documents) processor_instance.invoke.assert_called_once() - @patch("core.rag.datasource.retrieval_service.sign_upload_file", return_value="signed://file") + @patch("core.rag.datasource.retrieval_service.sign_upload_file_preview_url", return_value="signed://file") def test_get_segment_attachment_info_success(self, mock_sign): upload_file = SimpleNamespace( id="upload-1", @@ -1114,7 +1118,7 @@ class TestRetrievalServiceInternals: assert result == [] - @patch("core.rag.datasource.retrieval_service.sign_upload_file", return_value="signed://file") + @patch("core.rag.datasource.retrieval_service.sign_upload_file_preview_url", return_value="signed://file") def test_get_segment_attachment_infos_success(self, mock_sign): upload_file_1 = SimpleNamespace( id="upload-1", diff --git a/api/tests/unit_tests/core/rag/datasource/test_retrieval_attachment_access.py b/api/tests/unit_tests/core/rag/datasource/test_retrieval_attachment_access.py new file mode 100644 index 0000000000..426ffc498b --- /dev/null +++ b/api/tests/unit_tests/core/rag/datasource/test_retrieval_attachment_access.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from types import SimpleNamespace +from uuid import uuid4 + +from sqlalchemy import select + +from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom +from core.app.file_access import ( + DatabaseFileAccessController, + FileAccessScope, + bind_file_access_scope, + get_current_file_access_scope, + grant_retriever_segment_access, + grant_upload_file_access, + is_retriever_segment_access_granted, +) +from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.retrieval import dataset_retrieval as dataset_retrieval_module +from core.rag.retrieval.dataset_retrieval import DatasetRetrieval +from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest +from models import UploadFile + + +class _ScalarResult: + def __init__(self, values): + self._values = values + + def all(self): + return self._values + + +class _AttachmentSession: + def __init__(self, upload_file, binding): + self._results = [ + _ScalarResult([upload_file]), + _ScalarResult([binding]), + ] + + def scalars(self, _stmt): + return self._results.pop(0) + + +class _DatasetRetrievalSession: + def __init__(self, datasets, documents): + self._results = [ + _ScalarResult(datasets), + _ScalarResult(documents), + ] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def scalars(self, _stmt): + return self._results.pop(0) + + +def test_file_access_grants_ignore_empty_inputs_and_missing_scope() -> None: + grant_upload_file_access(["upload-file-id"]) + grant_retriever_segment_access(["segment-id"]) + assert is_retriever_segment_access_granted("segment-id") is True + + scope = FileAccessScope( + tenant_id=str(uuid4()), + user_id=str(uuid4()), + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + grant_upload_file_access([""]) + grant_retriever_segment_access([""]) + current_scope = get_current_file_access_scope() + + assert current_scope is not None + assert current_scope.granted_upload_file_ids == frozenset() + assert current_scope.granted_retriever_segment_ids == frozenset() + + +def test_segment_attachment_lookup_grants_returned_upload_files_to_current_scope() -> None: + tenant_id = str(uuid4()) + upload_file_id = str(uuid4()) + segment_id = str(uuid4()) + upload_file = SimpleNamespace( + id=upload_file_id, + name="chart.png", + extension="png", + mime_type="image/png", + size=1024, + ) + binding = SimpleNamespace(attachment_id=upload_file_id, segment_id=segment_id) + session = _AttachmentSession(upload_file, binding) + scope = FileAccessScope( + tenant_id=tenant_id, + user_id=str(uuid4()), + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + result = RetrievalService.get_segment_attachment_infos([upload_file_id], session) # type: ignore[arg-type] + scoped_stmt = DatabaseFileAccessController().apply_upload_file_filters( + select(UploadFile).where(UploadFile.id == upload_file_id) + ) + + assert result[0]["attachment_id"] == upload_file_id + whereclause = str(scoped_stmt.whereclause) + assert "upload_files.created_by_role" in whereclause + assert "upload_files.id IN" in whereclause + + +def test_knowledge_retrieval_grants_returned_segments_to_current_scope(monkeypatch) -> None: + tenant_id = str(uuid4()) + dataset_id = str(uuid4()) + document_id = str(uuid4()) + segment_id = str(uuid4()) + segment = SimpleNamespace( + id=segment_id, + dataset_id=dataset_id, + document_id=document_id, + hit_count=1, + word_count=10, + position=1, + index_node_hash="hash", + answer=None, + get_sign_content=lambda: "segment content", + ) + record = SimpleNamespace(segment=segment, score=0.8, child_chunks=None, files=None, summary=None) + dataset = SimpleNamespace(id=dataset_id, name="Dataset") + document = SimpleNamespace(id=document_id, name="Document", data_source_type="upload_file", doc_metadata={}) + retrieval = DatasetRetrieval() + monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", lambda tenant_id: None) + monkeypatch.setattr(retrieval, "_get_available_datasets", lambda tenant_id, dataset_ids: [dataset]) + monkeypatch.setattr(retrieval, "multiple_retrieve", lambda **kwargs: [SimpleNamespace(provider="dify")]) + monkeypatch.setattr(RetrievalService, "format_retrieval_documents", lambda documents: [record]) + session = _DatasetRetrievalSession([dataset], [document]) + monkeypatch.setattr(dataset_retrieval_module.session_factory, "create_session", lambda: session) + scope = FileAccessScope( + tenant_id=tenant_id, + user_id=str(uuid4()), + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + results = retrieval.knowledge_retrieval( + KnowledgeRetrievalRequest( + tenant_id=tenant_id, + user_id=str(uuid4()), + app_id=str(uuid4()), + user_from=UserFrom.END_USER.value, + dataset_ids=[dataset_id], + query="desktop picture", + retrieval_mode="multiple", + ) + ) + current_scope = get_current_file_access_scope() + + assert results[0].metadata.segment_id == segment_id + assert current_scope is not None + assert segment_id in current_scope.granted_retriever_segment_ids diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index 7b6ee97f1c..067159398d 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -102,7 +102,9 @@ def test_gen_index_struct_dict(vector_factory_module): ("HOLOGRES", "dify_vdb_hologres.hologres_vector", "HologresVectorFactory"), ], ) -def test_get_vector_factory_supported(vector_factory_module, monkeypatch, vector_type, module_path, class_name): +def test_get_vector_factory_supported( + vector_factory_module, monkeypatch: pytest.MonkeyPatch, vector_type, module_path, class_name +): expected_cls = _register_fake_factory_module(monkeypatch, module_path, class_name) result_cls = vector_factory_module.Vector.get_vector_factory(getattr(vector_factory_module.VectorType, vector_type)) @@ -119,7 +121,7 @@ class _PluginChromaFactory: """Stub used only for entry-point override test.""" -def test_get_vector_factory_entry_point_overrides_builtin(vector_factory_module, monkeypatch): +def test_get_vector_factory_entry_point_overrides_builtin(vector_factory_module, monkeypatch: pytest.MonkeyPatch): from importlib.metadata import EntryPoint from core.rag.datasource.vdb import vector_backend_registry as reg @@ -171,7 +173,7 @@ def test_vector_init_uses_default_and_custom_attributes(vector_factory_module): assert default_vector._vector_processor == "processor" -def test_lazy_embeddings_defer_real_load_until_first_embed_call(vector_factory_module, monkeypatch): +def test_lazy_embeddings_defer_real_load_until_first_embed_call(vector_factory_module, monkeypatch: pytest.MonkeyPatch): """``Vector(dataset)`` must not transitively call ``ModelManager`` during construction. The real embedding model should only be materialized on the first ``embed_*`` call (i.e. create / search paths) so cleanup paths @@ -214,7 +216,7 @@ def test_lazy_embeddings_defer_real_load_until_first_embed_call(vector_factory_m inner_model.embed_documents.assert_called_once_with(["world"]) -def test_init_vector_prefers_dataset_index_struct(vector_factory_module, monkeypatch): +def test_init_vector_prefers_dataset_index_struct(vector_factory_module, monkeypatch: pytest.MonkeyPatch): calls = {"vector_type": None, "init_args": None} class _Factory: @@ -242,7 +244,7 @@ def test_init_vector_prefers_dataset_index_struct(vector_factory_module, monkeyp assert calls["init_args"] == (vector._dataset, ["doc_id"], "embeddings") -def test_init_vector_uses_whitelist_override(vector_factory_module, monkeypatch): +def test_init_vector_uses_whitelist_override(vector_factory_module, monkeypatch: pytest.MonkeyPatch): class _Expr: def __eq__(self, _other): return "expr" @@ -279,7 +281,7 @@ def test_init_vector_uses_whitelist_override(vector_factory_module, monkeypatch) assert calls["vector_type"] == vector_factory_module.VectorType.TIDB_ON_QDRANT -def test_init_vector_raises_when_vector_store_missing(vector_factory_module, monkeypatch): +def test_init_vector_raises_when_vector_store_missing(vector_factory_module, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE", None) monkeypatch.setattr(vector_factory_module.dify_config, "VECTOR_STORE_WHITELIST_ENABLE", False) @@ -343,7 +345,7 @@ def test_create_skips_empty_text_documents_before_embedding(vector_factory_modul vector._vector_processor.create.assert_not_called() -def test_create_multimodal_filters_missing_uploads(vector_factory_module, monkeypatch): +def test_create_multimodal_filters_missing_uploads(vector_factory_module, monkeypatch: pytest.MonkeyPatch): class _Field: def in_(self, value): return value @@ -484,7 +486,7 @@ def test_vector_delegation_methods(vector_factory_module): vector._vector_processor.delete_by_metadata_field.assert_called_once_with("doc_id", "doc-1") -def test_search_by_file_handles_missing_and_existing_upload(vector_factory_module, monkeypatch): +def test_search_by_file_handles_missing_and_existing_upload(vector_factory_module, monkeypatch: pytest.MonkeyPatch): vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) vector._embeddings = MagicMock() vector._vector_processor = MagicMock() @@ -507,7 +509,7 @@ def test_search_by_file_handles_missing_and_existing_upload(vector_factory_modul assert payload["file_id"] == "file-2" -def test_delete_clears_redis_cache_when_collection_exists(vector_factory_module, monkeypatch): +def test_delete_clears_redis_cache_when_collection_exists(vector_factory_module, monkeypatch: pytest.MonkeyPatch): delete_mock = MagicMock() redis_delete = MagicMock() monkeypatch.setattr(vector_factory_module.redis_client, "delete", redis_delete) @@ -526,7 +528,7 @@ def test_delete_clears_redis_cache_when_collection_exists(vector_factory_module, redis_delete.assert_not_called() -def test_get_embeddings_builds_cache_embedding(vector_factory_module, monkeypatch): +def test_get_embeddings_builds_cache_embedding(vector_factory_module, monkeypatch: pytest.MonkeyPatch): model_manager = MagicMock() model_manager.get_model_instance.return_value = "model-instance" diff --git a/api/tests/unit_tests/core/rag/extractor/test_csv_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_csv_extractor.py index e6a06f163e..2e1c5715c2 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_csv_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_csv_extractor.py @@ -39,7 +39,7 @@ class TestCSVExtractor: with pytest.raises(ValueError, match="Source column 'missing_col' not found"): extractor.extract() - def test_extract_wraps_unicode_error_when_autodetect_disabled(self, monkeypatch): + def test_extract_wraps_unicode_error_when_autodetect_disabled(self, monkeypatch: pytest.MonkeyPatch): extractor = CSVExtractor("dummy.csv", autodetect_encoding=False) def raise_decode(*args, **kwargs): @@ -50,7 +50,7 @@ class TestCSVExtractor: with pytest.raises(RuntimeError, match="Error loading dummy.csv"): extractor.extract() - def test_extract_autodetect_encoding_success(self, monkeypatch): + def test_extract_autodetect_encoding_success(self, monkeypatch: pytest.MonkeyPatch): extractor = CSVExtractor("dummy.csv", autodetect_encoding=True) attempted_encodings: list[str | None] = [] @@ -75,7 +75,7 @@ class TestCSVExtractor: assert docs[0].page_content == "id: source-1;body: hello" assert attempted_encodings == [None, "bad", "utf-8"] - def test_extract_autodetect_encoding_all_attempts_fail_returns_empty(self, monkeypatch): + def test_extract_autodetect_encoding_all_attempts_fail_returns_empty(self, monkeypatch: pytest.MonkeyPatch): extractor = CSVExtractor("dummy.csv", autodetect_encoding=True) def always_raise(*args, **kwargs): @@ -86,7 +86,7 @@ class TestCSVExtractor: assert extractor.extract() == [] - def test_read_from_file_re_raises_csv_error(self, monkeypatch): + def test_read_from_file_re_raises_csv_error(self, monkeypatch: pytest.MonkeyPatch): extractor = CSVExtractor("dummy.csv") monkeypatch.setattr(pd, "read_csv", lambda *args, **kwargs: (_ for _ in ()).throw(csv.Error("bad csv"))) diff --git a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py index d2bcc1e2c4..2b42adc716 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py @@ -45,7 +45,7 @@ class _FakeWorkbook: class TestExcelExtractor: - def test_extract_xlsx_with_hyperlinks_and_sheet_skip(self, monkeypatch): + def test_extract_xlsx_with_hyperlinks_and_sheet_skip(self, monkeypatch: pytest.MonkeyPatch): sheet_with_data = _FakeSheet( header_rows=[("Name", "Link")], data_rows=[ @@ -68,7 +68,7 @@ class TestExcelExtractor: assert docs[1].page_content == '"Name":"";"Link":"123"' assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs) - def test_extract_xls_path(self, monkeypatch): + def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch): class FakeExcelFile: sheet_names = ["Sheet1"] diff --git a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py index 5beed88971..b4b08f57ec 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_extract_processor.py @@ -56,7 +56,7 @@ def _patch_all_extractors(monkeypatch) -> _ExtractorFactory: class TestExtractProcessorLoaders: - def test_load_from_upload_file_return_docs_and_text(self, monkeypatch): + def test_load_from_upload_file_return_docs_and_text(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(processor_module, "ExtractSetting", lambda **kwargs: SimpleNamespace(**kwargs)) monkeypatch.setattr( @@ -93,7 +93,9 @@ class TestExtractProcessorLoaders: ), ], ) - def test_load_from_url_builds_temp_file_with_correct_suffix(self, monkeypatch, url, headers, expected_suffix): + def test_load_from_url_builds_temp_file_with_correct_suffix( + self, monkeypatch: pytest.MonkeyPatch, url, headers, expected_suffix + ): response = SimpleNamespace(headers=headers, content=b"body") monkeypatch.setattr(processor_module.ssrf_proxy, "get", lambda *args, **kwargs: response) monkeypatch.setattr(processor_module, "ExtractSetting", lambda **kwargs: SimpleNamespace(**kwargs)) @@ -119,11 +121,13 @@ class TestExtractProcessorLoaders: class TestExtractProcessorFileRouting: @pytest.fixture(autouse=True) - def _set_unstructured_config(self, monkeypatch): + def _set_unstructured_config(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(processor_module.dify_config, "UNSTRUCTURED_API_URL", "https://unstructured") monkeypatch.setattr(processor_module.dify_config, "UNSTRUCTURED_API_KEY", "key") - def _run_extract_for_extension(self, monkeypatch, extension: str, etl_type: str, is_automatic: bool = False): + def _run_extract_for_extension( + self, monkeypatch: pytest.MonkeyPatch, extension: str, etl_type: str, is_automatic: bool = False + ): factory = _patch_all_extractors(monkeypatch) monkeypatch.setattr(processor_module.dify_config, "ETL_TYPE", etl_type) @@ -167,7 +171,7 @@ class TestExtractProcessorFileRouting: ], ) def test_extract_routes_file_extensions_for_unstructured_mode( - self, monkeypatch, extension, expected_extractor, is_automatic + self, monkeypatch: pytest.MonkeyPatch, extension, expected_extractor, is_automatic ): extractor_name, args, kwargs = self._run_extract_for_extension( monkeypatch, extension, etl_type="Unstructured", is_automatic=is_automatic @@ -189,7 +193,9 @@ class TestExtractProcessorFileRouting: (".txt", "TextExtractor"), ], ) - def test_extract_routes_file_extensions_for_default_mode(self, monkeypatch, extension, expected_extractor): + def test_extract_routes_file_extensions_for_default_mode( + self, monkeypatch: pytest.MonkeyPatch, extension, expected_extractor + ): extractor_name, _, _ = self._run_extract_for_extension(monkeypatch, extension, etl_type="SelfHosted") assert extractor_name == expected_extractor @@ -202,7 +208,7 @@ class TestExtractProcessorFileRouting: class TestExtractProcessorDatasourceRouting: - def test_extract_routes_notion_datasource(self, monkeypatch): + def test_extract_routes_notion_datasource(self, monkeypatch: pytest.MonkeyPatch): factory = _patch_all_extractors(monkeypatch) notion_info = SimpleNamespace( @@ -228,7 +234,9 @@ class TestExtractProcessorDatasourceRouting: ("jinareader", "JinaReaderWebExtractor"), ], ) - def test_extract_routes_website_datasource_providers(self, monkeypatch, provider: str, expected: str): + def test_extract_routes_website_datasource_providers( + self, monkeypatch: pytest.MonkeyPatch, provider: str, expected: str + ): factory = _patch_all_extractors(monkeypatch) website_info = SimpleNamespace( diff --git a/api/tests/unit_tests/core/rag/extractor/test_helpers.py b/api/tests/unit_tests/core/rag/extractor/test_helpers.py index 74387f749d..1c6f97ec53 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_helpers.py +++ b/api/tests/unit_tests/core/rag/extractor/test_helpers.py @@ -21,7 +21,7 @@ class TestHelpers: # Assert the language field for full coverage assert encodings[0].language is not None - def test_detect_file_encodings_timeout(self, monkeypatch): + def test_detect_file_encodings_timeout(self, monkeypatch: pytest.MonkeyPatch): class FakeFuture: def result(self, timeout=None): raise helpers.concurrent.futures.TimeoutError() @@ -41,7 +41,7 @@ class TestHelpers: with pytest.raises(TimeoutError, match="Timeout reached while detecting encoding"): detect_file_encodings("file.txt", timeout=1) - def test_detect_file_encodings_raises_when_encoding_not_detected(self, monkeypatch): + def test_detect_file_encodings_raises_when_encoding_not_detected(self, monkeypatch: pytest.MonkeyPatch): class FakeResult: encoding = None coherence = 0.0 diff --git a/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py index 7e78c86c7d..8ede44ec04 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py @@ -74,7 +74,7 @@ after assert "[link]" not in tups[1][1] assert "img.png" not in tups[1][1] - def test_parse_tups_autodetects_encoding_after_decode_error(self, monkeypatch): + def test_parse_tups_autodetects_encoding_after_decode_error(self, monkeypatch: pytest.MonkeyPatch): extractor = MarkdownExtractor(file_path="dummy_path", autodetect_encoding=True) calls: list[str | None] = [] @@ -99,7 +99,7 @@ after assert len(tups) == 2 assert calls == [None, "bad-encoding", "utf-8"] - def test_parse_tups_decode_error_with_autodetect_disabled_raises(self, monkeypatch): + def test_parse_tups_decode_error_with_autodetect_disabled_raises(self, monkeypatch: pytest.MonkeyPatch): extractor = MarkdownExtractor(file_path="dummy_path", autodetect_encoding=False) def raise_decode(self, encoding=None): @@ -110,7 +110,7 @@ after with pytest.raises(RuntimeError, match="Error loading dummy_path"): extractor.parse_tups("dummy_path") - def test_parse_tups_other_exceptions_are_wrapped(self, monkeypatch): + def test_parse_tups_other_exceptions_are_wrapped(self, monkeypatch: pytest.MonkeyPatch): extractor = MarkdownExtractor(file_path="dummy_path") def raise_other(self, encoding=None): @@ -121,7 +121,7 @@ after with pytest.raises(RuntimeError, match="Error loading dummy_path"): extractor.parse_tups("dummy_path") - def test_extract_builds_documents_for_header_and_non_header(self, monkeypatch): + def test_extract_builds_documents_for_header_and_non_header(self, monkeypatch: pytest.MonkeyPatch): extractor = MarkdownExtractor(file_path="dummy_path") monkeypatch.setattr(extractor, "parse_tups", lambda _: [(None, "plain"), ("Header", "value")]) diff --git a/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py index 808e41867e..49f7b592dc 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_notion_extractor.py @@ -28,7 +28,7 @@ class TestNotionExtractorInitAndPublicMethods: assert extractor._notion_access_token == "token" - def test_init_falls_back_to_env_token_when_credential_lookup_fails(self, monkeypatch): + def test_init_falls_back_to_env_token_when_credential_lookup_fails(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( notion_extractor.NotionExtractor, "_get_access_token", @@ -46,7 +46,7 @@ class TestNotionExtractorInitAndPublicMethods: assert extractor._notion_access_token == "env-token" - def test_init_raises_if_no_credential_and_no_env_token(self, monkeypatch): + def test_init_raises_if_no_credential_and_no_env_token(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( notion_extractor.NotionExtractor, "_get_access_token", @@ -63,7 +63,7 @@ class TestNotionExtractorInitAndPublicMethods: credential_id="cred", ) - def test_extract_updates_last_edited_and_loads_documents(self, monkeypatch): + def test_extract_updates_last_edited_and_loads_documents(self, monkeypatch: pytest.MonkeyPatch): extractor = notion_extractor.NotionExtractor( notion_workspace_id="ws", notion_obj_id="obj", @@ -83,7 +83,7 @@ class TestNotionExtractorInitAndPublicMethods: load_mock.assert_called_once_with("obj", "page") assert len(docs) == 1 - def test_load_data_as_documents_page_database_and_invalid(self, monkeypatch): + def test_load_data_as_documents_page_database_and_invalid(self, monkeypatch: pytest.MonkeyPatch): extractor = notion_extractor.NotionExtractor( notion_workspace_id="ws", notion_obj_id="obj", @@ -394,7 +394,7 @@ class TestNotionMetadataAndCredentialMethods: assert extractor.update_last_edited_time(None) is None - def test_update_last_edited_time_updates_document_and_commits(self, monkeypatch): + def test_update_last_edited_time_updates_document_and_commits(self, monkeypatch: pytest.MonkeyPatch): extractor = notion_extractor.NotionExtractor( notion_workspace_id="ws", notion_obj_id="obj", @@ -479,7 +479,7 @@ class TestNotionMetadataAndCredentialMethods: with pytest.raises(AssertionError, match="Notion access token is required"): extractor.get_notion_last_edited_time() - def test_get_access_token_success_and_errors(self, monkeypatch): + def test_get_access_token_success_and_errors(self, monkeypatch: pytest.MonkeyPatch): with pytest.raises(Exception, match="No credential id found"): notion_extractor.NotionExtractor._get_access_token("tenant", None) diff --git a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py index 47222a23a2..f2caf02d5e 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_pdf_extractor.py @@ -7,7 +7,7 @@ import core.rag.extractor.pdf_extractor as pe @pytest.fixture -def mock_dependencies(monkeypatch): +def mock_dependencies(monkeypatch: pytest.MonkeyPatch): # Mock storage saves = [] @@ -61,7 +61,9 @@ def mock_dependencies(monkeypatch): (b"\x89PNG\r\n\x1a\n some png", "image/png", "png", "test_file_id_png"), ], ) -def test_extract_images_formats(mock_dependencies, monkeypatch, image_bytes, expected_mime, expected_ext, file_id): +def test_extract_images_formats( + mock_dependencies, monkeypatch: pytest.MonkeyPatch, image_bytes, expected_mime, expected_ext, file_id +): saves = mock_dependencies.saves db_stub = mock_dependencies.db @@ -122,7 +124,7 @@ def test_extract_images_get_objects_scenarios(mock_dependencies, get_objects_sid assert result == "" -def test_extract_calls_extract_images(mock_dependencies, monkeypatch): +def test_extract_calls_extract_images(mock_dependencies, monkeypatch: pytest.MonkeyPatch): # Mock pypdfium2 mock_pdf_doc = MagicMock() mock_page = MagicMock() diff --git a/api/tests/unit_tests/core/rag/extractor/test_text_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_text_extractor.py index fb3c6e52c6..71046d73af 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_text_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_text_extractor.py @@ -19,7 +19,7 @@ class TestTextExtractor: assert docs[0].page_content == "hello world" assert docs[0].metadata == {"source": str(file_path)} - def test_extract_autodetect_success_after_decode_error(self, monkeypatch): + def test_extract_autodetect_success_after_decode_error(self, monkeypatch: pytest.MonkeyPatch): extractor = TextExtractor("dummy.txt", autodetect_encoding=True) calls = [] @@ -44,7 +44,7 @@ class TestTextExtractor: assert docs[0].page_content == "decoded text" assert calls == [None, "bad", "utf-8"] - def test_extract_autodetect_all_fail_raises_runtime_error(self, monkeypatch): + def test_extract_autodetect_all_fail_raises_runtime_error(self, monkeypatch: pytest.MonkeyPatch): extractor = TextExtractor("dummy.txt", autodetect_encoding=True) def always_decode_error(self, encoding=None): @@ -56,7 +56,7 @@ class TestTextExtractor: with pytest.raises(RuntimeError, match="all detected encodings failed"): extractor.extract() - def test_extract_decode_error_without_autodetect_raises_runtime_error(self, monkeypatch): + def test_extract_decode_error_without_autodetect_raises_runtime_error(self, monkeypatch: pytest.MonkeyPatch): extractor = TextExtractor("dummy.txt", autodetect_encoding=False) def always_decode_error(self, encoding=None): @@ -67,7 +67,7 @@ class TestTextExtractor: with pytest.raises(RuntimeError, match="specified encoding failed"): extractor.extract() - def test_extract_wraps_non_decode_exceptions(self, monkeypatch): + def test_extract_wraps_non_decode_exceptions(self, monkeypatch: pytest.MonkeyPatch): extractor = TextExtractor("dummy.txt") def raise_other(self, encoding=None): diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py index b9f2449cfb..513d232d7f 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py @@ -61,7 +61,7 @@ def test_parse_row(): assert extractor._parse_row(row, {}, 3) == gt[idx] -def test_init_downloads_via_ssrf_proxy(monkeypatch): +def test_init_downloads_via_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): doc = Document() doc.add_paragraph("hello") buf = io.BytesIO() @@ -97,7 +97,7 @@ def test_init_downloads_via_ssrf_proxy(monkeypatch): extractor.temp_file.close() -def test_extract_images_from_docx(monkeypatch): +def test_extract_images_from_docx(monkeypatch: pytest.MonkeyPatch): external_bytes = b"ext-bytes" internal_bytes = b"int-bytes" @@ -210,7 +210,7 @@ def test_extract_images_from_docx_uses_internal_files_url(): dify_config.INTERNAL_FILES_URL = original_internal_files_url -def test_extract_hyperlinks(monkeypatch): +def test_extract_hyperlinks(monkeypatch: pytest.MonkeyPatch): # Mock db and storage to avoid issues during image extraction (even if no images are present) monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda k, d: None)) db_stub = SimpleNamespace(session=SimpleNamespace(add=lambda o: None, commit=lambda: None)) @@ -255,7 +255,7 @@ def test_extract_hyperlinks(monkeypatch): os.remove(tmp_path) -def test_extract_legacy_hyperlinks(monkeypatch): +def test_extract_legacy_hyperlinks(monkeypatch: pytest.MonkeyPatch): # Mock db and storage monkeypatch.setattr(we, "storage", SimpleNamespace(save=lambda k, d: None)) db_stub = SimpleNamespace(session=SimpleNamespace(add=lambda o: None, commit=lambda: None)) @@ -317,7 +317,7 @@ def test_extract_legacy_hyperlinks(monkeypatch): os.remove(tmp_path) -def test_init_rejects_invalid_url_status(monkeypatch): +def test_init_rejects_invalid_url_status(monkeypatch: pytest.MonkeyPatch): class FakeResponse: status_code = 404 content = b"" @@ -392,7 +392,7 @@ def test_close_closes_awaitable_close_result(): extractor.temp_file.close.assert_called_once() -def test_extract_images_handles_invalid_external_cases(monkeypatch): +def test_extract_images_handles_invalid_external_cases(monkeypatch: pytest.MonkeyPatch): class FakeTargetRef: def __contains__(self, item): return item == "image" @@ -437,7 +437,7 @@ def test_extract_images_handles_invalid_external_cases(monkeypatch): db_stub.session.commit.assert_called_once() -def test_table_to_markdown_and_parse_helpers(monkeypatch): +def test_table_to_markdown_and_parse_helpers(monkeypatch: pytest.MonkeyPatch): extractor = object.__new__(WordExtractor) table = SimpleNamespace( @@ -500,7 +500,7 @@ def test_table_to_markdown_and_parse_helpers(monkeypatch): assert extractor._parse_cell(cell, image_map) == "EXT-IMGINT-IMGplain" -def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch): +def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch): extractor = object.__new__(WordExtractor) ext_image_id = "ext-image" diff --git a/api/tests/unit_tests/core/rag/extractor/unstructured/test_unstructured_extractors.py b/api/tests/unit_tests/core/rag/extractor/unstructured/test_unstructured_extractors.py index 26ce333e11..19fb385a6d 100644 --- a/api/tests/unit_tests/core/rag/extractor/unstructured/test_unstructured_extractors.py +++ b/api/tests/unit_tests/core/rag/extractor/unstructured/test_unstructured_extractors.py @@ -45,7 +45,7 @@ def _install_chunk_by_title(monkeypatch: pytest.MonkeyPatch, chunks: list[Simple class TestUnstructuredMarkdownMsgXml: - def test_markdown_extractor_without_api(self, monkeypatch): + def test_markdown_extractor_without_api(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text=" chunk-1 "), SimpleNamespace(text=" chunk-2 ")]) _register_module( monkeypatch, "unstructured.partition.md", partition_md=lambda filename: [SimpleNamespace(text="x")] @@ -55,7 +55,7 @@ class TestUnstructuredMarkdownMsgXml: assert [doc.page_content for doc in docs] == ["chunk-1", "chunk-2"] - def test_markdown_extractor_with_api(self, monkeypatch): + def test_markdown_extractor_with_api(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text=" via-api ")]) calls = {} @@ -70,7 +70,7 @@ class TestUnstructuredMarkdownMsgXml: assert docs[0].page_content == "via-api" assert calls == {"filename": "/tmp/file.md", "api_url": "https://u", "api_key": "k"} - def test_msg_extractor_local(self, monkeypatch): + def test_msg_extractor_local(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text="msg-doc")]) _register_module( monkeypatch, "unstructured.partition.msg", partition_msg=lambda filename: [SimpleNamespace(text="x")] @@ -78,7 +78,7 @@ class TestUnstructuredMarkdownMsgXml: assert UnstructuredMsgExtractor("/tmp/file.msg").extract()[0].page_content == "msg-doc" - def test_msg_extractor_with_api(self, monkeypatch): + def test_msg_extractor_with_api(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text="msg-doc")]) calls = {} @@ -94,7 +94,7 @@ class TestUnstructuredMarkdownMsgXml: ) assert calls["filename"] == "/tmp/file.msg" - def test_xml_extractor_local_and_api(self, monkeypatch): + def test_xml_extractor_local_and_api(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text="xml-doc")]) xml_calls = {} @@ -124,7 +124,7 @@ class TestUnstructuredMarkdownMsgXml: class TestUnstructuredEmailAndEpub: - def test_email_extractor_local_decodes_html_and_suppresses_decode_errors(self, monkeypatch): + def test_email_extractor_local_decodes_html_and_suppresses_decode_errors(self, monkeypatch: pytest.MonkeyPatch): _register_unstructured_packages(monkeypatch) captured = {} @@ -150,7 +150,7 @@ class TestUnstructuredEmailAndEpub: assert "Hello Email" in chunk_elements[0].text assert chunk_elements[1].text == bad_base64 - def test_email_extractor_with_api(self, monkeypatch): + def test_email_extractor_with_api(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text="api-email")]) _register_module( monkeypatch, @@ -162,7 +162,7 @@ class TestUnstructuredEmailAndEpub: assert docs[0].page_content == "api-email" - def test_epub_extractor_local_and_api(self, monkeypatch): + def test_epub_extractor_local_and_api(self, monkeypatch: pytest.MonkeyPatch): _install_chunk_by_title(monkeypatch, [SimpleNamespace(text="epub-doc")]) calls = {"download": 0, "partition": 0} @@ -198,7 +198,7 @@ class TestUnstructuredPPTAndPPTX: with pytest.raises(NotImplementedError, match="Unstructured API Url is not configured"): UnstructuredPPTExtractor("/tmp/file.ppt").extract() - def test_ppt_extractor_groups_text_by_page(self, monkeypatch): + def test_ppt_extractor_groups_text_by_page(self, monkeypatch: pytest.MonkeyPatch): _register_unstructured_packages(monkeypatch) _register_module( monkeypatch, @@ -215,7 +215,7 @@ class TestUnstructuredPPTAndPPTX: assert [doc.page_content for doc in docs] == ["A\nB", "C"] - def test_pptx_extractor_local_and_api(self, monkeypatch): + def test_pptx_extractor_local_and_api(self, monkeypatch: pytest.MonkeyPatch): _register_unstructured_packages(monkeypatch) _register_module( monkeypatch, @@ -244,7 +244,7 @@ class TestUnstructuredPPTAndPPTX: class TestUnstructuredWord: - def _install_doc_modules(self, monkeypatch, version: str, filetype_value): + def _install_doc_modules(self, monkeypatch: pytest.MonkeyPatch, version: str, filetype_value): _register_unstructured_packages(monkeypatch) class FileType: @@ -276,13 +276,13 @@ class TestUnstructuredWord: ], ) - def test_word_extractor_rejects_doc_on_old_unstructured_version(self, monkeypatch): + def test_word_extractor_rejects_doc_on_old_unstructured_version(self, monkeypatch: pytest.MonkeyPatch): self._install_doc_modules(monkeypatch, version="0.4.10", filetype_value="doc") with pytest.raises(ValueError, match="Partitioning .doc files is only supported"): UnstructuredWordExtractor("/tmp/file.doc", "https://u", "k").extract() - def test_word_extractor_doc_and_docx_paths(self, monkeypatch): + def test_word_extractor_doc_and_docx_paths(self, monkeypatch: pytest.MonkeyPatch): self._install_doc_modules(monkeypatch, version="0.4.11", filetype_value="doc") docs = UnstructuredWordExtractor("/tmp/file.doc", "https://u", "k").extract() @@ -292,7 +292,7 @@ class TestUnstructuredWord: docs = UnstructuredWordExtractor("/tmp/file.docx", "https://u", "k").extract() assert [doc.page_content for doc in docs] == ["chunk-1", "chunk-2"] - def test_word_extractor_magic_import_error_fallback_to_extension(self, monkeypatch): + def test_word_extractor_magic_import_error_fallback_to_extension(self, monkeypatch: pytest.MonkeyPatch): self._install_doc_modules(monkeypatch, version="0.4.10", filetype_value="not-used") monkeypatch.setitem(sys.modules, "magic", None) diff --git a/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py b/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py index d758be218a..95878fc688 100644 --- a/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py +++ b/api/tests/unit_tests/core/rag/extractor/watercrawl/test_watercrawl.py @@ -59,7 +59,7 @@ class TestWaterCrawlExceptions: class TestBaseAPIClient: - def test_init_session_builds_expected_headers(self, monkeypatch): + def test_init_session_builds_expected_headers(self, monkeypatch: pytest.MonkeyPatch): captured = {} def fake_client(**kwargs): @@ -74,7 +74,7 @@ class TestBaseAPIClient: assert captured["headers"]["X-API-Key"] == "k" assert captured["headers"]["User-Agent"] == "WaterCrawl-Plugin" - def test_request_stream_and_non_stream_paths(self, monkeypatch): + def test_request_stream_and_non_stream_paths(self, monkeypatch: pytest.MonkeyPatch): class FakeSession: def __init__(self): self.request_calls = [] @@ -106,7 +106,7 @@ class TestBaseAPIClient: assert fake_session.build_calls assert fake_session.send_calls[0][1] is True - def test_http_method_helpers_delegate_to_request(self, monkeypatch): + def test_http_method_helpers_delegate_to_request(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(BaseAPIClient, "init_session", lambda self: MagicMock()) client = BaseAPIClient(api_key="k", base_url="https://watercrawl.dev") @@ -127,7 +127,7 @@ class TestBaseAPIClient: class TestWaterCrawlAPIClient: - def test_process_eventstream_and_download(self, monkeypatch): + def test_process_eventstream_and_download(self, monkeypatch: pytest.MonkeyPatch): client = WaterCrawlAPIClient(api_key="k") response = MagicMock() @@ -174,7 +174,7 @@ class TestWaterCrawlAPIClient: client.process_response(_response(200, content_type="application/octet-stream", content=b"bin")) == b"bin" ) - def test_process_response_event_stream_returns_generator(self, monkeypatch): + def test_process_response_event_stream_returns_generator(self, monkeypatch: pytest.MonkeyPatch): client = WaterCrawlAPIClient(api_key="k") generator = (item for item in [{"type": "result", "data": {}}]) monkeypatch.setattr(client, "process_eventstream", lambda response, download=False: generator) @@ -193,7 +193,7 @@ class TestWaterCrawlAPIClient: with pytest.raises(RuntimeError, match="http error"): client.process_response(response) - def test_endpoint_wrappers(self, monkeypatch): + def test_endpoint_wrappers(self, monkeypatch: pytest.MonkeyPatch): client = WaterCrawlAPIClient(api_key="k") monkeypatch.setattr(client, "process_response", lambda resp: "processed") @@ -208,7 +208,7 @@ class TestWaterCrawlAPIClient: assert client.download_crawl_request("id") == "processed" assert client.get_crawl_request_results("id") == "processed" - def test_monitor_crawl_request_generator_and_validation(self, monkeypatch): + def test_monitor_crawl_request_generator_and_validation(self, monkeypatch: pytest.MonkeyPatch): client = WaterCrawlAPIClient(api_key="k") monkeypatch.setattr(client, "process_response", lambda _: (x for x in [{"type": "result", "data": 1}])) @@ -221,7 +221,7 @@ class TestWaterCrawlAPIClient: with pytest.raises(ValueError, match="Generator expected"): list(client.monitor_crawl_request("job-1")) - def test_scrape_url_sync_and_async(self, monkeypatch): + def test_scrape_url_sync_and_async(self, monkeypatch: pytest.MonkeyPatch): client = WaterCrawlAPIClient(api_key="k") monkeypatch.setattr(client, "create_crawl_request", lambda **kwargs: {"uuid": "job-1"}) @@ -238,7 +238,7 @@ class TestWaterCrawlAPIClient: sync_result = client.scrape_url("https://example.com", sync=True) assert sync_result == {"url": "https://example.com"} - def test_download_result_fetches_json_and_closes(self, monkeypatch): + def test_download_result_fetches_json_and_closes(self, monkeypatch: pytest.MonkeyPatch): client = WaterCrawlAPIClient(api_key="k") response = _response(200, {"markdown": "body"}) @@ -251,7 +251,7 @@ class TestWaterCrawlAPIClient: class TestWaterCrawlProvider: - def test_crawl_url_builds_options_and_min_wait_time(self, monkeypatch): + def test_crawl_url_builds_options_and_min_wait_time(self, monkeypatch: pytest.MonkeyPatch): provider = WaterCrawlProvider(api_key="k") captured_kwargs = {} @@ -290,7 +290,7 @@ class TestWaterCrawlProvider: assert captured_kwargs["page_options"]["only_main_content"] is False assert captured_kwargs["page_options"]["wait_time"] == 1000 - def test_get_crawl_status_active_and_completed(self, monkeypatch): + def test_get_crawl_status_active_and_completed(self, monkeypatch: pytest.MonkeyPatch): provider = WaterCrawlProvider(api_key="k") monkeypatch.setattr( @@ -327,7 +327,7 @@ class TestWaterCrawlProvider: assert completed["status"] == "completed" assert completed["data"] == [{"url": "u"}] - def test_get_crawl_url_data_and_scrape(self, monkeypatch): + def test_get_crawl_url_data_and_scrape(self, monkeypatch: pytest.MonkeyPatch): provider = WaterCrawlProvider(api_key="k") monkeypatch.setattr(provider, "scrape_url", lambda url: {"source_url": url}) @@ -339,7 +339,7 @@ class TestWaterCrawlProvider: monkeypatch.setattr(provider, "_get_results", lambda job_id, query_params=None: iter([])) assert provider.get_crawl_url_data("job", "u1") is None - def test_structure_data_validation_and_get_results_pagination(self, monkeypatch): + def test_structure_data_validation_and_get_results_pagination(self, monkeypatch: pytest.MonkeyPatch): provider = WaterCrawlProvider(api_key="k") with pytest.raises(ValueError, match="Invalid result object"): @@ -380,7 +380,7 @@ class TestWaterCrawlProvider: assert len(results) == 1 assert results[0]["source_url"] == "https://a" - def test_scrape_url_uses_client_and_structure(self, monkeypatch): + def test_scrape_url_uses_client_and_structure(self, monkeypatch: pytest.MonkeyPatch): provider = WaterCrawlProvider(api_key="k") monkeypatch.setattr( provider.client, "scrape_url", lambda **kwargs: {"result": {"metadata": {}, "markdown": "m"}, "url": "u"} @@ -392,7 +392,7 @@ class TestWaterCrawlProvider: class TestWaterCrawlWebExtractor: - def test_extract_crawl_and_scrape_modes(self, monkeypatch): + def test_extract_crawl_and_scrape_modes(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.rag.extractor.watercrawl.extractor.WebsiteService.get_crawl_url_data", lambda job_id, provider, url, tenant_id: { @@ -418,7 +418,7 @@ class TestWaterCrawlWebExtractor: assert crawl_extractor.extract()[0].page_content == "crawl" assert scrape_extractor.extract()[0].page_content == "scrape" - def test_extract_crawl_returns_empty_when_service_returns_none(self, monkeypatch): + def test_extract_crawl_returns_empty_when_service_returns_none(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( "core.rag.extractor.watercrawl.extractor.WebsiteService.get_crawl_url_data", lambda job_id, provider, url, tenant_id: None, diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index b556ddf528..9334ad9b2f 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -4562,7 +4562,7 @@ class TestRetrieveCoverage: "core.rag.retrieval.dataset_retrieval.RetrievalService.format_retrieval_documents", return_value=[record], ), - patch("core.rag.retrieval.dataset_retrieval.sign_upload_file", return_value="https://signed"), + patch("core.rag.retrieval.dataset_retrieval.sign_upload_file_preview_url", return_value="https://signed"), patch("core.rag.retrieval.dataset_retrieval.db.session.execute") as mock_execute, ): bound_model_instance = Mock() diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py index 18ae9fafc8..a2e10d924c 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py @@ -23,7 +23,7 @@ from core.workflow.human_input_adapter import ( ) from graphon.nodes.human_input.entities import ( FormDefinition, - UserAction, + UserActionConfig, ) from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now @@ -272,7 +272,7 @@ def _make_form_definition() -> str: return FormDefinition( form_content="hello", inputs=[], - user_actions=[UserAction(id="submit", title="Submit")], + user_actions=[UserActionConfig(id="submit", title="Submit")], rendered_content="

hello

", expiration_time=naive_utc_now(), ).model_dump_json() diff --git a/api/tests/unit_tests/core/repositories/test_human_input_repository.py b/api/tests/unit_tests/core/repositories/test_human_input_repository.py index 4248782d93..418537675d 100644 --- a/api/tests/unit_tests/core/repositories/test_human_input_repository.py +++ b/api/tests/unit_tests/core/repositories/test_human_input_repository.py @@ -29,7 +29,7 @@ from core.workflow.human_input_adapter import ( MemberRecipient, WebAppDeliveryMethod, ) -from graphon.nodes.human_input.entities import HumanInputNodeData, UserAction +from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.human_input import HumanInputFormRecipient, RecipientType @@ -467,7 +467,7 @@ def test_create_form_adds_console_and_backstage_recipients(monkeypatch: pytest.M delivery_methods=[], form_content="hello", inputs=[], - user_actions=[UserAction(id="submit", title="Submit")], + user_actions=[UserActionConfig(id="submit", title="Submit")], ) params = FormCreateParams( workflow_execution_id=None, diff --git a/api/tests/unit_tests/core/telemetry/test_facade.py b/api/tests/unit_tests/core/telemetry/test_facade.py index 36e8e1bbb1..95d653f55b 100644 --- a/api/tests/unit_tests/core/telemetry/test_facade.py +++ b/api/tests/unit_tests/core/telemetry/test_facade.py @@ -14,7 +14,7 @@ from core.telemetry.events import TelemetryContext, TelemetryEvent @pytest.fixture -def telemetry_test_setup(monkeypatch): +def telemetry_test_setup(monkeypatch: pytest.MonkeyPatch): module_name = "core.ops.ops_trace_manager" ops_stub = types.ModuleType(module_name) diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index a5a542c94f..e84fcba3d9 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -289,7 +289,7 @@ def test_get_default_model_uses_injected_runtime_for_existing_default_record(moc result = manager.get_default_model("tenant-id", ModelType.LLM) - mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_factory_cls.assert_called_once_with(runtime=manager._model_runtime) assert result is not None assert result.model == "gpt-4" assert result.provider.provider == "openai" @@ -316,7 +316,7 @@ def test_get_configurations_uses_injected_runtime_and_adds_provider_aliases(mock result = manager.get_configurations("tenant-id") expected_alias = str(ModelProviderID("openai")) - mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_factory_cls.assert_called_once_with(runtime=manager._model_runtime) assert result.tenant_id == "tenant-id" assert expected_alias in provider_records assert expected_alias in provider_model_records @@ -402,7 +402,7 @@ def test_get_configurations_reuses_cached_result_for_same_tenant(mocker: MockerF assert first is second mock_get_all_providers.assert_called_once_with("tenant-id") - mock_factory_cls.assert_called_once_with(model_runtime=manager._model_runtime) + mock_factory_cls.assert_called_once_with(runtime=manager._model_runtime) mock_provider_configuration.assert_called_once() provider_configuration.bind_model_runtime.assert_called_once_with(manager._model_runtime) @@ -570,8 +570,7 @@ def test_get_all_providers_normalizes_provider_names_with_model_provider_id() -> session.scalars.return_value = [openai_provider, gemini_provider] with ( - patch("core.provider_manager.db", SimpleNamespace(engine=object())), - patch("core.provider_manager.Session", return_value=_build_session_context(session)), + patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)), ): result = ProviderManager._get_all_providers("tenant-id") @@ -595,8 +594,7 @@ def test_provider_grouping_helpers_group_records_by_provider_name(method_name: s session.scalars.return_value = [openai_primary, openai_secondary, anthropic_record] with ( - patch("core.provider_manager.db", SimpleNamespace(engine=object())), - patch("core.provider_manager.Session", return_value=_build_session_context(session)), + patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)), ): result = getattr(ProviderManager, method_name)("tenant-id") @@ -611,8 +609,7 @@ def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() -> session.scalars.return_value = [openai_preference, anthropic_preference] with ( - patch("core.provider_manager.db", SimpleNamespace(engine=object())), - patch("core.provider_manager.Session", return_value=_build_session_context(session)), + patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)), ): result = ProviderManager._get_all_preferred_model_providers("tenant-id") @@ -626,13 +623,13 @@ def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_ with ( patch("core.provider_manager.redis_client.get", return_value=b"False"), patch("core.provider_manager.FeatureService.get_features") as mock_get_features, - patch("core.provider_manager.Session") as mock_session_cls, + patch("core.provider_manager.session_factory.create_session") as mock_create_session, ): result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id") assert result == {} mock_get_features.assert_not_called() - mock_session_cls.assert_not_called() + mock_create_session.assert_not_called() def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None: @@ -642,14 +639,13 @@ def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_conf session.scalars.return_value = [openai_config, anthropic_config] with ( - patch("core.provider_manager.db", SimpleNamespace(engine=object())), patch("core.provider_manager.redis_client.get", return_value=None), patch("core.provider_manager.redis_client.setex") as mock_setex, patch( "core.provider_manager.FeatureService.get_features", return_value=SimpleNamespace(model_load_balancing_enabled=True), ), - patch("core.provider_manager.Session", return_value=_build_session_context(session)), + patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)), ): result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id") diff --git a/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py b/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py index ad6d5906ae..b21a5c3e24 100644 --- a/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py +++ b/api/tests/unit_tests/core/tools/test_builtin_tool_provider.py @@ -78,7 +78,7 @@ def _tool_yaml() -> dict[str, Any]: } -def test_builtin_tool_provider_init_load_tools_and_basic_accessors(monkeypatch): +def test_builtin_tool_provider_init_load_tools_and_basic_accessors(monkeypatch: pytest.MonkeyPatch): yaml_payloads = [_provider_yaml(), _tool_yaml()] def _load_yaml(*args, **kwargs): diff --git a/api/tests/unit_tests/core/tools/test_builtin_tools_extra.py b/api/tests/unit_tests/core/tools/test_builtin_tools_extra.py index c7829fc0d7..3f6b1ec154 100644 --- a/api/tests/unit_tests/core/tools/test_builtin_tools_extra.py +++ b/api/tests/unit_tests/core/tools/test_builtin_tools_extra.py @@ -115,7 +115,7 @@ def test_weekday_tool(): list(weekday_tool.invoke(user_id="u", tool_parameters={"year": 2024, "day": 1})) -def test_simple_code_valid_execution(monkeypatch): +def test_simple_code_valid_execution(monkeypatch: pytest.MonkeyPatch): simple_code = _build_builtin_tool(SimpleCode) monkeypatch.setattr( @@ -138,7 +138,7 @@ def test_simple_code_invalid_language(): list(simple_code.invoke(user_id="u", tool_parameters={"language": "go", "code": "fmt.Println(1)"})) -def test_simple_code_execution_error(monkeypatch): +def test_simple_code_execution_error(monkeypatch: pytest.MonkeyPatch): simple_code = _build_builtin_tool(SimpleCode) monkeypatch.setattr( @@ -155,14 +155,14 @@ def test_webscraper_empty_url(): assert empty == "Please input url" -def test_webscraper_fetch(monkeypatch): +def test_webscraper_fetch(monkeypatch: pytest.MonkeyPatch): webscraper = _build_builtin_tool(WebscraperTool) monkeypatch.setattr("core.tools.builtin_tool.providers.webscraper.tools.webscraper.get_url", lambda *a, **k: "page") full = list(webscraper.invoke(user_id="u", tool_parameters={"url": "https://example.com"}))[0].message.text assert full == "page" -def test_webscraper_summary(monkeypatch): +def test_webscraper_summary(monkeypatch: pytest.MonkeyPatch): webscraper = _build_builtin_tool(WebscraperTool) monkeypatch.setattr("core.tools.builtin_tool.providers.webscraper.tools.webscraper.get_url", lambda *a, **k: "page") monkeypatch.setattr(webscraper, "summary", lambda user_id, content: "summary") @@ -175,7 +175,7 @@ def test_webscraper_summary(monkeypatch): assert summarized == "summary" -def test_webscraper_fetch_error(monkeypatch): +def test_webscraper_fetch_error(monkeypatch: pytest.MonkeyPatch): webscraper = _build_builtin_tool(WebscraperTool) monkeypatch.setattr( "core.tools.builtin_tool.providers.webscraper.tools.webscraper.get_url", @@ -192,7 +192,7 @@ def test_asr_invalid_file(): assert "not a valid audio file" in invalid_file -def test_asr_valid_file_invocation(monkeypatch): +def test_asr_valid_file_invocation(monkeypatch: pytest.MonkeyPatch): asr = _build_builtin_tool(ASRTool) model_instance = type("M", (), {"invoke_speech2text": lambda self, file: "transcript"})() model_manager = type("Mgr", (), {"get_model_instance": lambda *a, **k: model_instance})() @@ -209,7 +209,7 @@ def test_asr_valid_file_invocation(monkeypatch): assert captured_manager_kwargs == {"tenant_id": "tenant-1", "user_id": "u"} -def test_asr_available_models_and_runtime_parameters(monkeypatch): +def test_asr_available_models_and_runtime_parameters(monkeypatch: pytest.MonkeyPatch): asr = _build_builtin_tool(ASRTool) provider_model = type("PM", (), {"provider": "p", "models": [type("Model", (), {"model": "m"})()]})() monkeypatch.setattr( @@ -220,7 +220,7 @@ def test_asr_available_models_and_runtime_parameters(monkeypatch): assert asr.get_runtime_parameters()[0].name == "model" -def test_tts_invoke_returns_messages(monkeypatch): +def test_tts_invoke_returns_messages(monkeypatch: pytest.MonkeyPatch): tts = _build_builtin_tool(TTSTool) captured_manager_kwargs = {} voices_model_instance = type( @@ -280,7 +280,7 @@ def test_tts_tool_raises_when_voice_unavailable(monkeypatch, voices): list(tts.invoke(user_id="u", tool_parameters={"model": "p#m", "text": "hello"})) -def test_tts_tool_get_available_models_and_runtime_parameters(monkeypatch): +def test_tts_tool_get_available_models_and_runtime_parameters(monkeypatch: pytest.MonkeyPatch): tts = _build_builtin_tool(TTSTool) model_1 = SimpleNamespace( @@ -307,7 +307,7 @@ def test_tts_tool_get_available_models_and_runtime_parameters(monkeypatch): assert runtime_parameters[1].name == "voice#provider-a#model-a" -def test_provider_classes_and_builtin_sort(monkeypatch): +def test_provider_classes_and_builtin_sort(monkeypatch: pytest.MonkeyPatch): # Use object.__new__ to avoid YAML-loading __init__; only pass-through validation is exercised. # Ensure pass-through _validate_credentials methods are executed. AudioToolProvider._validate_credentials(object.__new__(AudioToolProvider), "u", {}) diff --git a/api/tests/unit_tests/core/tools/test_custom_tool.py b/api/tests/unit_tests/core/tools/test_custom_tool.py index f35546b025..f525baeaf2 100644 --- a/api/tests/unit_tests/core/tools/test_custom_tool.py +++ b/api/tests/unit_tests/core/tools/test_custom_tool.py @@ -47,7 +47,7 @@ def test_parsed_response_to_string(): assert ParsedResponse("ok", False).to_string() == "ok" -def test_api_tool_fork_runtime_and_validate_credentials(monkeypatch): +def test_api_tool_fork_runtime_and_validate_credentials(monkeypatch: pytest.MonkeyPatch): tool = _build_tool() forked = tool.fork_tool_runtime(ToolRuntime(tenant_id="tenant-2")) assert isinstance(forked, ApiTool) @@ -184,7 +184,7 @@ def test_get_parameter_value_and_type_conversion_helpers(): assert tool._convert_body_property_type({"anyOf": [{"type": "integer"}]}, "2") == 2 -def test_do_http_request_builds_arguments_and_handles_invalid_method(monkeypatch): +def test_do_http_request_builds_arguments_and_handles_invalid_method(monkeypatch: pytest.MonkeyPatch): openapi = { "parameters": [ {"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}, @@ -236,7 +236,7 @@ def test_do_http_request_builds_arguments_and_handles_invalid_method(monkeypatch invalid_method_tool.do_http_request("https://api.example.com", "TRACE", headers={}, parameters={}) -def test_do_http_request_handles_file_upload_and_invoke_paths(monkeypatch): +def test_do_http_request_handles_file_upload_and_invoke_paths(monkeypatch: pytest.MonkeyPatch): openapi = { "parameters": [], "requestBody": { diff --git a/api/tests/unit_tests/core/tools/test_signature.py b/api/tests/unit_tests/core/tools/test_signature.py index 353988d7a6..a75fdee908 100644 --- a/api/tests/unit_tests/core/tools/test_signature.py +++ b/api/tests/unit_tests/core/tools/test_signature.py @@ -9,7 +9,7 @@ import pytest from core.tools.signature import ( get_signed_file_url_for_plugin, sign_tool_file, - sign_upload_file, + sign_upload_file_preview_url, verify_plugin_file_signature, verify_tool_file_signature, ) @@ -89,32 +89,32 @@ def test_verify_tool_file_signature_rejects_expired_signature(monkeypatch: pytes assert verify_tool_file_signature("tool-file-id", timestamp, nonce, sign) is False -def test_sign_upload_file_prefers_internal_url(monkeypatch: pytest.MonkeyPatch) -> None: +def test_sign_upload_file_preview_url_uses_files_url(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x03" * 16) monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret") monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com") monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com") - url = sign_upload_file("upload-id", ".png") + url = sign_upload_file_preview_url("upload-id", ".png") parsed = urlparse(url) query = parse_qs(parsed.query) - assert parsed.netloc == "internal.example.com" + assert parsed.netloc == "files.example.com" assert parsed.path == "/files/upload-id/image-preview" assert query["timestamp"][0] assert query["nonce"][0] assert query["sign"][0] -def test_sign_upload_file_uses_files_url_fallback(monkeypatch: pytest.MonkeyPatch) -> None: +def test_sign_upload_file_preview_url_ignores_internal_files_url(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x05" * 16) monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret") monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com") - monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "") + monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com") - url = sign_upload_file("upload-id", ".png") + url = sign_upload_file_preview_url("upload-id", ".png") parsed = urlparse(url) query = parse_qs(parsed.query) diff --git a/api/tests/unit_tests/core/tools/test_tool_manager.py b/api/tests/unit_tests/core/tools/test_tool_manager.py index 9ebaa0417b..7c7d6eec2d 100644 --- a/api/tests/unit_tests/core/tools/test_tool_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_manager.py @@ -648,7 +648,7 @@ def test_list_default_builtin_providers_for_postgres_and_mysql(): assert providers == provider_records -def test_list_providers_from_api_covers_builtin_api_workflow_and_mcp(monkeypatch): +def test_list_providers_from_api_covers_builtin_api_workflow_and_mcp(monkeypatch: pytest.MonkeyPatch): hardcoded_controller = SimpleNamespace(entity=SimpleNamespace(identity=SimpleNamespace(name="hardcoded"))) plugin_controller = object.__new__(PluginToolProviderController) plugin_controller.entity = SimpleNamespace(identity=SimpleNamespace(name="plugin-provider")) @@ -925,3 +925,78 @@ def test_convert_tool_parameters_type_constant_branch(): ) assert constant == {"text": "fixed"} + + +def test_convert_tool_parameters_type_model_selector_from_legacy_top_level_config(): + model_param = ToolParameter.get_simple_instance( + name="vision_llm_model", + llm_description="vision model", + typ=ToolParameter.ToolParameterType.MODEL_SELECTOR, + required=True, + ) + model_param.form = ToolParameter.ToolParameterForm.FORM + variable_pool = Mock() + + runtime_parameters = ToolManager._convert_tool_parameters_type( + parameters=[model_param], + variable_pool=variable_pool, + tool_configurations={ + "vision_llm_model": { + "type": "constant", + "value": "", + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-vl-plus", + "model_type": "llm", + "mode": "chat", + } + }, + typ="workflow", + ) + + assert runtime_parameters == { + "vision_llm_model": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-vl-plus", + "model_type": "llm", + "mode": "chat", + } + } + + +def test_convert_tool_parameters_type_model_selector_from_constant_value_config(): + model_param = ToolParameter.get_simple_instance( + name="tts_model", + llm_description="tts model", + typ=ToolParameter.ToolParameterType.MODEL_SELECTOR, + required=True, + ) + model_param.form = ToolParameter.ToolParameterForm.FORM + variable_pool = Mock() + + runtime_parameters = ToolManager._convert_tool_parameters_type( + parameters=[model_param], + variable_pool=variable_pool, + tool_configurations={ + "tts_model": { + "type": "constant", + "value": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-tts-flash", + "model_type": "tts", + "language": "Chinese", + "voice": "Cherry", + }, + } + }, + typ="workflow", + ) + + assert runtime_parameters == { + "tts_model": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-tts-flash", + "model_type": "tts", + "language": "Chinese", + "voice": "Cherry", + } + } diff --git a/api/tests/unit_tests/core/tools/utils/test_configuration.py b/api/tests/unit_tests/core/tools/utils/test_configuration.py index ae5638784c..9e179536de 100644 --- a/api/tests/unit_tests/core/tools/utils/test_configuration.py +++ b/api/tests/unit_tests/core/tools/utils/test_configuration.py @@ -4,6 +4,8 @@ from collections.abc import Generator from typing import Any from unittest.mock import patch +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom from core.helper.tool_parameter_cache import ToolParameterCache from core.tools.__base.tool import Tool @@ -110,7 +112,7 @@ def test_encrypt_tool_parameters(): assert encrypted["plain"] == "x" -def test_decrypt_tool_parameters_cache_hit_and_miss(monkeypatch): +def test_decrypt_tool_parameters_cache_hit_and_miss(monkeypatch: pytest.MonkeyPatch): manager = _build_manager() with ( @@ -139,7 +141,7 @@ def test_delete_tool_parameters_cache(): mock_delete.assert_called_once() -def test_configuration_manager_decrypt_suppresses_errors(monkeypatch): +def test_configuration_manager_decrypt_suppresses_errors(monkeypatch: pytest.MonkeyPatch): manager = _build_manager() with ( patch.object(ToolParameterCache, "get", return_value=None), diff --git a/api/tests/unit_tests/core/tools/utils/test_message_transformer.py b/api/tests/unit_tests/core/tools/utils/test_message_transformer.py index 5f34135af4..354b395504 100644 --- a/api/tests/unit_tests/core/tools/utils/test_message_transformer.py +++ b/api/tests/unit_tests/core/tools/utils/test_message_transformer.py @@ -42,7 +42,7 @@ class _FakeToolFileManager: @pytest.fixture(autouse=True) -def _patch_tool_file_manager(monkeypatch): +def _patch_tool_file_manager(monkeypatch: pytest.MonkeyPatch): # Patch the manager used inside the transformer module monkeypatch.setattr(mt, "ToolFileManager", _FakeToolFileManager) # also ensure predictable URL generation (no need to patch; uses id and extension only) diff --git a/api/tests/unit_tests/core/tools/utils/test_parser.py b/api/tests/unit_tests/core/tools/utils/test_parser.py index 032b1377a4..99a90f3b67 100644 --- a/api/tests/unit_tests/core/tools/utils/test_parser.py +++ b/api/tests/unit_tests/core/tools/utils/test_parser.py @@ -17,7 +17,7 @@ def app(): return app -def test_parse_openapi_to_tool_bundle_operation_id(app): +def test_parse_openapi_to_tool_bundle_operation_id(app: Flask): openapi = { "openapi": "3.0.0", "info": {"title": "Simple API", "version": "1.0.0"}, @@ -63,7 +63,7 @@ def test_parse_openapi_to_tool_bundle_operation_id(app): assert tool_bundles[2].operation_id == "createResource" -def test_parse_openapi_to_tool_bundle_properties_all_of(app): +def test_parse_openapi_to_tool_bundle_properties_all_of(app: Flask): openapi = { "openapi": "3.0.0", "info": {"title": "Simple API", "version": "1.0.0"}, @@ -118,7 +118,7 @@ def test_parse_openapi_to_tool_bundle_properties_all_of(app): # assert set(tool_bundles[0].parameters[0].options) == {"option1", "option2", "option3"} -def test_parse_openapi_to_tool_bundle_default_value_type_casting(app): +def test_parse_openapi_to_tool_bundle_default_value_type_casting(app: Flask): """ Test that default values are properly cast to match parameter types. This addresses the issue where array default values like [] cause validation errors diff --git a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py index 6bb86ebe78..081b189745 100644 --- a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py +++ b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py @@ -34,7 +34,7 @@ def test_system_encrypter_raises_error_for_invalid_ciphertext(): encrypter.decrypt_params("not-base64") -def test_system_helpers_use_global_cached_instance(monkeypatch): +def test_system_helpers_use_global_cached_instance(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(encryption, "_encrypter", None) monkeypatch.setattr("core.tools.utils.system_encryption.dify_config.SECRET_KEY", "global-secret") diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py index 72a73dd936..6c563b0912 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py @@ -147,6 +147,142 @@ def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.Monke assert call_kwargs["pause_state_config"] is None +def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch): + """Ensure nested workflow runtime metadata is forwarded as parent trace context.""" + tool = _build_tool() + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert call_kwargs["args"]["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch): + """Ensure private trace context does not overwrite same-named workflow inputs.""" + tool = _build_tool() + tool.entity.parameters = [ + ToolParameter.get_simple_instance( + name="outer_workflow_run_id", + llm_description="User workflow input", + typ=ToolParameter.ToolParameterType.STRING, + required=False, + ), + ToolParameter.get_simple_instance( + name="outer_node_execution_id", + llm_description="User node input", + typ=ToolParameter.ToolParameterType.STRING, + required=False, + ), + ] + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list( + tool.invoke( + "test_user", + { + "outer_workflow_run_id": "user-workflow-input", + "outer_node_execution_id": "user-node-input", + }, + ) + ) + + call_kwargs = generate_mock.call_args.kwargs + assert call_kwargs["args"]["inputs"]["outer_workflow_run_id"] == "user-workflow-input" + assert call_kwargs["args"]["inputs"]["outer_node_execution_id"] == "user-node-input" + assert call_kwargs["args"]["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch): + """Ensure reused WorkflowTool instances do not keep stale parent trace context.""" + tool = _build_tool() + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + tool.clear_parent_trace_context() + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert "parent_trace_context" not in call_kwargs["args"] + + +@pytest.mark.parametrize( + "runtime_parameters", + [ + {}, + {"outer_workflow_run_id": "outer-workflow-run-1"}, + {"outer_node_execution_id": "outer-node-execution-1"}, + {"outer_workflow_run_id": None, "outer_node_execution_id": None}, + ], +) +def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete( + monkeypatch: pytest.MonkeyPatch, + runtime_parameters: dict[str, Any], +): + """Ensure incomplete runtime metadata does not leak parent trace context into generator args.""" + tool = _build_tool() + tool.runtime.runtime_parameters = runtime_parameters + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert "parent_trace_context" not in call_kwargs["args"] + + def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch): """Test that WorkflowTool should generate variable messages when there are outputs""" tool = _build_tool() diff --git a/api/tests/unit_tests/core/variables/test_segment_type.py b/api/tests/unit_tests/core/variables/test_segment_type.py index d4e862220a..009899a92d 100644 --- a/api/tests/unit_tests/core/variables/test_segment_type.py +++ b/api/tests/unit_tests/core/variables/test_segment_type.py @@ -233,8 +233,6 @@ class TestSegmentTypeAdditionalMethods: assert SegmentType.GROUP.is_valid([StringSegment(value="b")]) is True assert SegmentType.GROUP.is_valid(["not-segment"]) is False - def test_unreachable_assertion_branch(self, monkeypatch): - monkeypatch.setattr(SegmentType, "is_array_type", lambda self: False) - - with pytest.raises(AssertionError, match="unreachable"): - SegmentType.ARRAY_STRING.is_valid(["a"]) + def test_unreachable_assertion_branch(self): + with pytest.raises(AssertionError, match="Expected code to be unreachable"): + SegmentType.is_valid("not-a-segment-type", None) # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py index 5d6667257f..12c7f8113c 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_llm_quota.py @@ -1,12 +1,11 @@ +import logging import threading from datetime import datetime from types import SimpleNamespace from unittest.mock import MagicMock, patch -from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.app.workflow.layers.llm_quota import LLMQuotaLayer from core.errors.error import QuotaExceededError -from core.model_manager import ModelInstance from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.graph_engine.entities.commands import CommandType from graphon.graph_events import NodeRunSucceededEvent @@ -14,17 +13,7 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import NodeRunResult -def _build_dify_context() -> DifyRunContext: - return DifyRunContext( - tenant_id="tenant-id", - app_id="app-id", - user_id="user-id", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - ) - - -def _build_succeeded_event() -> NodeRunSucceededEvent: +def _build_succeeded_event(*, provider: str = "openai", model_name: str = "gpt-4o") -> NodeRunSucceededEvent: return NodeRunSucceededEvent( id="execution-id", node_id="llm-node-id", @@ -32,113 +21,162 @@ def _build_succeeded_event() -> NodeRunSucceededEvent: start_at=datetime.now(), node_run_result=NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, - inputs={"question": "hello"}, + inputs={ + "question": "hello", + "model_provider": provider, + "model_name": model_name, + }, llm_usage=LLMUsage.empty_usage(), ), ) -def _build_wrapped_model_instance() -> tuple[SimpleNamespace, ModelInstance]: - raw_model_instance = ModelInstance.__new__(ModelInstance) - return SimpleNamespace(_model_instance=raw_model_instance), raw_model_instance +def _build_public_model_identity(*, provider: str = "openai", model_name: str = "gpt-4o") -> SimpleNamespace: + return SimpleNamespace(provider=provider, name=model_name) + + +def _build_node_data(*, model: SimpleNamespace | None = None) -> SimpleNamespace: + return SimpleNamespace( + error_strategy=None, + retry_config=SimpleNamespace(retry_enabled=False), + model=model, + ) + + +def _build_node(*, node_type: BuiltinNodeTypes = BuiltinNodeTypes.LLM) -> MagicMock: + node = MagicMock() + node.id = "node-id" + node.execution_id = "execution-id" + node.node_type = node_type + node.node_data = _build_node_data(model=_build_public_model_identity()) + node.model_instance = SimpleNamespace(provider="stale-provider", model_name="stale-model") + return node + + +class _RunnableQuotaNode: + id = "node-id" + execution_id = "execution-id" + node_type = BuiltinNodeTypes.LLM + title = "LLM node" + + def __init__(self, *, stop_event: threading.Event, node_data: SimpleNamespace | None = None) -> None: + self.node_data = node_data or _build_node_data(model=_build_public_model_identity()) + self.graph_runtime_state = SimpleNamespace(stop_event=stop_event) + self.original_run_called = False + + def _run(self) -> NodeRunResult: + self.original_run_called = True + return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED) def test_deduct_quota_called_for_successful_llm_node() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "llm-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.LLM - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance, raw_model_instance = _build_wrapped_model_instance() - + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.LLM) result_event = _build_succeeded_event() - with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota", autospec=True) as mock_deduct: + + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: layer.on_node_run_end(node=node, error=None, result_event=result_event) mock_deduct.assert_called_once_with( tenant_id="tenant-id", - model_instance=raw_model_instance, + provider="openai", + model="gpt-4o", usage=result_event.node_run_result.llm_usage, ) def test_deduct_quota_called_for_question_classifier_node() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "question-classifier-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.QUESTION_CLASSIFIER - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance, raw_model_instance = _build_wrapped_model_instance() + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.QUESTION_CLASSIFIER) + result_event = _build_succeeded_event(provider="anthropic", model_name="claude-3-7-sonnet") - result_event = _build_succeeded_event() - with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota", autospec=True) as mock_deduct: + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: layer.on_node_run_end(node=node, error=None, result_event=result_event) mock_deduct.assert_called_once_with( tenant_id="tenant-id", - model_instance=raw_model_instance, + provider="anthropic", + model="claude-3-7-sonnet", usage=result_event.node_run_result.llm_usage, ) def test_non_llm_node_is_ignored() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "start-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.START - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node._model_instance = object() - + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.START) result_event = _build_succeeded_event() - with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota", autospec=True) as mock_deduct: + + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: layer.on_node_run_end(node=node, error=None, result_event=result_event) mock_deduct.assert_not_called() -def test_quota_error_is_handled_in_layer() -> None: - layer = LLMQuotaLayer() - node = MagicMock() - node.id = "llm-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.LLM - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance = object() +def test_precheck_ignores_non_quota_node() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = _build_node(node_type=BuiltinNodeTypes.START) - result_event = _build_succeeded_event() - with patch( - "core.app.workflow.layers.llm_quota.deduct_llm_quota", - autospec=True, - side_effect=ValueError("quota exceeded"), - ): - layer.on_node_run_end(node=node, error=None, result_event=result_event) + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + mock_check.assert_not_called() -def test_quota_deduction_exceeded_aborts_workflow_immediately() -> None: - layer = LLMQuotaLayer() +def test_quota_error_is_handled_in_layer(caplog) -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") stop_event = threading.Event() layer.command_channel = MagicMock() - node = MagicMock() - node.id = "llm-node-id" - node.execution_id = "execution-id" - node.node_type = BuiltinNodeTypes.LLM - node.tenant_id = "tenant-id" - node.require_run_context_value.return_value = _build_dify_context() - node.model_instance, _ = _build_wrapped_model_instance() + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + result_event = _build_succeeded_event() + + with ( + caplog.at_level(logging.ERROR, logger="core.app.workflow.layers.llm_quota"), + patch( + "core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", + autospec=True, + side_effect=ValueError("quota exceeded"), + ) as mock_deduct, + ): + layer.on_node_run_end(node=node, error=None, result_event=result_event) + + mock_deduct.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + usage=result_event.node_run_result.llm_usage, + ) + assert "LLM quota deduction failed, node_id=node-id" in caplog.text + assert not stop_event.is_set() + layer.command_channel.send_command.assert_not_called() + + +def test_send_abort_command_is_noop_without_channel_or_after_abort() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + + layer._send_abort_command(reason="no channel") + + layer.command_channel = MagicMock() + layer._abort_sent = True + layer._send_abort_command(reason="already aborted") + + layer.command_channel.send_command.assert_not_called() + + +def test_quota_deduction_exceeded_aborts_workflow_immediately() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) node.graph_runtime_state = MagicMock() node.graph_runtime_state.stop_event = stop_event result_event = _build_succeeded_event() with patch( - "core.app.workflow.layers.llm_quota.deduct_llm_quota", + "core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True, side_effect=QuotaExceededError("No credits remaining"), ): @@ -152,19 +190,16 @@ def test_quota_deduction_exceeded_aborts_workflow_immediately() -> None: def test_quota_precheck_failure_aborts_workflow_immediately() -> None: - layer = LLMQuotaLayer() + layer = LLMQuotaLayer(tenant_id="tenant-id") stop_event = threading.Event() layer.command_channel = MagicMock() - node = MagicMock() - node.id = "llm-node-id" - node.node_type = BuiltinNodeTypes.LLM - node.model_instance, _ = _build_wrapped_model_instance() + node = _build_node(node_type=BuiltinNodeTypes.LLM) node.graph_runtime_state = MagicMock() node.graph_runtime_state.stop_event = stop_event with patch( - "core.app.workflow.layers.llm_quota.ensure_llm_quota_available", + "core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True, side_effect=QuotaExceededError("Model provider openai quota exceeded."), ): @@ -177,21 +212,140 @@ def test_quota_precheck_failure_aborts_workflow_immediately() -> None: assert abort_command.reason == "Model provider openai quota exceeded." -def test_quota_precheck_passes_without_abort() -> None: - layer = LLMQuotaLayer() +def test_quota_precheck_failure_blocks_current_node_run() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") stop_event = threading.Event() layer.command_channel = MagicMock() - node = MagicMock() - node.id = "llm-node-id" - node.node_type = BuiltinNodeTypes.LLM - node.model_instance, raw_model_instance = _build_wrapped_model_instance() + node = _RunnableQuotaNode(stop_event=stop_event) + + with patch( + "core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", + autospec=True, + side_effect=QuotaExceededError("Model provider openai quota exceeded."), + ): + layer.on_node_run_start(node) + + result = node._run() + assert not node.original_run_called + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "Model provider openai quota exceeded." + assert result.error_type == QuotaExceededError.__name__ + + +def test_missing_model_identity_blocks_current_node_run() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _RunnableQuotaNode(stop_event=stop_event, node_data=_build_node_data()) + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + result = node._run() + assert not node.original_run_called + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert result.error == "LLM quota check requires public node model identity before execution." + assert result.error_type == "LLMQuotaIdentityError" + mock_check.assert_not_called() + + +def test_quota_precheck_passes_without_abort() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) node.graph_runtime_state = MagicMock() node.graph_runtime_state.stop_event = stop_event - with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available", autospec=True) as mock_check: + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: layer.on_node_run_start(node) assert not stop_event.is_set() - mock_check.assert_called_once_with(model_instance=raw_model_instance) + mock_check.assert_called_once_with( + tenant_id="tenant-id", + provider="openai", + model="gpt-4o", + ) layer.command_channel.send_command.assert_not_called() + + +def test_precheck_reads_model_identity_from_data_when_node_data_is_absent() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + node = SimpleNamespace( + id="node-id", + node_type=BuiltinNodeTypes.LLM, + data=_build_node_data(model=_build_public_model_identity(provider="anthropic", model_name="claude")), + ) + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + mock_check.assert_called_once_with( + tenant_id="tenant-id", + provider="anthropic", + model="claude", + ) + + +def test_precheck_rejects_invalid_public_model_identity() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.node_data = _build_node_data(model=_build_public_model_identity(provider="", model_name="gpt-4o")) + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + assert stop_event.is_set() + mock_check.assert_not_called() + layer.command_channel.send_command.assert_called_once() + + +def test_precheck_requires_public_node_model_config() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.node_data = _build_node_data() + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + + with patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model", autospec=True) as mock_check: + layer.on_node_run_start(node) + + assert stop_event.is_set() + mock_check.assert_not_called() + layer.command_channel.send_command.assert_called_once() + abort_command = layer.command_channel.send_command.call_args.args[0] + assert abort_command.command_type == CommandType.ABORT + assert abort_command.reason == "LLM quota check requires public node model identity before execution." + + +def test_deduction_requires_public_event_model_identity() -> None: + layer = LLMQuotaLayer(tenant_id="tenant-id") + stop_event = threading.Event() + layer.command_channel = MagicMock() + + node = _build_node(node_type=BuiltinNodeTypes.LLM) + node.graph_runtime_state = MagicMock() + node.graph_runtime_state.stop_event = stop_event + result_event = _build_succeeded_event() + result_event.node_run_result.inputs = {"question": "hello"} + + with patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model", autospec=True) as mock_deduct: + layer.on_node_run_end(node=node, error=None, result_event=result_event) + + assert stop_event.is_set() + mock_deduct.assert_not_called() + layer.command_channel.send_command.assert_called_once() + abort_command = layer.command_channel.send_command.call_args.args[0] + assert abort_command.command_type == CommandType.ABORT + assert abort_command.reason == "LLM quota deduction requires model identity in the node result event." diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py index 9f3e3b00b9..c721c7b0eb 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py @@ -96,7 +96,7 @@ class MockNodeFactory(DifyNodeFactory): if node_type == BuiltinNodeTypes.CODE: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -106,7 +106,7 @@ class MockNodeFactory(DifyNodeFactory): elif node_type == BuiltinNodeTypes.HTTP_REQUEST: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -122,7 +122,7 @@ class MockNodeFactory(DifyNodeFactory): }: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, @@ -132,7 +132,7 @@ class MockNodeFactory(DifyNodeFactory): else: mock_instance = mock_class( node_id=node_id, - config=resolved_node_data, + data=resolved_node_data, graph_init_params=self.graph_init_params, graph_runtime_state=self.graph_runtime_state, mock_config=self.mock_config, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index f9819c47ec..c3e6f5d76c 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -56,7 +56,7 @@ class MockNodeMixin: def __init__( self, node_id: str, - config: Any, + data: Any, *, graph_init_params: "GraphInitParams", graph_runtime_state: "GraphRuntimeState", @@ -81,11 +81,11 @@ class MockNodeMixin: if isinstance(self, TemplateTransformNode): kwargs.setdefault("jinja2_template_renderer", _TestJinja2Renderer()) - # Provide default tool_file_manager_factory for ToolNode subclasses + # Provide default ToolNode dependencies for ToolNode subclasses. from graphon.nodes.tool import ToolNode as _ToolNode # local import to avoid cycles if isinstance(self, _ToolNode): - kwargs.setdefault("tool_file_manager_factory", MagicMock(spec=ToolFileManagerProtocol)) + kwargs.setdefault("tool_file_manager", MagicMock(spec=ToolFileManagerProtocol)) kwargs.setdefault("runtime", DifyToolNodeRuntime(graph_init_params.run_context)) if isinstance(self, AgentNode): @@ -98,7 +98,7 @@ class MockNodeMixin: super().__init__( node_id=node_id, - config=config, + data=data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, **kwargs, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index 75bc6d05f7..fd6263be19 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -9,7 +9,7 @@ from core.repositories.human_input_repository import ( HumanInputFormEntity, HumanInputFormRepository, ) -from core.workflow.node_runtime import DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.graph import Graph @@ -24,7 +24,7 @@ from graphon.graph_events import ( from graphon.nodes.base.entities import OutputVariableEntity from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData -from graphon.nodes.human_input.entities import HumanInputNodeData, UserAction +from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig from graphon.nodes.human_input.enums import HumanInputFormStatus from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData @@ -111,7 +111,7 @@ class StaticRepo(HumanInputFormRepository): def _build_runtime_state() -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -140,7 +140,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()} start_node = StartNode( node_id=start_config["id"], - config=StartNodeData(title="Start", variables=[]), + data=StartNodeData(title="Start", variables=[]), graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) @@ -149,26 +149,28 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor title="Human Input", form_content="Human input required", inputs=[], - user_actions=[UserAction(id="approve", title="Approve")], + user_actions=[UserActionConfig(id="approve", title="Approve")], ) human_a_config = {"id": "human_a", "data": human_data.model_dump()} human_a = HumanInputNode( node_id=human_a_config["id"], - config=human_data, + data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, + file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), ) human_b_config = {"id": "human_b", "data": human_data.model_dump()} human_b = HumanInputNode( node_id=human_b_config["id"], - config=human_data, + data=human_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, form_repository=repo, + file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), ) @@ -183,7 +185,7 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor end_config = {"id": "end", "data": end_data.model_dump()} end_node = EndNode( node_id=end_config["id"], - config=end_data, + data=end_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py index 7d23b63049..100b294f52 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py @@ -297,7 +297,7 @@ class TableTestRunner: max_workers: int = 4, enable_logging: bool = False, log_level: str = "INFO", - graph_engine_min_workers: int = 1, + graph_engine_min_workers: int = 3, graph_engine_max_workers: int = 1, graph_engine_scale_up_threshold: int = 5, graph_engine_scale_down_idle_time: float = 30.0, @@ -310,7 +310,7 @@ class TableTestRunner: max_workers: Maximum number of parallel workers for test execution enable_logging: Enable detailed logging log_level: Logging level (DEBUG, INFO, WARNING, ERROR) - graph_engine_min_workers: Minimum workers for GraphEngine (default: 1) + graph_engine_min_workers: Minimum workers for GraphEngine (default: 3) graph_engine_max_workers: Maximum workers for GraphEngine (default: 1) graph_engine_scale_up_threshold: Queue depth to trigger scale up graph_engine_scale_down_idle_time: Idle time before scaling down diff --git a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py index ae9dae0646..2603e29be6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py +++ b/api/tests/unit_tests/core/workflow/nodes/answer/test_answer.py @@ -1,41 +1,36 @@ import time import uuid -from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables -from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus -from graphon.graph import Graph from graphon.nodes.answer.answer_node import AnswerNode from graphon.nodes.answer.entities import AnswerNodeData from graphon.runtime import GraphRuntimeState, VariablePool from tests.workflow_test_utils import build_test_graph_init_params -def test_execute_answer(): +def _build_variable_pool() -> VariablePool: + return VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="aaa", files=[]), + user_inputs={}, + ) + + +def _build_answer_node(*, answer: str, variable_pool: VariablePool) -> AnswerNode: graph_config = { - "edges": [ - { - "id": "start-source-answer-target", - "source": "start", - "target": "answer", - }, - ], + "edges": [], "nodes": [ - {"data": {"type": "start", "title": "Start"}, "id": "start"}, { "data": { - "title": "123", + "title": "Answer", "type": "answer", - "answer": "Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + "answer": answer, }, "id": "answer", - }, + } ], } - init_params = build_test_graph_init_params( workflow_id="1", graph_config=graph_config, @@ -46,42 +41,31 @@ def test_execute_answer(): invoke_from=InvokeFrom.DEBUGGER, call_depth=0, ) - - # construct variable pool - variable_pool = VariablePool( - system_variables=build_system_variables(user_id="aaa", files=[]), - user_inputs={}, - environment_variables=[], - conversation_variables=[], + graph_runtime_state = GraphRuntimeState( + variable_pool=variable_pool, + start_at=time.perf_counter(), ) - variable_pool.add(["start", "weather"], "sunny") - variable_pool.add(["llm", "text"], "You are a helpful AI.") - - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - # create node factory - node_factory = DifyNodeFactory( - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, - ) - - graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") - - node = AnswerNode( + return AnswerNode( node_id=str(uuid.uuid4()), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - config=AnswerNodeData( - title="123", + data=AnswerNodeData( + title="Answer", type="answer", - answer="Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + answer=answer, ), ) - # Mock db.session.close() - db.session.close = MagicMock() - # execute node +def test_execute_answer_renders_variable_selectors() -> None: + variable_pool = _build_variable_pool() + variable_pool.add(["start", "weather"], "sunny") + variable_pool.add(["llm", "text"], "You are a helpful AI.") + node = _build_answer_node( + answer="Today's weather is {{#start.weather#}}\n{{#llm.text#}}\n{{img}}\nFin.", + variable_pool=variable_pool, + ) + result = node._run() assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED @@ -89,36 +73,11 @@ def test_execute_answer(): def test_execute_answer_renders_structured_output_object_as_json() -> None: - init_params = build_test_graph_init_params( - workflow_id="1", - graph_config={"nodes": [], "edges": []}, - tenant_id="1", - app_id="1", - user_id="1", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - call_depth=0, - ) - - variable_pool = VariablePool( - system_variables=build_system_variables(user_id="aaa", files=[]), - user_inputs={}, - environment_variables=[], - conversation_variables=[], - ) + variable_pool = _build_variable_pool() variable_pool.add(["1777539038857", "structured_output"], {"type": "greeting"}) - - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - node = AnswerNode( - node_id=str(uuid.uuid4()), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, - config=AnswerNodeData( - title="123", - type="answer", - answer="{{#1777539038857.structured_output#}}", - ), + node = _build_answer_node( + answer="{{#1777539038857.structured_output#}}", + variable_pool=variable_pool, ) result = node._run() @@ -128,35 +87,9 @@ def test_execute_answer_renders_structured_output_object_as_json() -> None: def test_execute_answer_falls_back_to_plain_selector_text_when_structured_output_missing() -> None: - init_params = build_test_graph_init_params( - workflow_id="1", - graph_config={"nodes": [], "edges": []}, - tenant_id="1", - app_id="1", - user_id="1", - user_from=UserFrom.ACCOUNT, - invoke_from=InvokeFrom.DEBUGGER, - call_depth=0, - ) - - variable_pool = VariablePool( - system_variables=build_system_variables(user_id="aaa", files=[]), - user_inputs={}, - environment_variables=[], - conversation_variables=[], - ) - - graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - node = AnswerNode( - node_id=str(uuid.uuid4()), - graph_init_params=init_params, - graph_runtime_state=graph_runtime_state, - config=AnswerNodeData( - title="123", - type="answer", - answer="{{#1777539038857.structured_output#}}", - ), + node = _build_answer_node( + answer="{{#1777539038857.structured_output#}}", + variable_pool=_build_variable_pool(), ) result = node._run() diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py index d7ef781732..235d56e989 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py @@ -1,3 +1,5 @@ +from pytest_mock import MockerFixture + from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY from core.workflow.nodes.datasource.datasource_node import DatasourceNode from core.workflow.nodes.datasource.entities import DatasourceNodeData @@ -44,7 +46,7 @@ class _GraphParams: call_depth = 0 -def test_datasource_node_delegates_to_manager_stream(mocker): +def test_datasource_node_delegates_to_manager_stream(mocker: MockerFixture): # prepare sys variables sys_vars = { "sys": { @@ -79,7 +81,7 @@ def test_datasource_node_delegates_to_manager_stream(mocker): node = DatasourceNode( node_id="n", - config=DatasourceNodeData( + data=DatasourceNodeData( type="datasource", version="1", title="Datasource", diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py index be7cc073db..796fc7719d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py @@ -29,7 +29,7 @@ HTTP_REQUEST_CONFIG = HttpRequestNodeConfig( def test_executor_with_json_body_and_number_variable(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -85,7 +85,7 @@ def test_executor_with_json_body_and_number_variable(): def test_executor_with_json_body_and_object_variable(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -143,7 +143,7 @@ def test_executor_with_json_body_and_object_variable(): def test_executor_with_json_body_and_nested_object_variable(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -201,7 +201,7 @@ def test_executor_with_json_body_and_nested_object_variable(): def test_extract_selectors_from_template_with_newline(): - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) variable_pool.add(("node_id", "custom_query"), "line1\nline2") node_data = HttpRequestNodeData( title="Test JSON Body with Nested Object Variable", @@ -230,7 +230,7 @@ def test_extract_selectors_from_template_with_newline(): def test_executor_with_form_data(): # Prepare the variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -320,7 +320,7 @@ def test_init_headers(): node_data=node_data, timeout=timeout, http_request_config=HTTP_REQUEST_CONFIG, - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), http_client=ssrf_proxy, file_manager=file_manager, ) @@ -357,7 +357,7 @@ def test_init_params(): node_data=node_data, timeout=timeout, http_request_config=HTTP_REQUEST_CONFIG, - variable_pool=VariablePool(system_variables=default_system_variables()), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), http_client=ssrf_proxy, file_manager=file_manager, ) @@ -390,7 +390,7 @@ def test_init_params(): def test_empty_api_key_raises_error_bearer(): """Test that empty API key raises AuthorizationConfigError for bearer auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -417,7 +417,7 @@ def test_empty_api_key_raises_error_bearer(): def test_empty_api_key_raises_error_basic(): """Test that empty API key raises AuthorizationConfigError for basic auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -444,7 +444,7 @@ def test_empty_api_key_raises_error_basic(): def test_empty_api_key_raises_error_custom(): """Test that empty API key raises AuthorizationConfigError for custom auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -471,7 +471,7 @@ def test_empty_api_key_raises_error_custom(): def test_whitespace_only_api_key_raises_error(): """Test that whitespace-only API key raises AuthorizationConfigError.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -498,7 +498,7 @@ def test_whitespace_only_api_key_raises_error(): def test_valid_api_key_works(): """Test that valid API key works correctly for bearer auth.""" - variable_pool = VariablePool(system_variables=default_system_variables()) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables()) node_data = HttpRequestNodeData( title="test", method="get", @@ -536,7 +536,7 @@ def test_executor_with_json_body_and_unquoted_uuid_variable(): # UUID that triggers the json_repair truncation bug test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -583,7 +583,7 @@ def test_executor_with_json_body_and_unquoted_uuid_with_newlines(): """ test_uuid = "57eeeeb1-450b-482c-81b9-4be77e95dee2" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -624,7 +624,7 @@ def test_executor_with_json_body_and_unquoted_uuid_with_newlines(): def test_executor_with_json_body_preserves_numbers_and_strings(): """Test that numbers are preserved and string values are properly quoted.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py index 2e89a2da3c..afde541beb 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py @@ -110,12 +110,15 @@ def _build_http_node( call_depth=0, ) graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="user", files=[]), + user_inputs={}, + ), start_at=time.perf_counter(), ) return HttpRequestNode( node_id="http-node", - config=HttpRequestNodeData.model_validate(node_data), + data=HttpRequestNodeData.model_validate(node_data), graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, http_request_config=HTTP_REQUEST_CONFIG, diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 0659984c76..a5a8e877f2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -30,23 +30,23 @@ from core.workflow.human_input_adapter import ( WebAppDeliveryMethod, _WebAppDeliveryConfig, ) -from core.workflow.node_runtime import DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.entities import GraphInitParams from graphon.node_events import PauseRequestedEvent from graphon.node_events.node import StreamCompletedEvent from graphon.nodes.human_input.entities import ( - FormInput, - FormInputDefault, HumanInputNodeData, - UserAction, + ParagraphInputConfig, + StringSource, + UserActionConfig, ) from graphon.nodes.human_input.enums import ( ButtonStyle, FormInputType, HumanInputFormStatus, - PlaceholderType, TimeoutUnit, + ValueSourceType, ) from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.runtime import GraphRuntimeState, VariablePool @@ -149,9 +149,10 @@ def _build_human_input_node( ) return HumanInputNode( node_id=node_id, - config=typed_node_data, + data=typed_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, + file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), runtime=runtime, ) @@ -190,32 +191,36 @@ class TestDeliveryMethod: assert len(delivery_method.config.recipients.items) == 2 -class TestFormInput: - """Test FormInput entity.""" +class TestParagraphInputConfig: + """Test ParagraphInputConfig entity.""" - def test_text_input_with_constant_default(self): - """Test text input with constant default value.""" - default = FormInputDefault(type=PlaceholderType.CONSTANT, value="Enter your response here...") + def test_paragraph_input_with_constant_default(self): + """Test paragraph input with constant default value.""" + default = StringSource(type=ValueSourceType.CONSTANT, value="Enter your response here...") - form_input = FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="user_input", default=default) + form_input = ParagraphInputConfig( + type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default + ) - assert form_input.type == FormInputType.TEXT_INPUT + assert form_input.type == FormInputType.PARAGRAPH assert form_input.output_variable_name == "user_input" - assert form_input.default.type == PlaceholderType.CONSTANT + assert form_input.default.type == ValueSourceType.CONSTANT assert form_input.default.value == "Enter your response here..." - def test_text_input_with_variable_default(self): - """Test text input with variable default value.""" - default = FormInputDefault(type=PlaceholderType.VARIABLE, selector=["node_123", "output_var"]) + def test_paragraph_input_with_variable_default(self): + """Test paragraph input with variable default value.""" + default = StringSource(type=ValueSourceType.VARIABLE, selector=["node_123", "output_var"]) - form_input = FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="user_input", default=default) + form_input = ParagraphInputConfig( + type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default + ) - assert form_input.default.type == PlaceholderType.VARIABLE + assert form_input.default.type == ValueSourceType.VARIABLE assert form_input.default.selector == ["node_123", "output_var"] def test_form_input_without_default(self): """Test form input without default value.""" - form_input = FormInput(type=FormInputType.PARAGRAPH, output_variable_name="description") + form_input = ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="description") assert form_input.type == FormInputType.PARAGRAPH assert form_input.output_variable_name == "description" @@ -223,11 +228,11 @@ class TestFormInput: class TestUserAction: - """Test UserAction entity.""" + """Test UserActionConfig entity.""" def test_user_action_creation(self): """Test user action creation.""" - action = UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY) + action = UserActionConfig(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY) assert action.id == "approve" assert action.title == "Approve" @@ -235,22 +240,22 @@ class TestUserAction: def test_user_action_default_button_style(self): """Test user action with default button style.""" - action = UserAction(id="cancel", title="Cancel") + action = UserActionConfig(id="cancel", title="Cancel") assert action.button_style == ButtonStyle.DEFAULT def test_user_action_length_boundaries(self): """Test user action id and title length boundaries.""" - action = UserAction(id="a" * 20, title="b" * 20) + action = UserActionConfig(id="a" * 20, title="b" * 100) assert action.id == "a" * 20 - assert action.title == "b" * 20 + assert action.title == "b" * 100 @pytest.mark.parametrize( ("field_name", "value"), [ ("id", "a" * 21), - ("title", "b" * 21), + ("title", "b" * 101), ], ) def test_user_action_length_limits(self, field_name: str, value: str): @@ -259,7 +264,7 @@ class TestUserAction: data[field_name] = value with pytest.raises(ValidationError) as exc_info: - UserAction.model_validate(data) + UserActionConfig.model_validate(data) errors = exc_info.value.errors() assert any(error["loc"] == (field_name,) and error["type"] == "string_too_long" for error in errors) @@ -273,14 +278,14 @@ class TestHumanInputNodeData: delivery_methods = [WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())] inputs = [ - FormInput( - type=FormInputType.TEXT_INPUT, + ParagraphInputConfig( + type=FormInputType.PARAGRAPH, output_variable_name="content", - default=FormInputDefault(type=PlaceholderType.CONSTANT, value="Enter content..."), + default=StringSource(type=ValueSourceType.CONSTANT, value="Enter content..."), ) ] - user_actions = [UserAction(id="submit", title="Submit", button_style=ButtonStyle.PRIMARY)] + user_actions = [UserActionConfig(id="submit", title="Submit", button_style=ButtonStyle.PRIMARY)] node_data = HumanInputNodeData( title="Human Input Test", @@ -338,8 +343,8 @@ class TestHumanInputNodeData: def test_duplicate_input_output_variable_name_raises_validation_error(self): """Duplicate form input output_variable_name should raise validation error.""" duplicate_inputs = [ - FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="content"), - FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="content"), + ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"), + ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"), ] with pytest.raises(ValidationError, match="duplicated output_variable_name 'content'"): @@ -348,8 +353,8 @@ class TestHumanInputNodeData: def test_duplicate_user_action_ids_raise_validation_error(self): """Duplicate user action ids should raise validation error.""" duplicate_actions = [ - UserAction(id="submit", title="Submit"), - UserAction(id="submit", title="Submit Again"), + UserActionConfig(id="submit", title="Submit"), + UserActionConfig(id="submit", title="Submit Again"), ] with pytest.raises(ValidationError, match="duplicated user action id 'submit'"): @@ -427,7 +432,7 @@ class TestHumanInputNodeVariableResolution: """Tests for resolving variable-based defaults in HumanInputNode.""" def test_resolves_variable_defaults(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -458,18 +463,18 @@ class TestHumanInputNodeVariableResolution: title="Human Input", form_content="Provide your name", inputs=[ - FormInput( - type=FormInputType.TEXT_INPUT, + ParagraphInputConfig( + type=FormInputType.PARAGRAPH, output_variable_name="user_name", - default=FormInputDefault(type=PlaceholderType.VARIABLE, selector=["start", "name"]), + default=StringSource(type=ValueSourceType.VARIABLE, selector=["start", "name"]), ), - FormInput( - type=FormInputType.TEXT_INPUT, + ParagraphInputConfig( + type=FormInputType.PARAGRAPH, output_variable_name="user_email", - default=FormInputDefault(type=PlaceholderType.CONSTANT, value="foo@example.com"), + default=StringSource(type=ValueSourceType.CONSTANT, value="foo@example.com"), ), ], - user_actions=[UserAction(id="submit", title="Submit")], + user_actions=[UserActionConfig(id="submit", title="Submit")], ) config = {"id": "human", "data": node_data.model_dump()} @@ -504,7 +509,7 @@ class TestHumanInputNodeVariableResolution: assert params.resolved_default_values == expected_values def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -534,7 +539,7 @@ class TestHumanInputNodeVariableResolution: title="Human Input", form_content="Provide your name", inputs=[], - user_actions=[UserAction(id="submit", title="Submit")], + user_actions=[UserActionConfig(id="submit", title="Submit")], ) config = {"id": "human", "data": node_data.model_dump()} @@ -565,7 +570,7 @@ class TestHumanInputNodeVariableResolution: assert not hasattr(pause_event.reason, "form_token") def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -631,7 +636,7 @@ class TestHumanInputNodeVariableResolution: assert params.display_in_ui is True def test_debugger_debug_mode_overrides_email_recipients(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user-123", app_id="app", @@ -661,7 +666,7 @@ class TestHumanInputNodeVariableResolution: title="Human Input", form_content="Provide your name", inputs=[], - user_actions=[UserAction(id="submit", title="Submit")], + user_actions=[UserActionConfig(id="submit", title="Submit")], delivery_methods=[ EmailDeliveryMethod( enabled=True, @@ -721,7 +726,7 @@ class TestValidation: def test_invalid_form_input_type(self): """Test validation with invalid form input type.""" with pytest.raises(ValidationError): - FormInput( + ParagraphInputConfig( type="invalid-type", # Invalid type output_variable_name="test", ) @@ -729,7 +734,7 @@ class TestValidation: def test_invalid_button_style(self): """Test validation with invalid button style.""" with pytest.raises(ValidationError): - UserAction( + UserActionConfig( id="test", title="Test", button_style="invalid-style", # Invalid style @@ -748,7 +753,7 @@ class TestHumanInputNodeRenderedContent: """Tests for rendering submitted content.""" def test_replaces_outputs_placeholders_after_submission(self): - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", app_id="app", @@ -778,12 +783,12 @@ class TestHumanInputNodeRenderedContent: title="Human Input", form_content="Name: {{#$output.name#}}", inputs=[ - FormInput( - type=FormInputType.TEXT_INPUT, + ParagraphInputConfig( + type=FormInputType.PARAGRAPH, output_variable_name="name", ) ], - user_actions=[UserAction(id="approve", title="Approve")], + user_actions=[UserActionConfig(id="approve", title="Approve")], ) config = {"id": "human", "data": node_data.model_dump()} @@ -810,4 +815,4 @@ class TestHumanInputNodeRenderedContent: last_event = events[-1] assert isinstance(last_event, StreamCompletedEvent) node_run_result = last_event.node_run_result - assert node_run_result.outputs["__rendered_content"] == "Name: Alice" + assert node_run_result.outputs["__rendered_content"].to_object() == "Name: Alice" diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 4a9438b14f..40522a0d4f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -2,7 +2,7 @@ import datetime from types import SimpleNamespace from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom -from core.workflow.node_runtime import DifyHumanInputNodeRuntime +from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams from graphon.enums import BuiltinNodeTypes @@ -40,10 +40,11 @@ def _create_human_input_node( ) return HumanInputNode( node_id=config["id"], - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, form_repository=repo, + file_reference_factory=DifyFileReferenceFactory(graph_init_params.run_context), runtime=DifyHumanInputNodeRuntime(graph_init_params.run_context), ) @@ -51,7 +52,11 @@ def _create_human_input_node( def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]), + variable_pool=VariablePool.from_bootstrap( + system_variables=system_variables, + user_inputs={}, + environment_variables=[], + ), start_at=0.0, ) graph_init_params = GraphInitParams( @@ -77,7 +82,7 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# "form_content": form_content, "inputs": [ { - "type": "text_input", + "type": "paragraph", "output_variable_name": "name", "default": {"type": "constant", "value": ""}, } @@ -114,7 +119,11 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name# def _build_timeout_node() -> HumanInputNode: system_variables = default_system_variables() graph_runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]), + variable_pool=VariablePool.from_bootstrap( + system_variables=system_variables, + user_inputs={}, + environment_variables=[], + ), start_at=0.0, ) graph_init_params = GraphInitParams( @@ -140,7 +149,7 @@ def _build_timeout_node() -> HumanInputNode: "form_content": "Please enter your name:\n\n{{#$output.name#}}", "inputs": [ { - "type": "text_input", + "type": "paragraph", "output_variable_name": "name", "default": {"type": "constant", "value": ""}, } diff --git a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py index 8ffce39cd6..18ed7a0b1d 100644 --- a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py +++ b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration_child_engine_errors.py @@ -32,7 +32,7 @@ class _MissingGraphBuilder: def _build_runtime_state() -> GraphRuntimeState: return GraphRuntimeState( - variable_pool=VariablePool(system_variables=default_system_variables(), user_inputs={}), + variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}), start_at=0.0, ) @@ -46,7 +46,7 @@ def _build_iteration_node( init_params = build_test_graph_init_params(graph_config=graph_config) return IterationNode( node_id="iteration-node", - config=IterationNodeData( + data=IterationNodeData( type="iteration", title="Iteration", iterator_selector=["start", "items"], diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py index f254fc3d09..0d760a2db7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_index/test_knowledge_index_node.py @@ -3,6 +3,7 @@ import uuid from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.rag.index_processor.constant.index_type import IndexTechniqueType @@ -40,7 +41,7 @@ def mock_graph_init_params(): @pytest.fixture def mock_graph_runtime_state(): """Create mock GraphRuntimeState.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id=str(uuid.uuid4()), files=[]), user_inputs={}, environment_variables=[], @@ -50,7 +51,7 @@ def mock_graph_runtime_state(): @pytest.fixture -def mock_index_processor(mocker): +def mock_index_processor(mocker: MockerFixture): """Create mock IndexProcessorProtocol.""" mock_processor = Mock(spec=IndexProcessorProtocol) mocker.patch( @@ -61,7 +62,7 @@ def mock_index_processor(mocker): @pytest.fixture -def mock_summary_index_service(mocker): +def mock_summary_index_service(mocker: MockerFixture): """Create mock SummaryIndexServiceProtocol.""" mock_service = Mock(spec=SummaryIndexServiceProtocol) mocker.patch( @@ -102,7 +103,7 @@ def _build_node( ) -> KnowledgeIndexNode: return KnowledgeIndexNode( node_id=node_id, - config=( + data=( node_data if isinstance(node_data, KnowledgeIndexNodeData) else KnowledgeIndexNodeData.model_validate(node_data) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py index e923ee761b..3c821e75ba 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py @@ -3,6 +3,7 @@ import uuid from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.workflow.nodes.knowledge_retrieval.entities import ( @@ -46,7 +47,7 @@ def mock_graph_init_params(): @pytest.fixture def mock_graph_runtime_state(): """Create mock GraphRuntimeState.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id=str(uuid.uuid4()), files=[]), user_inputs={}, environment_variables=[], @@ -56,7 +57,7 @@ def mock_graph_runtime_state(): @pytest.fixture -def mock_rag_retrieval(mocker): +def mock_rag_retrieval(mocker: MockerFixture): """Create mock RAGRetrievalProtocol.""" mock_retrieval = Mock(spec=RAGRetrievalProtocol) mock_retrieval.knowledge_retrieval.return_value = [] @@ -117,7 +118,7 @@ class TestKnowledgeRetrievalNode: # Act node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -146,7 +147,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -205,7 +206,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -249,7 +250,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -285,7 +286,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -320,7 +321,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -361,7 +362,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -400,7 +401,7 @@ class TestKnowledgeRetrievalNode: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -481,7 +482,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -518,7 +519,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -573,7 +574,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -621,7 +622,7 @@ class TestFetchDatasetRetriever: node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -682,7 +683,7 @@ class TestFetchDatasetRetriever: config = {"id": node_id, "data": node_data.model_dump()} node = KnowledgeRetrievalNode( node_id=node_id, - config=KnowledgeRetrievalNodeData.model_validate(config["data"]), + data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py index 388654f279..20b94d5d50 100644 --- a/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/list_operator/node_spec.py @@ -16,10 +16,10 @@ class TestListOperatorNode: """Comprehensive tests for ListOperatorNode.""" @staticmethod - def _build_node(*, config, graph_init_params, graph_runtime_state): + def _build_node(*, data, graph_init_params, graph_runtime_state): return ListOperatorNode( node_id="test", - config=config if isinstance(config, ListOperatorNodeData) else ListOperatorNodeData.model_validate(config), + data=data if isinstance(data, ListOperatorNodeData) else ListOperatorNodeData.model_validate(data), graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) @@ -65,7 +65,7 @@ class TestListOperatorNode: def _create_node(config, mock_variable): mock_graph_runtime_state.variable_pool.get.return_value = mock_variable return self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -83,7 +83,7 @@ class TestListOperatorNode: } node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -127,7 +127,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -153,7 +153,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -177,7 +177,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -201,7 +201,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -228,7 +228,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -255,7 +255,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -282,7 +282,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -312,7 +312,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -335,7 +335,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = None node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -359,7 +359,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -384,7 +384,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -408,7 +408,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -432,7 +432,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -456,7 +456,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) @@ -483,7 +483,7 @@ class TestListOperatorNode: mock_graph_runtime_state.variable_pool.get.return_value = mock_var node = self._build_node( - config=config, + data=config, graph_init_params=graph_init_params, graph_runtime_state=mock_graph_runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py index 212ad07bd3..6a2fc81fef 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -613,7 +613,7 @@ def test_combine_message_content_with_role_handles_all_supported_roles(): SystemPromptMessage(content=contents) ) - with pytest.raises(NotImplementedError, match="Role custom is not supported"): + with pytest.raises(AssertionError, match="Expected code to be unreachable"): llm_utils.combine_message_content_with_role(contents=contents, role="custom") # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index c707cf28cd..fb50723402 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -15,7 +15,7 @@ from core.app.llm.model_access import ( ) from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle from core.entities.provider_entities import CustomConfiguration, SystemConfiguration -from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime +from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from core.prompt.entities.advanced_prompt_entities import MemoryConfig from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams @@ -187,7 +187,7 @@ def graph_init_params() -> GraphInitParams: @pytest.fixture def graph_runtime_state() -> GraphRuntimeState: - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -208,7 +208,7 @@ def llm_node( http_client = mock.MagicMock() node = LLMNode( node_id="1", - config=llm_node_data, + data=llm_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, credentials_provider=mock_credentials_provider, @@ -222,7 +222,7 @@ def llm_node( @pytest.fixture -def model_config(monkeypatch): +def model_config(monkeypatch: pytest.MonkeyPatch): from tests.integration_tests.model_runtime.__mock.plugin_model import MockModelClass def mock_model_providers(_self): @@ -241,9 +241,10 @@ def model_config(monkeypatch): ) # Create actual provider and model type instances - model_provider_factory = ModelProviderFactory(model_runtime=create_plugin_model_runtime(tenant_id="test")) + model_assembly = create_plugin_model_assembly(tenant_id="test") + model_provider_factory = model_assembly.model_provider_factory provider_instance = model_provider_factory.get_model_provider("openai") - model_type_instance = model_provider_factory.get_model_type_instance("openai", ModelType.LLM) + model_type_instance = model_assembly.create_model_type_instance(provider="openai", model_type=ModelType.LLM) # Create a ProviderModelBundle provider_model_bundle = ProviderModelBundle( @@ -1173,7 +1174,7 @@ def llm_node_for_multimodal(llm_node_data, graph_init_params, graph_runtime_stat http_client = mock.MagicMock() node = LLMNode( node_id="1", - config=llm_node_data, + data=llm_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, credentials_provider=mock_credentials_provider, @@ -1276,7 +1277,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: mock_file_saver.save_binary_string.assert_not_called() mock_file_saver.save_remote_url.assert_not_called() - def test_image_content_with_inline_data(self, llm_node_for_multimodal, monkeypatch): + def test_image_content_with_inline_data(self, llm_node_for_multimodal, monkeypatch: pytest.MonkeyPatch): llm_node, mock_file_saver = llm_node_for_multimodal image_raw_data = b"PNG_DATA" diff --git a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py index 8f8ec49f14..cd3b0b130b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py @@ -122,7 +122,7 @@ class TestValidateResult: parameters=[ ParameterConfig( name="status", - type="select", # pyright: ignore[reportArgumentType] + type="select", description="Status", required=True, options=["active", "inactive"], diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py index 892f6cc586..dd57dde1fe 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py @@ -28,7 +28,7 @@ def _build_template_transform_node( ) return TemplateTransformNode( node_id=node_id, - config=typed_node_data, + data=typed_node_data, graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, **kwargs, diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py index a846efbb43..c25ac7da0f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_node.py @@ -39,7 +39,7 @@ def mock_graph_runtime_state(): def test_node_uses_default_max_output_length_when_not_overridden(graph_init_params, mock_graph_runtime_state): node = TemplateTransformNode( node_id="test_node", - config=TemplateTransformNodeData( + data=TemplateTransformNodeData( title="Template Transform", type="template-transform", variables=[], diff --git a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py index 364408ead6..a05151f79b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py @@ -35,7 +35,10 @@ def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, invoke_from="debugger", ) runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="user", files=[]), + user_inputs={}, + ), start_at=0.0, ) return init_params, runtime_state @@ -62,7 +65,7 @@ def test_node_hydrates_data_during_initialization(): node = _SampleNode( node_id="node-1", - config=_build_node_data(), + data=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) @@ -82,13 +85,16 @@ def test_node_accepts_invoke_from_enum(): invoke_from=InvokeFrom.DEBUGGER, ) runtime_state = GraphRuntimeState( - variable_pool=VariablePool(system_variables=build_system_variables(user_id="user", files=[]), user_inputs={}), + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="user", files=[]), + user_inputs={}, + ), start_at=0.0, ) node = _SampleNode( node_id="node-1", - config=_build_node_data(), + data=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) @@ -140,7 +146,7 @@ def test_node_hydration_preserves_compatibility_extra_fields(): node = _SampleNode( node_id="node-1", - config=node_config["data"], + data=node_config["data"], graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py index dd75b32593..4c67f3fb02 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py @@ -49,7 +49,7 @@ def document_extractor_node(graph_init_params): http_client = Mock() node = DocumentExtractorNode( node_id="test_node_id", - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=Mock(), http_client=http_client, @@ -186,12 +186,13 @@ def test_run_extract_text( monkeypatch.setattr("graphon.file.file_manager.download", mock_download) + dispatch_mock = None if mime_type == "application/pdf": - mock_pdf_extract = Mock(return_value=expected_text[0]) - monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_from_pdf", mock_pdf_extract) + dispatch_mock = Mock(return_value=expected_text[0]) + monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_by_file_extension", dispatch_mock) elif mime_type.startswith("application/vnd.openxmlformats"): - mock_docx_extract = Mock(return_value=expected_text[0]) - monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_from_docx", mock_docx_extract) + dispatch_mock = Mock(return_value=expected_text[0]) + monkeypatch.setattr("graphon.nodes.document_extractor.node._extract_text_by_mime_type", dispatch_mock) result = document_extractor_node._run() @@ -200,6 +201,19 @@ def test_run_extract_text( assert result.outputs is not None assert result.outputs["text"] == ArrayStringSegment(value=expected_text) + if mime_type == "application/pdf": + dispatch_mock.assert_called_once_with( + file_content=file_content, + file_extension=extension, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + elif mime_type.startswith("application/vnd.openxmlformats"): + dispatch_mock.assert_called_once_with( + file_content=file_content, + mime_type=mime_type, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + if transfer_method == FileTransferMethod.REMOTE_URL: document_extractor_node._http_client.get.assert_called_once_with("https://example.com/file.txt") elif transfer_method == FileTransferMethod.LOCAL_FILE: @@ -439,24 +453,42 @@ def test_extract_text_from_file_routes_excel_inputs(document_extractor_node, ext file.extension = extension file.mime_type = mime_type - with ( - patch( - "graphon.nodes.document_extractor.node._download_file_content", - return_value=b"excel", - ), - patch( - "graphon.nodes.document_extractor.node._extract_text_from_excel", - return_value="excel text", - ) as mock_extract, + with patch( + "graphon.nodes.document_extractor.node._download_file_content", + return_value=b"excel", ): - result = _extract_text_from_file( - document_extractor_node.http_client, - file, - unstructured_api_config=document_extractor_node._unstructured_api_config, - ) + if extension: + with patch( + "graphon.nodes.document_extractor.node._extract_text_by_file_extension", + return_value="excel text", + ) as mock_extract: + result = _extract_text_from_file( + document_extractor_node.http_client, + file, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + mock_extract.assert_called_once_with( + file_content=b"excel", + file_extension=extension, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + else: + with patch( + "graphon.nodes.document_extractor.node._extract_text_by_mime_type", + return_value="excel text", + ) as mock_extract: + result = _extract_text_from_file( + document_extractor_node.http_client, + file, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) + mock_extract.assert_called_once_with( + file_content=b"excel", + mime_type=mime_type, + unstructured_api_config=document_extractor_node._unstructured_api_config, + ) assert result == "excel text" - mock_extract.assert_called_once_with(b"excel") def test_extract_text_from_file_rejects_missing_extension_and_mime_type(document_extractor_node): diff --git a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index aa9a1360b0..5965645c4f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py @@ -29,7 +29,7 @@ def _build_if_else_node( node_id=str(uuid.uuid4()), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - config=node_data if isinstance(node_data, IfElseNodeData) else IfElseNodeData.model_validate(node_data), + data=node_data if isinstance(node_data, IfElseNodeData) else IfElseNodeData.model_validate(node_data), ) @@ -48,7 +48,10 @@ def test_execute_if_else_result_true(): ) # construct variable pool - pool = VariablePool(system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}) + pool = VariablePool.from_bootstrap( + system_variables=build_system_variables(user_id="aaa", files=[]), + user_inputs={}, + ) pool.add(["start", "array_contains"], ["ab", "def"]) pool.add(["start", "array_not_contains"], ["ac", "def"]) pool.add(["start", "contains"], "cabcde") @@ -148,7 +151,7 @@ def test_execute_if_else_result_false(): ) # construct variable pool - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="aaa", files=[]), user_inputs={}, environment_variables=[], @@ -305,7 +308,7 @@ def test_execute_if_else_boolean_conditions(condition: Condition): ) # construct variable pool with boolean values - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) @@ -359,7 +362,7 @@ def test_execute_if_else_boolean_false_conditions(): ) # construct variable pool with boolean values - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) @@ -424,7 +427,7 @@ def test_execute_if_else_boolean_cases_structure(): ) # construct variable pool with boolean values - pool = VariablePool( + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py index 465a4c0ff4..1b4cecc757 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py @@ -22,7 +22,7 @@ from graphon.variables import ArrayFileSegment def _build_list_operator_node(node_data: ListOperatorNodeData, graph_init_params) -> ListOperatorNode: return ListOperatorNode( node_id="test_node_id", - config=node_data, + data=node_data, graph_init_params=graph_init_params, graph_runtime_state=MagicMock(), ) diff --git a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py index 5655f80737..f890f79511 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_start_node_json_object.py @@ -31,7 +31,7 @@ def make_start_node(user_inputs, variables): return StartNode( node_id="start", - config=node_data, + data=node_data, graph_init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, @@ -260,7 +260,7 @@ def test_start_node_outputs_full_variable_pool_snapshot(): graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) node = StartNode( node_id="start", - config=node_data, + data=node_data, graph_init_params=build_test_graph_init_params( workflow_id="wf", graph_config={}, diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index 284af68319..0ee70256d7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -15,16 +15,23 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import StreamChunkEvent, StreamCompletedEvent from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment -from tests.workflow_test_utils import build_test_graph_init_params +from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool if TYPE_CHECKING: # pragma: no cover - imported for type checking only from graphon.nodes.tool.tool_node import ToolNode class _StubToolRuntime: - def get_runtime(self, *, node_id: str, node_data: Any, variable_pool: Any) -> ToolRuntimeHandle: + def get_runtime( + self, + *, + node_id: str, + node_data: Any, + variable_pool: Any, + node_execution_id: str | None = None, + ) -> ToolRuntimeHandle: raise NotImplementedError def get_runtime_parameters(self, *, tool_runtime: ToolRuntimeHandle) -> list[Any]: @@ -99,21 +106,21 @@ def tool_node(monkeypatch) -> ToolNode: call_depth=0, ) - variable_pool = VariablePool(system_variables=build_system_variables(user_id="user-id")) + variable_pool = build_test_variable_pool(variables=build_system_variables(user_id="user-id")) graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) config = graph_config["nodes"][0] - # Provide a stub ToolFileManager to satisfy the updated ToolNode constructor - tool_file_manager_factory = MagicMock(spec=ToolFileManagerProtocol) + # Provide a stub ToolFileManager to satisfy the ToolNode constructor. + tool_file_manager = MagicMock(spec=ToolFileManagerProtocol) runtime = _StubToolRuntime() node = ToolNode( node_id="node-instance", - config=ToolNodeData.model_validate(config["data"]), + data=ToolNodeData.model_validate(config["data"]), graph_init_params=init_params, graph_runtime_state=graph_runtime_state, - tool_file_manager_factory=tool_file_manager_factory, + tool_file_manager=tool_file_manager, runtime=runtime, ) return node @@ -208,7 +215,7 @@ def test_image_link_messages_use_tool_file_id_metadata(tool_node: ToolNode): size=123, storage_key="file-key", ) - tool_node._tool_file_manager_factory.get_file_generator_by_tool_file_id.return_value = ( + tool_node._tool_file_manager.get_file_generator_by_tool_file_id.return_value = ( None, SimpleNamespace(mime_type="application/pdf"), ) @@ -221,9 +228,28 @@ def test_image_link_messages_use_tool_file_id_metadata(tool_node: ToolNode): events, _ = _run_transform(tool_node, message) - tool_node._tool_file_manager_factory.get_file_generator_by_tool_file_id.assert_called_once_with("file-id") + tool_node._tool_file_manager.get_file_generator_by_tool_file_id.assert_called_once_with("file-id") completed_events = [event for event in events if isinstance(event, StreamCompletedEvent)] assert len(completed_events) == 1 files_segment = completed_events[0].node_run_result.outputs["files"] assert isinstance(files_segment, ArrayFileSegment) assert files_segment.value == [file_obj] + + +def test_tool_node_passes_node_execution_id_when_runtime_accepts_it(tool_node: ToolNode): + runtime_handle = ToolRuntimeHandle(raw=object()) + tool_node._runtime.get_runtime = MagicMock(return_value=runtime_handle) + tool_node.ensure_execution_id = MagicMock(return_value="node-execution-id") + + result = tool_node._get_tool_runtime( + variable_pool=tool_node.graph_runtime_state.variable_pool, + node_execution_id="node-execution-id", + ) + + assert result is runtime_handle + tool_node._runtime.get_runtime.assert_called_once_with( + node_id="node-instance", + node_data=tool_node.node_data, + variable_pool=tool_node.graph_runtime_state.variable_pool, + node_execution_id="node-execution-id", + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 438af211f3..aece73ce8c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -147,6 +147,69 @@ def test_get_runtime_converts_graph_provider_type_for_tool_manager(runtime: Dify assert workflow_tool.provider_type == CoreToolProviderType.BUILT_IN +def test_get_runtime_stores_parent_trace_context_for_workflow_tools( + runtime: DifyToolNodeRuntime, +) -> None: + variable_pool: VariablePool = build_test_variable_pool( + variables=build_system_variables( + conversation_id="conversation-id", + workflow_execution_id="workflow-run-id", + ) + ) + workflow_runtime = MagicMock() + workflow_runtime.runtime.runtime_parameters = {} + node_data = ToolNodeData.model_validate( + { + "type": "tool", + "title": "Tool", + "provider_id": "provider", + "provider_type": ToolProviderType.WORKFLOW, + "provider_name": "provider", + "tool_name": "lookup", + "tool_label": "Lookup", + "tool_configurations": {}, + "tool_parameters": {}, + } + ) + + with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=workflow_runtime): + tool_runtime = runtime.get_runtime( + node_id="node-id", + node_data=node_data, + variable_pool=variable_pool, + node_execution_id="node-execution-id", + ) + + assert tool_runtime.raw.parent_trace_context.model_dump() == { + "parent_workflow_run_id": "workflow-run-id", + "parent_node_execution_id": "node-execution-id", + } + assert workflow_runtime.runtime.runtime_parameters == {} + + +def test_get_runtime_leaves_non_workflow_tool_runtime_parameters_unchanged( + runtime: DifyToolNodeRuntime, +) -> None: + variable_pool: VariablePool = build_test_variable_pool( + variables=build_system_variables( + conversation_id="conversation-id", + workflow_execution_id="workflow-run-id", + ) + ) + builtin_runtime = MagicMock() + builtin_runtime.runtime.runtime_parameters = {} + + with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=builtin_runtime): + runtime.get_runtime( + node_id="node-id", + node_data=_build_tool_node_data(), + variable_pool=variable_pool, + node_execution_id="node-execution-id", + ) + + assert builtin_runtime.runtime.runtime_parameters == {} + + def test_get_runtime_parameters_reads_required_flags(runtime: DifyToolNodeRuntime) -> None: tool_runtime = ToolRuntimeHandle( raw=SimpleNamespace( diff --git a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py index e3b5e3b591..c5ac8d2ce2 100644 --- a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py @@ -44,7 +44,7 @@ def test_trigger_event_node_run_populates_trigger_info_metadata() -> None: init_params, runtime_state = _build_context(graph_config={}) node = TriggerEventNode( node_id="node-1", - config=_build_node_data(), + data=_build_node_data(), graph_init_params=init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py index 07d03bec05..fccb5ab1c3 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -52,7 +52,7 @@ def create_webhook_node( node = TriggerWebhookNode( node_id="webhook-node-1", - config=webhook_data, + data=webhook_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index b839490d3c..c5ae542d8b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -44,7 +44,7 @@ def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) ) node = TriggerWebhookNode( node_id="1", - config=webhook_data, + data=webhook_data, graph_init_params=graph_init_params, graph_runtime_state=runtime_state, ) diff --git a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py index 8b5fceeb37..51049f8792 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py @@ -166,6 +166,71 @@ def test_adapt_node_data_for_graph_migrates_legacy_tool_configurations() -> None } +def test_adapt_node_data_for_graph_preserves_model_selector_top_level_configurations() -> None: + normalized = adapt_node_data_for_graph( + { + "type": BuiltinNodeTypes.TOOL, + "tool_configurations": { + "vision_llm_model": { + "type": "constant", + "value": "", + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-vl-plus", + "model_type": "llm", + "mode": "chat", + }, + }, + } + ) + + assert normalized["tool_configurations"] == {} + assert normalized["tool_parameters"] == { + "vision_llm_model": { + "type": "constant", + "value": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-vl-plus", + "model_type": "llm", + "mode": "chat", + }, + } + } + + +def test_adapt_node_data_for_graph_flattens_constant_model_selector_value() -> None: + normalized = adapt_node_data_for_graph( + { + "type": BuiltinNodeTypes.TOOL, + "tool_configurations": { + "tts_model": { + "type": "constant", + "value": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-tts-flash", + "model_type": "tts", + "language": "Chinese", + "voice": "Cherry", + }, + }, + }, + } + ) + + assert normalized["tool_configurations"] == {} + assert normalized["tool_parameters"] == { + "tts_model": { + "type": "constant", + "value": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-tts-flash", + "model_type": "tts", + "language": "Chinese", + "voice": "Cherry", + }, + } + } + + def test_adapt_node_config_for_graph_rewrites_nested_node_data() -> None: normalized = adapt_node_config_for_graph( { diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index 1821f72e0c..62e1a50291 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from types import SimpleNamespace from unittest.mock import MagicMock, patch, sentinel @@ -11,19 +12,21 @@ from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeType from graphon.nodes.code.entities import CodeLanguage from graphon.nodes.llm.entities import LLMNodeData -from graphon.variables.segments import StringSegment +from graphon.nodes.llm.node import LLMNode +from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData +from graphon.variables.segments import ArrayObjectSegment, StringSegment -def _assert_typed_node_config(config, *, node_id: str, node_type: NodeType, version: str = "1") -> None: +def _assert_constructor_node_data(data, *, node_id: str, node_type: NodeType, version: str = "1") -> None: _ = node_id - if isinstance(config, BaseNodeData): - assert config.type == node_type - assert config.version == version + if isinstance(data, BaseNodeData): + assert data.type == node_type + assert data.version == version return - assert isinstance(config, dict) - assert config["type"] == node_type - assert config["version"] == version + assert isinstance(data, Mapping) + assert data["type"] == node_type + assert data.get("version", "1") == version def _node_constructor(*, return_value): @@ -88,7 +91,7 @@ class TestFetchMemory: assert result is None - def test_returns_none_when_conversation_does_not_exist(self, monkeypatch): + def test_returns_none_when_conversation_does_not_exist(self, monkeypatch: pytest.MonkeyPatch): class FakeSelect: def where(self, *_args): return self @@ -106,9 +109,8 @@ class TestFetchMemory: def scalar(self, _stmt): return None - monkeypatch.setattr(node_factory, "db", SimpleNamespace(engine=sentinel.engine)) + monkeypatch.setattr(node_factory, "session_factory", SimpleNamespace(create_session=FakeSession)) monkeypatch.setattr(node_factory, "select", MagicMock(return_value=FakeSelect())) - monkeypatch.setattr(node_factory, "Session", FakeSession) result = node_factory.fetch_memory( conversation_id="conversation-id", @@ -119,7 +121,7 @@ class TestFetchMemory: assert result is None - def test_builds_token_buffer_memory_for_existing_conversation(self, monkeypatch): + def test_builds_token_buffer_memory_for_existing_conversation(self, monkeypatch: pytest.MonkeyPatch): conversation = sentinel.conversation memory = sentinel.memory @@ -141,9 +143,8 @@ class TestFetchMemory: return conversation token_buffer_memory = MagicMock(return_value=memory) - monkeypatch.setattr(node_factory, "db", SimpleNamespace(engine=sentinel.engine)) + monkeypatch.setattr(node_factory, "session_factory", SimpleNamespace(create_session=FakeSession)) monkeypatch.setattr(node_factory, "select", MagicMock(return_value=FakeSelect())) - monkeypatch.setattr(node_factory, "Session", FakeSession) monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) result = node_factory.fetch_memory( @@ -159,6 +160,41 @@ class TestFetchMemory: model_instance=sentinel.model_instance, ) + def test_uses_configured_session_factory_without_flask_app_context(self, monkeypatch: pytest.MonkeyPatch): + class FakeSelect: + def where(self, *_args): + return self + + class FakeSession: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def scalar(self, _stmt): + return sentinel.conversation + + class RaisingDB: + @property + def engine(self): + raise RuntimeError("Working outside of application context.") + + token_buffer_memory = MagicMock(return_value=sentinel.memory) + monkeypatch.setattr(node_factory, "db", RaisingDB(), raising=False) + monkeypatch.setattr(node_factory, "session_factory", SimpleNamespace(create_session=FakeSession)) + monkeypatch.setattr(node_factory, "select", MagicMock(return_value=FakeSelect())) + monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) + + result = node_factory.fetch_memory( + conversation_id="conversation-id", + app_id="app-id", + node_data_memory=object(), + model_instance=sentinel.model_instance, + ) + + assert result is sentinel.memory + class TestDifyGraphInitContext: def test_to_graph_init_params_preserves_explicit_values(self): @@ -189,7 +225,7 @@ class TestDifyGraphInitContext: class TestDefaultWorkflowCodeExecutor: - def test_execute_delegates_to_code_executor(self, monkeypatch): + def test_execute_delegates_to_code_executor(self, monkeypatch: pytest.MonkeyPatch): executor = node_factory.DefaultWorkflowCodeExecutor() execute_workflow_code_template = MagicMock(return_value={"answer": "ok"}) monkeypatch.setattr( @@ -219,7 +255,7 @@ class TestDefaultWorkflowCodeExecutor: class TestCodeExecutorJinja2TemplateRenderer: - def test_render_template_delegates_to_code_executor(self, monkeypatch): + def test_render_template_delegates_to_code_executor(self, monkeypatch: pytest.MonkeyPatch): renderer = workflow_template_rendering.CodeExecutorJinja2TemplateRenderer() execute_workflow_code_template = MagicMock(return_value={"result": "Hello workflow"}) monkeypatch.setattr( @@ -237,7 +273,7 @@ class TestCodeExecutorJinja2TemplateRenderer: inputs={"name": "workflow"}, ) - def test_render_template_wraps_code_execution_errors(self, monkeypatch): + def test_render_template_wraps_code_execution_errors(self, monkeypatch: pytest.MonkeyPatch): renderer = workflow_template_rendering.CodeExecutorJinja2TemplateRenderer() monkeypatch.setattr( workflow_template_rendering.CodeExecutor, @@ -416,7 +452,7 @@ class TestDifyNodeFactoryCreateNode: factory._jinja2_template_renderer = sentinel.jinja2_template_renderer factory._template_transform_max_output_length = 2048 factory._http_request_http_client = sentinel.http_client - factory._bound_tool_file_manager_factory = sentinel.tool_file_manager_factory + factory._bound_tool_file_manager_factory = MagicMock(return_value=sentinel.tool_file_manager) factory._file_reference_factory = sentinel.file_reference_factory factory._prompt_message_serializer = sentinel.prompt_message_serializer factory._retriever_attachment_loader = sentinel.retriever_attachment_loader @@ -428,13 +464,14 @@ class TestDifyNodeFactoryCreateNode: factory._http_request_config = sentinel.http_request_config factory._llm_credentials_provider = sentinel.credentials_provider factory._llm_model_factory = sentinel.model_factory + factory._build_retriever_attachment_loader = MagicMock(return_value=sentinel.retriever_attachment_loader) return factory def test_rejects_unknown_node_type(self, factory): with pytest.raises(ValueError, match="No class mapping found for node type: missing"): factory.create_node({"id": "node-id", "data": {"type": "missing"}}) - def test_rejects_missing_class_mapping(self, monkeypatch, factory): + def test_rejects_missing_class_mapping(self, monkeypatch: pytest.MonkeyPatch, factory): monkeypatch.setattr( factory, "_resolve_node_class", @@ -444,7 +481,7 @@ class TestDifyNodeFactoryCreateNode: with pytest.raises(ValueError, match="No class mapping found for node type: start"): factory.create_node({"id": "node-id", "data": {"type": BuiltinNodeTypes.START}}) - def test_rejects_missing_latest_class(self, monkeypatch, factory): + def test_rejects_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch, factory): monkeypatch.setattr( factory, "_resolve_node_class", @@ -454,7 +491,7 @@ class TestDifyNodeFactoryCreateNode: with pytest.raises(ValueError, match="No latest version class found for node type: start"): factory.create_node({"id": "node-id", "data": {"type": BuiltinNodeTypes.START}}) - def test_uses_version_specific_class_when_available(self, monkeypatch, factory): + def test_uses_version_specific_class_when_available(self, monkeypatch: pytest.MonkeyPatch, factory): matched_node = sentinel.matched_node latest_node_class = _node_constructor(return_value=sentinel.latest_node) matched_node_class = _node_constructor(return_value=matched_node) @@ -470,12 +507,14 @@ class TestDifyNodeFactoryCreateNode: matched_node_class.assert_called_once() kwargs = matched_node_class.call_args.kwargs assert kwargs["node_id"] == "node-id" - _assert_typed_node_config(kwargs["config"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") + _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") assert kwargs["graph_init_params"] is sentinel.graph_init_params assert kwargs["graph_runtime_state"] is factory.graph_runtime_state latest_node_class.assert_not_called() - def test_falls_back_to_latest_class_when_version_specific_mapping_is_missing(self, monkeypatch, factory): + def test_falls_back_to_latest_class_when_version_specific_mapping_is_missing( + self, monkeypatch: pytest.MonkeyPatch, factory + ): latest_node = sentinel.latest_node latest_node_class = _node_constructor(return_value=latest_node) monkeypatch.setattr( @@ -490,7 +529,7 @@ class TestDifyNodeFactoryCreateNode: latest_node_class.assert_called_once() kwargs = latest_node_class.call_args.kwargs assert kwargs["node_id"] == "node-id" - _assert_typed_node_config(kwargs["config"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") + _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=BuiltinNodeTypes.START, version="9") assert kwargs["graph_init_params"] is sentinel.graph_init_params assert kwargs["graph_runtime_state"] is factory.graph_runtime_state @@ -501,13 +540,14 @@ class TestDifyNodeFactoryCreateNode: (BuiltinNodeTypes.TEMPLATE_TRANSFORM, "TemplateTransformNode"), (BuiltinNodeTypes.HTTP_REQUEST, "HttpRequestNode"), (BuiltinNodeTypes.HUMAN_INPUT, "HumanInputNode"), + (BuiltinNodeTypes.TOOL, "ToolNode"), (KNOWLEDGE_INDEX_NODE_TYPE, "KnowledgeIndexNode"), (BuiltinNodeTypes.DATASOURCE, "DatasourceNode"), (BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, "KnowledgeRetrievalNode"), (BuiltinNodeTypes.DOCUMENT_EXTRACTOR, "DocumentExtractorNode"), ], ) - def test_creates_specialized_nodes(self, monkeypatch, factory, node_type, constructor_name): + def test_creates_specialized_nodes(self, monkeypatch: pytest.MonkeyPatch, factory, node_type, constructor_name): created_node = object() constructor = _node_constructor(return_value=created_node) constructor._mock_name = constructor_name @@ -528,7 +568,7 @@ class TestDifyNodeFactoryCreateNode: assert result is created_node kwargs = constructor.call_args.kwargs assert kwargs["node_id"] == "node-id" - _assert_typed_node_config(kwargs["config"], node_id="node-id", node_type=node_type) + _assert_constructor_node_data(kwargs["data"], node_id="node-id", node_type=node_type) assert kwargs["graph_init_params"] is sentinel.graph_init_params assert kwargs["graph_runtime_state"] is factory.graph_runtime_state @@ -541,13 +581,19 @@ class TestDifyNodeFactoryCreateNode: elif constructor_name == "HttpRequestNode": assert kwargs["http_request_config"] is sentinel.http_request_config assert kwargs["http_client"] is sentinel.http_client - assert kwargs["tool_file_manager_factory"] is sentinel.tool_file_manager_factory + assert kwargs["tool_file_manager_factory"] is factory._bound_tool_file_manager_factory assert kwargs["file_manager"] is sentinel.file_manager assert kwargs["file_reference_factory"] is sentinel.file_reference_factory + factory._bound_tool_file_manager_factory.assert_not_called() elif constructor_name == "HumanInputNode": assert kwargs["form_repository"] is form_repository + assert kwargs["file_reference_factory"] is sentinel.file_reference_factory assert kwargs["runtime"] is factory._human_input_runtime factory._human_input_runtime.build_form_repository.assert_called_once_with() + elif constructor_name == "ToolNode": + assert kwargs["tool_file_manager"] is sentinel.tool_file_manager + assert kwargs["runtime"] is sentinel.tool_runtime + factory._bound_tool_file_manager_factory.assert_called_once_with() elif constructor_name == "DocumentExtractorNode": assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config assert kwargs["http_client"] is sentinel.http_client @@ -597,9 +643,12 @@ class TestDifyNodeFactoryCreateNode: prepared_llm.assert_called_once_with(sentinel.model_instance) assert kwargs["model_instance"] is wrapped_model_instance - def test_create_node_passes_alias_preserving_llm_config_to_constructor(self, monkeypatch, factory): + def test_create_node_passes_alias_preserving_llm_data_to_constructor(self, monkeypatch, factory): created_node = object() constructor = _node_constructor(return_value=created_node) + constructor.validate_node_data.side_effect = lambda node_data: LLMNodeData.model_validate( + node_data.model_dump(mode="python") if isinstance(node_data, BaseNodeData) else node_data + ) monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=constructor)) monkeypatch.setattr(factory, "_build_llm_compatible_node_init_kwargs", MagicMock(return_value={})) @@ -625,10 +674,56 @@ class TestDifyNodeFactoryCreateNode: factory.create_node(node_config) - config = constructor.call_args.kwargs["config"] - assert isinstance(config, dict) - assert config["structured_output_enabled"] is True - assert "structured_output_switch_on" not in config + data = constructor.call_args.kwargs["data"] + assert isinstance(data, Mapping) + assert data["structured_output_enabled"] is True + assert "structured_output_switch_on" not in data + assert LLMNodeData.model_validate(data).structured_output_enabled is True + + def test_create_node_preserves_structured_output_switch_after_graphon_constructor(self, monkeypatch, factory): + factory.graph_init_params = SimpleNamespace( + workflow_id="workflow-id", + graph_config={}, + run_context={}, + call_depth=0, + ) + monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=LLMNode)) + monkeypatch.setattr( + factory, + "_build_llm_compatible_node_init_kwargs", + MagicMock( + return_value={ + "model_instance": sentinel.model_instance, + "llm_file_saver": sentinel.llm_file_saver, + "prompt_message_serializer": sentinel.prompt_message_serializer, + } + ), + ) + + node_config = { + "id": "llm-node-id", + "data": { + "type": BuiltinNodeTypes.LLM, + "title": "LLM", + "model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}}, + "prompt_template": [{"role": "system", "text": "x"}], + "context": {"enabled": False, "variable_selector": []}, + "vision": {"enabled": False}, + "structured_output_enabled": True, + "structured_output": { + "schema": { + "type": "object", + "properties": {"type": {"type": "string"}}, + "required": ["type"], + } + }, + }, + } + + node = factory.create_node(node_config) + + assert node.node_data.structured_output_switch_on is True + assert node.node_data.structured_output_enabled is True @pytest.mark.parametrize( ("node_type", "constructor_name", "expected_extra_kwargs"), @@ -665,7 +760,7 @@ class TestDifyNodeFactoryCreateNode: ) def test_creates_model_backed_nodes( self, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, factory, node_type, constructor_name, @@ -707,7 +802,7 @@ class TestDifyNodeFactoryCreateNode: constructor_kwargs = constructor.call_args.kwargs assert constructor_kwargs["node_id"] == "node-id" - _assert_typed_node_config(constructor_kwargs["config"], node_id="node-id", node_type=node_type) + _assert_constructor_node_data(constructor_kwargs["data"], node_id="node-id", node_type=node_type) assert constructor_kwargs["graph_init_params"] is sentinel.graph_init_params assert constructor_kwargs["graph_runtime_state"] is factory.graph_runtime_state assert constructor_kwargs["credentials_provider"] is sentinel.credentials_provider @@ -717,6 +812,128 @@ class TestDifyNodeFactoryCreateNode: for key, value in expected_extra_kwargs.items(): assert constructor_kwargs[key] is value + def test_parameter_extractor_init_does_not_require_retriever_context(self, factory): + node_data = ParameterExtractorNodeData.model_validate( + { + "type": BuiltinNodeTypes.PARAMETER_EXTRACTOR, + "title": "Parameter Extractor", + "model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}}, + "query": ["sys", "query"], + "parameters": [ + { + "name": "topic", + "type": "string", + "description": "Topic", + "required": True, + } + ], + "reasoning_mode": "prompt", + } + ) + factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance) + factory._build_memory_for_llm_node = MagicMock(return_value=sentinel.memory) + factory._build_retriever_attachment_loader = MagicMock(side_effect=AssertionError("unexpected loader build")) + + kwargs = factory._build_llm_compatible_node_init_kwargs( + node_class=sentinel.node_class, + node_data=node_data, + wrap_model_instance=True, + include_http_client=False, + include_llm_file_saver=False, + include_prompt_message_serializer=True, + include_retriever_attachment_loader=False, + include_jinja2_template_renderer=False, + ) + + assert "retriever_attachment_loader" not in kwargs + assert kwargs["prompt_message_serializer"] is sentinel.prompt_message_serializer + factory._build_retriever_attachment_loader.assert_not_called() + + +class TestDifyNodeFactoryRetrieverAttachmentAccess: + @pytest.fixture + def factory(self): + factory = object.__new__(node_factory.DifyNodeFactory) + factory.graph_runtime_state = SimpleNamespace(variable_pool=MagicMock()) + return factory + + def test_retriever_attachment_loader_is_typed_for_llm_node_data_only(self): + annotations = node_factory.DifyNodeFactory._build_retriever_attachment_loader.__annotations__ + + assert annotations["node_data"] is LLMNodeData + + def test_build_retriever_attachment_loader_uses_llm_context_selector(self, factory): + factory._file_reference_factory = sentinel.file_reference_factory + factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment( + value=[ + { + "metadata": { + "_source": "knowledge", + "segment_id": "allowed-segment", + } + } + ] + ) + node_data = LLMNodeData.model_validate( + { + "type": BuiltinNodeTypes.LLM, + "title": "LLM", + "model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}}, + "prompt_template": [{"role": "system", "text": "x"}], + "context": {"enabled": True, "variable_selector": ["knowledge-node", "result"]}, + "vision": {"enabled": False}, + } + ) + + loader = factory._build_retriever_attachment_loader(node_data) + + assert loader._segment_access_checker is not None + assert loader._segment_access_checker("allowed-segment") is True + factory.graph_runtime_state.variable_pool.get.assert_called_once_with(["knowledge-node", "result"]) + + def test_checker_rejects_missing_context_selector_without_reading_variable_pool(self, factory): + checker = factory._build_retriever_segment_access_checker(None) + + assert checker("segment-id") is False + factory.graph_runtime_state.variable_pool.get.assert_not_called() + + def test_checker_rejects_non_knowledge_context_items(self, factory): + factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment.model_construct( + value=[ + "plain-text", + {"metadata": "not-a-mapping"}, + ] + ) + + checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"]) + + assert checker("segment-id") is False + + def test_checker_rejects_non_array_context_value(self, factory): + factory.graph_runtime_state.variable_pool.get.return_value = StringSegment(value="not knowledge context") + + checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"]) + + assert checker("segment-id") is False + + def test_checker_allows_only_segments_from_selected_knowledge_context(self, factory): + factory.graph_runtime_state.variable_pool.get.return_value = ArrayObjectSegment( + value=[ + { + "metadata": { + "_source": "knowledge", + "segment_id": "allowed-segment", + } + } + ] + ) + + checker = factory._build_retriever_segment_access_checker(["knowledge-node", "result"]) + + assert checker("allowed-segment") is True + assert checker("other-segment") is False + factory.graph_runtime_state.variable_pool.get.assert_any_call(["knowledge-node", "result"]) + class TestDifyNodeFactoryModelInstance: @pytest.fixture @@ -726,7 +943,7 @@ class TestDifyNodeFactoryModelInstance: factory._llm_model_factory = sentinel.model_factory return factory - def test_delegates_to_fetch_model_config(self, monkeypatch, factory): + def test_delegates_to_fetch_model_config(self, monkeypatch: pytest.MonkeyPatch, factory): node_data_model = SimpleNamespace( provider="provider", name="model", @@ -755,7 +972,7 @@ class TestDifyNodeFactoryModelInstance: model_factory=sentinel.model_factory, ) - def test_propagates_fetch_model_config_errors(self, monkeypatch, factory): + def test_propagates_fetch_model_config_errors(self, monkeypatch: pytest.MonkeyPatch, factory): fetch_model_config = MagicMock(side_effect=ValueError("broken model config")) monkeypatch.setattr(node_factory, "fetch_model_config", fetch_model_config) @@ -780,7 +997,7 @@ class TestDifyNodeFactoryMemory: assert result is None factory.graph_runtime_state.variable_pool.get.assert_not_called() - def test_uses_string_segment_conversation_id(self, monkeypatch, factory): + def test_uses_string_segment_conversation_id(self, monkeypatch: pytest.MonkeyPatch, factory): memory_config = sentinel.memory_config factory.graph_runtime_state.variable_pool.get.return_value = StringSegment(value="conversation-id") fetch_memory = MagicMock(return_value=sentinel.memory) @@ -800,7 +1017,7 @@ class TestDifyNodeFactoryMemory: model_instance=sentinel.model_instance, ) - def test_ignores_non_string_segment_conversation_ids(self, monkeypatch, factory): + def test_ignores_non_string_segment_conversation_ids(self, monkeypatch: pytest.MonkeyPatch, factory): memory_config = sentinel.memory_config factory.graph_runtime_state.variable_pool.get.return_value = sentinel.segment fetch_memory = MagicMock(return_value=sentinel.memory) diff --git a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py index d18fc262ef..2dd3953d9a 100644 --- a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py +++ b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py @@ -7,6 +7,17 @@ from pathlib import Path def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): api_root = Path(__file__).resolve().parents[4] + + # `PYTHONSAFEPATH=1` enables Python's safe-path mode, which suppresses the + # usual implicit insertion of the working directory into `sys.path`. + # Set `PYTHONPATH` explicitly so this subprocess test stays deterministic in + # both CI and local shells that may export `PYTHONSAFEPATH`. + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(api_root) if not existing_pythonpath else os.pathsep.join([str(api_root), existing_pythonpath]) + ) + env["PYTHONSAFEPATH"] = "1" script = textwrap.dedent( """ from core.app.apps import workflow_app_runner @@ -34,7 +45,7 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): completed = subprocess.run( [sys.executable, "-c", script], cwd=api_root, - env=os.environ.copy(), + env=env, capture_output=True, text=True, check=False, diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 5a43369a1a..5e83863dc2 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -1,9 +1,11 @@ from types import SimpleNamespace from unittest.mock import MagicMock, Mock, sentinel +from uuid import uuid4 import pytest from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom +from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access from core.llm_generator.output_parser.errors import OutputParserError from core.workflow import node_runtime from core.workflow.file_reference import parse_file_reference @@ -22,6 +24,7 @@ from core.workflow.node_runtime import ( DifyPromptMessageSerializer, DifyRetrieverAttachmentLoader, DifyToolFileManager, + DifyToolNodeRuntime, apply_dify_debug_email_recipient, build_dify_llm_file_saver, resolve_dify_run_context, @@ -30,6 +33,7 @@ from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType from graphon.nodes.human_input.entities import HumanInputNodeData +from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from tests.workflow_test_utils import build_test_run_context @@ -266,6 +270,114 @@ def test_dify_retriever_attachment_loader_builds_graph_files(monkeypatch: pytest assert parse_file_reference(mapping["reference"]).storage_key is None +def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from factories.file_factory import builders as file_builders + + upload_file_id = str(uuid4()) + segment_id = str(uuid4()) + upload_file = SimpleNamespace( + id=upload_file_id, + tenant_id="tenant-id", + name="diagram.png", + extension="png", + mime_type="image/png", + source_url="https://example.com/diagram.png", + key="storage-key", + size=128, + ) + attachment_session = MagicMock() + attachment_session.execute.return_value.all.return_value = [(None, upload_file)] + + class _AttachmentSessionContext: + def __enter__(self): + return attachment_session + + def __exit__(self, exc_type, exc, tb): + return False + + upload_session = MagicMock() + upload_session.__enter__.return_value = upload_session + upload_session.__exit__.return_value = False + upload_session.scalar.return_value = upload_file + + monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(node_runtime, "Session", MagicMock(return_value=_AttachmentSessionContext())) + monkeypatch.setattr(file_builders, "session_factory", SimpleNamespace(create_session=lambda: upload_session)) + + loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) + scope = FileAccessScope( + tenant_id="tenant-id", + user_id="end-user-id", + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + grant_retriever_segment_access([segment_id]) + files = loader.load(segment_id=segment_id) + + assert files[0].related_id == upload_file_id + stmt = upload_session.scalar.call_args.args[0] + whereclause = str(stmt.whereclause) + assert "upload_files.tenant_id" in whereclause + assert "upload_files.id IN" in whereclause + + +def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( + monkeypatch: pytest.MonkeyPatch, +) -> None: + build_from_mapping = MagicMock() + session_factory = MagicMock() + monkeypatch.setattr(node_runtime, "Session", session_factory) + loader = DifyRetrieverAttachmentLoader( + file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) + ) + scope = FileAccessScope( + tenant_id="tenant-id", + user_id="end-user-id", + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + files = loader.load(segment_id=str(uuid4())) + + assert files == [] + session_factory.assert_not_called() + build_from_mapping.assert_not_called() + + +def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + segment_id = str(uuid4()) + build_from_mapping = MagicMock() + session_factory = MagicMock() + segment_access_checker = MagicMock(return_value=False) + monkeypatch.setattr(node_runtime, "Session", session_factory) + loader = DifyRetrieverAttachmentLoader( + file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping), + segment_access_checker=segment_access_checker, + ) + scope = FileAccessScope( + tenant_id="tenant-id", + user_id="end-user-id", + user_from=UserFrom.END_USER, + invoke_from=InvokeFrom.WEB_APP, + ) + + with bind_file_access_scope(scope): + grant_retriever_segment_access([segment_id]) + files = loader.load(segment_id=segment_id) + + assert files == [] + segment_access_checker.assert_called_once_with(segment_id) + session_factory.assert_not_called() + build_from_mapping.assert_not_called() + + def test_dify_tool_file_manager_resolves_conversation_id_for_tool_files(monkeypatch: pytest.MonkeyPatch) -> None: create_file_by_raw = MagicMock(return_value=SimpleNamespace(id="tool-file-id")) manager_instance = SimpleNamespace(create_file_by_raw=create_file_by_raw) @@ -314,6 +426,81 @@ def test_dify_tool_file_manager_delegates_file_generator_lookup(monkeypatch: pyt get_file_generator.assert_called_once_with("tool-file-id") +def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + get_runtime = MagicMock(return_value=runtime_tool) + monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + monkeypatch.setattr( + node_runtime, + "get_system_text", + lambda _pool, key: ( + "outer-workflow-run-id" if key == node_runtime.SystemVariableKey.WORKFLOW_EXECUTION_ID else None + ), + ) + + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + node_data = ToolNodeData( + title="Workflow Tool Node", + desc=None, + provider_id="workflow-provider-id", + provider_type=ToolProviderType.WORKFLOW, + provider_name="workflow-provider", + tool_name="workflow-tool", + tool_label="Workflow Tool", + tool_configurations={}, + tool_parameters={}, + ) + + handle = runtime.get_runtime( + node_id="tool-node", + node_data=node_data, + variable_pool=object(), + node_execution_id="node-execution-id", + ) + + assert handle.raw.tool is runtime_tool + assert handle.raw.parent_trace_context.model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-id", + "parent_node_execution_id": "node-execution-id", + } + assert runtime_tool.runtime.runtime_parameters == {} + get_runtime.assert_called_once() + + +def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + get_runtime = MagicMock(return_value=runtime_tool) + monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + monkeypatch.setattr(node_runtime, "get_system_text", lambda _pool, _key: None) + + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + node_data = ToolNodeData( + title="Builtin Tool Node", + desc=None, + provider_id="builtin-provider-id", + provider_type=ToolProviderType.BUILT_IN, + provider_name="builtin-provider", + tool_name="builtin-tool", + tool_label="Builtin Tool", + tool_configurations={}, + tool_parameters={}, + ) + + handle = runtime.get_runtime( + node_id="tool-node", + node_data=node_data, + variable_pool=object(), + ) + + assert handle.raw.tool is runtime_tool + assert "outer_workflow_run_id" not in runtime_tool.runtime.runtime_parameters + get_runtime.assert_called_once() + + def test_dify_human_input_runtime_builds_debug_repository(monkeypatch: pytest.MonkeyPatch) -> None: repository = MagicMock() repository_cls = MagicMock(return_value=repository) @@ -334,6 +521,41 @@ def test_dify_human_input_runtime_builds_debug_repository(monkeypatch: pytest.Mo ) +def test_dify_tool_runtime_spec_prefers_tool_parameters_for_runtime_form_values() -> None: + node_data = ToolNodeData( + provider_id="video-mixcut-agent", + provider_type=ToolProviderType.PLUGIN, + provider_name="sawyer-shi/video-mixcut-agent", + tool_name="mixcut", + tool_label="MixCut", + tool_configurations={"count": 2}, + tool_parameters={ + "vision_llm_model": { + "type": "constant", + "value": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-vl-plus", + "model_type": "llm", + }, + } + }, + ) + + spec = DifyToolNodeRuntime._build_tool_runtime_spec(node_data) + + assert spec.tool_configurations == { + "count": 2, + "vision_llm_model": { + "type": "constant", + "value": { + "provider": "langgenius/tongyi/tongyi", + "model": "qwen3-vl-plus", + "model_type": "llm", + }, + }, + } + + def test_dify_human_input_runtime_create_form_filters_debugger_delivery_methods() -> None: repository = MagicMock() repository.create_form.return_value = sentinel.form diff --git a/api/tests/unit_tests/core/workflow/test_variable_pool.py b/api/tests/unit_tests/core/workflow/test_variable_pool.py index 9dab38ed8e..0017cd8d3f 100644 --- a/api/tests/unit_tests/core/workflow/test_variable_pool.py +++ b/api/tests/unit_tests/core/workflow/test_variable_pool.py @@ -109,8 +109,8 @@ class TestVariablePool: assert pool.get([ENVIRONMENT_VARIABLE_NODE_ID, "env_var_1"]) is not None assert pool.get([CONVERSATION_VARIABLE_NODE_ID, "conv_var_1"]) is not None - def test_constructor_loads_legacy_bootstrap_kwargs(self): - pool = VariablePool( + def test_from_bootstrap_loads_legacy_bootstrap_kwargs(self): + pool = VariablePool.from_bootstrap( system_variables=build_system_variables(user_id="test_user_id"), environment_variables=[StringVariable(name="env_var", value="env-value")], conversation_variables=[StringVariable(name="conv_var", value="conv-value")], diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry.py b/api/tests/unit_tests/core/workflow/test_workflow_entry.py index 041c5cc612..661882f013 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry.py @@ -19,7 +19,7 @@ from graphon.variables.variables import StringVariable @pytest.fixture(autouse=True) -def _mock_ssrf_head(monkeypatch): +def _mock_ssrf_head(monkeypatch: pytest.MonkeyPatch): """Avoid any real network requests during tests. factories.file_factory.remote.get_remote_file_info() uses ssrf_proxy.head @@ -55,7 +55,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_to_variable_pool_with_system_variables(self): """Test mapping system variables from user inputs to variable pool.""" # Initialize variable pool with system variables - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="test_user_id", app_id="test_app_id", @@ -128,7 +128,7 @@ class TestWorkflowEntry: return NodeConfigDictAdapter.validate_python(node_config) workflow = StubWorkflow() - variable_pool = VariablePool(system_variables=default_system_variables(), user_inputs={}) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}) expected_limits = CodeNodeLimits( max_string_length=dify_config.CODE_MAX_STRING_LENGTH, max_number=dify_config.CODE_MAX_NUMBER, @@ -157,7 +157,7 @@ class TestWorkflowEntry: """Test mapping environment variables from user inputs to variable pool.""" # Initialize variable pool with environment variables env_var = StringVariable(name="API_KEY", value="existing_key") - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), environment_variables=[env_var], user_inputs={}, @@ -198,7 +198,7 @@ class TestWorkflowEntry: """Test mapping conversation variables from user inputs to variable pool.""" # Initialize variable pool with conversation variables conv_var = StringVariable(name="last_message", value="Hello") - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), conversation_variables=[conv_var], user_inputs={}, @@ -239,7 +239,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_to_variable_pool_with_regular_variables(self): """Test mapping regular node variables from user inputs to variable pool.""" # Initialize empty variable pool - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -281,7 +281,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_with_file_handling(self): """Test mapping file inputs from user inputs to variable pool.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -340,7 +340,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_missing_variable_error(self): """Test that mapping raises error when required variable is missing.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -366,7 +366,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_with_alternative_key_format(self): """Test mapping with alternative key format (without node prefix).""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -396,7 +396,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_with_complex_selectors(self): """Test mapping with complex node variable keys.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -432,7 +432,7 @@ class TestWorkflowEntry: def test_mapping_user_inputs_invalid_node_variable(self): """Test that mapping handles invalid node variable format.""" - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=default_system_variables(), user_inputs={}, ) @@ -463,7 +463,7 @@ class TestWorkflowEntry: env_var = StringVariable(name="API_KEY", value="existing_key") conv_var = StringVariable(name="session_id", value="session123") - variable_pool = VariablePool( + variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="test_user", app_id="test_app", diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index 270d0bf90d..a57cdd1337 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -7,7 +7,6 @@ import pytest from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.model_manager import ModelInstance from core.workflow import workflow_entry from core.workflow.system_variables import default_system_variables from graphon.entities.base_node_data import BaseNodeData @@ -16,10 +15,12 @@ from graphon.errors import WorkflowNodeRunFailedError from graphon.file import File, FileTransferMethod, FileType from graphon.graph import Graph from graphon.graph_events import GraphRunFailedEvent -from graphon.model_runtime.entities.llm_entities import LLMUsage +from graphon.model_runtime.entities.llm_entities import LLMMode, LLMUsage from graphon.node_events import NodeRunResult from graphon.nodes import BuiltinNodeTypes from graphon.nodes.base.node import Node +from graphon.nodes.llm.entities import ContextConfig, LLMNodeData, ModelConfig +from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData from graphon.runtime import ChildGraphNotFoundError, VariablePool from graphon.variables.variables import StringVariable from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @@ -29,9 +30,30 @@ def _build_typed_node_config(node_type: NodeType): return {"id": "node-id", "data": BaseNodeData(type=node_type)} -def _build_wrapped_model_instance() -> tuple[SimpleNamespace, ModelInstance]: - raw_model_instance = ModelInstance.__new__(ModelInstance) - return SimpleNamespace(_model_instance=raw_model_instance), raw_model_instance +def _build_model_config(*, provider: str = "openai", model_name: str = "gpt-4o") -> ModelConfig: + return ModelConfig(provider=provider, name=model_name, mode=LLMMode.CHAT) + + +def _build_llm_node_data(*, provider: str = "openai", model_name: str = "gpt-4o") -> LLMNodeData: + return LLMNodeData( + type=BuiltinNodeTypes.LLM, + title="Child Model", + model=_build_model_config(provider=provider, model_name=model_name), + prompt_template=[], + context=ContextConfig(enabled=False), + ) + + +def _build_question_classifier_node_data( + *, provider: str = "openai", model_name: str = "gpt-4o" +) -> QuestionClassifierNodeData: + return QuestionClassifierNodeData( + type=BuiltinNodeTypes.QUESTION_CLASSIFIER, + title="Child Model", + query_variable_selector=["sys", "query"], + model=_build_model_config(provider=provider, model_name=model_name), + classes=[], + ) class _FakeModelNodeMixin: @@ -40,22 +62,26 @@ class _FakeModelNodeMixin: return "1" def post_init(self) -> None: - self.model_instance, self.raw_model_instance = _build_wrapped_model_instance() + self.model_instance = SimpleNamespace(provider="stale-provider", model_name="stale-model") self.usage_snapshot = LLMUsage.empty_usage() self.usage_snapshot.total_tokens = 1 def _run(self) -> NodeRunResult: return NodeRunResult( status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs={ + "model_provider": self.node_data.model.provider, + "model_name": self.node_data.model.name, + }, llm_usage=self.usage_snapshot, ) -class _FakeLLMNode(_FakeModelNodeMixin, Node[BaseNodeData]): +class _FakeLLMNode(_FakeModelNodeMixin, Node[LLMNodeData]): node_type = BuiltinNodeTypes.LLM -class _FakeQuestionClassifierNode(_FakeModelNodeMixin, Node[BaseNodeData]): +class _FakeQuestionClassifierNode(_FakeModelNodeMixin, Node[QuestionClassifierNodeData]): node_type = BuiltinNodeTypes.QUESTION_CLASSIFIER @@ -75,7 +101,7 @@ class TestWorkflowChildEngineBuilder: assert result is expected def test_build_child_engine_raises_when_root_node_is_missing(self): - builder = workflow_entry._WorkflowChildEngineBuilder() + builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id") graph_init_params = SimpleNamespace(graph_config={"nodes": []}) parent_graph_runtime_state = SimpleNamespace( execution_context=sentinel.execution_context, @@ -92,7 +118,7 @@ class TestWorkflowChildEngineBuilder: ) def test_build_child_engine_constructs_graph_engine_with_quota_layer_only(self): - builder = workflow_entry._WorkflowChildEngineBuilder() + builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id") graph_init_params = SimpleNamespace(graph_config={"nodes": [{"id": "root"}]}) parent_graph_runtime_state = SimpleNamespace( execution_context=sentinel.execution_context, @@ -114,7 +140,7 @@ class TestWorkflowChildEngineBuilder: patch.object(workflow_entry, "GraphEngine", return_value=child_engine) as graph_engine_cls, patch.object(workflow_entry, "GraphEngineConfig", return_value=sentinel.graph_engine_config), patch.object(workflow_entry, "InMemoryChannel", return_value=sentinel.command_channel), - patch.object(workflow_entry, "LLMQuotaLayer", return_value=sentinel.llm_quota_layer), + patch.object(workflow_entry, "LLMQuotaLayer", return_value=sentinel.llm_quota_layer) as llm_quota_layer_cls, ): result = builder.build_child_engine( workflow_id="workflow-id", @@ -147,11 +173,12 @@ class TestWorkflowChildEngineBuilder: config=sentinel.graph_engine_config, child_engine_builder=builder, ) + llm_quota_layer_cls.assert_called_once_with(tenant_id="tenant-id") assert child_engine.layer.call_args_list == [((sentinel.llm_quota_layer,), {})] @pytest.mark.parametrize("node_cls", [_FakeLLMNode, _FakeQuestionClassifierNode]) def test_build_child_engine_runs_llm_quota_layer_for_child_model_nodes(self, node_cls): - builder = workflow_entry._WorkflowChildEngineBuilder() + builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id") graph_init_params = build_test_graph_init_params( graph_config={"nodes": [{"id": "root"}], "edges": []}, ) @@ -163,12 +190,10 @@ class TestWorkflowChildEngineBuilder: def build_graph(*, graph_config, node_factory, root_node_id): _ = graph_config + node_data = _build_llm_node_data() if node_cls is _FakeLLMNode else _build_question_classifier_node_data() node = node_cls( node_id=root_node_id, - config=BaseNodeData( - type=node_cls.node_type, - title="Child Model", - ), + data=node_data, graph_init_params=node_factory.graph_init_params, graph_runtime_state=node_factory.graph_runtime_state, ) @@ -191,8 +216,8 @@ class TestWorkflowChildEngineBuilder: ), ), patch.object(workflow_entry.Graph, "init", side_effect=build_graph), - patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available") as ensure_quota, - patch("core.app.workflow.layers.llm_quota.deduct_llm_quota") as deduct_quota, + patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model") as ensure_quota, + patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model") as deduct_quota, ): child_engine = builder.build_child_engine( workflow_id="workflow-id", @@ -203,10 +228,15 @@ class TestWorkflowChildEngineBuilder: list(child_engine.run()) node = created_node["node"] - ensure_quota.assert_called_once_with(model_instance=node.raw_model_instance) + ensure_quota.assert_called_once_with( + tenant_id="tenant-id", + provider=node.node_data.model.provider, + model=node.node_data.model.name, + ) deduct_quota.assert_called_once_with( - tenant_id="tenant", - model_instance=node.raw_model_instance, + tenant_id="tenant-id", + provider=node.node_data.model.provider, + model=node.node_data.model.name, usage=node.usage_snapshot, ) @@ -252,7 +282,7 @@ class TestWorkflowEntryInit: "ExecutionLimitsLayer", return_value=execution_limits_layer, ) as execution_limits_layer_cls, - patch.object(workflow_entry, "LLMQuotaLayer", return_value=llm_quota_layer), + patch.object(workflow_entry, "LLMQuotaLayer", return_value=llm_quota_layer) as llm_quota_layer_cls, patch.object(workflow_entry, "ObservabilityLayer", return_value=observability_layer), ): entry = workflow_entry.WorkflowEntry( @@ -291,6 +321,7 @@ class TestWorkflowEntryInit: max_steps=workflow_entry.dify_config.WORKFLOW_MAX_EXECUTION_STEPS, max_time=workflow_entry.dify_config.WORKFLOW_MAX_EXECUTION_TIME, ) + llm_quota_layer_cls.assert_called_once_with(tenant_id="tenant-id") assert graph_engine.layer.call_args_list == [ ((debug_layer,), {}), ((execution_limits_layer,), {}), @@ -334,7 +365,7 @@ class TestWorkflowEntrySingleStepRun: def extract_variable_selector_to_variable_mapping(**_kwargs): return {} - variable_pool = VariablePool(system_variables=default_system_variables(), user_inputs={}) + variable_pool = VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}) variable_loader = MagicMock() variable_loader.load_variables.return_value = [ StringVariable( @@ -603,7 +634,7 @@ class TestWorkflowEntryHelpers: user_inputs={}, ) - def test_run_free_node_rejects_missing_node_class(self, monkeypatch): + def test_run_free_node_rejects_missing_node_class(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( workflow_entry, "resolve_workflow_node_class", @@ -619,7 +650,9 @@ class TestWorkflowEntryHelpers: user_inputs={}, ) - def test_run_free_node_uses_empty_mapping_when_selector_extraction_is_not_implemented(self, monkeypatch): + def test_run_free_node_uses_empty_mapping_when_selector_extraction_is_not_implemented( + self, monkeypatch: pytest.MonkeyPatch + ): class FakeNodeClass: @staticmethod def extract_variable_selector_to_variable_mapping(**_kwargs): @@ -707,7 +740,7 @@ class TestWorkflowEntryHelpers: tenant_id="tenant-id", ) - def test_run_free_node_wraps_execution_failures(self, monkeypatch): + def test_run_free_node_wraps_execution_failures(self, monkeypatch: pytest.MonkeyPatch): class FakeNodeClass: @staticmethod def extract_variable_selector_to_variable_mapping(**_kwargs): diff --git a/api/tests/unit_tests/events/test_update_provider_when_message_created.py b/api/tests/unit_tests/events/test_update_provider_when_message_created.py new file mode 100644 index 0000000000..4b2a6438f4 --- /dev/null +++ b/api/tests/unit_tests/events/test_update_provider_when_message_created.py @@ -0,0 +1,141 @@ +from collections.abc import Generator +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import patch +from uuid import uuid4 + +from sqlalchemy import create_engine, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +from core.app.entities.app_invoke_entities import ChatAppGenerateEntity +from core.entities.provider_entities import ProviderQuotaType, QuotaUnit +from events.event_handlers import update_provider_when_message_created +from models import TenantCreditPool +from models.provider import ProviderType + + +@contextmanager +def _patched_credit_pool_session_factory(engine: Engine) -> Generator[None, None, None]: + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker): + yield + + +def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + tenant_id = str(uuid4()) + pool_id = str(uuid4()) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": pool_id, + "tenant_id": tenant_id, + "pool_type": ProviderQuotaType.TRIAL, + "quota_limit": 10, + "quota_used": 9, + }, + ) + + system_configuration = SimpleNamespace( + current_quota_type=ProviderQuotaType.TRIAL, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.TRIAL, + quota_unit=QuotaUnit.TOKENS, + quota_limit=10, + ) + ], + ) + application_generate_entity = ChatAppGenerateEntity.model_construct( + app_config=SimpleNamespace(tenant_id=tenant_id), + model_conf=SimpleNamespace( + provider="openai", + model="gpt-4o", + provider_model_bundle=SimpleNamespace( + configuration=SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=system_configuration, + ) + ), + ), + ) + message = SimpleNamespace(message_tokens=2, answer_tokens=1) + + with ( + _patched_credit_pool_session_factory(engine), + patch.object(update_provider_when_message_created, "_execute_provider_updates"), + ): + update_provider_when_message_created.handle( + sender=message, + application_generate_entity=application_generate_entity, + ) + + with engine.connect() as connection: + quota_used = connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id)) + + assert quota_used == 10 + + +def test_message_created_paid_credit_accounting_uses_paid_pool() -> None: + tenant_id = str(uuid4()) + system_configuration = SimpleNamespace( + current_quota_type=ProviderQuotaType.PAID, + quota_configurations=[ + SimpleNamespace( + quota_type=ProviderQuotaType.PAID, + quota_unit=QuotaUnit.TOKENS, + quota_limit=10, + ) + ], + ) + application_generate_entity = ChatAppGenerateEntity.model_construct( + app_config=SimpleNamespace(tenant_id=tenant_id), + model_conf=SimpleNamespace( + provider="openai", + model="gpt-4o", + provider_model_bundle=SimpleNamespace( + configuration=SimpleNamespace( + using_provider_type=ProviderType.SYSTEM, + system_configuration=system_configuration, + ) + ), + ), + ) + message = SimpleNamespace(message_tokens=2, answer_tokens=1) + + with ( + patch.object(update_provider_when_message_created, "_deduct_credit_pool_quota_capped") as mock_deduct, + patch.object(update_provider_when_message_created, "_execute_provider_updates"), + ): + update_provider_when_message_created.handle( + sender=message, + application_generate_entity=application_generate_entity, + ) + + mock_deduct.assert_called_once_with( + tenant_id=tenant_id, + credits_required=3, + pool_type="paid", + ) + + +def test_capped_credit_pool_accounting_skips_exhaustion_warning_when_full_amount_is_deducted(caplog) -> None: + with patch( + "services.credit_pool_service.CreditPoolService.deduct_credits_capped", + return_value=3, + ) as mock_deduct: + update_provider_when_message_created._deduct_credit_pool_quota_capped( + tenant_id="tenant-id", + credits_required=3, + pool_type="trial", + ) + + mock_deduct.assert_called_once_with( + tenant_id="tenant-id", + credits_required=3, + pool_type="trial", + ) + assert "Credit pool exhausted during message-created accounting" not in caplog.text diff --git a/api/tests/unit_tests/extensions/test_ext_request_logging.py b/api/tests/unit_tests/extensions/test_ext_request_logging.py index dcb457c806..03479686bb 100644 --- a/api/tests/unit_tests/extensions/test_ext_request_logging.py +++ b/api/tests/unit_tests/extensions/test_ext_request_logging.py @@ -71,7 +71,7 @@ def enable_request_logging(monkeypatch: pytest.MonkeyPatch): class TestRequestLoggingExtension: def test_receiver_should_not_be_invoked_if_configuration_is_disabled( self, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, mock_request_receiver, mock_response_receiver, ): @@ -266,7 +266,9 @@ class TestResponseUnmodified: class TestRequestFinishedInfoAccessLine: - def test_info_access_log_includes_method_path_status_duration_trace_id(self, monkeypatch, caplog): + def test_info_access_log_includes_method_path_status_duration_trace_id( + self, monkeypatch: pytest.MonkeyPatch, caplog + ): """Ensure INFO access line contains expected fields with computed duration and trace id.""" app = _get_test_app() # Push a real request context so flask.request and g are available @@ -299,7 +301,7 @@ class TestRequestFinishedInfoAccessLine: assert "123.456" in msg # rounded to 3 decimals assert "trace-xyz" in msg - def test_info_access_log_uses_dash_without_start_timestamp(self, monkeypatch, caplog): + def test_info_access_log_uses_dash_without_start_timestamp(self, monkeypatch: pytest.MonkeyPatch, caplog): app = _get_test_app() with app.test_request_context("/bar", method="POST"): # No g.__request_started_ts set -> duration should be '-' diff --git a/api/tests/unit_tests/extensions/test_pubsub_channel.py b/api/tests/unit_tests/extensions/test_pubsub_channel.py index 926c406ad4..24bbf55cb3 100644 --- a/api/tests/unit_tests/extensions/test_pubsub_channel.py +++ b/api/tests/unit_tests/extensions/test_pubsub_channel.py @@ -1,10 +1,12 @@ +import pytest + from configs import dify_config from extensions import ext_redis from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel -def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch): +def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) @@ -13,7 +15,7 @@ def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch): assert isinstance(channel, RedisBroadcastChannel) -def test_get_pubsub_broadcast_channel_sharded(monkeypatch): +def test_get_pubsub_broadcast_channel_sharded(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "sharded") monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) diff --git a/api/tests/unit_tests/extensions/test_set_secretkey.py b/api/tests/unit_tests/extensions/test_set_secretkey.py new file mode 100644 index 0000000000..8a8e4e2b19 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_set_secretkey.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest +from flask import Flask + +from extensions import ext_set_secretkey + + +class InMemoryStorage: + def __init__(self, files: dict[str, bytes] | None = None) -> None: + self.files = files or {} + self.saved_files: list[tuple[str, bytes]] = [] + + def load_once(self, filename: str) -> bytes: + try: + return self.files[filename] + except KeyError: + raise FileNotFoundError(filename) + + def save(self, filename: str, data: bytes) -> None: + self.files[filename] = data + self.saved_files.append((filename, data)) + + +def test_init_app_uses_configured_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: + secret_key = "configured-secret-key" + storage = InMemoryStorage() + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", secret_key) + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = secret_key + + ext_set_secretkey.init_app(app) + + assert app.secret_key == secret_key + assert app.config["SECRET_KEY"] == secret_key + assert storage.saved_files == [] + + +def test_init_app_generates_and_persists_secret_key_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + storage = InMemoryStorage() + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = "" + + ext_set_secretkey.init_app(app) + + persisted_key = storage.files[".dify_secret_key"].decode("utf-8").strip() + assert persisted_key + assert storage.saved_files == [(".dify_secret_key", f"{persisted_key}\n".encode())] + assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY + assert persisted_key == app.config["SECRET_KEY"] + assert persisted_key == app.secret_key + + +def test_init_app_reuses_persisted_secret_key_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + persisted_key = "persisted-secret-key" + storage = InMemoryStorage({".dify_secret_key": f"{persisted_key}\n".encode()}) + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = "" + + ext_set_secretkey.init_app(app) + + assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY + assert persisted_key == app.config["SECRET_KEY"] + assert persisted_key == app.secret_key + assert storage.saved_files == [] diff --git a/api/tests/unit_tests/factories/test_file_factory.py b/api/tests/unit_tests/factories/test_file_factory.py index c2835c4124..293be925ae 100644 --- a/api/tests/unit_tests/factories/test_file_factory.py +++ b/api/tests/unit_tests/factories/test_file_factory.py @@ -1,8 +1,11 @@ import re +from unittest.mock import MagicMock import pytest +from factories.file_factory import builders from factories.file_factory.remote import extract_filename, get_remote_file_info +from graphon.file import FileTransferMethod class _FakeResponse: @@ -291,3 +294,92 @@ class TestExtractFilename: """Test bare path (not full URL) with query string.""" result = extract_filename("/path/to/file.txt?extra=params", None) assert result == "file.txt" + + +class TestBuildFromDatasourceFile: + """Tests for _build_from_datasource_file extension handling.""" + + @staticmethod + def _patch_session(monkeypatch: pytest.MonkeyPatch, datasource_file): + """Stub session_factory.create_session() so it returns the given UploadFile-shaped record.""" + session = MagicMock() + session.scalar.return_value = datasource_file + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=session) + ctx.__exit__ = MagicMock(return_value=False) + monkeypatch.setattr(builders.session_factory, "create_session", lambda: ctx) + + def _make_datasource_file(self, *, key: str, mime_type: str = "text/csv"): + f = MagicMock() + f.id = "file-id" + f.key = key + f.name = key.split("/")[-1] + f.mime_type = mime_type + f.size = 123 + f.source_url = f"https://example.com/{key}" + return f + + def test_extension_passed_without_doubled_dot(self, monkeypatch: pytest.MonkeyPatch): + """Regression: standardize_file_type must receive the extension exactly once-prefixed. + + Previously the call was ``standardize_file_type(extension="." + extension, ...)`` while + ``extension`` already had a leading dot, producing ``"..csv"``. The mitigating + ``lstrip(".")`` inside ``standardize_file_type`` masked the bug from end users, but the + argument shape itself was wrong and showed up in any caller that didn't strip dots. + """ + captured: dict = {} + + def fake_standardize(*, extension: str = "", mime_type: str = ""): + from graphon.file import FileType + + captured["extension"] = extension + captured["mime_type"] = mime_type + return FileType.DOCUMENT + + monkeypatch.setattr(builders, "standardize_file_type", fake_standardize) + + datasource_file = self._make_datasource_file(key="folder/data.csv", mime_type="text/csv") + self._patch_session(monkeypatch, datasource_file) + + access_controller = MagicMock() + access_controller.apply_upload_file_filters = lambda stmt: stmt + + file = builders._build_from_datasource_file( + mapping={"datasource_file_id": "file-id", "transfer_method": "datasource_file"}, + tenant_id="tenant-id", + transfer_method=FileTransferMethod.DATASOURCE_FILE, + access_controller=access_controller, + ) + + assert captured["extension"] == ".csv", ( + f"standardize_file_type received {captured['extension']!r}; expected single-dot '.csv'" + ) + assert captured["mime_type"] == "text/csv" + assert file.extension == ".csv" + + def test_extension_falls_back_to_bin_when_key_has_no_dot(self, monkeypatch: pytest.MonkeyPatch): + captured: dict = {} + + def fake_standardize(*, extension: str = "", mime_type: str = ""): + from graphon.file import FileType + + captured["extension"] = extension + return FileType.CUSTOM + + monkeypatch.setattr(builders, "standardize_file_type", fake_standardize) + + datasource_file = self._make_datasource_file(key="dotless-key", mime_type="application/octet-stream") + self._patch_session(monkeypatch, datasource_file) + + access_controller = MagicMock() + access_controller.apply_upload_file_filters = lambda stmt: stmt + + file = builders._build_from_datasource_file( + mapping={"datasource_file_id": "file-id", "transfer_method": "datasource_file"}, + tenant_id="tenant-id", + transfer_method=FileTransferMethod.DATASOURCE_FILE, + access_controller=access_controller, + ) + + assert captured["extension"] == ".bin" + assert file.extension == ".bin" diff --git a/api/tests/unit_tests/factories/test_file_validation.py b/api/tests/unit_tests/factories/test_file_validation.py new file mode 100644 index 0000000000..61337fcf10 --- /dev/null +++ b/api/tests/unit_tests/factories/test_file_validation.py @@ -0,0 +1,159 @@ +"""Unit tests for is_file_valid_with_config.""" + +from __future__ import annotations + +import pytest + +from factories.file_factory.validation import is_file_valid_with_config +from graphon.file import FileTransferMethod, FileType, FileUploadConfig + + +def _validate( + *, + input_file_type: str, + file_extension: str = ".png", + file_transfer_method: FileTransferMethod = FileTransferMethod.LOCAL_FILE, + config: FileUploadConfig, +) -> bool: + return is_file_valid_with_config( + input_file_type=input_file_type, + file_extension=file_extension, + file_transfer_method=file_transfer_method, + config=config, + ) + + +@pytest.mark.parametrize( + ("input_file_type", "file_extension", "allowed_file_types", "allowed_file_extensions", "expected"), + [ + # round-1 happy path: literal "custom" mapping, ext whitelisted + ("custom", ".png", [FileType.CUSTOM], [".png"], True), + # round-2 replay: MessageFile.type is the resolved type, but config still allows CUSTOM + ("image", ".png", [FileType.CUSTOM], [".png"], True), + ("document", ".pdf", [FileType.CUSTOM], [".pdf"], True), + # mixed bucket [IMAGE, CUSTOM]: document falls into CUSTOM bucket via extension + ("document", ".pdf", [FileType.IMAGE, FileType.CUSTOM], [".pdf"], True), + ("document", ".exe", [FileType.IMAGE, FileType.CUSTOM], [".pdf"], False), + ("image", ".jpg", [FileType.IMAGE], [], True), + ("video", ".mp4", [FileType.IMAGE, FileType.DOCUMENT], [], False), + ("custom", ".exe", [FileType.CUSTOM], [".png"], False), + # empty allowed_file_types == no type restriction + ("video", ".mp4", [], [], True), + ], +) +def test_bucket_semantics(input_file_type, file_extension, allowed_file_types, allowed_file_extensions, expected): + config = FileUploadConfig( + allowed_file_types=allowed_file_types, + allowed_file_extensions=allowed_file_extensions, + ) + assert _validate(input_file_type=input_file_type, file_extension=file_extension, config=config) is expected + + +@pytest.mark.parametrize("whitelist_entry", [".png", ".PNG", "png", "PNG", " .Png ", "PnG"]) +def test_extension_match_is_case_and_dot_insensitive(whitelist_entry): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[whitelist_entry], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is True + + +def test_extension_mismatch_still_rejected_after_normalization(): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".png", ".jpg"], + ) + assert _validate(input_file_type="custom", file_extension=".pdf", config=config) is False + + +def test_mixed_case_whitelist_replicating_real_user_config(): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".PNG", "png", "JPG", ".WEBP", "SVG", "GIF"], + ) + for ext in (".png", ".jpg", ".webp", ".svg", ".gif"): + assert _validate(input_file_type="custom", file_extension=ext, config=config) is True + + +def test_tool_file_always_passes(): + config = FileUploadConfig(allowed_file_types=[FileType.CUSTOM], allowed_file_extensions=[".pdf"]) + assert ( + _validate( + input_file_type="image", + file_extension=".png", + file_transfer_method=FileTransferMethod.TOOL_FILE, + config=config, + ) + is True + ) + + +def test_transfer_method_gate_for_non_image(): + config = FileUploadConfig( + allowed_file_types=[FileType.DOCUMENT], + allowed_file_upload_methods=[FileTransferMethod.LOCAL_FILE], + ) + assert ( + _validate( + input_file_type="document", + file_extension=".pdf", + file_transfer_method=FileTransferMethod.LOCAL_FILE, + config=config, + ) + is True + ) + assert ( + _validate( + input_file_type="document", + file_extension=".pdf", + file_transfer_method=FileTransferMethod.REMOTE_URL, + config=config, + ) + is False + ) + + +def test_history_replay_matches_round_1_outcome_under_unchanged_config(): + """A file that passes round 1 must pass history replay when config is unchanged.""" + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[".png"], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is True + assert _validate(input_file_type="image", file_extension=".png", config=config) is True + + +def test_empty_whitelist_in_custom_bucket_denies_by_default(): + """Defensive: when a file lands in the CUSTOM bucket, an empty + allowed_file_extensions list rejects. The UI never submits empty; + this guards DSL / API paths that bypass the UI from accidentally + widening what's accepted.""" + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is False + assert _validate(input_file_type="image", file_extension=".png", config=config) is False + + +def test_normalize_handles_whitespace_and_empty_consistently(): + """Whitespace-only or empty entries in the whitelist must not match real + extensions (regression guard for _normalize_extension edge cases).""" + for noisy_entry in ("", " ", "\t"): + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=[noisy_entry], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is False + + +def test_empty_extension_does_not_spuriously_match_empty_whitelist_entry(): + """Defensive: even if the whitelist contains an empty / whitespace entry + (e.g., a stray comma in DSL), an extensionless file must not pass via + a both-sides-empty match. Real entries in the same whitelist still match.""" + config = FileUploadConfig( + allowed_file_types=[FileType.CUSTOM], + allowed_file_extensions=["", ".png"], + ) + assert _validate(input_file_type="custom", file_extension=".png", config=config) is True + assert _validate(input_file_type="custom", file_extension="", config=config) is False diff --git a/api/tests/unit_tests/libs/_human_input/support.py b/api/tests/unit_tests/libs/_human_input/support.py index e6cc23161e..6616cec9b8 100644 --- a/api/tests/unit_tests/libs/_human_input/support.py +++ b/api/tests/unit_tests/libs/_human_input/support.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any -from graphon.nodes.human_input.entities import FormInput +from graphon.nodes.human_input.entities import ParagraphInputConfig from graphon.nodes.human_input.enums import TimeoutUnit from libs.datetime_utils import naive_utc_now @@ -45,7 +45,7 @@ class HumanInputForm: tenant_id: str app_id: str | None form_content: str - inputs: list[FormInput] + inputs: list[ParagraphInputConfig] user_actions: list[dict[str, Any]] timeout: int timeout_unit: TimeoutUnit diff --git a/api/tests/unit_tests/libs/_human_input/test_form_service.py b/api/tests/unit_tests/libs/_human_input/test_form_service.py index fa2c02020b..cb4c2715d0 100644 --- a/api/tests/unit_tests/libs/_human_input/test_form_service.py +++ b/api/tests/unit_tests/libs/_human_input/test_form_service.py @@ -7,8 +7,8 @@ from datetime import timedelta import pytest from graphon.nodes.human_input.entities import ( - FormInput, - UserAction, + ParagraphInputConfig, + UserActionConfig, ) from graphon.nodes.human_input.enums import ( FormInputType, @@ -50,8 +50,8 @@ class TestFormService: "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "# Test Form\n\nInput: {{#$output.input#}}", - "inputs": [FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="input", default=None)], - "user_actions": [UserAction(id="submit", title="Submit")], + "inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)], + "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 1, "timeout_unit": TimeoutUnit.HOUR, "form_token": "token-xyz", @@ -304,8 +304,10 @@ class TestFormValidation: "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "Test form", - "inputs": [FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="required_input", default=None)], - "user_actions": [UserAction(id="submit", title="Submit")], + "inputs": [ + ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="required_input", default=None) + ], + "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 1, "timeout_unit": TimeoutUnit.HOUR, } diff --git a/api/tests/unit_tests/libs/_human_input/test_models.py b/api/tests/unit_tests/libs/_human_input/test_models.py index 866ee61b3e..1413eed51f 100644 --- a/api/tests/unit_tests/libs/_human_input/test_models.py +++ b/api/tests/unit_tests/libs/_human_input/test_models.py @@ -7,8 +7,8 @@ from datetime import datetime, timedelta import pytest from graphon.nodes.human_input.entities import ( - FormInput, - UserAction, + ParagraphInputConfig, + UserActionConfig, ) from graphon.nodes.human_input.enums import ( FormInputType, @@ -32,8 +32,8 @@ class TestHumanInputForm: "tenant_id": "tenant-abc", "app_id": "app-def", "form_content": "# Test Form\n\nInput: {{#$output.input#}}", - "inputs": [FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="input", default=None)], - "user_actions": [UserAction(id="submit", title="Submit")], + "inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)], + "user_actions": [UserActionConfig(id="submit", title="Submit")], "timeout": 2, "timeout_unit": TimeoutUnit.HOUR, "form_token": "token-xyz", @@ -132,7 +132,7 @@ class TestHumanInputForm: assert "site" not in response assert response["form_content"] == "# Test Form\n\nInput: {{#$output.input#}}" assert len(response["inputs"]) == 1 - assert response["inputs"][0]["type"] == "text-input" + assert response["inputs"][0]["type"] == "paragraph" assert response["inputs"][0]["output_variable_name"] == "input" def test_form_to_response_dict_with_site_info(self, sample_form_data): diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py index 8bef01c1ed..7c7f20374e 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py @@ -673,7 +673,7 @@ class TestRedisShardedSubscription: """Test cases for the _RedisShardedSubscription class.""" @pytest.fixture(autouse=True) - def patch_sharded_redis_type(self, monkeypatch): + def patch_sharded_redis_type(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient) @pytest.fixture @@ -889,7 +889,9 @@ class TestRedisShardedSubscription: assert not sharded_subscription._queue.empty() assert sharded_subscription._queue.get_nowait() == b"test sharded payload" - def test_get_message_uses_target_node_for_cluster_client(self, mock_pubsub: MagicMock, monkeypatch): + def test_get_message_uses_target_node_for_cluster_client( + self, mock_pubsub: MagicMock, monkeypatch: pytest.MonkeyPatch + ): """Test that cluster clients use target_node for sharded messages.""" class DummyRedisCluster: @@ -1177,7 +1179,7 @@ class TestRedisSubscriptionCommon: return request.param @pytest.fixture(autouse=True) - def patch_sharded_redis_type(self, monkeypatch): + def patch_sharded_redis_type(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("libs.broadcast_channel.redis.sharded_channel.Redis", FakeRedisClient) @pytest.fixture diff --git a/api/tests/unit_tests/libs/test_archive_storage.py b/api/tests/unit_tests/libs/test_archive_storage.py index de3c9c4737..4363c23571 100644 --- a/api/tests/unit_tests/libs/test_archive_storage.py +++ b/api/tests/unit_tests/libs/test_archive_storage.py @@ -34,7 +34,7 @@ def _client_error(code: str) -> ClientError: return ClientError({"Error": {"Code": code}}, "Operation") -def _mock_client(monkeypatch): +def _mock_client(monkeypatch: pytest.MonkeyPatch): client = MagicMock() client.head_bucket.return_value = None # Configure put_object to return a proper ETag that matches the MD5 hash @@ -56,19 +56,19 @@ def _mock_client(monkeypatch): return client, boto_client -def test_init_disabled(monkeypatch): +def test_init_disabled(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch, ARCHIVE_STORAGE_ENABLED=False) with pytest.raises(ArchiveStorageNotConfiguredError, match="not enabled"): ArchiveStorage(bucket=BUCKET_NAME) -def test_init_missing_config(monkeypatch): +def test_init_missing_config(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch, ARCHIVE_STORAGE_ENDPOINT=None) with pytest.raises(ArchiveStorageNotConfiguredError, match="incomplete"): ArchiveStorage(bucket=BUCKET_NAME) -def test_init_bucket_not_found(monkeypatch): +def test_init_bucket_not_found(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.head_bucket.side_effect = _client_error("404") @@ -77,7 +77,7 @@ def test_init_bucket_not_found(monkeypatch): ArchiveStorage(bucket=BUCKET_NAME) -def test_init_bucket_access_denied(monkeypatch): +def test_init_bucket_access_denied(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.head_bucket.side_effect = _client_error("403") @@ -86,7 +86,7 @@ def test_init_bucket_access_denied(monkeypatch): ArchiveStorage(bucket=BUCKET_NAME) -def test_init_bucket_other_error(monkeypatch): +def test_init_bucket_other_error(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.head_bucket.side_effect = _client_error("500") @@ -95,7 +95,7 @@ def test_init_bucket_other_error(monkeypatch): ArchiveStorage(bucket=BUCKET_NAME) -def test_init_sets_client(monkeypatch): +def test_init_sets_client(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, boto_client = _mock_client(monkeypatch) @@ -113,7 +113,7 @@ def test_init_sets_client(monkeypatch): assert storage.bucket == BUCKET_NAME -def test_put_object_returns_checksum(monkeypatch): +def test_put_object_returns_checksum(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) storage = ArchiveStorage(bucket=BUCKET_NAME) @@ -132,7 +132,7 @@ def test_put_object_returns_checksum(monkeypatch): assert checksum == expected_md5 -def test_put_object_raises_on_error(monkeypatch): +def test_put_object_raises_on_error(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) storage = ArchiveStorage(bucket=BUCKET_NAME) @@ -142,7 +142,7 @@ def test_put_object_raises_on_error(monkeypatch): storage.put_object("key", b"data") -def test_get_object_returns_bytes(monkeypatch): +def test_get_object_returns_bytes(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) body = MagicMock() @@ -153,7 +153,7 @@ def test_get_object_returns_bytes(monkeypatch): assert storage.get_object("key") == b"payload" -def test_get_object_missing(monkeypatch): +def test_get_object_missing(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.get_object.side_effect = _client_error("NoSuchKey") @@ -163,7 +163,7 @@ def test_get_object_missing(monkeypatch): storage.get_object("missing") -def test_get_object_stream(monkeypatch): +def test_get_object_stream(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) body = MagicMock() @@ -174,7 +174,7 @@ def test_get_object_stream(monkeypatch): assert list(storage.get_object_stream("key")) == [b"a", b"b"] -def test_get_object_stream_missing(monkeypatch): +def test_get_object_stream_missing(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.get_object.side_effect = _client_error("NoSuchKey") @@ -184,7 +184,7 @@ def test_get_object_stream_missing(monkeypatch): list(storage.get_object_stream("missing")) -def test_object_exists(monkeypatch): +def test_object_exists(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) storage = ArchiveStorage(bucket=BUCKET_NAME) @@ -194,7 +194,7 @@ def test_object_exists(monkeypatch): assert storage.object_exists("missing") is False -def test_delete_object_error(monkeypatch): +def test_delete_object_error(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.delete_object.side_effect = _client_error("500") @@ -204,7 +204,7 @@ def test_delete_object_error(monkeypatch): storage.delete_object("key") -def test_list_objects(monkeypatch): +def test_list_objects(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) paginator = MagicMock() @@ -219,7 +219,7 @@ def test_list_objects(monkeypatch): paginator.paginate.assert_called_once_with(Bucket="archive-bucket", Prefix="prefix") -def test_list_objects_error(monkeypatch): +def test_list_objects_error(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) paginator = MagicMock() @@ -231,7 +231,7 @@ def test_list_objects_error(monkeypatch): storage.list_objects("prefix") -def test_generate_presigned_url(monkeypatch): +def test_generate_presigned_url(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.generate_presigned_url.return_value = "http://signed-url" @@ -247,7 +247,7 @@ def test_generate_presigned_url(monkeypatch): assert url == "http://signed-url" -def test_generate_presigned_url_error(monkeypatch): +def test_generate_presigned_url_error(monkeypatch: pytest.MonkeyPatch): _configure_storage(monkeypatch) client, _ = _mock_client(monkeypatch) client.generate_presigned_url.side_effect = _client_error("500") diff --git a/api/tests/unit_tests/libs/test_oauth_base.py b/api/tests/unit_tests/libs/test_oauth_base.py index 7b7f086dac..1c0066ed9a 100644 --- a/api/tests/unit_tests/libs/test_oauth_base.py +++ b/api/tests/unit_tests/libs/test_oauth_base.py @@ -1,6 +1,6 @@ import pytest -from libs.oauth import OAuth +from libs.oauth import OAuth, decode_oauth_state, encode_oauth_state def test_oauth_base_methods_raise_not_implemented(): @@ -17,3 +17,17 @@ def test_oauth_base_methods_raise_not_implemented(): with pytest.raises(NotImplementedError): oauth._transform_user_info({}) + + +def test_oauth_state_round_trips_invite_token_timezone_and_language(): + state = encode_oauth_state(invite_token="invite-123", timezone="Asia/Shanghai", language="zh-Hans") + + assert decode_oauth_state(state) == { + "invite_token": "invite-123", + "timezone": "Asia/Shanghai", + "language": "zh-Hans", + } + + +def test_oauth_state_returns_empty_payload_for_invalid_state(): + assert decode_oauth_state("invalid-state") == {} diff --git a/api/tests/unit_tests/libs/test_oauth_clients.py b/api/tests/unit_tests/libs/test_oauth_clients.py index 830284e697..b3ecc5a06d 100644 --- a/api/tests/unit_tests/libs/test_oauth_clients.py +++ b/api/tests/unit_tests/libs/test_oauth_clients.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import httpx import pytest -from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo +from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo, decode_oauth_state class BaseOAuthTest: @@ -37,15 +37,25 @@ class TestGitHubOAuth(BaseOAuthTest): return GitHubOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"]) @pytest.mark.parametrize( - ("invite_token", "expected_state"), + ("invite_token", "timezone", "language", "expected_state"), [ - (None, None), - ("test_invite_token", "test_invite_token"), - ("", None), + (None, None, None, None), + ("test_invite_token", None, None, {"invite_token": "test_invite_token"}), + ("", None, None, None), + (None, "Asia/Shanghai", None, {"timezone": "Asia/Shanghai"}), + (None, None, "zh-Hans", {"language": "zh-Hans"}), + ( + "test_invite_token", + "Asia/Shanghai", + "zh-Hans", + {"invite_token": "test_invite_token", "timezone": "Asia/Shanghai", "language": "zh-Hans"}, + ), ], ) - def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state): - url = oauth.get_authorization_url(invite_token) + def test_should_generate_authorization_url_correctly( + self, oauth, oauth_config, invite_token, timezone, language, expected_state + ): + url = oauth.get_authorization_url(invite_token, timezone=timezone, language=language) parsed, params = self.parse_auth_url(url) assert parsed.scheme == "https" @@ -56,7 +66,7 @@ class TestGitHubOAuth(BaseOAuthTest): assert params["scope"][0] == "user:email" if expected_state: - assert params["state"][0] == expected_state + assert decode_oauth_state(params["state"][0]) == expected_state else: assert "state" not in params @@ -208,15 +218,25 @@ class TestGoogleOAuth(BaseOAuthTest): return GoogleOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"]) @pytest.mark.parametrize( - ("invite_token", "expected_state"), + ("invite_token", "timezone", "language", "expected_state"), [ - (None, None), - ("test_invite_token", "test_invite_token"), - ("", None), + (None, None, None, None), + ("test_invite_token", None, None, {"invite_token": "test_invite_token"}), + ("", None, None, None), + (None, "Asia/Shanghai", None, {"timezone": "Asia/Shanghai"}), + (None, None, "zh-Hans", {"language": "zh-Hans"}), + ( + "test_invite_token", + "Asia/Shanghai", + "zh-Hans", + {"invite_token": "test_invite_token", "timezone": "Asia/Shanghai", "language": "zh-Hans"}, + ), ], ) - def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state): - url = oauth.get_authorization_url(invite_token) + def test_should_generate_authorization_url_correctly( + self, oauth, oauth_config, invite_token, timezone, language, expected_state + ): + url = oauth.get_authorization_url(invite_token, timezone=timezone, language=language) parsed, params = self.parse_auth_url(url) assert parsed.scheme == "https" @@ -228,7 +248,7 @@ class TestGoogleOAuth(BaseOAuthTest): assert params["scope"][0] == "openid email" if expected_state: - assert params["state"][0] == expected_state + assert decode_oauth_state(params["state"][0]) == expected_state else: assert "state" not in params diff --git a/api/tests/unit_tests/libs/test_pandas.py b/api/tests/unit_tests/libs/test_pandas.py index 21c2f0781d..a4739dbbc2 100644 --- a/api/tests/unit_tests/libs/test_pandas.py +++ b/api/tests/unit_tests/libs/test_pandas.py @@ -1,7 +1,8 @@ import pandas as pd +import pytest -def test_pandas_csv(tmp_path, monkeypatch): +def test_pandas_csv(tmp_path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(tmp_path) data = {"col1": [1, 2.2, -3.3, 4.0, 5], "col2": ["A", "B", "C", "D", "E"]} df1 = pd.DataFrame(data) @@ -16,7 +17,7 @@ def test_pandas_csv(tmp_path, monkeypatch): assert df2[df2.columns[1]].to_list() == data["col2"] -def test_pandas_xlsx(tmp_path, monkeypatch): +def test_pandas_xlsx(tmp_path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(tmp_path) data = {"col1": [1, 2.2, -3.3, 4.0, 5], "col2": ["A", "B", "C", "D", "E"]} df1 = pd.DataFrame(data) @@ -31,7 +32,7 @@ def test_pandas_xlsx(tmp_path, monkeypatch): assert df2[df2.columns[1]].to_list() == data["col2"] -def test_pandas_xlsx_with_sheets(tmp_path, monkeypatch): +def test_pandas_xlsx_with_sheets(tmp_path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(tmp_path) data1 = {"col1": [1, 2, 3, 4, 5], "col2": ["A", "B", "C", "D", "E"]} df1 = pd.DataFrame(data1) diff --git a/api/tests/unit_tests/libs/test_passport.py b/api/tests/unit_tests/libs/test_passport.py index f33484c18d..90b58ae548 100644 --- a/api/tests/unit_tests/libs/test_passport.py +++ b/api/tests/unit_tests/libs/test_passport.py @@ -143,28 +143,13 @@ class TestPassportService: assert str(exc_info.value) == "401 Unauthorized: Token has expired." # Configuration tests - def test_should_handle_empty_secret_key(self): - """Test behavior when SECRET_KEY is empty""" + def test_should_use_configured_secret_key_without_policy_validation(self): + """Test that policy decisions are owned by config, not PassportService.""" with patch("libs.passport.dify_config") as mock_config: - mock_config.SECRET_KEY = "" + mock_config.SECRET_KEY = "configured" service = PassportService() - # Empty secret key should still work but is insecure - payload = {"test": "data"} - token = service.issue(payload) - decoded = service.verify(token) - assert decoded == payload - - def test_should_handle_none_secret_key(self): - """Test behavior when SECRET_KEY is None""" - with patch("libs.passport.dify_config") as mock_config: - mock_config.SECRET_KEY = None - service = PassportService() - - payload = {"test": "data"} - # JWT library will raise TypeError when secret is None - with pytest.raises((TypeError, jwt.exceptions.InvalidKeyError)): - service.issue(payload) + assert service.sk == "configured" # Boundary condition tests def test_should_handle_large_payload(self, passport_service): diff --git a/api/tests/unit_tests/libs/test_rate_limiter.py b/api/tests/unit_tests/libs/test_rate_limiter.py index 9d44b07b5e..5052033db8 100644 --- a/api/tests/unit_tests/libs/test_rate_limiter.py +++ b/api/tests/unit_tests/libs/test_rate_limiter.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock +import pytest + from libs import helper as helper_module @@ -31,7 +33,7 @@ class _FakeRedis: return True -def test_rate_limiter_counts_attempts_within_same_second(monkeypatch): +def test_rate_limiter_counts_attempts_within_same_second(monkeypatch: pytest.MonkeyPatch): fake_redis = _FakeRedis() monkeypatch.setattr(helper_module.time, "time", lambda: 1000) @@ -48,7 +50,7 @@ def test_rate_limiter_counts_attempts_within_same_second(monkeypatch): assert limiter.is_rate_limited("203.0.113.10") is True -def test_rate_limiter_uses_injected_redis(monkeypatch): +def test_rate_limiter_uses_injected_redis(monkeypatch: pytest.MonkeyPatch): redis_client = MagicMock() redis_client.zcard.return_value = 1 monkeypatch.setattr(helper_module.time, "time", lambda: 1000) diff --git a/api/tests/unit_tests/libs/test_token.py b/api/tests/unit_tests/libs/test_token.py index 6a65b5faa0..6bd794b24d 100644 --- a/api/tests/unit_tests/libs/test_token.py +++ b/api/tests/unit_tests/libs/test_token.py @@ -1,5 +1,8 @@ +from typing import cast from unittest.mock import MagicMock +import pytest +from flask import Request from werkzeug.wrappers import Response from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN @@ -15,8 +18,8 @@ class MockRequest: def test_extract_access_token(): - def _mock_request(headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]): - return MockRequest(headers, cookies, args) + def _mock_request(headers: dict[str, str], cookies: dict[str, str], args: dict[str, str]) -> Request: + return cast(Request, MockRequest(headers, cookies, args)) test_cases = [ (_mock_request({"Authorization": "Bearer 123"}, {}, {}), "123", "123"), @@ -26,11 +29,11 @@ def test_extract_access_token(): (_mock_request({}, {COOKIE_NAME_WEBAPP_ACCESS_TOKEN: "123"}, {}), None, "123"), ] for request, expected_console, expected_webapp in test_cases: - assert extract_access_token(request) == expected_console # pyright: ignore[reportArgumentType] - assert extract_webapp_access_token(request) == expected_webapp # pyright: ignore[reportArgumentType] + assert extract_access_token(request) == expected_console + assert extract_webapp_access_token(request) == expected_webapp -def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch): +def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False) monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False) monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", "", raising=False) @@ -38,7 +41,7 @@ def test_real_cookie_name_uses_host_prefix_without_domain(monkeypatch): assert token._real_cookie_name("csrf_token") == "__Host-csrf_token" -def test_real_cookie_name_without_host_prefix_when_domain_present(monkeypatch): +def test_real_cookie_name_without_host_prefix_when_domain_present(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False) monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False) monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False) @@ -46,7 +49,7 @@ def test_real_cookie_name_without_host_prefix_when_domain_present(monkeypatch): assert token._real_cookie_name("csrf_token") == "csrf_token" -def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch): +def test_set_csrf_cookie_includes_domain_when_configured(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(token.dify_config, "CONSOLE_WEB_URL", "https://console.example.com", raising=False) monkeypatch.setattr(token.dify_config, "CONSOLE_API_URL", "https://api.example.com", raising=False) monkeypatch.setattr(token.dify_config, "COOKIE_DOMAIN", ".example.com", raising=False) diff --git a/api/tests/unit_tests/libs/test_token_manager.py b/api/tests/unit_tests/libs/test_token_manager.py new file mode 100644 index 0000000000..832210c7f2 --- /dev/null +++ b/api/tests/unit_tests/libs/test_token_manager.py @@ -0,0 +1,58 @@ +""" +Regression tests for the `_TokenData` TypedDict used by +`libs.helper.TokenManager`. + +These tests guard the contract that every field a caller writes via +`generate_token` survives the TypedDict-validated round-trip performed +by `get_token_data`. Specifically, the `phase` field that the console +and web `forgot-password` + `change-email` controllers depend on for +the security check introduced in PR #35425 (GHSA-4q3w-q5mc-45rq) must +be preserved — otherwise downstream `if data.get("phase", "") != "reset"` +checks always fail with `InvalidTokenError`. +""" + +import json + +# pyright: reportPrivateUsage=false +from libs.helper import _token_data_adapter + + +def test_token_data_adapter_preserves_phase_field() -> None: + """`phase` written by callers like generate_reset_password_token must + survive the TypedDict-validated round-trip in get_token_data. + + Regression: PR #34380 introduced `_TokenData` but did not list + `phase`, so the TypeAdapter silently dropped it and the security + gate from PR #35425 (GHSA-4q3w-q5mc-45rq) always failed. + """ + payload = { + "account_id": None, + "email": "user@example.com", + "token_type": "reset_password", + "code": "123456", + "phase": "reset", + } + data = dict(_token_data_adapter.validate_json(json.dumps(payload))) + + assert data.get("phase") == "reset", ( + "phase field was stripped by the _TokenData TypedDict adapter; " + "the forgot-password phase-bound check (PR #35425) will always fail." + ) + + +def test_token_data_adapter_preserves_change_email_payload() -> None: + """Sanity round-trip for the change-email flow: every field set by + `generate_change_email_token` must come back, including the phase + string the controller branches on.""" + payload = { + "account_id": "acc-1", + "email": "new@example.com", + "token_type": "change_email", + "code": "654321", + "old_email": "old@example.com", + "phase": "verify_old_email", + } + data = dict(_token_data_adapter.validate_json(json.dumps(payload))) + + assert data.get("old_email") == "old@example.com" + assert data.get("phase") == "verify_old_email" diff --git a/api/tests/unit_tests/models/test_comment_models.py b/api/tests/unit_tests/models/test_comment_models.py index 277335cbef..8c8985aff8 100644 --- a/api/tests/unit_tests/models/test_comment_models.py +++ b/api/tests/unit_tests/models/test_comment_models.py @@ -4,7 +4,15 @@ from models.comment import WorkflowComment, WorkflowCommentMention, WorkflowComm def test_workflow_comment_account_properties_and_cache() -> None: - comment = WorkflowComment(created_by="user-1", resolved_by="user-2", content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by="user-2", + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) created_account = Mock(id="user-1") resolved_account = Mock(id="user-2") @@ -21,6 +29,8 @@ def test_workflow_comment_account_properties_and_cache() -> None: get_mock.assert_not_called() comment_without_resolver = WorkflowComment( + tenant_id="xxx", + app_id="yyy", created_by="user-1", resolved_by=None, content="hello", @@ -37,7 +47,15 @@ def test_workflow_comment_counts_and_participants() -> None: reply_2 = WorkflowCommentReply(comment_id="comment-1", content="reply-2", created_by="user-2") mention_1 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3") mention_2 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-4") - comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by=None, + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) comment.replies = [reply_1, reply_2] comment.mentions = [mention_1, mention_2] @@ -63,7 +81,15 @@ def test_workflow_comment_counts_and_participants() -> None: def test_workflow_comment_participants_use_cached_accounts() -> None: reply = WorkflowCommentReply(comment_id="comment-1", content="reply-1", created_by="user-2") mention = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3") - comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by=None, + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) comment.replies = [reply] comment.mentions = [mention] diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 51d95c4239..f4ccfb4191 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -12,7 +12,9 @@ This test suite covers: import json import pickle from datetime import UTC, datetime +from types import SimpleNamespace from unittest.mock import Mock, patch +from urllib.parse import parse_qs, urlparse from uuid import uuid4 from core.rag.index_processor.constant.index_type import IndexTechniqueType @@ -676,6 +678,51 @@ class TestDocumentSegmentIndexing: # Assert assert segment.hit_count == 5 + def test_document_segment_attachments_prefers_files_url_for_source_url(self, monkeypatch): + """Test attachment source URLs use FILES_URL before falling back to CONSOLE_API_URL.""" + # Arrange + segment = DocumentSegment( + tenant_id="tenant-1", + dataset_id="dataset-1", + document_id="document-1", + position=1, + content="Test", + word_count=1, + tokens=2, + created_by="user-1", + ) + segment.id = "segment-1" + attachment = SimpleNamespace( + id="upload-1", + name="image.png", + size=128, + extension="png", + mime_type="image/png", + ) + + monkeypatch.setattr("models.dataset.time.time", lambda: 1700000000) + monkeypatch.setattr("models.dataset.os.urandom", lambda _: b"\x01" * 16) + monkeypatch.setattr("models.dataset.dify_config.SECRET_KEY", "unit-secret") + monkeypatch.setattr("models.dataset.dify_config.FILES_URL", "https://files.example.com") + monkeypatch.setattr("models.dataset.dify_config.CONSOLE_API_URL", "https://console.example.com") + + with patch("models.dataset.db") as mock_db: + mock_db.session.execute.return_value.all.return_value = [(Mock(), attachment)] + + # Act + attachments = segment.attachments + + # Assert + assert len(attachments) == 1 + source_url = attachments[0]["source_url"] + parsed = urlparse(source_url) + query = parse_qs(parsed.query) + assert parsed.netloc == "files.example.com" + assert parsed.path == "/files/upload-1/image-preview" + assert query["timestamp"] == ["1700000000"] + assert query["nonce"] == ["01010101010101010101010101010101"] + assert query["sign"][0] + def test_document_segment_error_tracking(self): """Test document segment error tracking.""" # Arrange @@ -800,9 +847,7 @@ class TestDatasetProcessRule: # Act process_rule = DatasetProcessRule( - dataset_id=dataset_id, - mode=ProcessRuleMode.AUTOMATIC, - created_by=created_by, + dataset_id=dataset_id, mode=ProcessRuleMode.AUTOMATIC, created_by=created_by, rules=None ) # Assert diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index ac4b087b91..8f27d0938d 100644 --- a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import UTC, datetime from types import SimpleNamespace -from graphon.nodes.human_input.entities import FormDefinition, FormInput, UserAction +from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig from graphon.nodes.human_input.enums import FormInputType from models.human_input import RecipientType from repositories.sqlalchemy_api_workflow_run_repository import _build_human_input_required_reason @@ -13,8 +13,8 @@ def _build_form_model() -> SimpleNamespace: expiration_time = datetime(2024, 1, 1, tzinfo=UTC) definition = FormDefinition( form_content="content", - inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], - user_actions=[UserAction(id="approve", title="Approve")], + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")], + user_actions=[UserActionConfig(id="approve", title="Approve")], rendered_content="rendered", expiration_time=expiration_time, default_values={"name": "Alice"}, diff --git a/api/tests/unit_tests/services/controller_api.py b/api/tests/unit_tests/services/controller_api.py index 762d7b9090..e7f7cabecd 100644 --- a/api/tests/unit_tests/services/controller_api.py +++ b/api/tests/unit_tests/services/controller_api.py @@ -146,7 +146,7 @@ class ControllerApiTestDataFactory: return app @staticmethod - def create_api_instance(app): + def create_api_instance(app: Flask): """ Create a Flask-RESTX API instance. @@ -160,7 +160,12 @@ class ControllerApiTestDataFactory: return api @staticmethod - def create_test_client(app, api, resource_class, route): + def create_test_client( + app: Flask, + api: Api, + resource_class: type, + route: str, + ): """ Create a Flask test client with a resource registered. @@ -302,7 +307,7 @@ class TestDatasetListApi: return ControllerApiTestDataFactory.create_flask_app() @pytest.fixture - def api(self, app): + def api(self, app: Flask): """ Create Flask-RESTX API instance. @@ -311,7 +316,7 @@ class TestDatasetListApi: return ControllerApiTestDataFactory.create_api_instance(app) @pytest.fixture - def client(self, app, api): + def client(self, app: Flask, api: Api): """ Create test client with DatasetListApi registered. @@ -472,12 +477,12 @@ class TestDatasetApiGet: return ControllerApiTestDataFactory.create_flask_app() @pytest.fixture - def api(self, app): + def api(self, app: Flask): """Create Flask-RESTX API instance.""" return ControllerApiTestDataFactory.create_api_instance(app) @pytest.fixture - def client(self, app, api): + def client(self, app: Flask, api: Api): """Create test client with DatasetApi registered.""" return ControllerApiTestDataFactory.create_test_client(app, api, DatasetApi, "/datasets/") @@ -588,12 +593,12 @@ class TestDatasetApiCreate: return ControllerApiTestDataFactory.create_flask_app() @pytest.fixture - def api(self, app): + def api(self, app: Flask): """Create Flask-RESTX API instance.""" return ControllerApiTestDataFactory.create_api_instance(app) @pytest.fixture - def client(self, app, api): + def client(self, app: Flask, api: Api): """Create test client with DatasetApi registered.""" return ControllerApiTestDataFactory.create_test_client(app, api, DatasetApi, "/datasets") @@ -681,12 +686,12 @@ class TestHitTestingApi: return ControllerApiTestDataFactory.create_flask_app() @pytest.fixture - def api(self, app): + def api(self, app: Flask): """Create Flask-RESTX API instance.""" return ControllerApiTestDataFactory.create_api_instance(app) @pytest.fixture - def client(self, app, api): + def client(self, app: Flask, api: Api): """Create test client with HitTestingApi registered.""" return ControllerApiTestDataFactory.create_test_client( app, api, HitTestingApi, "/datasets//hit-testing" @@ -799,12 +804,12 @@ class TestExternalDatasetApi: return ControllerApiTestDataFactory.create_flask_app() @pytest.fixture - def api(self, app): + def api(self, app: Flask): """Create Flask-RESTX API instance.""" return ControllerApiTestDataFactory.create_api_instance(app) @pytest.fixture - def client_list(self, app, api): + def client_list(self, app: Flask, api: Api): """Create test client for external knowledge API list endpoint.""" return ControllerApiTestDataFactory.create_test_client( app, api, ExternalApiTemplateListApi, "/datasets/external-knowledge-api" diff --git a/api/tests/unit_tests/services/plugin/conftest.py b/api/tests/unit_tests/services/plugin/conftest.py index 80c6077b0c..9dc4fa0390 100644 --- a/api/tests/unit_tests/services/plugin/conftest.py +++ b/api/tests/unit_tests/services/plugin/conftest.py @@ -21,7 +21,7 @@ def make_features( @pytest.fixture -def mock_installer(monkeypatch): +def mock_installer(monkeypatch: pytest.MonkeyPatch): """Patch PluginInstaller at the service import site.""" mock = MagicMock() monkeypatch.setattr("services.plugin.plugin_service.PluginInstaller", lambda: mock) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_migration.py b/api/tests/unit_tests/services/plugin/test_plugin_migration.py new file mode 100644 index 0000000000..12b6ea23a1 --- /dev/null +++ b/api/tests/unit_tests/services/plugin/test_plugin_migration.py @@ -0,0 +1,76 @@ +from unittest.mock import MagicMock, patch + +import pytest +from pytest_mock import MockerFixture + +from services.plugin.plugin_migration import PluginMigration + +MIGRATION_MODULE = "services.plugin.plugin_migration" + + +def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None: + mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False) + batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests") + + result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai") + + assert result is None + batch_fetch.assert_not_called() + + +def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None: + mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True) + manifest = mocker.MagicMock() + manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc" + mocker.patch( + "services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests", + return_value=[manifest], + ) + + result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai") + + assert result == "langgenius/openai:1.0.0@abc" + + +class TestHandlePluginInstanceInstall: + def test_raises_when_disabled_and_map_nonempty(self) -> None: + with patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg: + mock_cfg.MARKETPLACE_ENABLED = False + + with pytest.raises(ValueError, match="Marketplace disabled"): + PluginMigration.handle_plugin_instance_install( + "tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"} + ) + + def test_no_raise_when_disabled_and_map_empty(self) -> None: + with ( + patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg, + patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls, + ): + mock_cfg.MARKETPLACE_ENABLED = False + mock_installer = MagicMock() + mock_installer_cls.return_value = mock_installer + mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True) + + result = PluginMigration.handle_plugin_instance_install("tenant1", {}) + + assert isinstance(result, dict) + + def test_proceeds_when_enabled(self) -> None: + with ( + patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg, + patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace, + patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls, + ): + mock_cfg.MARKETPLACE_ENABLED = True + mock_marketplace.download_plugin_pkg.return_value = b"pkg_data" + mock_installer = MagicMock() + mock_installer_cls.return_value = mock_installer + mock_installer.install_from_identifiers.return_value = MagicMock(all_installed=True) + + result = PluginMigration.handle_plugin_instance_install( + "tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"} + ) + + mock_marketplace.download_plugin_pkg.assert_called_once() + assert "success" in result or "failed" in result diff --git a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py b/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py deleted file mode 100644 index 53a9e6210c..0000000000 --- a/api/tests/unit_tests/services/plugin/test_plugin_permission_service.py +++ /dev/null @@ -1,79 +0,0 @@ -from unittest.mock import MagicMock, patch - -from models.account import TenantPluginPermission - -MODULE = "services.plugin.plugin_permission_service" - - -def _patched_session(): - """Patch session_factory.create_session() to return a mock session as context manager.""" - session = MagicMock() - session.__enter__ = MagicMock(return_value=session) - session.__exit__ = MagicMock(return_value=False) - session.begin.return_value.__enter__ = MagicMock(return_value=session) - session.begin.return_value.__exit__ = MagicMock(return_value=False) - mock_factory = MagicMock() - mock_factory.create_session.return_value = session - patcher = patch(f"{MODULE}.session_factory", mock_factory) - return patcher, session - - -class TestGetPermission: - def test_returns_permission_when_found(self): - p1, session = _patched_session() - permission = MagicMock() - session.scalar.return_value = permission - - with p1: - from services.plugin.plugin_permission_service import PluginPermissionService - - result = PluginPermissionService.get_permission("t1") - - assert result is permission - - def test_returns_none_when_not_found(self): - p1, session = _patched_session() - session.scalar.return_value = None - - with p1: - from services.plugin.plugin_permission_service import PluginPermissionService - - result = PluginPermissionService.get_permission("t1") - - assert result is None - - -class TestChangePermission: - def test_creates_new_permission_when_not_exists(self): - p1, session = _patched_session() - session.scalar.return_value = None - - with p1, patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginPermission") as perm_cls: - perm_cls.return_value = MagicMock() - from services.plugin.plugin_permission_service import PluginPermissionService - - result = PluginPermissionService.change_permission( - "t1", TenantPluginPermission.InstallPermission.EVERYONE, TenantPluginPermission.DebugPermission.EVERYONE - ) - - assert result is True - session.begin.assert_called_once() - session.add.assert_called_once() - - def test_updates_existing_permission(self): - p1, session = _patched_session() - existing = MagicMock() - session.scalar.return_value = existing - - with p1: - from services.plugin.plugin_permission_service import PluginPermissionService - - result = PluginPermissionService.change_permission( - "t1", TenantPluginPermission.InstallPermission.ADMINS, TenantPluginPermission.DebugPermission.ADMINS - ) - - assert result is True - session.begin.assert_called_once() - assert existing.install_permission == TenantPluginPermission.InstallPermission.ADMINS - assert existing.debug_permission == TenantPluginPermission.DebugPermission.ADMINS - session.add.assert_not_called() diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py new file mode 100644 index 0000000000..05bb3b65c0 --- /dev/null +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -0,0 +1,50 @@ +from unittest.mock import MagicMock, patch + +MODULE = "services.plugin.plugin_service" + + +class TestFetchLatestPluginVersion: + def test_skips_marketplace_fetch_when_disabled(self) -> None: + """Cache misses stay None; marketplace is never called when disabled.""" + with ( + patch(f"{MODULE}.dify_config") as mock_cfg, + patch(f"{MODULE}.redis_client") as mock_redis, + patch(f"{MODULE}.marketplace") as mock_marketplace, + ): + mock_cfg.MARKETPLACE_ENABLED = False + mock_redis.get.return_value = None # all cache misses + + from services.plugin.plugin_service import PluginService + + result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"]) + + mock_marketplace.batch_fetch_plugin_manifests.assert_not_called() + assert result == {"langgenius/openai": None, "langgenius/anthropic": None} + + def test_calls_marketplace_fetch_when_enabled(self) -> None: + """Cache misses trigger marketplace fetch when enabled.""" + manifest = MagicMock() + manifest.plugin_id = "langgenius/openai" + manifest.latest_version = "1.0.0" + manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc" + manifest.status = "active" + manifest.deprecated_reason = "" + manifest.alternative_plugin_id = "" + + with ( + patch(f"{MODULE}.dify_config") as mock_cfg, + patch(f"{MODULE}.redis_client") as mock_redis, + patch(f"{MODULE}.marketplace") as mock_marketplace, + ): + mock_cfg.MARKETPLACE_ENABLED = True + mock_redis.get.return_value = None + mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest] + + from services.plugin.plugin_service import PluginService + + result = PluginService.fetch_latest_plugin_version(["langgenius/openai"]) + + # The list arg is mutated by remove() after the call, so check call count + result. + mock_marketplace.batch_fetch_plugin_manifests.assert_called_once() + assert result["langgenius/openai"] is not None + assert result["langgenius/openai"].version == "1.0.0" diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py new file mode 100644 index 0000000000..2ddb1ea448 --- /dev/null +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline.py @@ -0,0 +1,36 @@ +from pytest_mock import MockerFixture + +from services.rag_pipeline.rag_pipeline import RagPipelineService + + +def _make_service() -> RagPipelineService: + return RagPipelineService.__new__(RagPipelineService) + + +def test_fetch_recommended_plugin_manifests_returns_empty_when_disabled( + mocker: MockerFixture, +) -> None: + mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", False) + batch_fetch = mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids") + + service = _make_service() + result = service._fetch_recommended_plugin_manifests(["langgenius/openai"]) + + assert result == [] + batch_fetch.assert_not_called() + + +def test_fetch_recommended_plugin_manifests_returns_data_when_enabled( + mocker: MockerFixture, +) -> None: + mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.MARKETPLACE_ENABLED", True) + expected = [{"plugin_id": "langgenius/openai", "name": "OpenAI"}] + mocker.patch( + "services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids", + return_value=expected, + ) + + service = _make_service() + result = service._fetch_recommended_plugin_manifests(["langgenius/openai"]) + + assert result == expected diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py index 337659b15f..e72ebb4907 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_dsl_service.py @@ -8,11 +8,12 @@ from sqlalchemy.orm import Session from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from graphon.enums import BuiltinNodeTypes +from services.dsl_version import check_version_compatibility from services.entities.knowledge_entities.rag_pipeline_entities import IconInfo, RagPipelineDatasetCreateEntity +from services.rag_pipeline import rag_pipeline_dsl_service from services.rag_pipeline.rag_pipeline_dsl_service import ( ImportStatus, RagPipelineDslService, - _check_version_compatibility, ) @@ -26,7 +27,9 @@ from services.rag_pipeline.rag_pipeline_dsl_service import ( ], ) def test_check_version_compatibility(imported_version: str, expected_status: ImportStatus) -> None: - assert _check_version_compatibility(imported_version) == expected_status + assert ( + check_version_compatibility(imported_version, rag_pipeline_dsl_service.CURRENT_DSL_VERSION) == expected_status + ) def test_encrypt_decrypt_dataset_id_roundtrip() -> None: @@ -259,6 +262,60 @@ workflow: if result.status == ImportStatus.FAILED: print(f"DEBUG: {result.error}") assert result.status == ImportStatus.COMPLETED + session.commit.assert_not_called() + session.flush.assert_called() + + +def test_import_rag_pipeline_flushes_new_collection_binding_without_commit(mocker) -> None: + yaml_content = """ +version: 0.1.0 +kind: rag_pipeline +rag_pipeline: + name: Test Pipeline +workflow: + graph: + nodes: + - data: + type: knowledge-index +""" + pipeline = Mock(id="p1", description="desc", is_published=False) + pipeline.name = "Test Pipeline" + mocker.patch.object(RagPipelineDslService, "_create_or_update_pipeline", return_value=pipeline) + + config_mock = Mock() + config_mock.indexing_technique = "high_quality" + config_mock.embedding_model = "m" + config_mock.embedding_model_provider = "p" + config_mock.chunk_structure = "text_model" + config_mock.retrieval_model.model_dump.return_value = {} + config_mock.summary_index_setting = None + mocker.patch( + "services.rag_pipeline.rag_pipeline_dsl_service.KnowledgeConfiguration.model_validate", + return_value=config_mock, + ) + + dataset_mock = Mock(id="d1") + binding_mock = Mock(id="b1") + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset_mock) + binding_cls = mocker.patch( + "services.rag_pipeline.rag_pipeline_dsl_service.DatasetCollectionBinding", + return_value=binding_mock, + ) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) + + session = cast(MagicMock, Mock()) + session.scalar.return_value = None + session.scalars.return_value.all.return_value = [] + service = RagPipelineDslService(session=cast(Session, session)) + account = Mock(current_tenant_id="t1") + + result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content=yaml_content) + + assert result.status == ImportStatus.COMPLETED + binding_cls.assert_called_once() + assert dataset_mock.collection_binding_id == "b1" + session.commit.assert_not_called() + assert session.flush.call_count >= 2 def test_import_rag_pipeline_pending_version(mocker) -> None: @@ -338,6 +395,67 @@ workflow: assert result.dataset_id == "d1" +def test_confirm_import_flushes_new_collection_binding_without_commit(mocker) -> None: + from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData + + yaml_content = """ +version: 0.1.0 +kind: rag_pipeline +rag_pipeline: + name: Test Pipeline +workflow: + graph: + nodes: + - data: + type: knowledge-index +""" + pending = RagPipelinePendingData(import_mode="yaml-content", yaml_content=yaml_content, pipeline_id="p1") + mocker.patch( + "services.rag_pipeline.rag_pipeline_dsl_service.redis_client.get", + return_value=pending.model_dump_json(), + ) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.delete") + + pipeline = Mock(id="p1", description="desc") + pipeline.name = "Test Pipeline" + pipeline.retrieve_dataset.return_value = None + mocker.patch.object(RagPipelineDslService, "_create_or_update_pipeline", return_value=pipeline) + + config_mock = Mock() + config_mock.indexing_technique = "high_quality" + config_mock.embedding_model = "m" + config_mock.embedding_model_provider = "p" + config_mock.chunk_structure = "text_model" + config_mock.retrieval_model.model_dump.return_value = {} + config_mock.summary_index_setting = None + mocker.patch( + "services.rag_pipeline.rag_pipeline_dsl_service.KnowledgeConfiguration.model_validate", + return_value=config_mock, + ) + + dataset_mock = Mock(id="d1") + binding_mock = Mock(id="b1") + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.Dataset", return_value=dataset_mock) + binding_cls = mocker.patch( + "services.rag_pipeline.rag_pipeline_dsl_service.DatasetCollectionBinding", + return_value=binding_mock, + ) + mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.select", return_value=MagicMock()) + + session = cast(MagicMock, Mock()) + session.scalar.side_effect = [pipeline, None] + service = RagPipelineDslService(session=cast(Session, session)) + account = Mock(id="u1", current_tenant_id="t1") + + result = service.confirm_import(account=account, import_id="imp-1") + + assert result.status == ImportStatus.COMPLETED + binding_cls.assert_called_once() + assert dataset_mock.collection_binding_id == "b1" + session.commit.assert_not_called() + assert session.flush.call_count >= 2 + + # --- _extract_dependencies_from_workflow_graph all types --- @@ -421,6 +539,8 @@ def test_create_or_update_pipeline_create_new(mocker) -> None: assert result == pipeline_instance session.add.assert_called() + session.commit.assert_not_called() + session.flush.assert_called() # --- export_rag_pipeline_dsl comprehensive --- @@ -984,7 +1104,7 @@ def test_extract_dependencies_from_model_config_includes_dataset_reranking_and_t def test_check_version_compatibility_hits_major_older_branch(mocker) -> None: mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.CURRENT_DSL_VERSION", "1.0.0") - status = _check_version_compatibility("0.9.0") + status = check_version_compatibility("0.9.0", rag_pipeline_dsl_service.CURRENT_DSL_VERSION) assert status == ImportStatus.PENDING diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py index 1a2d062208..287391c24c 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py @@ -2,12 +2,13 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture from services.rag_pipeline.rag_pipeline_task_proxy import RagPipelineTaskProxy @pytest.fixture -def proxy(mocker): +def proxy(mocker: MockerFixture): """Create a RagPipelineTaskProxy with mocked dependencies.""" mocker.patch("services.rag_pipeline.rag_pipeline_task_proxy.TenantIsolatedTaskQueue") entity = Mock() diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py index 82e5e973c1..3f511a109a 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_transform_service.py @@ -1,8 +1,10 @@ +import logging from datetime import UTC, datetime from types import SimpleNamespace from typing import cast import pytest +from pytest_mock import MockerFixture from models.dataset import Dataset from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration @@ -514,3 +516,64 @@ def test_deal_document_data_upload_file_with_existing_file(mocker) -> None: assert document.data_source_type == "local_file" assert "real_file_id" in document.data_source_info assert add_mock.call_count >= 2 + + +def _make_service(): + return RagPipelineTransformService.__new__(RagPipelineTransformService) + + +def test_deal_dependencies_skips_marketplace_when_disabled(mocker: MockerFixture, caplog) -> None: + mocker.patch( + "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", + False, + ) + installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value + installer.list_plugins.return_value = [] + mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration") + install_call = mocker.patch( + "services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg" + ) + + pipeline_yaml = { + "dependencies": [ + { + "type": "marketplace", + "value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"}, + } + ] + } + + service = _make_service() + with caplog.at_level(logging.WARNING): + service._deal_dependencies(pipeline_yaml, "tenant-1") + + install_call.assert_not_called() + assert any("Marketplace disabled" in rec.message for rec in caplog.records) + + +def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None: + mocker.patch( + "services.rag_pipeline.rag_pipeline_transform_service.dify_config.MARKETPLACE_ENABLED", + True, + ) + installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value + installer.list_plugins.return_value = [] + migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value + migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc" + install_call = mocker.patch( + "services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg" + ) + + pipeline_yaml = { + "dependencies": [ + { + "type": "marketplace", + "value": {"plugin_unique_identifier": "langgenius/openai:1.0.0@abc"}, + } + ] + } + + service = _make_service() + service._deal_dependencies(pipeline_yaml, "tenant-1") + + install_call.assert_called_once_with("tenant-1", ["langgenius/openai:1.0.0@abc"]) diff --git a/api/tests/unit_tests/services/recommend_app/test_category_order.py b/api/tests/unit_tests/services/recommend_app/test_category_order.py new file mode 100644 index 0000000000..3b94021f26 --- /dev/null +++ b/api/tests/unit_tests/services/recommend_app/test_category_order.py @@ -0,0 +1,26 @@ +import json +from unittest.mock import patch + +from services.recommend_app.category_order import get_explore_app_category_order, order_categories + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_get_explore_app_category_order_returns_redis_list(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert get_explore_app_category_order("en-US") == ["C", "A", "B"] + mock_get.assert_called_once_with("explore:apps:category_order:en-US") + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_uses_redis_order_as_source_of_truth(mock_get): + mock_get.return_value = json.dumps(["C", "A", "B"]).encode() + + assert order_categories({"A", "B", "C", "D"}, "en-US") == ["C", "A", "B"] + + +@patch("services.recommend_app.category_order.redis_client.get") +def test_order_categories_falls_back_to_sorted_categories_without_redis_order(mock_get): + mock_get.return_value = None + + assert order_categories({"B", "A", "C"}, "en-US") == ["A", "B", "C"] diff --git a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py index e322fbed4c..c7b86e5743 100644 --- a/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py +++ b/api/tests/unit_tests/services/recommend_app/test_remote_retrieval.py @@ -85,7 +85,7 @@ class TestFetchFromDifyOfficial: @patch("services.recommend_app.remote.remote_retrieval.dify_config") @patch("services.recommend_app.remote.remote_retrieval.httpx.get") - def test_apps_returns_sorted_categories_on_200(self, mock_get, mock_config): + def test_apps_preserves_remote_categories_order_on_200(self, mock_get, mock_config): mock_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN = "https://example.com" mock_response = MagicMock(status_code=200) mock_response.json.return_value = { @@ -96,7 +96,7 @@ class TestFetchFromDifyOfficial: result = RemoteRecommendAppRetrieval.fetch_recommended_apps_from_dify_official("en-US") - assert result["categories"] == ["agent", "chat", "writing"] + assert result["categories"] == ["writing", "agent", "chat"] @patch("services.recommend_app.remote.remote_retrieval.dify_config") @patch("services.recommend_app.remote.remote_retrieval.httpx.get") diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index e9d2f1481e..8c554e012d 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -13,6 +13,7 @@ from services.errors.account import ( AccountPasswordError, AccountRegisterError, CurrentPasswordIncorrectError, + NoPermissionError, ) @@ -259,7 +260,7 @@ class TestAccountService: assert result.interface_theme == "light" assert result.password is not None assert result.password_salt is not None - assert result.timezone is not None + assert result.timezone == "America/New_York" # Verify database operations mock_db_dependencies["db"].session.add.assert_called_once() @@ -270,7 +271,28 @@ class TestAccountService: assert added_account.interface_theme == "light" assert added_account.password is not None assert added_account.password_salt is not None - assert added_account.timezone is not None + assert added_account.timezone == "America/New_York" + self._assert_database_operations_called(mock_db_dependencies["db"]) + + def test_create_account_uses_explicit_timezone( + self, mock_db_dependencies, mock_password_dependencies, mock_external_service_dependencies + ): + """Test account creation prefers explicit browser timezone.""" + mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False + mock_password_dependencies["hash_password"].return_value = b"hashed_password" + + result = AccountService.create_account( + email="test@example.com", + name="Test User", + interface_language="en-US", + password="password123", + timezone="Asia/Shanghai", + ) + + assert result.timezone == "Asia/Shanghai" + added_account = mock_db_dependencies["db"].session.add.call_args[0][0] + assert added_account.timezone == "Asia/Shanghai" self._assert_database_operations_called(mock_db_dependencies["db"]) def test_create_account_registration_disabled(self, mock_external_service_dependencies): @@ -817,8 +839,8 @@ class TestTenantService: # Mock the database queries in update_member_role method with patch("services.account_service.db") as mock_db: - # scalar calls: permission check, target member lookup - mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join] + # scalar calls: permission check, target member lookup, operator role lookup + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] # Execute test TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator) @@ -827,6 +849,65 @@ class TestTenantService: assert mock_target_join.role == "admin" self._assert_database_operations_called(mock_db) + def test_admin_can_update_admin_member_role(self): + """Test admin can update another non-owner member, including an admin.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator) + + assert mock_target_join.role == "editor" + self._assert_database_operations_called(mock_db) + + def test_admin_cannot_update_owner_member_role(self): + """Test admin cannot update an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="owner" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + with pytest.raises(NoPermissionError): + TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator) + + def test_admin_cannot_promote_member_to_owner(self): + """Test admin cannot promote a non-owner member to owner.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + + with patch("services.account_service.db") as mock_db: + mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join] + + with pytest.raises(NoPermissionError): + TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator) + # ==================== Permission Check Tests ==================== def test_check_member_permission_success(self, mock_db_dependencies): @@ -864,6 +945,39 @@ class TestTenantService: "add", ) + def test_admin_can_remove_non_owner_member(self, mock_db_dependencies): + """Test admin can remove a non-owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="admin" + ) + mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join] + + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_admin_cannot_remove_owner_member(self, mock_db_dependencies): + """Test admin cannot remove an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="operator-123", role="admin" + ) + mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock( + tenant_id="tenant-456", account_id="member-789", role="owner" + ) + mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join] + + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + class TestRegisterService: """ @@ -1128,6 +1242,7 @@ class TestRegisterService: interface_language="en-US", password="password123", is_setup=False, + timezone=None, ) mock_create_tenant.assert_called_once_with("Test User's Workspace") mock_create_member.assert_called_once_with(mock_tenant, mock_account, role="owner") diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index d3f9c5dd9f..216c5d9db6 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -20,6 +20,7 @@ from contextlib import contextmanager from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom @@ -96,7 +97,7 @@ def _noop_rate_limit_context(rate_limit, request_id): class TestBuildStreamingTaskOnSubscribe: """Tests for AppGenerateService._build_streaming_task_on_subscribe.""" - def test_streams_mode_starts_immediately(self, monkeypatch): + def test_streams_mode_starts_immediately(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams") called = [] cb = AppGenerateService._build_streaming_task_on_subscribe(lambda: called.append(1)) @@ -106,7 +107,7 @@ class TestBuildStreamingTaskOnSubscribe: cb() assert called == [1] # not called again - def test_pubsub_mode_starts_on_subscribe(self, monkeypatch): + def test_pubsub_mode_starts_on_subscribe(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") monkeypatch.setattr(ags_module, "SSE_TASK_START_FALLBACK_MS", 60_000) # large to prevent timer called = [] @@ -118,7 +119,7 @@ class TestBuildStreamingTaskOnSubscribe: cb() assert called == [1] - def test_sharded_mode_starts_on_subscribe(self, monkeypatch): + def test_sharded_mode_starts_on_subscribe(self, monkeypatch: pytest.MonkeyPatch): """sharded is treated like pubsub (i.e. not 'streams').""" monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "sharded") monkeypatch.setattr(ags_module, "SSE_TASK_START_FALLBACK_MS", 60_000) @@ -128,7 +129,7 @@ class TestBuildStreamingTaskOnSubscribe: cb() assert called == [1] - def test_pubsub_fallback_timer_fires(self, monkeypatch): + def test_pubsub_fallback_timer_fires(self, monkeypatch: pytest.MonkeyPatch): """When nobody subscribes fast enough the fallback timer fires.""" monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") monkeypatch.setattr(ags_module, "SSE_TASK_START_FALLBACK_MS", 50) # 50 ms @@ -137,7 +138,7 @@ class TestBuildStreamingTaskOnSubscribe: time.sleep(0.2) # give the timer time to fire assert called == [1] - def test_exception_in_start_task_returns_false(self, monkeypatch): + def test_exception_in_start_task_returns_false(self, monkeypatch: pytest.MonkeyPatch): """When start_task raises, _try_start returns False and next call retries.""" monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "streams") call_count = 0 @@ -154,7 +155,7 @@ class TestBuildStreamingTaskOnSubscribe: cb() assert call_count == 2 - def test_concurrent_subscribe_only_starts_once(self, monkeypatch): + def test_concurrent_subscribe_only_starts_once(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") monkeypatch.setattr(ags_module, "SSE_TASK_START_FALLBACK_MS", 60_000) call_count = 0 @@ -176,31 +177,31 @@ class TestBuildStreamingTaskOnSubscribe: # _get_max_active_requests # --------------------------------------------------------------------------- class TestGetMaxActiveRequests: - def test_both_zero_returns_zero(self, monkeypatch): + def test_both_zero_returns_zero(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 0) monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0) app = _make_app(AppMode.CHAT, max_active_requests=0) assert AppGenerateService._get_max_active_requests(app) == 0 - def test_app_limit_only(self, monkeypatch): + def test_app_limit_only(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 0) monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0) app = _make_app(AppMode.CHAT, max_active_requests=5) assert AppGenerateService._get_max_active_requests(app) == 5 - def test_config_limit_only(self, monkeypatch): + def test_config_limit_only(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 10) monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0) app = _make_app(AppMode.CHAT, max_active_requests=0) assert AppGenerateService._get_max_active_requests(app) == 10 - def test_both_non_zero_returns_min(self, monkeypatch): + def test_both_non_zero_returns_min(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 20) monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 0) app = _make_app(AppMode.CHAT, max_active_requests=5) assert AppGenerateService._get_max_active_requests(app) == 5 - def test_default_active_requests_used_when_app_has_none(self, monkeypatch): + def test_default_active_requests_used_when_app_has_none(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "APP_MAX_ACTIVE_REQUESTS", 0) monkeypatch.setattr(ags_module.dify_config, "APP_DEFAULT_ACTIVE_REQUESTS", 15) app = _make_app(AppMode.CHAT, max_active_requests=0) @@ -214,7 +215,7 @@ class TestGenerate: """Tests for AppGenerateService.generate covering each mode.""" @pytest.fixture(autouse=True) - def _common(self, mocker, monkeypatch): + def _common(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit) # Prevent AppExecutionParams.new from touching real models via isinstance @@ -224,7 +225,7 @@ class TestGenerate: ) # -- COMPLETION --------------------------------------------------------- - def test_completion_mode(self, mocker): + def test_completion_mode(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.CompletionAppGenerator.generate", return_value={"result": "ok"}, @@ -244,7 +245,7 @@ class TestGenerate: gen_spy.assert_called_once() # -- AGENT_CHAT via mode ------------------------------------------------ - def test_agent_chat_mode(self, mocker): + def test_agent_chat_mode(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.AgentChatAppGenerator.generate", return_value={"result": "agent"}, @@ -264,7 +265,7 @@ class TestGenerate: gen_spy.assert_called_once() # -- AGENT_CHAT via is_agent flag (non-AGENT_CHAT mode) ----------------- - def test_agent_via_is_agent_flag(self, mocker): + def test_agent_via_is_agent_flag(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.AgentChatAppGenerator.generate", return_value={"result": "agent-via-flag"}, @@ -285,7 +286,7 @@ class TestGenerate: gen_spy.assert_called_once() # -- CHAT --------------------------------------------------------------- - def test_chat_mode(self, mocker): + def test_chat_mode(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.ChatAppGenerator.generate", return_value={"result": "chat"}, @@ -306,7 +307,7 @@ class TestGenerate: gen_spy.assert_called_once() # -- ADVANCED_CHAT blocking --------------------------------------------- - def test_advanced_chat_blocking(self, mocker): + def test_advanced_chat_blocking(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) @@ -333,7 +334,7 @@ class TestGenerate: retrieve_spy.assert_not_called() # -- ADVANCED_CHAT streaming -------------------------------------------- - def test_advanced_chat_streaming(self, mocker, monkeypatch): + def test_advanced_chat_streaming(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) mocker.patch( @@ -365,7 +366,7 @@ class TestGenerate: delay_spy.assert_called_once() # -- WORKFLOW blocking -------------------------------------------------- - def test_workflow_blocking(self, mocker): + def test_workflow_blocking(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) gen_spy = mocker.patch( @@ -390,7 +391,7 @@ class TestGenerate: assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id" # -- WORKFLOW streaming ------------------------------------------------- - def test_workflow_streaming(self, mocker, monkeypatch): + def test_workflow_streaming(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) mocker.patch( @@ -422,7 +423,7 @@ class TestGenerate: delay_spy.assert_called_once() # -- Invalid mode ------------------------------------------------------- - def test_invalid_mode_raises(self, mocker): + def test_invalid_mode_raises(self, mocker: MockerFixture): app = _make_app("invalid-mode", is_agent=False) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate( @@ -439,14 +440,14 @@ class TestGenerate: # --------------------------------------------------------------------------- class TestGenerateBilling: @pytest.fixture(autouse=True) - def _common(self, mocker, monkeypatch): + def _common(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit) mocker.patch( "services.app_generate_service.rate_limit_context", _noop_rate_limit_context, ) - def test_billing_enabled_consumes_quota(self, mocker, monkeypatch): + def test_billing_enabled_consumes_quota(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() reserve_mock = mocker.patch( @@ -472,7 +473,9 @@ class TestGenerateBilling: reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") quota_charge.commit.assert_called_once() - def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch): + def test_billing_quota_exceeded_raises_rate_limit_error( + self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch + ): from services.errors.app import QuotaExceededError from services.errors.llm import InvokeRateLimitError @@ -491,7 +494,7 @@ class TestGenerateBilling: streaming=False, ) - def test_exception_refunds_quota_and_exits_rate_limit(self, mocker, monkeypatch): + def test_exception_refunds_quota_and_exits_rate_limit(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() mocker.patch( @@ -517,7 +520,9 @@ class TestGenerateBilling: ) quota_charge.refund.assert_called_once() - def test_rate_limit_exit_called_in_finally_for_blocking(self, mocker, monkeypatch): + def test_rate_limit_exit_called_in_finally_for_blocking( + self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch + ): """For non-streaming (blocking) calls, rate_limit.exit should be called in finally.""" monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) @@ -552,7 +557,7 @@ class TestGenerateBilling: # _get_workflow # --------------------------------------------------------------------------- class TestGetWorkflow: - def test_debugger_fetches_draft(self, mocker): + def test_debugger_fetches_draft(self, mocker: MockerFixture): draft_wf = _make_workflow() ws = MagicMock() ws.get_draft_workflow.return_value = draft_wf @@ -562,7 +567,7 @@ class TestGetWorkflow: assert result is draft_wf ws.get_draft_workflow.assert_called_once() - def test_debugger_raises_when_no_draft(self, mocker): + def test_debugger_raises_when_no_draft(self, mocker: MockerFixture): ws = MagicMock() ws.get_draft_workflow.return_value = None mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) @@ -570,7 +575,7 @@ class TestGetWorkflow: with pytest.raises(ValueError, match="Workflow not initialized"): AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.DEBUGGER) - def test_non_debugger_fetches_published(self, mocker): + def test_non_debugger_fetches_published(self, mocker: MockerFixture): pub_wf = _make_workflow() ws = MagicMock() ws.get_published_workflow.return_value = pub_wf @@ -580,7 +585,7 @@ class TestGetWorkflow: assert result is pub_wf ws.get_published_workflow.assert_called_once() - def test_non_debugger_raises_when_no_published(self, mocker): + def test_non_debugger_raises_when_no_published(self, mocker: MockerFixture): ws = MagicMock() ws.get_published_workflow.return_value = None mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) @@ -588,7 +593,7 @@ class TestGetWorkflow: with pytest.raises(ValueError, match="Workflow not published"): AppGenerateService._get_workflow(_make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API) - def test_specific_workflow_id_valid_uuid(self, mocker): + def test_specific_workflow_id_valid_uuid(self, mocker: MockerFixture): valid_uuid = str(uuid.uuid4()) specific_wf = _make_workflow(workflow_id=valid_uuid) ws = MagicMock() @@ -601,7 +606,7 @@ class TestGetWorkflow: assert result is specific_wf ws.get_published_workflow_by_id.assert_called_once() - def test_specific_workflow_id_invalid_uuid(self, mocker): + def test_specific_workflow_id_invalid_uuid(self, mocker: MockerFixture): ws = MagicMock() mocker.patch("services.app_generate_service.WorkflowService", return_value=ws) @@ -610,7 +615,7 @@ class TestGetWorkflow: _make_app(AppMode.WORKFLOW), InvokeFrom.SERVICE_API, workflow_id="not-a-uuid" ) - def test_specific_workflow_id_not_found(self, mocker): + def test_specific_workflow_id_not_found(self, mocker: MockerFixture): valid_uuid = str(uuid.uuid4()) ws = MagicMock() ws.get_published_workflow_by_id.return_value = None @@ -626,7 +631,7 @@ class TestGetWorkflow: # generate_single_iteration # --------------------------------------------------------------------------- class TestGenerateSingleIteration: - def test_advanced_chat_mode(self, mocker): + def test_advanced_chat_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) gen_spy = mocker.patch( @@ -644,7 +649,7 @@ class TestGenerateSingleIteration: iter_spy.assert_called_once() assert result == {"event": "iteration"} - def test_workflow_mode(self, mocker): + def test_workflow_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) mocker.patch( @@ -662,7 +667,7 @@ class TestGenerateSingleIteration: iter_spy.assert_called_once() assert result == {"event": "wf-iteration"} - def test_invalid_mode_raises(self, mocker): + def test_invalid_mode_raises(self, mocker: MockerFixture): app = _make_app(AppMode.CHAT) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate_single_iteration(app_model=app, user=_make_user(), node_id="n1", args={}) @@ -672,7 +677,7 @@ class TestGenerateSingleIteration: # generate_single_loop # --------------------------------------------------------------------------- class TestGenerateSingleLoop: - def test_advanced_chat_mode(self, mocker): + def test_advanced_chat_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) mocker.patch( @@ -690,7 +695,7 @@ class TestGenerateSingleLoop: loop_spy.assert_called_once() assert result == {"event": "loop"} - def test_workflow_mode(self, mocker): + def test_workflow_mode(self, mocker: MockerFixture): workflow = _make_workflow() mocker.patch.object(AppGenerateService, "_get_workflow", return_value=workflow) mocker.patch( @@ -708,7 +713,7 @@ class TestGenerateSingleLoop: loop_spy.assert_called_once() assert result == {"event": "wf-loop"} - def test_invalid_mode_raises(self, mocker): + def test_invalid_mode_raises(self, mocker: MockerFixture): app = _make_app(AppMode.COMPLETION) with pytest.raises(ValueError, match="Invalid app mode"): AppGenerateService.generate_single_loop(app_model=app, user=_make_user(), node_id="n1", args=MagicMock()) @@ -718,7 +723,7 @@ class TestGenerateSingleLoop: # generate_more_like_this # --------------------------------------------------------------------------- class TestGenerateMoreLikeThis: - def test_delegates_to_completion_generator(self, mocker): + def test_delegates_to_completion_generator(self, mocker: MockerFixture): gen_spy = mocker.patch( "services.app_generate_service.CompletionAppGenerator.generate_more_like_this", return_value={"result": "similar"}, @@ -739,7 +744,7 @@ class TestGenerateMoreLikeThis: # get_response_generator # --------------------------------------------------------------------------- class TestGetResponseGenerator: - def test_non_ended_workflow_run(self, mocker): + def test_non_ended_workflow_run(self, mocker: MockerFixture): app = _make_app(AppMode.ADVANCED_CHAT) workflow_run = MagicMock() workflow_run.id = "run-1" @@ -756,7 +761,7 @@ class TestGetResponseGenerator: result = AppGenerateService.get_response_generator(app_model=app, workflow_run=workflow_run) gen_instance.retrieve_events.assert_called_once() - def test_ended_workflow_run_still_returns_generator(self, mocker): + def test_ended_workflow_run_still_returns_generator(self, mocker: MockerFixture): """Even when the run is ended, the current code still returns a generator (TODO branch).""" app = _make_app(AppMode.WORKFLOW) workflow_run = MagicMock() diff --git a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py index 30aa359b45..4293be8f72 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py +++ b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py @@ -89,7 +89,7 @@ class _FakeStreams: @pytest.fixture -def _patch_get_channel_streams(monkeypatch): +def _patch_get_channel_streams(monkeypatch: pytest.MonkeyPatch): from libs.broadcast_channel.redis.streams_channel import StreamsBroadcastChannel fake = _FakeStreams() @@ -108,7 +108,7 @@ def _patch_get_channel_streams(monkeypatch): @pytest.fixture -def _patch_get_channel_pubsub(monkeypatch): +def _patch_get_channel_pubsub(monkeypatch: pytest.MonkeyPatch): from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel store: dict[str, deque[bytes]] = defaultdict(deque) @@ -163,7 +163,7 @@ def test_streams_full_flow_prepublish_and_replay(): @pytest.mark.usefixtures("_patch_get_channel_pubsub") -def test_pubsub_full_flow_start_on_subscribe_gated(monkeypatch): +def test_pubsub_full_flow_start_on_subscribe_gated(monkeypatch: pytest.MonkeyPatch): # Speed up any potential timer if it accidentally triggers monkeypatch.setattr("services.app_generate_service.SSE_TASK_START_FALLBACK_MS", 50) diff --git a/api/tests/unit_tests/services/test_audio_service.py b/api/tests/unit_tests/services/test_audio_service.py index 83258fd1b7..5d148974f8 100644 --- a/api/tests/unit_tests/services/test_audio_service.py +++ b/api/tests/unit_tests/services/test_audio_service.py @@ -173,7 +173,8 @@ class AudioServiceTestDataFactory: file = Mock(spec=FileStorage) file.filename = filename file.mimetype = mimetype - file.read = Mock(return_value=content) + file.stream = Mock() + file.stream.read = Mock(return_value=content) for key, value in kwargs.items(): setattr(file, key, value) return file @@ -216,7 +217,7 @@ class TestAudioServiceASR: """Test speech-to-text (ASR) operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_asr_success_chat_mode(self, mock_model_manager_class, factory): + def test_transcript_asr_success_chat_mode(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful ASR transcription in CHAT mode.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -241,7 +242,9 @@ class TestAudioServiceASR: mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="user-123") @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_asr_success_advanced_chat_mode(self, mock_model_manager_class, factory): + def test_transcript_asr_success_advanced_chat_mode( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test successful ASR transcription in ADVANCED_CHAT mode.""" # Arrange workflow = factory.create_workflow_mock(features_dict={"speech_to_text": {"enabled": True}}) @@ -263,7 +266,7 @@ class TestAudioServiceASR: # Assert assert result == {"text": "Workflow transcribed text"} - def test_transcript_asr_raises_error_when_feature_disabled_chat_mode(self, factory): + def test_transcript_asr_raises_error_when_feature_disabled_chat_mode(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when speech-to-text is disabled in CHAT mode.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": False}) @@ -277,7 +280,9 @@ class TestAudioServiceASR: with pytest.raises(ValueError, match="Speech to text is not enabled"): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_when_feature_disabled_workflow_mode(self, factory): + def test_transcript_asr_raises_error_when_feature_disabled_workflow_mode( + self, factory: AudioServiceTestDataFactory + ): """Test that ASR raises error when speech-to-text is disabled in WORKFLOW mode.""" # Arrange workflow = factory.create_workflow_mock(features_dict={"speech_to_text": {"enabled": False}}) @@ -291,7 +296,7 @@ class TestAudioServiceASR: with pytest.raises(ValueError, match="Speech to text is not enabled"): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_when_workflow_missing(self, factory): + def test_transcript_asr_raises_error_when_workflow_missing(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when workflow is missing in WORKFLOW mode.""" # Arrange app = factory.create_app_mock( @@ -304,7 +309,7 @@ class TestAudioServiceASR: with pytest.raises(ValueError, match="Speech to text is not enabled"): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_when_no_file_uploaded(self, factory): + def test_transcript_asr_raises_error_when_no_file_uploaded(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when no file is uploaded.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -317,7 +322,7 @@ class TestAudioServiceASR: with pytest.raises(NoAudioUploadedServiceError): AudioService.transcript_asr(app_model=app, file=None) - def test_transcript_asr_raises_error_for_unsupported_audio_type(self, factory): + def test_transcript_asr_raises_error_for_unsupported_audio_type(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error for unsupported audio file types.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -331,7 +336,7 @@ class TestAudioServiceASR: with pytest.raises(UnsupportedAudioTypeServiceError): AudioService.transcript_asr(app_model=app, file=file) - def test_transcript_asr_raises_error_for_large_file(self, factory): + def test_transcript_asr_raises_error_for_large_file(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when file exceeds size limit (30MB).""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -348,7 +353,9 @@ class TestAudioServiceASR: AudioService.transcript_asr(app_model=app, file=file) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_asr_raises_error_when_no_model_instance(self, mock_model_manager_class, factory): + def test_transcript_asr_raises_error_when_no_model_instance( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that ASR raises error when no model instance is available.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) @@ -371,7 +378,7 @@ class TestAudioServiceTTS: """Test text-to-speech (TTS) operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_with_text_success(self, mock_model_manager_class, factory): + def test_transcript_tts_with_text_success(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful TTS with text input.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -405,7 +412,7 @@ class TestAudioServiceTTS: ) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_with_default_voice(self, mock_model_manager_class, factory): + def test_transcript_tts_with_default_voice(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test TTS uses default voice when none specified.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -435,7 +442,9 @@ class TestAudioServiceTTS: assert call_args.kwargs["voice"] == "default-voice" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_gets_first_available_voice_when_none_configured(self, mock_model_manager_class, factory): + def test_transcript_tts_gets_first_available_voice_when_none_configured( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test TTS gets first available voice when none is configured.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -467,7 +476,7 @@ class TestAudioServiceTTS: @patch("services.audio_service.WorkflowService", autospec=True) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_workflow_mode_with_draft( - self, mock_model_manager_class, mock_workflow_service_class, factory + self, mock_model_manager_class, mock_workflow_service_class, factory: AudioServiceTestDataFactory ): """Test TTS in WORKFLOW mode with draft workflow.""" # Arrange @@ -499,7 +508,7 @@ class TestAudioServiceTTS: assert result == b"draft audio" mock_workflow_service.get_draft_workflow.assert_called_once_with(app_model=app) - def test_transcript_tts_raises_error_when_text_missing(self, factory): + def test_transcript_tts_raises_error_when_text_missing(self, factory: AudioServiceTestDataFactory): """Test that TTS raises error when text is missing.""" # Arrange app = factory.create_app_mock() @@ -509,7 +518,9 @@ class TestAudioServiceTTS: AudioService.transcript_tts(app_model=app, text=None) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_raises_error_when_no_voices_available(self, mock_model_manager_class, factory): + def test_transcript_tts_raises_error_when_no_voices_available( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that TTS raises error when no voices are available.""" # Arrange app_model_config = factory.create_app_model_config_mock( @@ -535,7 +546,7 @@ class TestAudioServiceTTSVoices: """Test TTS voice listing operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_voices_success(self, mock_model_manager_class, factory): + def test_transcript_tts_voices_success(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful retrieval of TTS voices.""" # Arrange tenant_id = "tenant-123" @@ -560,7 +571,9 @@ class TestAudioServiceTTSVoices: mock_model_instance.get_tts_voices.assert_called_once_with(language) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_voices_raises_error_when_no_model_instance(self, mock_model_manager_class, factory): + def test_transcript_tts_voices_raises_error_when_no_model_instance( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that TTS voices raises error when no model instance is available.""" # Arrange tenant_id = "tenant-123" @@ -575,7 +588,9 @@ class TestAudioServiceTTSVoices: AudioService.transcript_tts_voices(tenant_id=tenant_id, language=language) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) - def test_transcript_tts_voices_propagates_exceptions(self, mock_model_manager_class, factory): + def test_transcript_tts_voices_propagates_exceptions( + self, mock_model_manager_class, factory: AudioServiceTestDataFactory + ): """Test that TTS voices propagates exceptions from model instance.""" # Arrange tenant_id = "tenant-123" diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index 1bbd214110..6c54e9c572 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -304,8 +304,11 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(service_module, "select", fake_select) # Repositories for workflow node executions and workflow runs + node_execution = SimpleNamespace(id="ne-1") + node_execution.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) + node_repo = MagicMock() - node_repo.get_expired_executions_batch.side_effect = [[SimpleNamespace(id="ne-1")], []] + node_repo.get_expired_executions_batch.side_effect = [[node_execution], []] node_repo.delete_executions_by_ids.return_value = 1 run_repo = MagicMock() @@ -329,6 +332,21 @@ def test_process_tenant_processes_all_batches(monkeypatch: pytest.MonkeyPatch) - clear_related.assert_called() +def test_serialize_record_falls_back_to_table_columns() -> None: + record = SimpleNamespace(id="ne-1", node_id="node-1") + record.__table__ = SimpleNamespace( + columns=[ + SimpleNamespace(name="id"), + SimpleNamespace(name="node_id"), + ] + ) + + assert ClearFreePlanTenantExpiredLogs._serialize_record(record) == { + "id": "ne-1", + "node_id": "node-1", + } + + def test_process_with_tenant_ids_filters_by_plan_and_logs_errors(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=object())) @@ -533,9 +551,14 @@ def test_process_tenant_repo_loops_break_on_empty_second_batch(monkeypatch: pyte monkeypatch.setattr(service_module, "select", fake_select) # Repos: first returns exactly batch items -> no "< batch" break, second returns [] -> hit the len==0 break. + node_execution_1 = SimpleNamespace(id="ne-1") + node_execution_1.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) + node_execution_2 = SimpleNamespace(id="ne-2") + node_execution_2.__table__ = SimpleNamespace(columns=[SimpleNamespace(name="id")]) + node_repo = MagicMock() node_repo.get_expired_executions_batch.side_effect = [ - [SimpleNamespace(id="ne-1"), SimpleNamespace(id="ne-2")], + [node_execution_1, node_execution_2], [], ] node_repo.delete_executions_by_ids.return_value = 2 diff --git a/api/tests/unit_tests/services/test_credit_pool_service.py b/api/tests/unit_tests/services/test_credit_pool_service.py new file mode 100644 index 0000000000..6956dbbd6e --- /dev/null +++ b/api/tests/unit_tests/services/test_credit_pool_service.py @@ -0,0 +1,178 @@ +from collections.abc import Generator +from contextlib import contextmanager +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import sessionmaker + +from core.errors.error import QuotaExceededError +from models import TenantCreditPool +from models.enums import ProviderQuotaType +from services.credit_pool_service import CreditPoolService + + +def _create_engine_with_pool(*, quota_limit: int, quota_used: int) -> tuple[Engine, str, str]: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + tenant_id = str(uuid4()) + pool_id = str(uuid4()) + with engine.begin() as connection: + connection.execute( + TenantCreditPool.__table__.insert(), + { + "id": pool_id, + "tenant_id": tenant_id, + "pool_type": ProviderQuotaType.TRIAL, + "quota_limit": quota_limit, + "quota_used": quota_used, + }, + ) + return engine, tenant_id, pool_id + + +@contextmanager +def _patched_session_factory(engine: Engine) -> Generator[None, None, None]: + session_maker = sessionmaker(bind=engine, expire_on_commit=False) + with patch("services.credit_pool_service.session_factory.get_session_maker", return_value=session_maker): + yield + + +def _get_quota_used(*, engine: Engine, pool_id: str) -> int | None: + with engine.connect() as connection: + return connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id)) + + +def test_get_pool_uses_configured_session_factory_without_flask_app_context() -> None: + engine, tenant_id, _ = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with _patched_session_factory(engine): + pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type=ProviderQuotaType.TRIAL) + + assert pool is not None + assert pool.tenant_id == tenant_id + assert pool.quota_used == 2 + + +def test_check_and_deduct_credits_deducts_exact_amount_when_sufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3) + + assert deducted_credits == 3 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 5 + + +def test_check_and_deduct_credits_returns_zero_for_non_positive_request() -> None: + assert CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=0) == 0 + + +def test_check_and_deduct_credits_raises_when_pool_is_missing() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + + with ( + _patched_session_factory(engine), + pytest.raises(QuotaExceededError, match="Credit pool not found"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=str(uuid4()), credits_required=1) + + +def test_check_and_deduct_credits_raises_when_pool_is_empty() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10) + + with ( + _patched_session_factory(engine), + pytest.raises(QuotaExceededError, match="No credits remaining"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_check_and_deduct_credits_raises_without_partial_deduction_when_insufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9) + + with ( + _patched_session_factory(engine), + pytest.raises(QuotaExceededError, match="Insufficient credits remaining"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=3) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 9 + + +def test_check_and_deduct_credits_wraps_unexpected_deduction_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + _patched_session_factory(engine), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.check_and_deduct_credits(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_deduct_credits_capped_returns_zero_for_non_positive_request() -> None: + assert CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=0) == 0 + + +def test_deduct_credits_capped_returns_zero_when_pool_is_missing() -> None: + engine = create_engine("sqlite:///:memory:") + TenantCreditPool.__table__.create(engine) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=str(uuid4()), credits_required=1) + + assert deducted_credits == 0 + + +def test_deduct_credits_capped_returns_zero_when_pool_is_empty() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=10) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert deducted_credits == 0 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_deduct_credits_capped_deducts_only_remaining_balance_when_insufficient() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=9) + + with _patched_session_factory(engine): + deducted_credits = CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=3) + + assert deducted_credits == 1 + assert _get_quota_used(engine=engine, pool_id=pool_id) == 10 + + +def test_deduct_credits_capped_wraps_unexpected_deduction_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + _patched_session_factory(engine), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=RuntimeError("database unavailable")), + pytest.raises(QuotaExceededError, match="Failed to deduct credits"), + ): + CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 + + +def test_deduct_credits_capped_reraises_quota_exceeded_errors() -> None: + engine, tenant_id, pool_id = _create_engine_with_pool(quota_limit=10, quota_used=2) + + with ( + _patched_session_factory(engine), + patch.object(CreditPoolService, "_get_locked_pool", side_effect=QuotaExceededError("quota unavailable")), + pytest.raises(QuotaExceededError, match="quota unavailable"), + ): + CreditPoolService.deduct_credits_capped(tenant_id=tenant_id, credits_required=1) + + assert _get_quota_used(engine=engine, pool_id=pool_id) == 2 diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index 1633194aa8..a78bc7f9d6 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -1297,7 +1297,7 @@ class TestDocumentServiceEstimateValidation: """Unit tests for estimate_args_validate branches.""" def test_estimate_args_validate_rejects_missing_info_list(self): - with pytest.raises(ValueError, match="Data source info is required"): + with pytest.raises(ValueError, match="Field required"): DocumentService.estimate_args_validate({}) def test_estimate_args_validate_sets_empty_rules_for_automatic_mode(self): @@ -1357,7 +1357,7 @@ class TestDocumentServiceEstimateValidation: }, } - with pytest.raises(ValueError, match="Summary index model provider name is required"): + with pytest.raises(ValueError, match="Field required"): DocumentService.estimate_args_validate(args) diff --git a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py index 9a513c3fe6..f5879d973d 100644 --- a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py +++ b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py @@ -22,7 +22,7 @@ class FakeLock: @pytest.fixture -def fake_current_user(monkeypatch): +def fake_current_user(monkeypatch: pytest.MonkeyPatch): user = create_autospec(Account, instance=True) user.id = "user-1" user.current_tenant_id = "tenant-1" @@ -31,7 +31,7 @@ def fake_current_user(monkeypatch): @pytest.fixture -def fake_features(monkeypatch): +def fake_features(monkeypatch: pytest.MonkeyPatch): """Features.billing.enabled == False to skip quota logic.""" features = types.SimpleNamespace( billing=types.SimpleNamespace(enabled=False, subscription=types.SimpleNamespace(plan="ENTERPRISE")), @@ -45,7 +45,7 @@ def fake_features(monkeypatch): @pytest.fixture -def fake_lock(monkeypatch): +def fake_lock(monkeypatch: pytest.MonkeyPatch): """Patch redis_client.lock to always raise LockNotOwnedError on enter.""" def _fake_lock(name, timeout=None, *args, **kwargs): @@ -61,7 +61,7 @@ def fake_lock(monkeypatch): def test_save_document_with_dataset_id_ignores_lock_not_owned( - monkeypatch, + monkeypatch: pytest.MonkeyPatch, fake_current_user, fake_features, fake_lock, @@ -118,7 +118,7 @@ def test_save_document_with_dataset_id_ignores_lock_not_owned( def test_add_segment_ignores_lock_not_owned( - monkeypatch, + monkeypatch: pytest.MonkeyPatch, fake_current_user, fake_lock, ): @@ -161,7 +161,7 @@ def test_add_segment_ignores_lock_not_owned( def test_multi_create_segment_ignores_lock_not_owned( - monkeypatch, + monkeypatch: pytest.MonkeyPatch, fake_current_user, fake_lock, ): diff --git a/api/tests/unit_tests/services/test_dataset_service_segment.py b/api/tests/unit_tests/services/test_dataset_service_segment.py index 6330e53765..1f8586e32f 100644 --- a/api/tests/unit_tests/services/test_dataset_service_segment.py +++ b/api/tests/unit_tests/services/test_dataset_service_segment.py @@ -282,7 +282,6 @@ class TestSegmentServiceQueries: def test_get_segment_by_id_returns_only_document_segment_instances(self): segment = DocumentSegment( - id="segment-1", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", @@ -292,7 +291,7 @@ class TestSegmentServiceQueries: tokens=2, created_by="user-1", ) - + segment.id = "segment-1" with patch("services.dataset_service.db") as mock_db: mock_db.session.scalar.return_value = segment result = SegmentService.get_segment_by_id("segment-1", "tenant-1") @@ -307,7 +306,6 @@ class TestSegmentServiceQueries: def test_get_segments_by_document_and_dataset_returns_scalars_result(self): segment = DocumentSegment( - id="segment-1", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", @@ -318,6 +316,7 @@ class TestSegmentServiceQueries: created_by="user-1", ) + segment.id = "segment-1" with patch("services.dataset_service.db") as mock_db: mock_db.session.scalars.return_value.all.return_value = [segment] @@ -461,6 +460,7 @@ class TestSegmentServiceMutations: vector_service.create_segments_vector.side_effect = RuntimeError("vector failed") result = SegmentService.multi_create_segment(segments, document, dataset) + assert result assert len(result) == 2 assert [segment.position for segment in result] == [2, 3] diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index c389c4a635..f374a29482 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -243,7 +243,7 @@ class TestDatasourceProviderService: assert service.get_datasource_credentials("t1", "prov", "org/plug") == {} def test_should_refresh_oauth_tokens_when_expired(self, service, mock_db_session, mock_user): - """Expired OAuth credential (expires_at near zero) triggers a silent refresh.""" + """Expired OAuth credential (expires_at near zero) triggers a refresh.""" p = MagicMock(spec=DatasourceProvider) p.auth_type = "oauth2" p.expires_at = 0 # expired @@ -256,6 +256,24 @@ class TestDatasourceProviderService: ): service.get_datasource_credentials("t1", "prov", "org/plug") + def test_should_include_provider_name_when_refresh_fails(self, service, mock_db_session, mock_user): + p = MagicMock(spec=DatasourceProvider) + p.id = "cred-id" + p.name = "Credential" + p.auth_type = "oauth2" + p.expires_at = 0 + p.encrypted_credentials = {"tok": "x"} + mock_db_session.scalar.return_value = p + with ( + patch("services.datasource_provider_service.get_current_user", return_value=mock_user), + patch("services.datasource_provider_service.OAuthHandler") as oauth_handler, + patch.object(service, "get_oauth_client", return_value={"oc": "v"}), + patch.object(service, "decrypt_datasource_provider_credentials", return_value={"tok": "plain"}), + ): + oauth_handler.return_value.refresh_credentials.side_effect = RuntimeError("token endpoint failed") + with pytest.raises(ValueError, match="provider prov"): + service.get_datasource_credentials("t1", "prov", "org/plug") + def test_should_return_decrypted_credentials_when_api_key_not_expired(self, service, mock_db_session, mock_user): """API key credentials with expires_at=-1 skip refresh and return directly.""" p = MagicMock(spec=DatasourceProvider) @@ -307,6 +325,51 @@ class TestDatasourceProviderService: result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug") assert len(result) == 1 + def test_should_skip_failed_provider_when_refreshing_all_credentials( + self, service, mock_db_session, mock_user, caplog + ): + failed_provider = MagicMock(spec=DatasourceProvider) + failed_provider.id = "failed-cred" + failed_provider.name = "Failed" + failed_provider.auth_type = "oauth2" + failed_provider.expires_at = 0 + working_provider = MagicMock(spec=DatasourceProvider) + working_provider.id = "working-cred" + working_provider.name = "Working" + working_provider.auth_type = "oauth2" + working_provider.expires_at = 0 + mock_db_session.scalars.return_value.all.return_value = [failed_provider, working_provider] + with ( + patch("services.datasource_provider_service.get_current_user", return_value=mock_user), + patch.object( + service, + "_refresh_datasource_credentials", + side_effect=[ValueError("refresh failed"), ({"t": "enc"}, 9999)], + ) as refresh_credentials, + patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}), + ): + result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug") + assert result == [{"t": "plain"}] + assert refresh_credentials.call_count == 2 + assert "Skipping datasource credentials for provider prov" in caplog.text + + def test_should_return_valid_credentials_without_refresh_when_getting_all_credentials( + self, service, mock_db_session, mock_user + ): + p = MagicMock(spec=DatasourceProvider) + p.auth_type = "oauth2" + p.expires_at = -1 + p.encrypted_credentials = {"t": "x"} + mock_db_session.scalars.return_value.all.return_value = [p] + with ( + patch("services.datasource_provider_service.get_current_user", return_value=mock_user), + patch.object(service, "_refresh_datasource_credentials") as refresh_credentials, + patch.object(service, "decrypt_datasource_provider_credentials", return_value={"t": "plain"}), + ): + result = service.get_all_datasource_credentials_by_provider("t1", "prov", "org/plug") + assert result == [{"t": "plain"}] + refresh_credentials.assert_not_called() + # ----------------------------------------------------------------------- # update_datasource_provider_name (lines 236-303) # ----------------------------------------------------------------------- diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 8e1b22886b..69bd194a68 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -221,7 +221,7 @@ class TestFileService: mock_extract.return_value = "Extracted text content" # Execute - result = file_service.get_file_preview("file_id") + result = file_service.get_file_preview("file_id", "tenant_id") # Assert assert result == "Extracted text content" @@ -229,7 +229,7 @@ class TestFileService: def test_get_file_preview_not_found(self, file_service, mock_db_session): mock_db_session.scalar.return_value = None with pytest.raises(NotFound, match="File not found"): - file_service.get_file_preview("non_existent") + file_service.get_file_preview("non_existent", "tenant_id") def test_get_file_preview_unsupported_type(self, file_service, mock_db_session): upload_file = MagicMock(spec=UploadFile) @@ -237,7 +237,7 @@ class TestFileService: upload_file.extension = "exe" mock_db_session.scalar.return_value = upload_file with pytest.raises(UnsupportedFileTypeError): - file_service.get_file_preview("file_id") + file_service.get_file_preview("file_id", "tenant_id") def test_get_image_preview_success(self, file_service, mock_db_session): # Setup diff --git a/api/tests/unit_tests/services/test_hit_testing_service_dump_records.py b/api/tests/unit_tests/services/test_hit_testing_service_dump_records.py new file mode 100644 index 0000000000..f933c5e440 --- /dev/null +++ b/api/tests/unit_tests/services/test_hit_testing_service_dump_records.py @@ -0,0 +1,88 @@ +from unittest.mock import Mock, patch + +from services.hit_testing_service import HitTestingService + + +def _retrieval_record(payload: dict): + record = Mock() + record.model_dump.return_value = payload + return record + + +def _dataset_document( + document_id: str = "document-1", + name: str = "guide.md", + data_source_type: str = "upload_file", + doc_type: str | None = None, + doc_metadata: dict | None = None, +): + document = Mock() + document.id = document_id + document.name = name + document.data_source_type = data_source_type + document.doc_type = doc_type + document.doc_metadata = doc_metadata + return document + + +class TestHitTestingServiceDumpRecords: + def test_dump_dataset_document_returns_frontend_required_fields(self): + document = _dataset_document(doc_metadata={"source": "manual"}) + + assert HitTestingService._dump_dataset_document(document) == { + "id": "document-1", + "data_source_type": "upload_file", + "name": "guide.md", + "doc_type": None, + "doc_metadata": {"source": "manual"}, + } + + def test_dump_retrieval_records_returns_dumped_records_without_document_ids(self): + record = _retrieval_record({"segment": None, "score": 0.95}) + + assert HitTestingService._dump_retrieval_records([record]) == [{"segment": None, "score": 0.95}] + + def test_dump_retrieval_records_injects_documents_and_keeps_non_segment_records(self): + record_without_segment = _retrieval_record({"segment": None, "score": 0.95}) + record_with_document = _retrieval_record( + { + "segment": { + "id": "segment-1", + "document_id": "document-1", + }, + "score": 0.9, + } + ) + scalars_result = Mock() + scalars_result.all.return_value = [_dataset_document()] + + with patch("services.hit_testing_service.db.session.scalars", return_value=scalars_result): + result = HitTestingService._dump_retrieval_records([record_without_segment, record_with_document]) + + assert result[0] == {"segment": None, "score": 0.95} + assert result[1]["segment"]["document"] == { + "id": "document-1", + "data_source_type": "upload_file", + "name": "guide.md", + "doc_type": None, + "doc_metadata": None, + } + + def test_dump_retrieval_records_skips_records_with_missing_documents(self, caplog): + record = _retrieval_record( + { + "segment": { + "id": "segment-1", + "document_id": "missing-document", + }, + "score": 0.95, + } + ) + scalars_result = Mock() + scalars_result.all.return_value = [] + + with patch("services.hit_testing_service.db.session.scalars", return_value=scalars_result): + result = HitTestingService._dump_retrieval_records([record]) + + assert result == [] + assert "Skipping hit-testing records with missing documents" in caplog.text diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 55af564821..b6370c0365 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture import services.human_input_service as human_input_service_module from core.repositories.human_input_repository import ( @@ -11,8 +12,8 @@ from core.repositories.human_input_repository import ( ) from graphon.nodes.human_input.entities import ( FormDefinition, - FormInput, - UserAction, + ParagraphInputConfig, + UserActionConfig, ) from graphon.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus from libs.datetime_utils import naive_utc_now @@ -50,7 +51,7 @@ def sample_form_record(): definition=FormDefinition( form_content="hello", inputs=[], - user_actions=[UserAction(id="submit", title="Submit")], + user_actions=[UserActionConfig(id="submit", title="Submit")], rendered_content="

hello

", expiration_time=naive_utc_now() + timedelta(hours=1), ), @@ -177,7 +178,9 @@ def test_get_form_definition_by_token_for_console_uses_repository(sample_form_re assert form.get_definition() == console_record.definition -def test_submit_form_by_token_calls_repository_and_enqueue(sample_form_record, mock_session_factory, mocker): +def test_submit_form_by_token_calls_repository_and_enqueue( + sample_form_record, mock_session_factory, mocker: MockerFixture +): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record @@ -204,7 +207,9 @@ def test_submit_form_by_token_calls_repository_and_enqueue(sample_form_record, m enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) -def test_submit_form_by_token_skips_enqueue_for_delivery_test(sample_form_record, mock_session_factory, mocker): +def test_submit_form_by_token_skips_enqueue_for_delivery_test( + sample_form_record, mock_session_factory, mocker: MockerFixture +): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) test_record = dataclasses.replace( @@ -227,7 +232,9 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test(sample_form_record enqueue_spy.assert_not_called() -def test_submit_form_by_token_passes_submission_user_id(sample_form_record, mock_session_factory, mocker): +def test_submit_form_by_token_passes_submission_user_id( + sample_form_record, mock_session_factory, mocker: MockerFixture +): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record @@ -273,7 +280,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa definition_with_input = FormDefinition( form_content="hello", - inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="content")], + inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content")], user_actions=sample_form_record.definition.user_actions, rendered_content="

hello

", expiration_time=sample_form_record.expiration_time, @@ -314,7 +321,7 @@ def test_form_submitted_error_init(): assert error.code == 412 -def test_human_input_service_init_with_engine(mocker): +def test_human_input_service_init_with_engine(mocker: MockerFixture): engine = MagicMock(spec=human_input_service_module.Engine) sessionmaker_mock = mocker.patch("services.human_input_service.sessionmaker") @@ -371,7 +378,7 @@ def test_submit_form_by_token_delivery_not_enabled(mock_session_factory): service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "action", {}) -def test_submit_form_by_token_no_workflow_run_id(sample_form_record, mock_session_factory, mocker): +def test_submit_form_by_token_no_workflow_run_id(sample_form_record, mock_session_factory, mocker: MockerFixture): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index 7adc15d63e..51f8b3ef5b 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -906,7 +906,7 @@ class TestMessageServiceSuggestedQuestions: ): """Test successful suggested questions generation in basic Chat mode.""" # Arrange - app = factory.create_app_mock(mode=AppMode.CHAT.value) + app = factory.create_app_mock(mode=AppMode.CHAT) user = factory.create_end_user_mock() message = factory.create_message_mock() mock_get_message.return_value = message @@ -953,7 +953,7 @@ class TestMessageServiceSuggestedQuestions: """Test suggested question generation uses frontend configured model and prompt.""" from core.app.entities.app_invoke_entities import InvokeFrom - app = factory.create_app_mock(mode=AppMode.CHAT.value) + app = factory.create_app_mock(mode=AppMode.CHAT) app.tenant_id = "tenant-123" user = factory.create_end_user_mock() message = factory.create_message_mock() @@ -1024,7 +1024,7 @@ class TestMessageServiceSuggestedQuestions: factory, ): """Test invalid frontend configured model falls back to tenant default model.""" - app = factory.create_app_mock(mode=AppMode.CHAT.value) + app = factory.create_app_mock(mode=AppMode.CHAT) app.tenant_id = "tenant-123" user = factory.create_end_user_mock() message = factory.create_message_mock() diff --git a/api/tests/unit_tests/services/test_model_load_balancing_service.py b/api/tests/unit_tests/services/test_model_load_balancing_service.py index 3119af40a2..beecf73caa 100644 --- a/api/tests/unit_tests/services/test_model_load_balancing_service.py +++ b/api/tests/unit_tests/services/test_model_load_balancing_service.py @@ -104,7 +104,7 @@ def test_enable_disable_model_load_balancing_should_call_provider_configuration_ service.provider_manager.get_configurations.return_value = {"openai": provider_configuration} # Act - getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM.value) + getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM) # Assert getattr(provider_configuration, expected_provider_method).assert_called_once_with( @@ -125,7 +125,7 @@ def test_enable_disable_model_load_balancing_should_raise_value_error_when_provi # Act + Assert with pytest.raises(ValueError, match="Provider openai does not exist"): - getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM.value) + getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM) def test_get_load_balancing_configs_should_raise_value_error_when_provider_missing( @@ -136,7 +136,7 @@ def test_get_load_balancing_configs_should_raise_value_error_when_provider_missi # Act + Assert with pytest.raises(ValueError, match="Provider openai does not exist"): - service.get_load_balancing_configs("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM.value) + service.get_load_balancing_configs("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM) def test_get_load_balancing_configs_should_insert_inherit_config_when_missing_for_custom_provider( @@ -177,7 +177,7 @@ def test_get_load_balancing_configs_should_insert_inherit_config_when_missing_fo "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, ) # Assert @@ -238,7 +238,7 @@ def test_get_load_balancing_configs_should_reorder_existing_inherit_and_tolerate "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, config_from="predefined-model", ) @@ -259,7 +259,7 @@ def test_get_load_balancing_config_should_raise_value_error_when_provider_missin # Act + Assert with pytest.raises(ValueError, match="Provider openai does not exist"): - service.get_load_balancing_config("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM.value, "cfg-1") + service.get_load_balancing_config("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM, "cfg-1") def test_get_load_balancing_config_should_return_none_when_config_not_found( @@ -272,7 +272,7 @@ def test_get_load_balancing_config_should_return_none_when_config_not_found( mock_db.session.scalar.return_value = None # Act - result = service.get_load_balancing_config("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM.value, "cfg-1") + result = service.get_load_balancing_config("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM, "cfg-1") # Assert assert result is None @@ -292,7 +292,7 @@ def test_get_load_balancing_config_should_return_obfuscated_payload_when_config_ mock_db.session.scalar.return_value = config # Act - result = service.get_load_balancing_config("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM.value, "cfg-1") + result = service.get_load_balancing_config("tenant-1", "openai", "gpt-4o-mini", ModelType.LLM, "cfg-1") # Assert assert result == { @@ -335,7 +335,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_provider_mi "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [], "custom-model", ) @@ -354,7 +354,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_configs_is_ "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, cast(list[dict[str, object]], "invalid-configs"), "custom-model", ) @@ -375,7 +375,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_config_item "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, cast(list[dict[str, object]], ["bad-item"]), "custom-model", ) @@ -397,7 +397,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_credential_ "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"credential_id": "cred-1", "enabled": True}], "predefined-model", ) @@ -418,7 +418,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_name_or_ena "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"enabled": True}], "custom-model", ) @@ -428,7 +428,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_name_or_ena "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"name": "cfg-without-enabled"}], "custom-model", ) @@ -450,7 +450,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_existing_co "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"id": "cfg-2", "name": "invalid", "enabled": True}], "custom-model", ) @@ -472,7 +472,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_credentials "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"id": "cfg-1", "name": "new", "enabled": True, "credentials": "bad"}], "custom-model", ) @@ -482,7 +482,7 @@ def test_update_load_balancing_configs_should_raise_value_error_when_credentials "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"name": "new-config", "enabled": True, "credentials": "bad"}], "custom-model", ) @@ -519,7 +519,7 @@ def test_update_load_balancing_configs_should_update_existing_create_new_and_del "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [ {"id": "cfg-1", "name": "updated-name", "enabled": False, "credentials": {"api_key": "plain"}}, {"name": "new-config", "enabled": True, "credentials": {"api_key": "plain"}}, @@ -553,7 +553,7 @@ def test_update_load_balancing_configs_should_raise_value_error_for_invalid_new_ "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"name": "__inherit__", "enabled": True, "credentials": {"api_key": "x"}}], "custom-model", ) @@ -563,7 +563,7 @@ def test_update_load_balancing_configs_should_raise_value_error_for_invalid_new_ "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"name": "new", "enabled": True}], "custom-model", ) @@ -585,7 +585,7 @@ def test_update_load_balancing_configs_should_create_from_existing_provider_cred "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, [{"credential_id": "cred-1", "enabled": True}], "predefined-model", ) @@ -611,7 +611,7 @@ def test_validate_load_balancing_credentials_should_raise_value_error_when_provi "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, {"api_key": "plain"}, ) @@ -631,7 +631,7 @@ def test_validate_load_balancing_credentials_should_raise_value_error_when_confi "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, {"api_key": "plain"}, config_id="cfg-1", ) @@ -654,7 +654,7 @@ def test_validate_load_balancing_credentials_should_delegate_to_custom_validate_ "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, {"api_key": "plain"}, config_id="cfg-1", ) @@ -662,7 +662,7 @@ def test_validate_load_balancing_credentials_should_delegate_to_custom_validate_ "tenant-1", "openai", "gpt-4o-mini", - ModelType.LLM.value, + ModelType.LLM, {"api_key": "plain"}, ) diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py index 28d459eac9..9e4eeb2d6e 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -90,7 +90,7 @@ class TestModelProviderServiceConfiguration: ) manager.get_configurations.return_value = {"openai": allowed, "embedding": filtered} - result = service.get_provider_list(tenant_id="tenant-1", model_type=ModelType.LLM.value) + result = service.get_provider_list(tenant_id="tenant-1", model_type=ModelType.LLM) assert len(result) == 1 assert result[0].provider == "openai" @@ -232,7 +232,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1", }, @@ -245,7 +245,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credentials": {"api_key": "x"}, }, @@ -258,7 +258,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credentials": {"api_key": "x"}, "credential_name": "cred-a", @@ -277,7 +277,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credentials": {"api_key": "x"}, "credential_id": "cred-1", @@ -298,7 +298,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1", }, @@ -311,7 +311,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1", }, @@ -324,7 +324,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", "credential_id": "cred-1", }, @@ -337,7 +337,7 @@ class TestModelProviderServiceDelegation: { "tenant_id": "tenant-1", "provider": "openai", - "model_type": ModelType.LLM.value, + "model_type": ModelType.LLM, "model": "gpt-4o", }, "delete_custom_model", @@ -425,7 +425,7 @@ class TestModelProviderServiceListingsAndDefaults: provider_configurations = SimpleNamespace(get_models=MagicMock(return_value=models)) manager.get_configurations.return_value = provider_configurations - result = service.get_models_by_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + result = service.get_models_by_model_type(tenant_id="tenant-1", model_type=ModelType.LLM) provider_configurations.get_models.assert_called_once_with(model_type=ModelType.LLM, only_active=True) assert len(result) == 1 @@ -495,7 +495,7 @@ class TestModelProviderServiceListingsAndDefaults: ), ) - result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM) assert result is not None assert result.model == "gpt-4o" @@ -506,7 +506,7 @@ class TestModelProviderServiceListingsAndDefaults: service, manager = _create_service_with_mocked_manager() manager.get_default_model.return_value = None - result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM) assert result is None @@ -514,7 +514,7 @@ class TestModelProviderServiceListingsAndDefaults: service, manager = _create_service_with_mocked_manager() manager.get_default_model.side_effect = RuntimeError("boom") - result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM.value) + result = service.get_default_model_of_model_type(tenant_id="tenant-1", model_type=ModelType.LLM) assert result is None @@ -523,7 +523,7 @@ class TestModelProviderServiceListingsAndDefaults: service.update_default_model_of_model_type( tenant_id="tenant-1", - model_type=ModelType.LLM.value, + model_type=ModelType.LLM, provider="openai", model="gpt-4o", ) @@ -593,7 +593,7 @@ class TestModelProviderServiceListingsAndDefaults: tenant_id="tenant-1", provider="openai", model="gpt-4o", - model_type=ModelType.LLM.value, + model_type=ModelType.LLM, ) getattr(provider_configuration, provider_method_name).assert_called_once_with( diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py new file mode 100644 index 0000000000..030d0d73fe --- /dev/null +++ b/api/tests/unit_tests/services/test_recommended_app_service.py @@ -0,0 +1,46 @@ +"""Unit tests for RecommendedAppService.get_recommend_app_detail null handling. + +Regression tests for #36096: accessing result['id'] when the retrieval +returns None causes a TypeError / KeyError in self-hosted mode. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from services.recommended_app_service import RecommendedAppService + + +class TestGetRecommendAppDetailNullCheck: + @patch("services.recommended_app_service.FeatureService", autospec=True) + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_returns_none_when_retrieval_returns_none_and_trial_disabled( + self, mock_config, mock_factory_class, mock_feature_service + ): + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = None + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False) + + result = RecommendedAppService.get_recommend_app_detail("nonexistent") + + assert result is None + + @patch("services.recommended_app_service.FeatureService", autospec=True) + @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) + @patch("services.recommended_app_service.dify_config", autospec=True) + def test_returns_none_when_retrieval_returns_none_and_trial_enabled( + self, mock_config, mock_factory_class, mock_feature_service + ): + """Regression for #36096: must not crash when result is None and enable_trial_app is True.""" + mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" + mock_instance = MagicMock() + mock_instance.get_recommend_app_detail.return_value = None + mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) + mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=True) + + result = RecommendedAppService.get_recommend_app_detail("nonexistent") + + assert result is None + mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent") diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index 6eba60e5f1..4da4af2d93 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -325,7 +325,7 @@ def test_update_trigger_subscription_should_raise_error_when_name_conflicts( id="sub-1", name="old", provider_id="langgenius/github/github", - credential_type=CredentialType.API_KEY.value, + credential_type=CredentialType.API_KEY, ) mock_session.scalar.side_effect = [subscription, object()] # found sub, name conflict _mock_get_trigger_provider(mocker, provider_controller) @@ -350,7 +350,7 @@ def test_update_trigger_subscription_should_update_fields_and_clear_cache( properties={"project": "enc-old"}, parameters={"event": "old"}, credentials={"api_key": "enc-old"}, - credential_type=CredentialType.API_KEY.value, + credential_type=CredentialType.API_KEY, credential_expires_at=0, expires_at=0, ) @@ -456,7 +456,7 @@ def test_delete_trigger_provider_should_delete_and_clear_cache_even_if_unsubscri id="sub-1", user_id="user-1", provider_id=str(provider_id), - credential_type=CredentialType.OAUTH2.value, + credential_type=CredentialType.OAUTH2, credentials={"token": "enc"}, to_entity=lambda: SimpleNamespace(id="sub-1"), ) @@ -492,7 +492,7 @@ def test_delete_trigger_provider_should_skip_unsubscribe_for_unauthorized( id="sub-2", user_id="user-1", provider_id=str(provider_id), - credential_type=CredentialType.UNAUTHORIZED.value, + credential_type=CredentialType.UNAUTHORIZED, credentials={}, to_entity=lambda: SimpleNamespace(id="sub-2"), ) @@ -527,7 +527,7 @@ def test_refresh_oauth_token_should_raise_error_for_non_oauth_credentials( mocker: MockerFixture, mock_session: MagicMock ) -> None: # Arrange - subscription = SimpleNamespace(credential_type=CredentialType.API_KEY.value) + subscription = SimpleNamespace(credential_type=CredentialType.API_KEY) mock_session.scalar.return_value = subscription # Act + Assert @@ -545,7 +545,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials( subscription = SimpleNamespace( provider_id=str(provider_id), user_id="user-1", - credential_type=CredentialType.OAUTH2.value, + credential_type=CredentialType.OAUTH2, credentials={"access_token": "enc"}, credential_expires_at=0, ) @@ -613,7 +613,7 @@ def test_refresh_subscription_should_refresh_and_persist_properties( parameters={"event": "push"}, properties={"p": "enc"}, credentials={"c": "enc"}, - credential_type=CredentialType.API_KEY.value, + credential_type=CredentialType.API_KEY, ) mock_session.scalar.return_value = subscription _mock_get_trigger_provider(mocker, provider_controller) @@ -989,7 +989,7 @@ def test_verify_subscription_credentials_should_raise_when_api_key_validation_fa provider_controller: MagicMock, ) -> None: # Arrange - subscription = SimpleNamespace(credential_type=CredentialType.API_KEY.value, credentials={"api_key": "old"}) + subscription = SimpleNamespace(credential_type=CredentialType.API_KEY, credentials={"api_key": "old"}) _mock_get_trigger_provider(mocker, provider_controller) mocker.patch.object(TriggerProviderService, "get_subscription_by_id", return_value=subscription) provider_controller.validate_credentials.side_effect = RuntimeError("bad credentials") @@ -1012,7 +1012,7 @@ def test_verify_subscription_credentials_should_return_verified_when_api_key_val provider_controller: MagicMock, ) -> None: # Arrange - subscription = SimpleNamespace(credential_type=CredentialType.API_KEY.value, credentials={"api_key": "old"}) + subscription = SimpleNamespace(credential_type=CredentialType.API_KEY, credentials={"api_key": "old"}) _mock_get_trigger_provider(mocker, provider_controller) mocker.patch.object(TriggerProviderService, "get_subscription_by_id", return_value=subscription) @@ -1036,7 +1036,7 @@ def test_verify_subscription_credentials_should_return_verified_for_non_api_key_ provider_controller: MagicMock, ) -> None: # Arrange - subscription = SimpleNamespace(credential_type=CredentialType.OAUTH2.value, credentials={}) + subscription = SimpleNamespace(credential_type=CredentialType.OAUTH2, credentials={}) _mock_get_trigger_provider(mocker, provider_controller) mocker.patch.object(TriggerProviderService, "get_subscription_by_id", return_value=subscription) @@ -1100,7 +1100,7 @@ def test_rebuild_trigger_subscription_should_raise_for_unsupported_credential_ty provider_controller: MagicMock, ) -> None: # Arrange - subscription = SimpleNamespace(credential_type=CredentialType.UNAUTHORIZED.value) + subscription = SimpleNamespace(credential_type=CredentialType.UNAUTHORIZED) _mock_get_trigger_provider(mocker, provider_controller) mocker.patch.object(TriggerProviderService, "get_subscription_by_id", return_value=subscription) @@ -1126,7 +1126,7 @@ def test_rebuild_trigger_subscription_should_raise_when_unsubscribe_fails( id="sub-1", user_id="user-1", endpoint_id="endpoint-1", - credential_type=CredentialType.API_KEY.value, + credential_type=CredentialType.API_KEY, credentials={"api_key": "old"}, to_entity=lambda: SimpleNamespace(id="sub-1"), ) @@ -1159,7 +1159,7 @@ def test_rebuild_trigger_subscription_should_resubscribe_and_update_existing_sub id="sub-1", user_id="user-1", endpoint_id="endpoint-1", - credential_type=CredentialType.API_KEY.value, + credential_type=CredentialType.API_KEY, credentials={"api_key": "old-key"}, to_entity=lambda: SimpleNamespace(id="sub-1"), ) diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index ffdcc046f9..a2b56fe777 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -140,7 +140,7 @@ class TestWebhookServiceUnit: assert args[1] == "text/plain" assert args[2] is webhook_trigger - def test_detect_binary_mimetype_uses_magic(self, monkeypatch): + def test_detect_binary_mimetype_uses_magic(self, monkeypatch: pytest.MonkeyPatch): """python-magic output should be used when available.""" fake_magic = MagicMock() fake_magic.from_buffer.return_value = "image/png" @@ -151,7 +151,7 @@ class TestWebhookServiceUnit: assert result == "image/png" fake_magic.from_buffer.assert_called_once() - def test_detect_binary_mimetype_fallback_without_magic(self, monkeypatch): + def test_detect_binary_mimetype_fallback_without_magic(self, monkeypatch: pytest.MonkeyPatch): """Fallback MIME type should be used when python-magic is unavailable.""" monkeypatch.setattr("services.trigger.webhook_service.magic", None) @@ -159,7 +159,7 @@ class TestWebhookServiceUnit: assert result == "application/octet-stream" - def test_detect_binary_mimetype_handles_magic_exception(self, monkeypatch): + def test_detect_binary_mimetype_handles_magic_exception(self, monkeypatch: pytest.MonkeyPatch): """Fallback MIME type should be used when python-magic raises an exception.""" try: import magic as real_magic @@ -268,8 +268,8 @@ class TestWebhookServiceUnit: } # Mock file reads - files["file1"].read.return_value = b"content1" - files["file2"].read.return_value = b"content2" + files["file1"].stream.read.return_value = b"content1" + files["file2"].stream.read.return_value = b"content2" webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" @@ -304,8 +304,8 @@ class TestWebhookServiceUnit: "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), } - files["good_file"].read.return_value = b"content" - files["bad_file"].read.side_effect = Exception("Read error") + files["good_file"].stream.read.return_value = b"content" + files["bad_file"].stream.read.side_effect = Exception("Read error") webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index feafada59a..f105364094 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -11,6 +11,7 @@ This test suite covers: import json import uuid +from types import SimpleNamespace from typing import Any, cast from unittest.mock import ANY, MagicMock, Mock, patch, sentinel @@ -61,7 +62,7 @@ class TestWorkflowAssociatedDataFactory: def create_app_mock( app_id: str = "app-123", tenant_id: str = "tenant-456", - mode: str = AppMode.WORKFLOW.value, + mode: str = AppMode.WORKFLOW, workflow_id: str | None = None, **kwargs, ) -> MagicMock: @@ -93,7 +94,7 @@ class TestWorkflowAssociatedDataFactory: tenant_id: str = "tenant-456", app_id: str = "app-123", version: str = Workflow.VERSION_DRAFT, - workflow_type: str = WorkflowType.WORKFLOW.value, + workflow_type: str = WorkflowType.WORKFLOW, graph: dict[str, Any] | None = None, features: dict[str, Any] | None = None, unique_hash: str | None = None, @@ -584,7 +585,7 @@ class TestWorkflowService: id="published-workflow-id", tenant_id=app.tenant_id, app_id=app.id, - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="2026-03-19T00:00:00", graph=json.dumps(TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()), features=json.dumps(legacy_features), @@ -597,7 +598,7 @@ class TestWorkflowService: id="draft-workflow-id", tenant_id=app.tenant_id, app_id=app.id, - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version=Workflow.VERSION_DRAFT, graph=json.dumps({"nodes": [], "edges": []}), features=json.dumps({}), @@ -685,7 +686,7 @@ class TestWorkflowService: Different app modes have different feature configurations. This ensures the features match the expected schema for workflow apps. """ - app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW.value) + app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW) features = {"file_upload": {"enabled": False}} with patch("services.workflow_service.WorkflowAppConfigManager.config_validate") as mock_validate: @@ -696,7 +697,7 @@ class TestWorkflowService: def test_validate_features_structure_advanced_chat_mode(self, workflow_service): """Test validate_features_structure for advanced chat mode.""" - app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.ADVANCED_CHAT.value) + app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.ADVANCED_CHAT) features = {"opening_statement": "Hello"} with patch("services.workflow_service.AdvancedChatAppConfigManager.config_validate") as mock_validate: @@ -707,7 +708,7 @@ class TestWorkflowService: def test_validate_features_structure_invalid_mode_raises_error(self, workflow_service): """Test validate_features_structure raises error for invalid mode.""" - app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.COMPLETION.value) + app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.COMPLETION) features = {} with pytest.raises(ValueError, match="Invalid app mode"): @@ -1326,7 +1327,7 @@ class TestWorkflowService: The conversion creates equivalent workflow nodes from the chat configuration, giving users more control and customization options. """ - app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.CHAT.value) + app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.CHAT) account = TestWorkflowAssociatedDataFactory.create_account_mock() args = { "name": "Converted Workflow", @@ -1337,7 +1338,7 @@ class TestWorkflowService: with patch("services.workflow_service.WorkflowConverter") as MockConverter: mock_converter = MockConverter.return_value - mock_new_app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW.value) + mock_new_app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW) mock_converter.convert_to_workflow.return_value = mock_new_app result = workflow_service.convert_to_workflow(app, account, args) @@ -1353,13 +1354,13 @@ class TestWorkflowService: Completion apps are simpler (single prompt-response), so the conversion creates a basic workflow with fewer nodes. """ - app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.COMPLETION.value) + app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.COMPLETION) account = TestWorkflowAssociatedDataFactory.create_account_mock() args = {"name": "Converted Workflow"} with patch("services.workflow_service.WorkflowConverter") as MockConverter: mock_converter = MockConverter.return_value - mock_new_app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW.value) + mock_new_app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW) mock_converter.convert_to_workflow.return_value = mock_new_app result = workflow_service.convert_to_workflow(app, account, args) @@ -1373,7 +1374,7 @@ class TestWorkflowService: Only chat and completion apps can be converted to workflows. Apps that are already workflows or have other modes cannot be converted. """ - app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW.value) + app = TestWorkflowAssociatedDataFactory.create_app_mock(mode=AppMode.WORKFLOW) account = TestWorkflowAssociatedDataFactory.create_account_mock() args = {} @@ -2087,7 +2088,7 @@ class TestSetupVariablePool: This helper initialises the VariablePool used for single-step workflow execution. """ - def _make_workflow(self, workflow_type: str = WorkflowType.WORKFLOW.value) -> MagicMock: + def _make_workflow(self, workflow_type: str = WorkflowType.WORKFLOW) -> MagicMock: wf = MagicMock(spec=Workflow) wf.app_id = "app-1" wf.id = "wf-1" @@ -2176,7 +2177,7 @@ class TestSetupVariablePool: from models.workflow import WorkflowType # Arrange - workflow = self._make_workflow(workflow_type=WorkflowType.CHAT.value) + workflow = self._make_workflow(workflow_type=WorkflowType.CHAT) # Act with ( @@ -2649,7 +2650,12 @@ class TestWorkflowServiceHumanInputOperations: mock_node = MagicMock() mock_node.node_data = MagicMock() + mock_node.node_data.user_actions = [ + SimpleNamespace(id="submit", title="card_visa_enterprise_001"), + ] mock_node.node_data.outputs_field_names.return_value = ["field1"] + mock_node.render_form_content_before_submission.return_value = "Ticket: {{#$output.field1#}}" + mock_node.render_form_content_with_outputs.return_value = "Ticket: val1" with ( patch("services.workflow_service.db"), @@ -2665,6 +2671,8 @@ class TestWorkflowServiceHumanInputOperations: app_model=app_model, account=account, node_id="node-1", form_inputs={"field1": "val1"}, action="submit" ) assert result["__action_id"] == "submit" + assert result["__action_value"] == "card_visa_enterprise_001" + assert result["__rendered_content"] == "Ticket: val1" mock_saver_cls.return_value.save.assert_called_once() def test_test_human_input_delivery_success(self, service: WorkflowService) -> None: @@ -2825,6 +2833,7 @@ class TestWorkflowServiceFreeNodeExecution: return_value=sentinel.adapted_node_data, ) as mock_adapt_node_data, patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context, + patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls, patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls, patch("services.workflow_service.HumanInputNode") as mock_node_cls, ): @@ -2843,10 +2852,14 @@ class TestWorkflowServiceFreeNodeExecution: mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value) mock_adapt_node_data.assert_called_once_with(node_config["data"]) mock_node_cls.validate_node_data.assert_called_once_with(sentinel.adapted_node_data) + mock_file_reference_factory_cls.assert_called_once_with( + mock_graph_init_context_cls.return_value.to_graph_init_params.return_value.run_context + ) mock_node_cls.assert_called_once_with( node_id="n-1", - config=sentinel.node_data, + data=sentinel.node_data, graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value, graph_runtime_state=ANY, + file_reference_factory=mock_file_reference_factory_cls.return_value, runtime=mock_runtime_cls.return_value, ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index ce0d94398d..c210db580e 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -180,7 +180,7 @@ class TestSetDefaultProvider: session.scalar.return_value = None with pytest.raises(ValueError, match="provider not found"): - BuiltinToolManageService.set_default_provider("t", "u", "p", "id") + BuiltinToolManageService.set_default_provider("t", "p", "id") @patch(f"{MODULE}.sessionmaker") @patch(f"{MODULE}.db") @@ -189,11 +189,29 @@ class TestSetDefaultProvider: target = MagicMock() session.scalar.return_value = target - result = BuiltinToolManageService.set_default_provider("t", "u", "p", "id") + result = BuiltinToolManageService.set_default_provider("t", "p", "id") assert result == {"result": "success"} assert target.is_default is True + @patch(f"{MODULE}.sessionmaker") + @patch(f"{MODULE}.db") + def test_clear_default_is_tenant_scoped_not_user_scoped(self, mock_db, mock_sm_cls): + # Regression: clearing prior defaults must NOT filter by user_id, otherwise + # two workspace members can each leave their own credential as default at + # the same time (the default flag is tenant-scoped, not per-user). + session = _mock_sessionmaker(mock_sm_cls) + session.scalar.return_value = MagicMock() + + BuiltinToolManageService.set_default_provider("tenant-1", "google", "cred-id") + + session.execute.assert_called_once() + update_stmt = session.execute.call_args.args[0] + compiled = str(update_stmt.compile(compile_kwargs={"literal_binds": True})) + assert "user_id" not in compiled + assert "tenant_id" in compiled + assert "provider" in compiled + class TestUpdateBuiltinToolProvider: @patch(f"{MODULE}.sessionmaker") diff --git a/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py b/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py index 663eec6a06..b5b9f0bd97 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py @@ -398,7 +398,7 @@ class TestWorkflowDraftVariableService: self, mock_engine, mock_session, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, ): """Test resetting a node variable when execution record doesn't exist""" mock_repo_session = Mock(spec=Session) @@ -435,7 +435,7 @@ class TestWorkflowDraftVariableService: def test_reset_node_variable_with_valid_execution_record( self, mock_session, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, ): """Test resetting a node variable with valid execution record - should restore from execution""" mock_repo_session = Mock(spec=Session) diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index dfdbd9acd6..17e9a077d6 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -414,8 +414,8 @@ def test_parse_event_message_should_parse_only_json_object( def test_is_terminal_event_should_recognize_finished_and_optional_paused_events() -> None: # Arrange - finished_event = {"event": StreamEvent.WORKFLOW_FINISHED.value} - paused_event = {"event": StreamEvent.WORKFLOW_PAUSED.value} + finished_event = {"event": StreamEvent.WORKFLOW_FINISHED} + paused_event = {"event": StreamEvent.WORKFLOW_PAUSED} # Act is_finished = service_module._is_terminal_event(finished_event, close_on_pause=False) @@ -426,7 +426,7 @@ def test_is_terminal_event_should_recognize_finished_and_optional_paused_events( assert is_finished is True assert paused_without_flag is False assert paused_with_flag is True - assert service_module._is_terminal_event(StreamEvent.PING.value, close_on_pause=True) is False + assert service_module._is_terminal_event(StreamEvent.PING, close_on_pause=True) is False def test_apply_message_context_should_update_payload_when_context_exists() -> None: @@ -569,7 +569,7 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even monkeypatch.setattr( service_module, "_build_snapshot_events", - MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"}]), + MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED, "task_id": "task-1"}]), ) # Act @@ -584,9 +584,9 @@ def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_even ) # Assert - assert events[0] == StreamEvent.PING.value + assert events[0] == StreamEvent.PING finished_event = cast(Mapping[str, Any], events[1]) - assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED.value + assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED assert buffer_state.stop_event.is_set() is True node_repo.get_execution_snapshots_by_workflow_run.assert_called_once() called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs @@ -643,7 +643,7 @@ def test_build_workflow_event_stream_should_emit_periodic_ping_and_stop_after_id ) # Assert - assert events == [StreamEvent.PING.value, StreamEvent.PING.value] + assert events == [StreamEvent.PING, StreamEvent.PING] assert buffer_state.stop_event.is_set() is True @@ -686,7 +686,7 @@ def test_build_workflow_event_stream_should_exit_when_buffer_done_and_empty( ) # Assert - assert events == [StreamEvent.PING.value] + assert events == [StreamEvent.PING] assert buffer_state.stop_event.is_set() is True @@ -706,7 +706,7 @@ def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) - snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value}]) + snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED}]) monkeypatch.setattr(service_module, "_build_snapshot_events", snapshot_builder) buffer_state = BufferState( queue=queue.Queue(), @@ -729,7 +729,7 @@ def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( ) # Assert - assert events[0] == StreamEvent.PING.value + assert events[0] == StreamEvent.PING assert snapshot_builder.call_args.kwargs["pause_entity"] is None @@ -779,7 +779,7 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M session_maker=cast(sessionmaker[Session], session_maker), ) - assert events[-2]["event"] == StreamEvent.HUMAN_INPUT_REQUIRED.value + assert events[-2]["event"] == StreamEvent.HUMAN_INPUT_REQUIRED assert events[-2]["data"]["form_token"] == "wtok" assert events[-2]["data"]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) pause_data = events[-1]["data"] @@ -837,6 +837,6 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex ) pause_event = cast(Mapping[str, Any], events[-1]) - assert pause_event["event"] == StreamEvent.WORKFLOW_PAUSED.value + assert pause_event["event"] == StreamEvent.WORKFLOW_PAUSED assert pause_event["data"]["reasons"][0]["form_token"] == "wtok" assert pause_event["data"]["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py index d2634d7d7b..4d711f1bf8 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service_additional.py @@ -215,8 +215,8 @@ class TestWorkflowEventSnapshotHelpers: assert result == expected def test_is_terminal_event_should_recognize_finished_and_optional_paused_events(self) -> None: - finished_event = {"event": StreamEvent.WORKFLOW_FINISHED.value} - paused_event = {"event": StreamEvent.WORKFLOW_PAUSED.value} + finished_event = {"event": StreamEvent.WORKFLOW_FINISHED} + paused_event = {"event": StreamEvent.WORKFLOW_PAUSED} is_finished = service_module._is_terminal_event(finished_event, include_paused=False) paused_without_flag = service_module._is_terminal_event(paused_event, include_paused=False) @@ -225,7 +225,7 @@ class TestWorkflowEventSnapshotHelpers: assert is_finished is True assert paused_without_flag is False assert paused_with_flag is True - assert service_module._is_terminal_event(StreamEvent.PING.value, include_paused=True) is False + assert service_module._is_terminal_event(StreamEvent.PING, include_paused=True) is False def test_apply_message_context_should_update_payload_when_context_exists(self) -> None: payload: dict[str, Any] = {"event": "workflow_started"} @@ -352,7 +352,7 @@ class TestBuildWorkflowEventStream: monkeypatch.setattr( service_module, "_build_snapshot_events", - MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"}]), + MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED, "task_id": "task-1"}]), ) events = list( @@ -365,9 +365,9 @@ class TestBuildWorkflowEventStream: ) ) - assert events[0] == StreamEvent.PING.value + assert events[0] == StreamEvent.PING finished_event = cast(Mapping[str, Any], events[1]) - assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED.value + assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED assert buffer_state.stop_event.is_set() is True node_repo.get_execution_snapshots_by_workflow_run.assert_called_once() called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs @@ -421,7 +421,7 @@ class TestBuildWorkflowEventStream: ) ) - assert events == [StreamEvent.PING.value, StreamEvent.PING.value] + assert events == [StreamEvent.PING, StreamEvent.PING] assert buffer_state.stop_event.is_set() is True def test_build_workflow_event_stream_should_exit_when_buffer_done_and_empty( @@ -461,7 +461,7 @@ class TestBuildWorkflowEventStream: ) ) - assert events == [StreamEvent.PING.value] + assert events == [StreamEvent.PING] assert buffer_state.stop_event.is_set() is True def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( @@ -480,7 +480,7 @@ class TestBuildWorkflowEventStream: monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) - snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value}]) + snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED}]) monkeypatch.setattr(service_module, "_build_snapshot_events", snapshot_builder) buffer_state = BufferState( queue=queue.Queue(), @@ -501,5 +501,5 @@ class TestBuildWorkflowEventStream: ) ) - assert events[0] == StreamEvent.PING.value + assert events[0] == StreamEvent.PING assert snapshot_builder.call_args.kwargs["pause_entity"] is None diff --git a/api/tests/unit_tests/tasks/test_document_indexing_update_task.py b/api/tests/unit_tests/tasks/test_document_indexing_update_task.py new file mode 100644 index 0000000000..b73275b97d --- /dev/null +++ b/api/tests/unit_tests/tasks/test_document_indexing_update_task.py @@ -0,0 +1,524 @@ +""" +Unit tests for document_indexing_update_task summary generation. + +After updating a document via the API, the summary index should be +regenerated under the same conditions as during initial creation: +- indexing_technique is HIGH_QUALITY +- summary_index_setting has enable=True +- document.indexing_status is COMPLETED +- document.doc_form is not QA_INDEX +- document.need_summary is True +""" + +from contextlib import nullcontext +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.indexing_runner import DocumentIsPausedError +from tasks.document_indexing_update_task import document_indexing_update_task + + +class _SessionContext: + """Minimal context manager that yields a mock session.""" + + def __init__(self, session: MagicMock) -> None: + self._session = session + + def __enter__(self) -> MagicMock: + return self._session + + def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override] + return None + + +def _make_dataset_and_documents( + *, + dataset_id: str = "ds-1", + document_id: str = "doc-1", + indexing_technique: str = "high_quality", + summary_index_setting: dict | None = None, + doc_form: str = "text_model", + need_summary: bool = True, +): + """Create mock dataset and document objects. + + Returns (dataset, doc_for_session1, doc_for_session3). + + session1 doc: before IndexingRunner runs (status irrelevant for summary). + session3 doc: re-queried after IndexingRunner completes — normally COMPLETED. + """ + dataset = SimpleNamespace( + id=dataset_id, + indexing_technique=indexing_technique, + summary_index_setting=summary_index_setting, + ) + doc_s1 = SimpleNamespace( + id=document_id, + dataset_id=dataset_id, + indexing_status="waiting", + doc_form=doc_form, + need_summary=need_summary, + ) + # After IndexingRunner.run the document status is COMPLETED in the DB + doc_s3 = SimpleNamespace( + id=document_id, + dataset_id=dataset_id, + indexing_status="completed", + doc_form=doc_form, + need_summary=need_summary, + ) + return dataset, doc_s1, doc_s3 + + +def _patch_all(monkeypatch: pytest.MonkeyPatch, *, sessions, runner, processor): + """Wire up all mocks for document_indexing_update_task.""" + monkeypatch.setattr( + "tasks.document_indexing_update_task.session_factory.create_session", + MagicMock(side_effect=sessions), + ) + monkeypatch.setattr( + "tasks.document_indexing_update_task.IndexProcessorFactory", + MagicMock(return_value=MagicMock(init_index_processor=MagicMock(return_value=processor))), + ) + monkeypatch.setattr( + "tasks.document_indexing_update_task.IndexingRunner", + MagicMock(return_value=runner), + ) + + +def _session_with_begin(): + """Create a mock session with a begin() context manager.""" + s = MagicMock() + s.begin.return_value = nullcontext() + return s + + +class TestUpdateTaskSummaryGeneration: + """Tests for summary index generation in the document update task. + + The update task creates sessions in this order: + 1. session1: fetch document + dataset + segments (uses begin()) + 2. session2: delete segments — only if segments exist (uses begin()) + 3. session3: summary check — only if indexing succeeded (no begin()) + + With empty segments (default), only sessions 1 and 3 are created. + """ + + def test_should_queue_summary_when_conditions_met(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary task is queued when all conditions are met.""" + dataset, doc_s1, doc_s3 = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.side_effect = [dataset, doc_s3] + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_called_once_with("ds-1", "doc-1", None) + + def test_should_not_queue_when_not_high_quality(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when indexing_technique is not high_quality.""" + dataset, doc_s1, _ = _make_dataset_and_documents( + indexing_technique="economy", + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.return_value = dataset # dataset.indexing_technique == "economy" + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_summary_setting_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when summary_index_setting has enable=False.""" + dataset, doc_s1, _ = _make_dataset_and_documents( + summary_index_setting={"enable": False}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.return_value = dataset + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_summary_setting_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when summary_index_setting is None.""" + dataset, doc_s1, _ = _make_dataset_and_documents( + summary_index_setting=None, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.return_value = dataset + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_need_summary_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when document.need_summary is False.""" + dataset, doc_s1, doc_s3 = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + need_summary=False, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.side_effect = [dataset, doc_s3] + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_qa_index_form(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when doc_form is QA_INDEX.""" + dataset, doc_s1, doc_s3 = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + doc_form="qa_model", + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.side_effect = [dataset, doc_s3] + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_indexing_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when IndexingRunner.run raises.""" + dataset, doc_s1, _ = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + runner = MagicMock() + runner.run.side_effect = Exception("indexing failed") + processor = MagicMock() + + # Only session1 needed — task returns early after indexing failure + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_document_is_paused(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when IndexingRunner raises DocumentIsPausedError.""" + + dataset, doc_s1, _ = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + runner = MagicMock() + runner.run.side_effect = DocumentIsPausedError("doc-1 is paused") + processor = MagicMock() + + # Only session1 needed — task returns early after paused error + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_dataset_not_found_after_indexing(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when the dataset disappears after indexing.""" + dataset, doc_s1, _ = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + # Session 3: dataset is None + session3 = MagicMock() + session3.scalar.return_value = None + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_not_queue_when_document_not_completed_after_indexing(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Summary is skipped when document indexing_status is not COMPLETED after indexing.""" + dataset, doc_s1, _ = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + # Document still in error status after indexing + doc_s3_error = SimpleNamespace( + id="doc-1", + dataset_id="ds-1", + indexing_status="error", + doc_form="text_model", + need_summary=True, + ) + session3 = MagicMock() + session3.scalar.side_effect = [dataset, doc_s3_error] + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_not_called() + + def test_should_swallow_summary_queue_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Task should not raise when generate_summary_index_task.delay raises.""" + dataset, doc_s1, doc_s3 = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[])) + + session3 = MagicMock() + session3.scalar.side_effect = [dataset, doc_s3] + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[_SessionContext(session1), _SessionContext(session3)], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock(side_effect=Exception("queue full")) + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + # Should not raise + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_called_once_with("ds-1", "doc-1", None) + + def test_should_queue_summary_with_segments_and_session2(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When segments exist, session2 is also created for deletion. + Verify summary generation still works correctly.""" + dataset, doc_s1, doc_s3 = _make_dataset_and_documents( + summary_index_setting={"enable": True}, + ) + + session1 = _session_with_begin() + session1.scalar.side_effect = [doc_s1, dataset] + seg = SimpleNamespace(index_node_id="node-1") + session1.scalars.return_value = MagicMock(all=MagicMock(return_value=[seg])) + + # Session 2: segment deletion + session2 = _session_with_begin() + + session3 = MagicMock() + session3.scalar.side_effect = [dataset, doc_s3] + + runner = MagicMock() + processor = MagicMock() + + _patch_all( + monkeypatch, + sessions=[ + _SessionContext(session1), + _SessionContext(session2), + _SessionContext(session3), + ], + runner=runner, + processor=processor, + ) + + delay_mock = MagicMock() + monkeypatch.setattr( + "tasks.document_indexing_update_task.generate_summary_index_task.delay", + delay_mock, + ) + + document_indexing_update_task("ds-1", "doc-1") + + delay_mock.assert_called_once_with("ds-1", "doc-1", None) diff --git a/api/tests/unit_tests/tasks/test_ops_trace_task.py b/api/tests/unit_tests/tasks/test_ops_trace_task.py new file mode 100644 index 0000000000..5844c55c04 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_ops_trace_task.py @@ -0,0 +1,301 @@ +import json +import sys +from contextlib import contextmanager +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest +from celery.exceptions import Retry + +from core.ops.entities.config_entity import OPS_TRACE_FAILED_KEY +from core.ops.exceptions import RetryableTraceDispatchError +from tasks.ops_trace_task import process_trace_tasks + + +@contextmanager +def fake_app_context(): + yield + + +class FakeCurrentApp: + def app_context(self): + return fake_app_context() + + +def _install_trace_manager( + trace_instance: MagicMock, + *, + enterprise_enabled: bool = False, + enterprise_trace_cls: MagicMock | None = None, +) -> dict[str, ModuleType]: + ops_trace_manager_module = ModuleType("core.ops.ops_trace_manager") + + class StubOpsTraceManager: + @staticmethod + def get_ops_trace_instance(app_id: str) -> MagicMock: + return trace_instance + + telemetry_module = ModuleType("extensions.ext_enterprise_telemetry") + telemetry_module.is_enabled = lambda: enterprise_enabled + + ops_trace_manager_module.OpsTraceManager = StubOpsTraceManager + modules = { + "core.ops.ops_trace_manager": ops_trace_manager_module, + "extensions.ext_enterprise_telemetry": telemetry_module, + } + if enterprise_trace_cls is not None: + enterprise_module = ModuleType("enterprise") + enterprise_telemetry_module = ModuleType("enterprise.telemetry") + enterprise_trace_module = ModuleType("enterprise.telemetry.enterprise_trace") + enterprise_trace_module.EnterpriseOtelTrace = enterprise_trace_cls + modules.update( + { + "enterprise": enterprise_module, + "enterprise.telemetry": enterprise_telemetry_module, + "enterprise.telemetry.enterprise_trace": enterprise_trace_module, + } + ) + return modules + + +def _make_payload() -> str: + return json.dumps({"trace_info": {}, "trace_info_type": None}) + + +def _decode_saved_payload(payload: bytes | str) -> dict[str, object]: + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + return json.loads(payload) + + +def _retryable_dispatch_error() -> RetryableTraceDispatchError: + return RetryableTraceDispatchError("transient trace dispatch failure") + + +def _run_task(file_info: dict[str, str], retries: int = 0) -> None: + process_trace_tasks.push_request(retries=retries) + try: + process_trace_tasks.run(file_info) + finally: + process_trace_tasks.pop_request() + + +def test_process_trace_tasks_retries_retryable_dispatch_failure_and_preserves_payload(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_marks_enterprise_trace_dispatched_before_retryable_dispatch_retry(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + enterprise_tracer = MagicMock() + enterprise_trace_cls = MagicMock(return_value=enterprise_tracer) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + enterprise_tracer.trace.assert_called_once_with({}) + saved_path, saved_payload = mock_save.call_args.args + assert saved_path == "ops_trace/app-id/file-id.json" + assert _decode_saved_payload(saved_payload)["_enterprise_trace_dispatched"] is True + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_does_not_mark_failed_enterprise_trace_as_dispatched_before_retry(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + enterprise_tracer = MagicMock() + enterprise_tracer.trace.side_effect = RuntimeError("enterprise trace failed") + enterprise_trace_cls = MagicMock(return_value=enterprise_tracer) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + enterprise_tracer.trace.assert_called_once_with({}) + mock_save.assert_not_called() + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_skips_enterprise_trace_when_retry_payload_was_already_dispatched(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + enterprise_trace_cls = MagicMock() + payload = json.dumps({"trace_info": {}, "trace_info_type": None, "_enterprise_trace_dispatched": True}) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=payload), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + enterprise_trace_cls.assert_not_called() + trace_instance.trace.assert_called_once_with({}) + mock_save.assert_not_called() + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_not_called() + + +def test_process_trace_tasks_default_retry_window_covers_parent_span_context_ttl(): + assert process_trace_tasks.max_retries * process_trace_tasks.default_retry_delay >= 300 + + +def test_process_trace_tasks_deletes_payload_on_success(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + trace_instance.trace.assert_called_once_with({}) + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_not_called() + + +def test_process_trace_tasks_deletes_payload_and_counts_terminal_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + trace_instance.trace.side_effect = RuntimeError("trace failed") + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") + + +def test_process_trace_tasks_treats_retry_enqueue_failure_as_terminal_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + retry_enqueue_error = RuntimeError("retry enqueue failed") + trace_instance.trace.side_effect = pending_error + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_enqueue_error) as mock_retry, + ): + _run_task(file_info) + + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") + + +def test_process_trace_tasks_deletes_payload_and_counts_exhausted_retryable_dispatch_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry") as mock_retry, + ): + _run_task(file_info, retries=process_trace_tasks.max_retries) + + mock_retry.assert_not_called() + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") diff --git a/api/tests/unit_tests/tasks/test_workflow_execute_task.py b/api/tests/unit_tests/tasks/test_workflow_execute_task.py index 72508bef52..2544c9d61a 100644 --- a/api/tests/unit_tests/tasks/test_workflow_execute_task.py +++ b/api/tests/unit_tests/tasks/test_workflow_execute_task.py @@ -122,7 +122,7 @@ def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(m workflow_run = SimpleNamespace( workflow_id="wf-id", app_id="app-id", - created_by_role=CreatorUserRole.ACCOUNT.value, + created_by_role=CreatorUserRole.ACCOUNT, created_by="account-id", tenant_id="tenant-id", ) @@ -208,7 +208,7 @@ def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversat workflow_run = SimpleNamespace( workflow_id="wf-id", app_id="app-id", - created_by_role=CreatorUserRole.ACCOUNT.value, + created_by_role=CreatorUserRole.ACCOUNT, created_by="account-id", tenant_id="tenant-id", ) diff --git a/api/uv.lock b/api/uv.lock index 9806f506aa..5ffda81f39 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -50,7 +50,10 @@ members = [ "dify-vdb-vikingdb", "dify-vdb-weaviate", ] -overrides = [{ name = "pyarrow", specifier = ">=18.0.0" }] +overrides = [ + { name = "litellm", specifier = ">=1.83.7" }, + { name = "pyarrow", specifier = ">=18.0.0" }, +] [[package]] name = "abnf" @@ -379,14 +382,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.11" +version = "1.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/10/b325d58ffe86815b399334a101e63bc6fa4e1953921cb23703b48a0a0220/authlib-1.6.11.tar.gz", hash = "sha256:64db35b9b01aeccb4715a6c9a6613a06f2bd7be2ab9d2eb89edd1dfc7580a38f", size = 165359, upload-time = "2026-04-16T07:22:50.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/2f/55fca558f925a51db046e5b929deb317ddb05afed74b22d89f4eca578980/authlib-1.6.11-py2.py3-none-any.whl", hash = "sha256:c8687a9a26451c51a34a06fa17bb97cb15bba46a6a626755e2d7f50da8bff3e3", size = 244469, upload-time = "2026-04-16T07:22:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" }, ] [[package]] @@ -467,18 +470,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, ] -[[package]] -name = "basedpyright" -version = "1.39.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodejs-wheel-binaries" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" }, -] - [[package]] name = "bce-python-sdk" version = "0.9.71" @@ -604,16 +595,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.3" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/50/ea184e159c4ac64fef816a72094fb8656eb071361a39ed22c0e3b15a35b4/boto3-1.43.3.tar.gz", hash = "sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5", size = 113111, upload-time = "2026-05-04T19:34:09.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/ad/8a6946a329f0127322108e537dc1c0d9f8eea4f1d1231702c073d2e85f46/boto3-1.43.3-py3-none-any.whl", hash = "sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0", size = 140501, upload-time = "2026-05-04T19:34:07.991Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" }, ] [[package]] @@ -636,16 +627,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.43.3" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/ac/cd55f886e17b6b952dbc95b792d3645a73d58586a1400ababe54406073bd/botocore-1.43.3.tar.gz", hash = "sha256:eac6da0fffccf87888ebf4d89f0b2378218a707efa748cd955b838995e944695", size = 15308705, upload-time = "2026-05-04T19:33:56.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/99/1d9e296edf244f47e0508032f20999f8fd40704dd3c5b601fed099424eb6/botocore-1.43.3-py3-none-any.whl", hash = "sha256:ec0769eb0f7c5034856bb406a92698dbc02a3d4be0f78a384747106b161d8ea3", size = 14989027, upload-time = "2026-05-04T19:33:50.81Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] [[package]] @@ -889,14 +880,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] @@ -1287,9 +1278,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dify-agent" +version = "0.1.0" +source = { directory = "../dify-agent" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-ai-slim" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" }, + { name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" }, + { name = "pydantic", specifier = ">=2.12.5,<2.13" }, + { name = "pydantic-ai-slim", specifier = ">=1.85.1" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" }, + { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" }, + { name = "redis", marker = "extra == 'server'", specifier = ">=5" }, + { name = "typing-extensions", specifier = ">=4.12.2" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" }, +] +provides-extras = ["server"] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.39.3" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.10.7" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-examples", specifier = ">=0.0.18" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "ruff", specifier = ">=0.15.11" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1,<2" }, + { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, + { name = "mkdocs-material", specifier = ">=9.7.0" }, + { name = "mkdocstrings-python", specifier = ">=2.0.0" }, +] + [[package]] name = "dify-api" -version = "1.14.0" +version = "1.14.2" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, @@ -1335,10 +1369,10 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "basedpyright" }, { name = "boto3-stubs" }, { name = "celery-types" }, { name = "coverage" }, + { name = "dify-agent" }, { name = "dotenv-linter" }, { name = "faker" }, { name = "hypothesis" }, @@ -1578,7 +1612,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.43.3" }, + { name = "boto3", specifier = ">=1.43.6" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1592,10 +1626,10 @@ requires-dist = [ { name = "gevent", specifier = ">=26.4.0" }, { name = "gevent-websocket", specifier = ">=0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0" }, - { name = "google-api-python-client", specifier = ">=2.195.0" }, - { name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" }, - { name = "graphon", specifier = "~=0.2.2" }, - { name = "gunicorn", specifier = ">=25.3.0" }, + { name = "google-api-python-client", specifier = ">=2.196.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, + { name = "graphon", specifier = "~=0.4.0" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "json-repair", specifier = "~=0.59.4" }, @@ -1618,10 +1652,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "basedpyright", specifier = ">=1.39.3" }, { name = "boto3-stubs", specifier = ">=1.43.2" }, { name = "celery-types", specifier = ">=0.23.0" }, { name = "coverage", specifier = ">=7.13.4" }, + { name = "dify-agent", directory = "../dify-agent" }, { name = "dotenv-linter", specifier = ">=0.7.0" }, { name = "faker", specifier = ">=40.15.0" }, { name = "hypothesis", specifier = ">=6.152.4" }, @@ -1629,7 +1663,7 @@ dev = [ { name = "lxml-stubs", specifier = ">=0.5.1" }, { name = "mypy", specifier = ">=1.20.2" }, { name = "pandas-stubs", specifier = ">=3.0.0" }, - { name = "pyrefly", specifier = ">=0.62.0" }, + { name = "pyrefly", specifier = ">=1.0.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-benchmark", specifier = ">=5.2.3" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -1689,7 +1723,7 @@ storage = [ { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = ">=0.46.0" }, { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.29.0" }, + { name = "supabase", specifier = ">=2.30.0" }, { name = "tos", specifier = ">=2.9.0" }, ] tools = [ @@ -2609,6 +2643,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "genai-prices" +version = "0.0.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/c8/b61a028b8d8ee286ffab3f9b9f1c9229087184e7d543cea4e349e11375b0/genai_prices-0.0.59.tar.gz", hash = "sha256:3e1c7dcd9b38163589c8cf4a9bcfd286c52ea57a3becdc062a2cbaa8295b08c4", size = 67406, upload-time = "2026-05-07T12:08:40.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/f9/4693c127f9fab0a8d39c47c198e378ecafcb043463e6dd73df205eacbc13/genai_prices-0.0.59-py3-none-any.whl", hash = "sha256:88fd8818e6807374e5a5c03f293b574ade5f18a3060622080cdd94a03cf43115", size = 70509, upload-time = "2026-05-07T12:08:39.075Z" }, +] + [[package]] name = "gevent" version = "26.4.0" @@ -2657,14 +2704,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.47" +version = "3.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" }, + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] [[package]] @@ -2719,7 +2766,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.195.0" +version = "2.196.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2728,9 +2775,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/07/08d759b9cb10f48af14b25262dd0d6685ca8cda6c1f9e8a8109f57457205/google_api_python_client-2.195.0.tar.gz", hash = "sha256:c72cf2661c3addf01c880ce60541e83e1df354644b874f7f9d8d5ed2070446ae", size = 14584819, upload-time = "2026-04-30T21:51:50.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/b9/2c71095e31fff57668fec7c07ac897df065f15521d070e63229e13689590/google_api_python_client-2.195.0-py3-none-any.whl", hash = "sha256:753e62057f23049a89534bea0162b60fe391b85fb86d80bcdf884d05ec91c5bf", size = 15162418, upload-time = "2026-04-30T21:51:47.444Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, ] [[package]] @@ -2766,7 +2813,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.149.0" +version = "1.151.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2782,9 +2829,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" }, ] [[package]] @@ -2937,7 +2984,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.2.2" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -2958,9 +3005,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/50/e745a79c5f742f88f6011a1f7c9ba2c2f9cc1beedd982f0b192f1ab8c748/graphon-0.2.2.tar.gz", hash = "sha256:141f0de536171850f1af6f738dc66f0285aadd3c097f1dad2a038636789e0aa5", size = 236360, upload-time = "2026-04-17T08:52:28.047Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/24/eb1e7983404dcac84816b76ea450e1bb97023e55e00c699d609340bc361e/graphon-0.4.0.tar.gz", hash = "sha256:afb0c7a58f89e09cfa585296429b4d08cd0df80b9ac54d550f88e7d76ec48ee0", size = 261812, upload-time = "2026-05-13T11:48:39.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/bad6b3fd1e4b4defc16e6ea106e55c44725a159f1d191a99877bce1c9931/graphon-0.4.0-py3-none-any.whl", hash = "sha256:b33f95886da823d5b1b53d663a4f5f8fa383c37740f3bd19297b8d140fcb804c", size = 372711, upload-time = "2026-05-13T11:48:37.712Z" }, ] [[package]] @@ -3000,6 +3047,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "grimp" version = "3.14" @@ -3099,14 +3155,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "25.3.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -3355,14 +3411,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.4.0" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320, upload-time = "2024-08-20T17:11:42.348Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269, upload-time = "2024-08-20T17:11:41.102Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] @@ -3503,7 +3559,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -3511,9 +3567,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, ] [[package]] @@ -3604,7 +3660,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.7.31" +version = "0.7.38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3617,9 +3673,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/11/696019490992db5c87774dc20515529ef42a01e1d770fb754ed6d9b12fb0/langsmith-0.7.31.tar.gz", hash = "sha256:331ee4f7c26bb5be4022b9859b7d7b122cbf8c9d01d9f530114c1914b0349ffb", size = 1178480, upload-time = "2026-04-14T17:55:41.242Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c9/b3e54cfcb480876dfe33ecfdd64feeb621a86d9e6f4a6b9eb46851807018/langsmith-0.7.38.tar.gz", hash = "sha256:0db529b768d66c45f22fe959a0af7151342704fefafdecf3c60b14097c14fdb1", size = 4431914, upload-time = "2026-04-29T00:21:42.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/a1/a013cf458c301cda86a213dd153ce0a01c93f1ab5833f951e6a44c9763ce/langsmith-0.7.31-py3-none-any.whl", hash = "sha256:0291d49203f6e80dda011af1afda61eb0595a4d697adb684590a8805e1d61fb6", size = 373276, upload-time = "2026-04-14T17:55:39.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bc/a19d0a6d5575c637796675831dbef3555568e84d913f14ec579f92162ffa/langsmith-0.7.38-py3-none-any.whl", hash = "sha256:9c400ad508c0e4edc37bd55987047c6b8aac36ddd55f6096e3806f4d6a100618", size = 392310, upload-time = "2026-04-29T00:21:40.534Z" }, ] [[package]] @@ -3654,7 +3710,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.83.0" +version = "1.83.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3670,9 +3726,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/92/6ce9737554994ca8e536e5f4f6a87cc7c4774b656c9eb9add071caf7d54b/litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a", size = 17333062, upload-time = "2026-03-31T05:08:25.331Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/2c/a670cc050fcd6f45c6199eb99e259c73aea92edba8d5c2fc1b3686d36217/litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8", size = 15610306, upload-time = "2026-03-31T05:08:21.987Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" }, ] [[package]] @@ -3687,6 +3743,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, ] +[[package]] +name = "logfire-api" +version = "4.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/1b/0c74ad85f977743ba4c589e46e0cb138d6a6e69487830f4e86ebbdb145a3/logfire_api-4.32.1.tar.gz", hash = "sha256:5e8714b2bb5fb5d1f4a4a833941e4ca711b75d2c1f98e76c5ad680fe6991af6a", size = 78788, upload-time = "2026-04-15T14:11:58.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ab/d5adeab6253c7ecd5904fc5ef3265859f218610caf4e1e55efe9aff6ac49/logfire_api-4.32.1-py3-none-any.whl", hash = "sha256:4b4c27cf6e27e8e26ef4b22a77f2a2988dd1d07e2d24ee70673ef34b234fb8a5", size = 124394, upload-time = "2026-04-15T14:11:56.157Z" }, +] + [[package]] name = "lxml" version = "6.1.0" @@ -3740,14 +3805,14 @@ wheels = [ [[package]] name = "mako" -version = "1.3.11" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/8a/805404d0c0b9f3d7a326475ca008db57aea9c5c9f2e1e39ed0faa335571c/mako-1.3.11.tar.gz", hash = "sha256:071eb4ab4c5010443152255d77db7faa6ce5916f35226eb02dc34479b6858069", size = 399811, upload-time = "2026-04-14T20:19:51.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/a5/19d7aaa7e433713ffe881df33705925a196afb9532efc8475d26593921a6/mako-1.3.11-py3-none-any.whl", hash = "sha256:e372c6e333cf004aa736a15f425087ec977e1fcbd2966aae7f17c8dc1da27a77", size = 78503, upload-time = "2026-04-14T20:19:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -4023,22 +4088,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, ] -[[package]] -name = "nodejs-wheel-binaries" -version = "24.11.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/89/da307731fdbb05a5f640b26de5b8ac0dc463fef059162accfc89e32f73bc/nodejs_wheel_binaries-24.11.1.tar.gz", hash = "sha256:413dfffeadfb91edb4d8256545dea797c237bba9b3faefea973cde92d96bb922", size = 8059, upload-time = "2025-11-18T18:21:58.207Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/5f/be5a4112e678143d4c15264d918f9a2dc086905c6426eb44515cf391a958/nodejs_wheel_binaries-24.11.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:0e14874c3579def458245cdbc3239e37610702b0aa0975c1dc55e2cb80e42102", size = 55114309, upload-time = "2025-11-18T18:21:21.697Z" }, - { url = "https://files.pythonhosted.org/packages/fa/1c/2e9d6af2ea32b65928c42b3e5baa7a306870711d93c3536cb25fc090a80d/nodejs_wheel_binaries-24.11.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:c2741525c9874b69b3e5a6d6c9179a6fe484ea0c3d5e7b7c01121c8e5d78b7e2", size = 55285957, upload-time = "2025-11-18T18:21:27.177Z" }, - { url = "https://files.pythonhosted.org/packages/d0/79/35696d7ba41b1bd35ef8682f13d46ba38c826c59e58b86b267458eb53d87/nodejs_wheel_binaries-24.11.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5ef598101b0fb1c2bf643abb76dfbf6f76f1686198ed17ae46009049ee83c546", size = 59645875, upload-time = "2025-11-18T18:21:33.004Z" }, - { url = "https://files.pythonhosted.org/packages/b4/98/2a9694adee0af72bc602a046b0632a0c89e26586090c558b1c9199b187cc/nodejs_wheel_binaries-24.11.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cde41d5e4705266688a8d8071debf4f8a6fcea264c61292782672ee75a6905f9", size = 60140941, upload-time = "2025-11-18T18:21:37.228Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d6/573e5e2cba9d934f5f89d0beab00c3315e2e6604eb4df0fcd1d80c5a07a8/nodejs_wheel_binaries-24.11.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:78bc5bb889313b565df8969bb7423849a9c7fc218bf735ff0ce176b56b3e96f0", size = 61644243, upload-time = "2025-11-18T18:21:43.325Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e6/643234d5e94067df8ce8d7bba10f3804106668f7a1050aeb10fdd226ead4/nodejs_wheel_binaries-24.11.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c79a7e43869ccecab1cae8183778249cceb14ca2de67b5650b223385682c6239", size = 62225657, upload-time = "2025-11-18T18:21:47.708Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/2fb05127102a80225cab7a75c0e9edf88a0a1b79f912e1e36c7c1aaa8f4e/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_amd64.whl", hash = "sha256:10197b1c9c04d79403501766f76508b0dac101ab34371ef8a46fcf51773497d0", size = 41322308, upload-time = "2025-11-18T18:21:51.347Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b7/bc0cdbc2cc3a66fcac82c79912e135a0110b37b790a14c477f18e18d90cd/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_arm64.whl", hash = "sha256:376b9ea1c4bc1207878975dfeb604f7aa5668c260c6154dcd2af9d42f7734116", size = 39026497, upload-time = "2025-11-18T18:21:54.634Z" }, -] - [[package]] name = "numba" version = "0.65.0" @@ -4135,7 +4184,7 @@ wheels = [ [[package]] name = "openai" -version = "2.8.1" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -4147,9 +4196,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/e4/42591e356f1d53c568418dc7e30dcda7be31dd5a4d570bca22acb0525862/openai-2.8.1.tar.gz", hash = "sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f", size = 602490, upload-time = "2025-11-17T22:39:59.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/4f/dbc0c124c40cb390508a82770fb9f6e3ed162560181a85089191a851c59a/openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463", size = 1022688, upload-time = "2025-11-17T22:39:57.675Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -4262,32 +4311,32 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/b7/845565a2ab5d22c1486bc7729a06b05cd0964c61539d766e1f107c9eea0c/opentelemetry_exporter_otlp-1.41.0.tar.gz", hash = "sha256:97ff847321f8d4c919032a67d20d3137fb7b34eac0c47f13f71112858927fc5b", size = 6152, upload-time = "2026-04-09T14:38:35.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f2/f1076fff152858773f22cda146713f9ae3661795af6bacd411a76f2151ac/opentelemetry_exporter_otlp-1.41.0-py3-none-any.whl", hash = "sha256:443b6a45c990ae4c55e147f97049a86c5f5b704f3d78b48b44a073a886ec4d6e", size = 7022, upload-time = "2026-04-09T14:38:13.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -4298,14 +4347,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/46/d75a3f8c91915f2e58f61d0a2e4ada63891e7c7a37a20ff7949ba184a6b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0.tar.gz", hash = "sha256:f704201251c6f65772b11bddea1c948000554459101bdbb0116e0a01b70592f6", size = 25754, upload-time = "2026-04-09T14:38:37.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f6/b09e2e0c9f0b5750cebc6eaf31527b910821453cef40a5a0fe93550422b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0-py3-none-any.whl", hash = "sha256:3a1a86bd24806ccf136ec9737dbfa4c09b069f9130ff66b0acb014f9c5255fd1", size = 20299, upload-time = "2026-04-09T14:38:17.01Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -4316,9 +4365,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] [[package]] @@ -4476,14 +4525,14 @@ wheels = [ [[package]] name = "opentelemetry-proto" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, ] [[package]] @@ -4810,7 +4859,7 @@ wheels = [ [[package]] name = "postgrest" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -4818,9 +4867,9 @@ dependencies = [ { name = "pydantic" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/98/f216b8b5c4d116ab6a2fb21339b5821da279ee773e163612418e1c56c012/postgrest-2.29.0.tar.gz", hash = "sha256:a87081858f627fcd57e8e7137004a1ef0adbdf0dbdfed1384e9ea1d7a9c525ec", size = 14217, upload-time = "2026-04-24T13:13:00.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/7c/54e7be05adc9fd6fd98dc572ddfc8982d45bec314a55711e37277d440698/postgrest-2.30.0.tar.gz", hash = "sha256:4f89eec56ce605ab6fbddd9b96d526a9bb44962796d44a5d85cb77640eb766c3", size = 14430, upload-time = "2026-05-06T17:35:21.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/0b/08b670a93a90d625c557b9e64b8a5fdeec80c3542d2d0265f0b4d6b16646/postgrest-2.29.0-py3-none-any.whl", hash = "sha256:3ee48e146f726272733d20e2b12de354cdb6cb9dd9cc3a61ed97ce69047aeb96", size = 22735, upload-time = "2026-04-24T13:12:58.405Z" }, + { url = "https://files.pythonhosted.org/packages/22/aa/ff2e09f99f95ea96fddeb373646bf907dd89a24fc00b5d38e5674ca7c9ca/postgrest-2.30.0-py3-none-any.whl", hash = "sha256:30631e7993da542419f4217cf3b60aa641084731ea15e66a18526a3a52e40a7d", size = 23108, upload-time = "2026-05-06T17:35:20.531Z" }, ] [[package]] @@ -5096,6 +5145,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] +[[package]] +name = "pydantic-ai-slim" +version = "1.94.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/0b/ce4992e0e29ba81ba48d5bba955c53b72e2cda3636f9b6417386ae7e45f7/pydantic_ai_slim-1.94.0.tar.gz", hash = "sha256:7d7b1d6aec4d0fd31533a4ef5848863e8513ec75e82910296247a08b737aa828", size = 640338, upload-time = "2026-05-12T07:03:55.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/dd/b104641c2af7044a788b1071159679aa755e5c9281f110fdc54b4729117b/pydantic_ai_slim-1.94.0-py3-none-any.whl", hash = "sha256:f47cf89c61ef45a48dd575a8b32707edfec2b33ef7af80aa069bde1ce3fb6795", size = 805546, upload-time = "2026-05-12T07:03:46.535Z" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -5138,6 +5205,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, ] +[[package]] +name = "pydantic-graph" +version = "1.94.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/80/c41aa5ccf7104eba172ff45e617967b0075b3fa34ab76cd3795d7d62334a/pydantic_graph-1.94.0.tar.gz", hash = "sha256:8f991c05d412c9d12d6560c1e131de48bfde12ebd27a0b196440620210f2d52c", size = 59252, upload-time = "2026-05-12T07:03:58.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/7e/edcc6177d89174024bca1fb85044bec4855b96f4b724a310638283dfc8c5/pydantic_graph-1.94.0-py3-none-any.whl", hash = "sha256:c6e285abbc8a55d1b65162c238006913edd1ef05e63a29401a580e51f798503e", size = 73063, upload-time = "2026-05-12T07:03:50.651Z" }, +] + [[package]] name = "pydantic-settings" version = "2.13.1" @@ -5359,19 +5441,19 @@ wheels = [ [[package]] name = "pyrefly" -version = "0.62.0" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/3a/9045b0097ac58979c7c30a4fa0e673db942d4adbc7b6d439bd54ae58c441/pyrefly-1.0.0.tar.gz", hash = "sha256:5c2b810ffcebd84be71de5df1223651edee951653a66935c6f091e957c452455", size = 5677995, upload-time = "2026-05-12T20:12:46.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" }, - { url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" }, - { url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" }, - { url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" }, - { url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c6/90788819bac9c61dd7bacba53b79f3c12d47ccbe5e51b3d6d89f2387e1d2/pyrefly-1.0.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e355a0908555348ed4b9585ef25c76ff566673e345c866c325f1633f44d890b6", size = 13122950, upload-time = "2026-05-12T20:12:20.711Z" }, + { url = "https://files.pythonhosted.org/packages/82/91/a3cf2a1e87d336eaa804a1e6fc93266faf6dc2a97eecdbc7eae289628022/pyrefly-1.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7038efc3a40f8294edee339895633cf22db268c0d434cdbcbefc34f78a9ecc3", size = 12599494, upload-time = "2026-05-12T20:12:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ab/74d1e11e737e99b1c003ecc5d7d2e846c4ea1f328966bfdbbd0ac63fad0a/pyrefly-1.0.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da331ca515ed1c08791da2b5f664cf9c1294c48fd802133262e7d5d51e0f4416", size = 12995507, upload-time = "2026-05-12T20:12:25.951Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ac/2df0899f8464c97e5d995f994c97c5cb5b0f58610432aa90d26d924e1db5/pyrefly-1.0.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c74219d8f3e63cdaa5501a0b21d1c9d37011820f9606728d0ed06f09ae86a878", size = 13947693, upload-time = "2026-05-12T20:12:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3e/b247c24321e36f04b7d51f9ccf3df93e5009e4b29939524b36ec2e17dc2a/pyrefly-1.0.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0d05543b1bb6ee6d64149eb5d6b2fb15aa72d3962d6a97abca0afaca8b0c131", size = 13925803, upload-time = "2026-05-12T20:12:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/16/cfa2d61a4aa1e1f7bca48bb37acd01c6a09db4864b16a54f9587092765ff/pyrefly-1.0.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1382d5b1fcdb49a4de9f34d112d2bddf290a78ff93ee8149492ad5f1077ddffc", size = 13470398, upload-time = "2026-05-12T20:12:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/6372c7dddb326223e24a46b17efd0d4bd7b4fe22c821e523157577eed2d2/pyrefly-1.0.0-py3-none-win32.whl", hash = "sha256:aa8b5d0e47080e3202a2547b39f7a5a61d2c781c712b3b67884f745ca2c759d2", size = 12222643, upload-time = "2026-05-12T20:12:38.618Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/1d23be700b6b2ddaeb362360c7145917a8edbbf7240ae428d40541772fce/pyrefly-1.0.0-py3-none-win_amd64.whl", hash = "sha256:c8abcb0f2082e83c890375128f9cff4aa4d3f210b85eea7b3046c1ae764e77f5", size = 13146369, upload-time = "2026-05-12T20:12:41.423Z" }, + { url = "https://files.pythonhosted.org/packages/8c/38/16589134f3012fd097a10dcc85771555f1a5fb76e04b682597180743af30/pyrefly-1.0.0-py3-none-win_arm64.whl", hash = "sha256:d150fa9e40e8392832be81c3bcfc0497c146674ce4d0f8e04e1ec29e775ffb8c", size = 12538326, upload-time = "2026-05-12T20:12:43.996Z" }, ] [[package]] @@ -5723,16 +5805,16 @@ wheels = [ [[package]] name = "realtime" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/f1/08c42a42653942fadfbef495d5b0239356140e7186cc528704956c5f06d4/realtime-2.29.0.tar.gz", hash = "sha256:8efe4a1b3a548a5fda09de701bd041fa0970c5a2fe7d13db0b9861ce11828be2", size = 18715, upload-time = "2026-04-24T13:13:02.315Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/a2/0328d49d3b5fb427068e9200e7de5b0d708d021a1ad98d004bc685d2529e/realtime-2.30.0.tar.gz", hash = "sha256:7aa593da52ed5f92c34ec4e50e32043afa62f219c94f717ad64a66ab0ef9f1ba", size = 18718, upload-time = "2026-05-06T17:35:23.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/48/f6375c0a24923beb988f0c71c052604c96641cf43c2d22b91ec1df86afa0/realtime-2.29.0-py3-none-any.whl", hash = "sha256:1a4891e6c82e88ac9d96ac715e435e086f6f8c7665212a8717346de829cbb509", size = 22374, upload-time = "2026-04-24T13:13:01.103Z" }, + { url = "https://files.pythonhosted.org/packages/b4/75/1b2cfc949595e22d8c05a2aa2cfc222921f7f94177d7e8a90542f3f73b33/realtime-2.30.0-py3-none-any.whl", hash = "sha256:7c93b63d2cf99aa1da4fa8826b03b00cd32f7b38abb27ff47b19eb5dcb5707c6", size = 22376, upload-time = "2026-05-06T17:35:22.568Z" }, ] [[package]] @@ -6214,7 +6296,7 @@ wheels = [ [[package]] name = "storage3" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -6223,9 +6305,9 @@ dependencies = [ { name = "pyiceberg" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/be/771246434b5caf3c6187bfdc932eaede00bf5f2937b47475ab25209ede3e/storage3-2.29.0.tar.gz", hash = "sha256:b0cc2f6714655d725c998d2c5ae8c6fb4f56a513bd31e4f85770df557fe021e3", size = 20160, upload-time = "2026-04-24T13:13:04.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/b2/6df208d64630744704d00f2c07197170390d6b4d0098617740f6a7a4fa98/storage3-2.30.0.tar.gz", hash = "sha256:b74e3cac149f2c0553dcb5f4d55d8c35d420d88183a1a2df77727d482665972b", size = 20162, upload-time = "2026-05-06T17:35:25.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/c3/790c31866f52c13b26f108b45759bf50dafae3a0bafb4511fadc98ba7c33/storage3-2.29.0-py3-none-any.whl", hash = "sha256:043ef7ff27cc8b9da12be403cf78ee4586180edfcf62b227ff61e1bd79594b06", size = 28284, upload-time = "2026-04-24T13:13:03.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/5c/bb8c8cc448cfae671c4ffee67f3651892ea59b341f27bed54666190eb8ef/storage3-2.30.0-py3-none-any.whl", hash = "sha256:2bd23a34011c018bd9c130d8a70a09ebd060ae80d946c6204a6fc08161ad728d", size = 28284, upload-time = "2026-05-06T17:35:24.659Z" }, ] [[package]] @@ -6251,7 +6333,7 @@ wheels = [ [[package]] name = "supabase" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6262,37 +6344,37 @@ dependencies = [ { name = "supabase-functions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/a0/2407d616fdf68e8632bbbfb063d1685c38377ac0199e8ca11deaea1f3bf0/supabase-2.29.0.tar.gz", hash = "sha256:a88c4a4eb50fbb903e2e962fbc7c27733b00589140139f9e837bc9fe30dd3615", size = 9689, upload-time = "2026-04-24T13:13:06.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/a6/d2b17021c2db1a9d219c383e0762ac03a62b25468e61ab126b6b561c2f21/supabase-2.30.0.tar.gz", hash = "sha256:efdba41d474038ed220736ba4e64946df56043057ad785c4c3499d27e459975c", size = 9689, upload-time = "2026-05-06T17:35:27.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/52/232f6bbf5326e04ae12e2ef04a24f011a0d7cab379a8b9698652bc8ff78f/supabase-2.29.0-py3-none-any.whl", hash = "sha256:16c3ec4b7094f6b92efc5cd3bb3f96826d3b6dd5d24fe15c89c81166efce88fe", size = 16633, upload-time = "2026-04-24T13:13:05.722Z" }, + { url = "https://files.pythonhosted.org/packages/f0/82/d213be7d0ce0bb18018744c0ee38ba0d6648d41dbc46ac8558cffe80541f/supabase-2.30.0-py3-none-any.whl", hash = "sha256:f9b259194554f7bfd2dca6c23261f2df588016ca18b18e774f4d85bc941edb03", size = 16634, upload-time = "2026-05-06T17:35:26.696Z" }, ] [[package]] name = "supabase-auth" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/7f/7ceeb4c7a2caa188062e934897f0e08e1af0a0e47e376c7645c26b4c39d8/supabase_auth-2.29.0.tar.gz", hash = "sha256:46efc6a3455a23957b846dc974303a844ba0413718cfa899425477ac977f95b3", size = 39154, upload-time = "2026-04-24T13:13:08.509Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/8a/48bbbe0b6703d0670b67e45b90d6a791fd01aace67443d286f760bf48895/supabase_auth-2.30.0.tar.gz", hash = "sha256:6138a53a306a95ed59c03d4e4975469dfc3343a0ade33cc4b37e4ef967ad83f8", size = 39135, upload-time = "2026-05-06T17:35:30.371Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/ac/3c35cf52281f940b9497cf17abfc5c2050ca49f342d60cfafe22dac3482b/supabase_auth-2.29.0-py3-none-any.whl", hash = "sha256:64de6ef8cae80f97d3aa8d5ca507d5427dda5c89885c0bcfe9f8b0263b6fb9a4", size = 48379, upload-time = "2026-04-24T13:13:07.417Z" }, + { url = "https://files.pythonhosted.org/packages/db/40/a99cb4373353bcbf302d962e51da9eac78b3b0f257eb0362c0852b1667f4/supabase_auth-2.30.0-py3-none-any.whl", hash = "sha256:e85e1f51ec0de2172c3a2a8514205f71731a9914f9a770ed199ac0cf054bc82c", size = 48352, upload-time = "2026-05-06T17:35:28.936Z" }, ] [[package]] name = "supabase-functions" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "strenum" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/19/1a1d22749f38f2a6cbca93a6f5a35c9f816c2c3c06bfaa077fa336e90537/supabase_functions-2.29.0.tar.gz", hash = "sha256:0f8a14a2ea9f12b1c208f61dc6f55e2f4b1121f81bf01c08f9b487d22888744d", size = 4683, upload-time = "2026-04-24T13:13:10.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/e6/5cd8559ec2bb332e6027840c1be292f9989c2fc7b47bf40800aec5586791/supabase_functions-2.30.0.tar.gz", hash = "sha256:025acfd25f1c000ba43d0f7b8e366b0d2e9dfc784b842528e21973eb33006113", size = 4683, upload-time = "2026-05-06T17:35:32.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/10/6f8ef0b408ade76b5a439afab588ce5849e9604a23040ca73cfe0b90cb9e/supabase_functions-2.29.0-py3-none-any.whl", hash = "sha256:6f08de52eec5820eae53616868b85e849e181beffaa5d05b8ea1708ceae5e48e", size = 8799, upload-time = "2026-04-24T13:13:09.214Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/9dedab32775df04cc22ca72f194b78e895d940f195bed3e02882a65daa9b/supabase_functions-2.30.0-py3-none-any.whl", hash = "sha256:92419459f102767b954cd034856e4ded8e34c78660b32442d66c8b2899c68011", size = 8803, upload-time = "2026-05-06T17:35:31.342Z" }, ] [[package]] @@ -6447,27 +6529,28 @@ wheels = [ [[package]] name = "tokenizers" -version = "0.22.1" +version = "0.22.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/46/fb6854cec3278fbfa4a75b50232c77622bc517ac886156e6afbfa4d8fc6e/tokenizers-0.22.1.tar.gz", hash = "sha256:61de6522785310a309b3407bac22d99c4db5dba349935e99e4d15ea2226af2d9", size = 363123, upload-time = "2025-09-19T09:49:23.424Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/33/f4b2d94ada7ab297328fc671fed209368ddb82f965ec2224eb1892674c3a/tokenizers-0.22.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:59fdb013df17455e5f950b4b834a7b3ee2e0271e6378ccb33aa74d178b513c73", size = 3069318, upload-time = "2025-09-19T09:49:11.848Z" }, - { url = "https://files.pythonhosted.org/packages/1c/58/2aa8c874d02b974990e89ff95826a4852a8b2a273c7d1b4411cdd45a4565/tokenizers-0.22.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8d4e484f7b0827021ac5f9f71d4794aaef62b979ab7608593da22b1d2e3c4edc", size = 2926478, upload-time = "2025-09-19T09:49:09.759Z" }, - { url = "https://files.pythonhosted.org/packages/1e/3b/55e64befa1e7bfea963cf4b787b2cea1011362c4193f5477047532ce127e/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19d2962dd28bc67c1f205ab180578a78eef89ac60ca7ef7cbe9635a46a56422a", size = 3256994, upload-time = "2025-09-19T09:48:56.701Z" }, - { url = "https://files.pythonhosted.org/packages/71/0b/fbfecf42f67d9b7b80fde4aabb2b3110a97fac6585c9470b5bff103a80cb/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:38201f15cdb1f8a6843e6563e6e79f4abd053394992b9bbdf5213ea3469b4ae7", size = 3153141, upload-time = "2025-09-19T09:48:59.749Z" }, - { url = "https://files.pythonhosted.org/packages/17/a9/b38f4e74e0817af8f8ef925507c63c6ae8171e3c4cb2d5d4624bf58fca69/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1cbe5454c9a15df1b3443c726063d930c16f047a3cc724b9e6e1a91140e5a21", size = 3508049, upload-time = "2025-09-19T09:49:05.868Z" }, - { url = "https://files.pythonhosted.org/packages/d2/48/dd2b3dac46bb9134a88e35d72e1aa4869579eacc1a27238f1577270773ff/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e7d094ae6312d69cc2a872b54b91b309f4f6fbce871ef28eb27b52a98e4d0214", size = 3710730, upload-time = "2025-09-19T09:49:01.832Z" }, - { url = "https://files.pythonhosted.org/packages/93/0e/ccabc8d16ae4ba84a55d41345207c1e2ea88784651a5a487547d80851398/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afd7594a56656ace95cdd6df4cca2e4059d294c5cfb1679c57824b605556cb2f", size = 3412560, upload-time = "2025-09-19T09:49:03.867Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4", size = 3250221, upload-time = "2025-09-19T09:49:07.664Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a6/2c8486eef79671601ff57b093889a345dd3d576713ef047776015dc66de7/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba0a64f450b9ef412c98f6bcd2a50c6df6e2443b560024a09fa6a03189726879", size = 9345569, upload-time = "2025-09-19T09:49:14.214Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/32ce667f14c35537f5f605fe9bea3e415ea1b0a646389d2295ec348d5657/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:331d6d149fa9c7d632cde4490fb8bbb12337fa3a0232e77892be656464f4b446", size = 9271599, upload-time = "2025-09-19T09:49:16.639Z" }, - { url = "https://files.pythonhosted.org/packages/51/7c/a5f7898a3f6baa3fc2685c705e04c98c1094c523051c805cdd9306b8f87e/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:607989f2ea68a46cb1dfbaf3e3aabdf3f21d8748312dbeb6263d1b3b66c5010a", size = 9533862, upload-time = "2025-09-19T09:49:19.146Z" }, - { url = "https://files.pythonhosted.org/packages/36/65/7e75caea90bc73c1dd8d40438adf1a7bc26af3b8d0a6705ea190462506e1/tokenizers-0.22.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a0f307d490295717726598ef6fa4f24af9d484809223bbc253b201c740a06390", size = 9681250, upload-time = "2025-09-19T09:49:21.501Z" }, - { url = "https://files.pythonhosted.org/packages/30/2c/959dddef581b46e6209da82df3b78471e96260e2bc463f89d23b1bf0e52a/tokenizers-0.22.1-cp39-abi3-win32.whl", hash = "sha256:b5120eed1442765cd90b903bb6cfef781fd8fe64e34ccaecbae4c619b7b12a82", size = 2472003, upload-time = "2025-09-19T09:49:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/e33a8c93907b631a99377ef4c5f817ab453d0b34f93529421f42ff559671/tokenizers-0.22.1-cp39-abi3-win_amd64.whl", hash = "sha256:65fd6e3fb11ca1e78a6a93602490f134d1fdeb13bcef99389d5102ea318ed138", size = 2674684, upload-time = "2025-09-19T09:49:24.953Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] @@ -7029,26 +7112,24 @@ wheels = [ [[package]] name = "ujson" -version = "5.12.0" +version = "5.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/78/937198ea8708182dd1edbf0237bf255a96feab3f511691ad08b84da98e5d/ujson-5.12.1.tar.gz", hash = "sha256:5b7e96406c301a1366534479a7352ec40ec68bb327c0c119091635acd5925e35", size = 7164538, upload-time = "2026-05-05T22:05:01.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" }, - { url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" }, - { url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" }, - { url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" }, - { url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" }, - { url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f0/123ffaac17e45ef2b915e3e3303f8f4ea78bb8d42afad828844e08622b1e/ujson-5.12.0-cp312-cp312-win32.whl", hash = "sha256:2a248750abce1c76fbd11b2e1d88b95401e72819295c3b851ec73399d6849b3d", size = 39773, upload-time = "2026-03-11T22:18:28.244Z" }, - { url = "https://files.pythonhosted.org/packages/b5/20/f3bd2b069c242c2b22a69e033bfe224d1d15d3649e6cd7cc7085bb1412ff/ujson-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:1b5c6ceb65fecd28a1d20d1eba9dbfa992612b86594e4b6d47bb580d2dd6bcb3", size = 44040, upload-time = "2026-03-11T22:18:29.236Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a7/01b5a0bcded14cd2522b218f2edc3533b0fcbccdea01f3e14a2b699071aa/ujson-5.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:9a5fcbe7b949f2e95c47ea8a80b410fcdf2da61c98553b45a4ee875580418b68", size = 38526, upload-time = "2026-03-11T22:18:30.551Z" }, - { url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" }, - { url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" }, - { url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/1df8e6217c92e57a1266bf5be750b1dddc126ee96e53fe959d5693503bc6/ujson-5.12.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:8712b61eb1b74a4478cfd1c54f576056199e9f093659334aeb5c4a6b385338e5", size = 44615, upload-time = "2026-03-11T22:19:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/d7/40/dbb8e2fe6ee33769602fba203dacaa3963b6599f0d0aefdf2b8811af5f70/ujson-5.12.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:10f44bd08ae52ee23ca6e8b472692e5da1768af2d53ff1bad6f40b532e0bc7ee", size = 57951, upload-time = "2026-05-05T22:03:31.606Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/627472e6b4ac34148ea52e6d3d15f6f366fc21c72fe7d6c7d3729d4b3ac5/ujson-5.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cc6ea753b7303fa5629fa9ac9257ea4b001c4d72583b2bb36ff1855a07db49f", size = 55562, upload-time = "2026-05-05T22:03:32.853Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/1248c966da197ae7d2673542444a2d9a1ff7c46e3ec2a302c3caf902b922/ujson-5.12.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:289f13095764d03734adfa10107da9b530ceb64dc1b02a5f507588d978d5b7df", size = 59448, upload-time = "2026-05-05T22:03:34.143Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d7/60c1ca71a09c0654c3edca1192a18fc55e6cc06107be86d7d3f2b39fb29b/ujson-5.12.1-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:427893168d074e59214b0ee058337c57f5bb80175cdd5b4799a9c931aae22022", size = 61608, upload-time = "2026-05-05T22:03:35.386Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0a/c619525576219bfc50084100117481b1a732a16716a3878355570995de4e/ujson-5.12.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7a81724d5d90a2da7155d15d8b156ce57eaed7cdd622df813f36a8e612fd4c8", size = 59113, upload-time = "2026-05-05T22:03:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/4d/79c1674036085e8dfdb77f8d87c1fd2896e97e6affd117c5e8ecc40f0ae4/ujson-5.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a6efff7dc6515416366819de4a1bc449b77107c5b48508b101fd40f7f8bec08", size = 1038914, upload-time = "2026-05-05T22:03:38.954Z" }, + { url = "https://files.pythonhosted.org/packages/94/b1/9409bba17189ee282b6314cdf0ecdcc72e3d38cd565c870c0227d0494569/ujson-5.12.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77a71fe53427a0cf49d56eafd801d9f7e203b784b7f99cc717783fd6f6f7b732", size = 1198408, upload-time = "2026-05-05T22:03:40.943Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ad/fafbce7ac59f1a10a83892d0a34add23cc06492308e1330493aab707dc20/ujson-5.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea3bed53d2ea8e5642e814a9e41f3e29420a8067874ba03ace8c0462e160490c", size = 1091451, upload-time = "2026-05-05T22:03:42.739Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1f/76fc9d5b1dcb9eb73ed45fd56e5114391bd30808eb1cea7f8bc5c9a64324/ujson-5.12.1-cp312-cp312-win32.whl", hash = "sha256:758e5c8fbe4e6d483041e03b307b01fb5d2f2dd4452d4d4b927ab902e188939e", size = 41049, upload-time = "2026-05-05T22:03:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/7ce3b6fda10d05b79a245db03405734b521ba3da6c377f173b018dce6d4e/ujson-5.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:f6074d3d3267ba1914c624b6e1fa3d8152648ff36b0ab77ddf83b92db488c30d", size = 45330, upload-time = "2026-05-05T22:03:45.828Z" }, + { url = "https://files.pythonhosted.org/packages/d7/66/5a37bba7a2e2ab36ae467521c4511e6593ad74c869f62ec4ba6330f3f71e/ujson-5.12.1-cp312-cp312-win_arm64.whl", hash = "sha256:7642a41520ac1b2bc25ea282b66b8da522cc43424442e6fb5e039be4d4f96530", size = 39828, upload-time = "2026-05-05T22:03:47.123Z" }, + { url = "https://files.pythonhosted.org/packages/6d/26/c9d0479236b3f5690d6a8bb45f708aabc2c91ca80d275eba24b1e9e464ab/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c419bf42ae40963fc27f70c59e24e9a97f5cf168dbce2c572f3c0ce3595912", size = 56153, upload-time = "2026-05-05T22:04:40.326Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c8/785f4e132500aff2f1fd2bd4a4b86fe396a5519f830a098358c90ebb92ee/ujson-5.12.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0be2b4f2f547b9f0f3d902640e410e5a2fc851576cbe033c88445a23e3e7aef1", size = 57352, upload-time = "2026-05-05T22:04:42.005Z" }, + { url = "https://files.pythonhosted.org/packages/8f/13/b688a905653871b10b4ff0403c2ff562c17a0bd50be0d44324f3c85ca48f/ujson-5.12.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:4ea0c490c702c20495e97345acfcf0c2f3153e658ef537ff111929c48b89e10a", size = 45988, upload-time = "2026-05-05T22:04:43.36Z" }, ] [[package]] @@ -7143,11 +7224,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/dev/basedpyright-check b/dev/basedpyright-check deleted file mode 100755 index 1b3d1df7ad..0000000000 --- a/dev/basedpyright-check +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -set -x - -SCRIPT_DIR="$(dirname "$(realpath "$0")")" -cd "$SCRIPT_DIR/.." - -# Get the path argument if provided -PATH_TO_CHECK="$1" - -# Determine CPU core count based on OS -CPU_CORES=$( - if [[ "$(uname -s)" == "Darwin" ]]; then - sysctl -n hw.ncpu 2>/dev/null - else - nproc - fi -) - -# Run basedpyright checks -uv run --directory api --dev -- basedpyright --threads "$CPU_CORES" $PATH_TO_CHECK diff --git a/dev/pyrefly-check-local b/dev/pyrefly-check-local index 8fa5f121fc..be78230662 100755 --- a/dev/pyrefly-check-local +++ b/dev/pyrefly-check-local @@ -8,6 +8,15 @@ cd "$REPO_ROOT" EXCLUDES_FILE="api/pyrefly-local-excludes.txt" +target_paths=() +for target_path in "$@"; do + if [[ "$target_path" == api/* ]]; then + target_paths+=("${target_path#api/}") + else + target_paths+=("$target_path") + fi +done + pyrefly_args=( "--summary=none" "--use-ignore-files=false" @@ -25,8 +34,16 @@ if [[ -f "$EXCLUDES_FILE" ]]; then fi tmp_output="$(mktemp)" +pyrefly_command=( + uv run --directory api --dev pyrefly check + "${pyrefly_args[@]}" +) +if (( ${#target_paths[@]} > 0 )); then + pyrefly_command+=("${target_paths[@]}") +fi + set +e -uv run --directory api --dev pyrefly check "${pyrefly_args[@]}" >"$tmp_output" 2>&1 +"${pyrefly_command[@]}" >"$tmp_output" 2>&1 pyrefly_status=$? set -e diff --git a/dev/pytest/pytest_config_tests.py b/dev/pytest/pytest_config_tests.py index d56cceff5e..b136f09c61 100644 --- a/dev/pytest/pytest_config_tests.py +++ b/dev/pytest/pytest_config_tests.py @@ -93,10 +93,16 @@ BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset( API_CONFIG_SET = set(dotenv_values(Path("api") / Path(".env.example")).keys()) DOCKER_CONFIG_SET = set(dotenv_values(Path("docker") / Path(".env.example")).keys()) -DOCKER_COMPOSE_CONFIG_SET = set() +DOCKER_COMPOSE_CONFIG_SET = set(DOCKER_CONFIG_SET) -with open(Path("docker") / Path("docker-compose.yaml")) as f: - DOCKER_COMPOSE_CONFIG_SET = set(yaml.safe_load(f.read())["x-shared-env"].keys()) +# Read environment variables from the split env files used by docker-compose +# Walk through all .env.example files in subdirectories (per-module structure) +envs_dir = Path("docker") / Path("envs") +if envs_dir.exists(): + for env_file_path in envs_dir.rglob("*.env.example"): + env_keys = set(dotenv_values(env_file_path).keys()) + DOCKER_CONFIG_SET.update(env_keys) + DOCKER_COMPOSE_CONFIG_SET.update(env_keys) def test_yaml_config(): diff --git a/dev/pytest/pytest_full.sh b/dev/pytest/pytest_full.sh index 2989a74ad8..ca09aeb729 100755 --- a/dev/pytest/pytest_full.sh +++ b/dev/pytest/pytest_full.sh @@ -15,7 +15,7 @@ mkdir -p "${OPENDAL_FS_ROOT}" # Prepare env files like CI cp -n docker/.env.example docker/.env || true -cp -n docker/middleware.env.example docker/middleware.env || true +cp -n docker/envs/middleware.env.example docker/middleware.env || true cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true # Expose service ports (same as CI) without leaving the repo dirty diff --git a/dev/reformat b/dev/reformat index 6966267193..f179b43ca0 100755 --- a/dev/reformat +++ b/dev/reformat @@ -17,5 +17,5 @@ uv run --directory api --dev ruff format ./ # run dotenv-linter linter uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example -# run basedpyright check -dev/basedpyright-check +# run pyrefly check +dev/pyrefly-check-local diff --git a/dev/setup b/dev/setup index 4236ff7fa7..1d2501a48b 100755 --- a/dev/setup +++ b/dev/setup @@ -8,7 +8,7 @@ API_ENV_EXAMPLE="$ROOT/api/.env.example" API_ENV="$ROOT/api/.env" WEB_ENV_EXAMPLE="$ROOT/web/.env.example" WEB_ENV="$ROOT/web/.env.local" -MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/middleware.env.example" +MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/envs/middleware.env.example" MIDDLEWARE_ENV="$ROOT/docker/middleware.env" # 1) Copy api/.env.example -> api/.env @@ -17,7 +17,7 @@ cp "$API_ENV_EXAMPLE" "$API_ENV" # 2) Copy web/.env.example -> web/.env.local cp "$WEB_ENV_EXAMPLE" "$WEB_ENV" -# 3) Copy docker/middleware.env.example -> docker/middleware.env +# 3) Copy docker/envs/middleware.env.example -> docker/middleware.env cp "$MIDDLEWARE_ENV_EXAMPLE" "$MIDDLEWARE_ENV" # 4) Install deps diff --git a/dify-agent/.example.env b/dify-agent/.example.env new file mode 100644 index 0000000000..679a467488 --- /dev/null +++ b/dify-agent/.example.env @@ -0,0 +1,37 @@ +# Dify Agent run server settings template. +# Mirrors dify_agent.server.settings.ServerSettings with the DIFY_AGENT_ prefix. +# This template intentionally covers the current run-server settings only. + +# Redis +# Redis connection URL for run records and per-run event streams. +DIFY_AGENT_REDIS_URL=redis://localhost:6379/0 +# Prefix for Redis run-record and event-stream keys. +DIFY_AGENT_REDIS_PREFIX=dify-agent + +# Shutdown and retention +# Seconds to wait for active local runs during graceful shutdown before cancellation. +DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30 +# Seconds to retain Redis run records and per-run event streams (default: 3 days). +DIFY_AGENT_RUN_RETENTION_SECONDS=259200 + +# Plugin daemon +# Base URL for the Dify plugin daemon used by local runs. +DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 +# API key sent to the Dify plugin daemon. +DIFY_AGENT_PLUGIN_DAEMON_API_KEY= + +# Shared plugin-daemon HTTP client timeouts and limits. +# Plugin-daemon HTTP connect timeout in seconds. +DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10 +# Plugin-daemon HTTP read timeout in seconds. +DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT=600 +# Plugin-daemon HTTP write timeout in seconds. +DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT=30 +# Plugin-daemon HTTP connection-pool wait timeout in seconds. +DIFY_AGENT_PLUGIN_DAEMON_POOL_TIMEOUT=10 +# Maximum total plugin-daemon HTTP connections. +DIFY_AGENT_PLUGIN_DAEMON_MAX_CONNECTIONS=100 +# Maximum idle keep-alive plugin-daemon HTTP connections. +DIFY_AGENT_PLUGIN_DAEMON_MAX_KEEPALIVE_CONNECTIONS=20 +# Keep-alive expiry in seconds for idle plugin-daemon HTTP connections. +DIFY_AGENT_PLUGIN_DAEMON_KEEPALIVE_EXPIRY=30 diff --git a/dify-agent/.gitignore b/dify-agent/.gitignore new file mode 100644 index 0000000000..f644f2e545 --- /dev/null +++ b/dify-agent/.gitignore @@ -0,0 +1 @@ +dify-aio diff --git a/dify-agent/AGENTS.md b/dify-agent/AGENTS.md new file mode 100644 index 0000000000..43c68448f2 --- /dev/null +++ b/dify-agent/AGENTS.md @@ -0,0 +1,184 @@ +# Agent Guide + +## Notes for Agent (must-check) + +Before changing any source code under this folder, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec. + +Look for: + +- The module (file) docstring at the top of a source code file +- Docstrings on classes and functions/methods +- Paragraph/block comments for non-obvious logic + +### What to write where + +- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse. +- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing. + - Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard. + - Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes. +- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used). + - If the class is intentionally stateful, note what state exists and what methods mutate it. + - If concurrency/async assumptions matter, state them explicitly. +- **Function/method docstring**: behavioural contract. + - Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions. + - Add examples only when they prevent misuse. +- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states. + - Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality. + +### Rules (must follow) + +In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments. + +- **Before working** + - Read the notes in the area you’ll touch; treat them as part of the spec. + - If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality. + - If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour). +- **During working** + - Keep the notes in sync as you discover constraints, make decisions, or change approach. + - If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants. + - Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct. + - Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions. +- **When finishing** + - Update the notes to reflect what changed, why, and any new edge cases/tests. + - Remove or rewrite any comments that could be mistaken as current guidance but no longer apply. + - Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery. + +## Coding Style + +This is the default standard for backend code in this repo. Follow it for new code and use it as the checklist when reviewing changes. + +### Linting & Formatting + +- Use Ruff for formatting and linting (follow `.ruff.toml`). +- Keep each line under 120 characters (including spaces). + +### Naming Conventions + +- Use `snake_case` for variables and functions. +- Use `PascalCase` for classes. +- Use `UPPER_CASE` for constants. + +### Typing & Class Layout + +- Code should usually include type annotations that match the repo’s current Python version (avoid untyped public APIs and “mystery” values). +- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless there’s a strong reason. +- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`. +- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional). +- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown. + +```python +from datetime import datetime +from typing import NotRequired, TypedDict + + +class UserProfile(TypedDict): + user_id: str + email: str + created_at: datetime + nickname: NotRequired[str] +``` + +- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance: + +```python +from datetime import datetime + + +class Example: + user_id: str + created_at: datetime + + def __init__(self, user_id: str, created_at: datetime) -> None: + self.user_id = user_id + self.created_at = created_at +``` + +- For dataclasses, prefer `field(default_factory=...)` over `field(init=False)` when a default can be provided declaratively. +- Prefer dataclasses with `slots=True` when defining lightweight data containers: + +```python +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(slots=True) +class Example: + user_id: str + created_at: datetime +``` + +### General Rules + +- Use Pydantic v2 conventions. +- Use `uv` for Python package management in this repo (usually with `--project dify-agent`). +- Use `make typecheck` to run `basedpyright` against `dify-agent/src` and `dify-agent/tests`. +- Keep type checking passing after every edit you make. +- Use `pytest` for all tests in this package. +- When integrating with, implementing, or mocking a dependency, inspect the dependency's source code to confirm its API shape and runtime behavior instead of guessing from names alone. +- Prefer simple functions over small “utility classes” for lightweight helpers. +- Avoid implementing dunder methods unless it’s clearly needed and matches existing patterns. +- Keep code readable and explicit—avoid clever hacks. + +### Testing + +- Work in TDD style: write or update a failing test first when changing behavior, then make the implementation pass, then refactor while keeping tests and typecheck green. +- Use `make test` to run the agent pytest suite. +- Keep local tests under `dify-agent/tests/local/`. +- Mirror the `dify-agent/src/` package structure inside `dify-agent/tests/local/` so test locations stay predictable. + +#### Local Tests + +- Write local tests for stable, externally observable behavior that can run quickly without real external services. +- In this repo, code, comments, docs, and tests are expected to change together. Because of that, a local test is only useful if it would still be correct after an internal refactor that does not change the intended contract. +- Local tests should verify: + - what callers and downstream code can observe and rely on + - how the unit is expected to use its dependencies at the boundary + - how the unit handles dependency success, failure, empty responses, malformed responses, and documented error cases + - documented invariants, error mapping, and output/input shape guarantees +- When asserting dependency interactions, assert only the parts of the request or response that are part of the real boundary contract. Do not over-specify incidental details that callers or dependencies do not rely on. +- It is acceptable to mock dependencies in local tests, but only when the mock represents a real contract, schema, documented behavior, or known regression. +- Tests may use line-scoped type-ignore comments when intentionally exercising runtime validation paths that static typing would normally reject. Keep the ignore on the exact invalid call. +- Do not use local tests to prove real integration, network wiring, serialization, framework configuration, or third-party runtime behavior; cover those in higher-level tests. +- Meaningless local tests include: + - tests that only mirror the current implementation or must be updated whenever internal code changes even though the contract did not change + - tests of private helpers, local variables, temporary state, internal branching, or exact internal call order unless those details are part of the published contract + - tests with mocked dependency behavior that is invented only to make the current implementation pass + - tests that add no value beyond static type checking or linting + +### Logging & Errors + +- Never use `print`; use a module-level logger: + - `logger = logging.getLogger(__name__)` +- Include tenant/app/workflow identifiers in log context when relevant. +- Raise domain-specific exceptions and translate them into HTTP responses in controllers. +- Log retryable events at `warning`, terminal failures at `error`. + +### Pydantic Usage + +- Define DTOs with Pydantic v2 models and forbid extras by default. +- Use `@field_validator` / `@model_validator` for domain rules. + +Example: + +```python +from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator + + +class TriggerConfig(BaseModel): + endpoint: HttpUrl + secret: str + + model_config = ConfigDict(extra="forbid") + + @field_validator("secret") + def ensure_secret_prefix(cls, value: str) -> str: + if not value.startswith("dify_"): + raise ValueError("secret must start with dify_") + return value +``` + +### Generics & Protocols + +- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces). +- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers. +- Validate dynamic inputs at runtime when generics cannot enforce safety alone. diff --git a/dify-agent/Makefile b/dify-agent/Makefile new file mode 100644 index 0000000000..a9b258fa5f --- /dev/null +++ b/dify-agent/Makefile @@ -0,0 +1,45 @@ +.DEFAULT_GOAL := help + +PROJECT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) + +.PHONY: help serve dev check fix typecheck test update-examples docs docs-serve + +help: + @echo "Dify agent targets:" + @echo " make serve - Run the Dify Agent server" + @echo " make dev - Run the Dify Agent server with reload" + @echo " make check - Run Ruff for dify-agent" + @echo " make fix - Format and fix Ruff issues" + @echo " make typecheck - Run basedpyright for src, examples, and tests" + @echo " make test - Run local tests and docs/example tests" + @echo " make update-examples - Rewrite docs example outputs when needed" + @echo " make docs - Build MkDocs documentation" + @echo " make docs-serve - Serve MkDocs documentation locally" + +serve: + @uv --directory "$(PROJECT_DIR)" run --project . --extra server uvicorn dify_agent.server.app:app --host 127.0.0.1 --port 8000 + +dev: + @uv --directory "$(PROJECT_DIR)" run --project . --extra server uvicorn dify_agent.server.app:app --host 127.0.0.1 --port 8000 --reload + +check: + @uv --directory "$(PROJECT_DIR)" run --project . python -m ruff check . + +fix: + @uv --directory "$(PROJECT_DIR)" run --project . python -m ruff format . + @uv --directory "$(PROJECT_DIR)" run --project . python -m ruff check --fix . + +typecheck: + @uv --directory "$(PROJECT_DIR)" run --project . basedpyright --level error src examples tests + +test: + @uv --directory "$(PROJECT_DIR)" run --project . python -m pytest tests + +update-examples: + @uv --directory "$(PROJECT_DIR)" run --project . python -m pytest --update-examples tests/docs/test_examples.py + +docs: + @uv --directory "$(PROJECT_DIR)" run --project . --group docs python -m mkdocs build --no-strict + +docs-serve: + @uv --directory "$(PROJECT_DIR)" run --project . --group docs python -m mkdocs serve --no-strict diff --git a/dify-agent/README.md b/dify-agent/README.md new file mode 100644 index 0000000000..f370721775 --- /dev/null +++ b/dify-agent/README.md @@ -0,0 +1,7 @@ +# Dify Agent + +Agenton documentation lives in [`docs/agenton/guide/index.md`](docs/agenton/guide/index.md) and +[`docs/agenton/api/index.md`](docs/agenton/api/index.md). + +Dify Agent runtime documentation lives in [`docs/dify-agent/index.md`](docs/dify-agent/index.md). +Build all docs with `make docs` from this directory. diff --git a/dify-agent/docs/.hooks/main.py b/dify-agent/docs/.hooks/main.py new file mode 100644 index 0000000000..1000a80ab3 --- /dev/null +++ b/dify-agent/docs/.hooks/main.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path + +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.structure.files import Files +from mkdocs.structure.pages import Page +from snippets import inject_snippets + +DOCS_ROOT = Path(__file__).resolve().parent.parent + + +def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str: + """Inject repository snippets before MkDocs renders Markdown.""" + relative_path = DOCS_ROOT / page.file.src_uri + return inject_snippets(markdown, relative_path.parent) diff --git a/dify-agent/docs/.hooks/snippets.py b/dify-agent/docs/.hooks/snippets.py new file mode 100644 index 0000000000..6c23a2438b --- /dev/null +++ b/dify-agent/docs/.hooks/snippets.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SNIPPET_DIRECTIVE_PATTERN = re.compile(r"^```snippet\s+\{[^}]+\}\s*(?:```|\n```)$", re.MULTILINE) + + +@dataclass(frozen=True, slots=True) +class SnippetDirective: + path: str + title: str | None = None + fragment: str | None = None + highlight: str | None = None + extra_attrs: dict[str, str] | None = None + + +@dataclass(frozen=True, slots=True) +class LineRange: + start_line: int + end_line: int + + def intersection(self, ranges: list[LineRange]) -> list[LineRange]: + intersections: list[LineRange] = [] + for line_range in ranges: + start_line = max(self.start_line, line_range.start_line) + end_line = min(self.end_line, line_range.end_line) + if start_line < end_line: + intersections.append(LineRange(start_line, end_line)) + return intersections + + @staticmethod + def merge(ranges: list[LineRange]) -> list[LineRange]: + if not ranges: + return [] + + merged: list[LineRange] = [] + for line_range in sorted(ranges, key=lambda item: item.start_line): + if not merged or merged[-1].end_line < line_range.start_line: + merged.append(line_range) + else: + previous = merged[-1] + merged[-1] = LineRange(previous.start_line, max(previous.end_line, line_range.end_line)) + return merged + + +@dataclass(frozen=True, slots=True) +class RenderedSnippet: + content: str + highlights: list[LineRange] + original_range: LineRange + + +@dataclass(frozen=True, slots=True) +class ParsedFile: + lines: list[str] + sections: dict[str, list[LineRange]] + lines_mapping: dict[int, int] + + def render(self, fragment_sections: list[str], highlight_sections: list[str]) -> RenderedSnippet: + fragment_ranges: list[LineRange] = [] + if fragment_sections: + for section_name in fragment_sections: + fragment_ranges.extend(_section_ranges(self.sections, section_name)) + fragment_ranges = LineRange.merge(fragment_ranges) + else: + fragment_ranges = [LineRange(0, len(self.lines))] + + highlight_ranges: list[LineRange] = [] + for section_name in highlight_sections: + highlight_ranges.extend(_section_ranges(self.sections, section_name)) + highlight_ranges = LineRange.merge(highlight_ranges) + + rendered_highlights: list[LineRange] = [] + rendered_lines: list[str] = [] + last_end_line = 0 + current_line = 0 + for fragment_range in fragment_ranges: + if fragment_range.start_line > last_end_line: + rendered_lines.append("..." if current_line == 0 else "\n...") + current_line += 1 + + for highlight_range in fragment_range.intersection(highlight_ranges): + rendered_highlights.append( + LineRange( + highlight_range.start_line - fragment_range.start_line + current_line, + highlight_range.end_line - fragment_range.start_line + current_line, + ) + ) + + for line_number in range(fragment_range.start_line, fragment_range.end_line): + rendered_lines.append(self.lines[line_number]) + current_line += 1 + last_end_line = fragment_range.end_line + + if last_end_line < len(self.lines): + rendered_lines.append("\n...") + + return RenderedSnippet( + content="\n".join(rendered_lines), + highlights=LineRange.merge(rendered_highlights), + original_range=LineRange( + self.lines_mapping[fragment_ranges[0].start_line], + self.lines_mapping[fragment_ranges[-1].end_line - 1] + 1, + ), + ) + + +def parse_snippet_directive(line: str) -> SnippetDirective | None: + match = re.fullmatch(r"```snippet\s+\{([^}]+)\}\s*(?:```|\n```)", line.strip()) + if not match: + return None + + attrs = {key: value for key, value in re.findall(r'(\w+)="([^"]*)"', match.group(1))} + if "path" not in attrs: + raise ValueError('Missing required key "path" in snippet directive') + + extra_attrs = {key: value for key, value in attrs.items() if key not in {"path", "title", "fragment", "highlight"}} + return SnippetDirective( + path=attrs["path"], + title=attrs.get("title"), + fragment=attrs.get("fragment"), + highlight=attrs.get("highlight"), + extra_attrs=extra_attrs or None, + ) + + +def parse_file_sections(file_path: Path) -> ParsedFile: + input_lines = file_path.read_text(encoding="utf-8").splitlines() + output_lines: list[str] = [] + lines_mapping: dict[int, int] = {} + sections: dict[str, list[LineRange]] = {} + section_starts: dict[str, int] = {} + + output_line_number = 0 + for source_line_number, line in enumerate(input_lines): + section_match = re.search(r"\s*(?:###|///)\s*\[([^]]+)]\s*$", line) + if section_match is None: + output_lines.append(line) + lines_mapping[output_line_number] = source_line_number + output_line_number += 1 + continue + + line_before_marker = line[: section_match.start()] + for section_name in section_match.group(1).split(","): + section_name = section_name.strip() + if section_name.startswith("/"): + start_line = section_starts.pop(section_name[1:], None) + if start_line is None: + raise ValueError(f"Cannot end unstarted section {section_name!r} at {file_path}") + end_line = output_line_number + 1 if line_before_marker else output_line_number + sections.setdefault(section_name[1:], []).append(LineRange(start_line, end_line)) + else: + if section_name in section_starts: + raise ValueError(f"Cannot nest section {section_name!r} at {file_path}") + section_starts[section_name] = output_line_number + + if line_before_marker: + output_lines.append(line_before_marker) + lines_mapping[output_line_number] = source_line_number + output_line_number += 1 + + if section_starts: + raise ValueError(f"Some sections were not finished in {file_path}: {list(section_starts)}") + + return ParsedFile(lines=output_lines, sections=sections, lines_mapping=lines_mapping) + + +def format_highlight_lines(highlight_ranges: list[LineRange]) -> str: + parts: list[str] = [] + for highlight_range in highlight_ranges: + start_line = highlight_range.start_line + 1 + end_line = highlight_range.end_line + parts.append(str(start_line) if start_line == end_line else f"{start_line}-{end_line}") + return " ".join(parts) + + +def inject_snippets(markdown: str, relative_path_root: Path) -> str: + def replace_snippet(match: re.Match[str]) -> str: + directive = parse_snippet_directive(match.group(0)) + if directive is None: + return match.group(0) + + file_path = _resolve_snippet_path(directive.path, relative_path_root) + parsed_file = parse_file_sections(file_path) + rendered = parsed_file.render( + directive.fragment.split() if directive.fragment else [], + directive.highlight.split() if directive.highlight else [], + ) + + attrs: list[str] = [] + title = directive.title or _default_title(file_path, rendered.original_range, bool(directive.fragment)) + if title: + attrs.append(f'title="{title}"') + if rendered.highlights: + attrs.append(f'hl_lines="{format_highlight_lines(rendered.highlights)}"') + if directive.extra_attrs: + attrs.extend(f'{key}="{value}"' for key, value in directive.extra_attrs.items()) + + attrs_text = f" {{{' '.join(attrs)}}}" if attrs else "" + file_extension = file_path.suffix.lstrip(".") or "text" + return f"```{file_extension}{attrs_text}\n{rendered.content}\n```" + + return SNIPPET_DIRECTIVE_PATTERN.sub(replace_snippet, markdown) + + +def _section_ranges(sections: dict[str, list[LineRange]], section_name: str) -> list[LineRange]: + if section_name not in sections: + raise ValueError(f"Unrecognized snippet section {section_name!r}; expected one of {list(sections)}") + return sections[section_name] + + +def _resolve_snippet_path(path: str, relative_path_root: Path) -> Path: + file_path = (REPO_ROOT / path[1:]).resolve() if path.startswith("/") else (relative_path_root / path).resolve() + if not file_path.exists(): + raise FileNotFoundError(f"Snippet file {file_path} not found") + if not file_path.is_relative_to(REPO_ROOT): + raise ValueError(f"Snippet file {file_path} must be inside {REPO_ROOT}") + return file_path + + +def _default_title(file_path: Path, original_range: LineRange, has_fragment: bool) -> str: + relative_path = file_path.relative_to(REPO_ROOT) + if not has_fragment: + return str(relative_path) + return f"{relative_path} (L{original_range.start_line + 1}-L{original_range.end_line})" diff --git a/dify-agent/docs/agenton/examples/index.md b/dify-agent/docs/agenton/examples/index.md new file mode 100644 index 0000000000..61487f5a11 --- /dev/null +++ b/dify-agent/docs/agenton/examples/index.md @@ -0,0 +1,19 @@ +# Agenton examples + +The Agenton examples live under `examples/agenton/agenton_examples` and are kept +importable as a package so documentation can reference real source files. + +## Basics + +```snippet {path="/examples/agenton/agenton_examples/basics.py"} +``` + +## Pydantic AI bridge + +```snippet {path="/examples/agenton/agenton_examples/pydantic_ai_bridge.py"} +``` + +## Session snapshots + +```snippet {path="/examples/agenton/agenton_examples/session_snapshot.py"} +``` diff --git a/dify-agent/docs/agenton/guide/index.md b/dify-agent/docs/agenton/guide/index.md new file mode 100644 index 0000000000..e9d322d7c8 --- /dev/null +++ b/dify-agent/docs/agenton/guide/index.md @@ -0,0 +1,187 @@ +# Agenton user guide + +Agenton composes reusable graph plans from `LayerNode`s and `LayerProvider`s. +The core is state-only: a `Compositor` stores no live layer instances, clients, +cleanup stacks, or run state. Each `Compositor.enter(...)` call creates a fresh +`CompositorRun` with new layer instances, direct dependency bindings, lifecycle +state, and an optional hydrated session snapshot. + +## Config and runtime state + +- **Graph config** is serializable topology: node `name`, provider `type`, + dependency mappings, and metadata. `LayerNodeConfig` deliberately contains no + layer config. +- **Per-run layer config** is passed to `Compositor.enter(configs=...)` as a + mapping keyed by node name. Providers validate each value with the layer's + `config_type` before any factory runs. +- **Runtime state** is serializable per-layer invocation state on + `layer.runtime_state`. Session snapshots persist only lifecycle state and this + model's JSON-safe data. +- **Live Python resources** such as clients, files, sockets, or process handles + stay outside Agenton core. Own them in application code or integration-specific + context managers that wrap compositor entry. + +## Define a config-backed layer + +Use a `LayerConfig` model for per-run config and inherit from a typed layer family +so `Layer.__init_subclass__` can infer schemas: + +```python {test="skip" lint="skip"} +from dataclasses import dataclass + +from pydantic import ConfigDict +from typing_extensions import Self, override + +from agenton.layers import LayerConfig, NoLayerDeps, PlainLayer + + +class GreetingConfig(LayerConfig): + prefix: str + + model_config = ConfigDict(extra="forbid") + + +@dataclass(slots=True) +class GreetingLayer(PlainLayer[NoLayerDeps, GreetingConfig]): + type_id = "example.greeting" + + prefix: str + + @classmethod + @override + def from_config(cls, config: GreetingConfig) -> Self: + return cls(prefix=config.prefix) + + @property + @override + def prefix_prompts(self) -> list[str]: + return [self.prefix] +``` + +Omitted schema slots default to `EmptyLayerConfig` and `EmptyRuntimeState`. +Lifecycle hooks are no-argument methods on the layer instance; use `self.deps` +for dependencies and `self.runtime_state` for serializable mutable state. + +## Live resources + +Agenton does not own resource cleanup. Keep live resources in the surrounding +application and pass them to capability methods explicitly: + +```python {test="skip" lint="skip"} +@dataclass(slots=True) +class ClientUserLayer(PlainLayer[NoLayerDeps]): + def make_client_user(self, *, http_client: httpx.AsyncClient) -> ClientUser: + return ClientUser(http_client) + + +compositor = Compositor([LayerNode("client_user", ClientUserLayer)]) +async with httpx.AsyncClient() as http_client: + async with compositor.enter() as run: + layer = run.get_layer("client_user", ClientUserLayer) + user = layer.make_client_user(http_client=http_client) +``` + +This keeps deterministic cleanup at the integration boundary and leaves Agenton +snapshots limited to serializable runtime state. + +## Build a compositor + +Use providers for config-backed layers and pass per-run config at entry time: + +```python {test="skip" lint="skip"} +from agenton.compositor import Compositor, CompositorConfig, LayerNodeConfig, LayerProvider +from agenton_collections.layers.plain import PromptLayer, PromptLayerConfig + + +providers = ( + LayerProvider.from_layer_type(PromptLayer), + LayerProvider.from_layer_type(GreetingLayer), +) +compositor = Compositor.from_config( + CompositorConfig( + layers=[ + LayerNodeConfig(name="prompt", type="plain.prompt"), + LayerNodeConfig(name="greeting", type="example.greeting"), + ] + ), + providers=providers, +) + +async with compositor.enter( + configs={ + "prompt": PromptLayerConfig(user="Answer with examples."), + "greeting": GreetingConfig(prefix="Hi"), + } +) as run: + prompts = run.prompts +``` + +Use `LayerProvider.from_factory(...)` when construction needs Python objects or +callables. Provider factories receive only validated config and must return a +fresh layer instance for every invocation. For node-specific construction with +`Compositor.from_config`, pass a `node_providers={"node_name": provider}` mapping +to override the provider selected by type id for that node. + +## Dependencies + +Layer dependencies bind direct layer instances onto `self.deps` for one run. +Dependency mappings use dependency field names as keys and compositor node names +as values: + +```python {test="skip" lint="skip"} +class ModelDeps(LayerDeps): + plugin: PluginLayer + + +@dataclass(slots=True) +class ModelLayer(PlainLayer[ModelDeps]): + def make_model(self) -> Model: + return self.deps.plugin.make_provider() +``` + +Optional dependencies are assigned `None` when absent. Missing required +dependencies, unknown dependency keys, and dependency targets with the wrong layer +type fail before lifecycle hooks run. + +## System prompts, user prompts, and tools + +Layers expose four authoring surfaces: + +- `prefix_prompts`: system prompt fragments collected in layer order. +- `suffix_prompts`: system prompt fragments collected in reverse layer order. +- `user_prompts`: user-message fragments collected in layer order. +- `tools`: tool entries collected in layer order. + +`PromptLayer` accepts `prefix`, `user`, and `suffix` config fields. Aggregation is +available on the active `CompositorRun` as `run.prompts`, `run.user_prompts`, and +`run.tools`. For pydantic-ai, import +`agenton_collections.transformers.pydantic_ai.PYDANTIC_AI_TRANSFORMERS` and pass +it to `Compositor(...)` or `Compositor.from_config(...)` so tagged layer items are +converted to Pydantic AI prompt, user prompt, and tool values. + +## Session snapshot and restore + +Core Agenton run slots default to delete-on-exit. Call `run.suspend_on_exit()` or +`run.suspend_layer_on_exit(name)` inside the active context when the next snapshot +should be resumable: + +```python {test="skip" lint="skip"} +async with compositor.enter(configs=configs) as run: + run.suspend_on_exit() + +snapshot = run.session_snapshot +async with compositor.enter(configs=configs, session_snapshot=snapshot) as restored_run: + restored_layer = restored_run.get_layer("stateful", StatefulLayer) +``` + +`run.session_snapshot` is populated after context exit. Snapshots include ordered +layer names, non-active lifecycle states, and JSON-safe runtime state only. Active +state is rejected at the DTO boundary, and closed layers cannot be entered again. +To resume, pass the snapshot to a later `Compositor.enter(...)` call with the same +layer names and order. + +See also: + +- `examples/agenton/agenton_examples/basics.py` +- `examples/agenton/agenton_examples/pydantic_ai_bridge.py` +- `examples/agenton/agenton_examples/session_snapshot.py` diff --git a/dify-agent/docs/agenton/index.md b/dify-agent/docs/agenton/index.md new file mode 100644 index 0000000000..f96db54256 --- /dev/null +++ b/dify-agent/docs/agenton/index.md @@ -0,0 +1,6 @@ +# Agenton documentation + +- [User guide](guide/index.md) explains how to compose layers, register config-backed + plugins, use system/user prompts, and snapshot sessions. +- [API reference](api/index.md) lists the public Agenton classes, methods, and extension + points. diff --git a/dify-agent/docs/dify-agent/examples/index.md b/dify-agent/docs/dify-agent/examples/index.md new file mode 100644 index 0000000000..8624dc790e --- /dev/null +++ b/dify-agent/docs/dify-agent/examples/index.md @@ -0,0 +1,25 @@ +# Dify Agent examples + +These examples live under `examples/dify_agent/dify_agent_examples`. They are +separated from Agenton examples because they depend on Dify Agent runtime services +such as the FastAPI server, Redis, or the plugin daemon. + +## Run a Dify plugin-daemon backed model + +```snippet {path="/examples/dify_agent/dify_agent_examples/run_pydantic_ai_agent.py"} +``` + +## Poll run events + +```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_consumer.py"} +``` + +## Use the synchronous client + +```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_sync_client.py"} +``` + +## Stream run events with SSE + +```snippet {path="/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py"} +``` diff --git a/dify-agent/docs/dify-agent/get-started/index.md b/dify-agent/docs/dify-agent/get-started/index.md new file mode 100644 index 0000000000..466c3fe8c2 --- /dev/null +++ b/dify-agent/docs/dify-agent/get-started/index.md @@ -0,0 +1,268 @@ +# Get started with Dify Agent + +This guide walks through the smallest end-to-end path for the current Dify Agent +runtime: install dependencies, configure the server, start it, then use the Python +client to create one plugin-daemon-backed run. + +## Prerequisites + +Install or prepare: + +- Python 3.12 or newer +- `uv` +- Redis +- a reachable Dify plugin daemon +- a plugin/provider already available through that plugin daemon, such as + `langgenius/openai` + +## Install dependencies + +From the repository root, enter the `dify-agent` package and install all extras +and dependency groups: + +```bash +cd dify-agent +uv sync --all-extras --all-groups +``` + +Only the `server` extra is required to run the API server, but installing all +extras and groups gives you the local test, docs, and server dependencies in one +environment. + +## Start Redis + +Skip this step if you already have a reachable Redis instance. + +```bash +docker run -d \ + --name dify-agent-redis \ + -p 6379:6379 \ + redis:7-alpine +``` + +## Configure the server + +Create or update `.env` in the `dify-agent` directory: + +```bash +cat > .env <<'EOF' +DIFY_AGENT_REDIS_URL=redis://localhost:6379/0 +DIFY_AGENT_REDIS_PREFIX=dify-agent + +DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 +DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key +EOF +``` + +The minimum settings are: + +- `DIFY_AGENT_REDIS_URL`: Redis connection URL used for run records and event + streams. +- `DIFY_AGENT_REDIS_PREFIX`: Redis key prefix for this server. +- `DIFY_AGENT_PLUGIN_DAEMON_URL`: base URL for the Dify plugin daemon. +- `DIFY_AGENT_PLUGIN_DAEMON_API_KEY`: API key sent by the server to the plugin + daemon. In a Dify Docker setup this is usually the value previously configured + as `PLUGIN_DAEMON_KEY`. + +See `.example.env` for the full server settings template. + +## Start the Dify Agent server + +For a normal local server process: + +```bash +make serve +``` + +For development with uvicorn reload: + +```bash +make dev +``` + +Both commands serve the API at: + +```text +http://127.0.0.1:8000 +``` + +The equivalent development command is: + +```bash +uv run --extra server uvicorn dify_agent.server.app:app \ + --host 127.0.0.1 \ + --port 8000 \ + --reload +``` + +`ServerSettings` reads `.env` from the current `dify-agent` directory, or from +`dify-agent/.env` when the command is run from the repository root. + +## Create a one-file uv script client + +In another shell, keep working from the `dify-agent` directory and create this +script. The script depends on the local `dify-agent` package only; it does not +install the server extra because it talks to the already running server through +the public Python client. + +```bash +DIFY_AGENT_PACKAGE_URL="$(python3 - <<'PY' +from pathlib import Path + +print(Path.cwd().resolve().as_uri()) +PY +)" + +cat > ./run_dify_agent_client.py < str: + value = os.environ.get(name, default) + if value is None or value == "": + raise SystemExit(f"Missing required environment variable: {name}") + return value + + +def load_credentials() -> dict[str, Any]: + raw = env("DIFY_AGENT_MODEL_CREDENTIALS_JSON") + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise SystemExit(f"DIFY_AGENT_MODEL_CREDENTIALS_JSON must be valid JSON: {exc}") from exc + + if not isinstance(data, dict): + raise SystemExit("DIFY_AGENT_MODEL_CREDENTIALS_JSON must be a JSON object") + + return data + + +async def main() -> int: + api_base_url = env("DIFY_AGENT_SERVER_URL", "http://127.0.0.1:8000") + + tenant_id = env("DIFY_AGENT_TENANT_ID") + plugin_id = env("DIFY_AGENT_PLUGIN_ID", "langgenius/openai") + user_id = os.environ.get("DIFY_AGENT_USER_ID") or None + + model_provider = env("DIFY_AGENT_PROVIDER", "openai") + model_name = env("DIFY_AGENT_MODEL_NAME", "gpt-4o-mini") + model_credentials = load_credentials() + + system_prompt = env("DIFY_AGENT_SYSTEM_PROMPT", "You are a concise assistant.") + user_prompt = env("DIFY_AGENT_PROMPT", "Say hello from the Dify Agent client.") + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type=PLAIN_PROMPT_LAYER_TYPE_ID, + config=PromptLayerConfig(prefix=system_prompt, user=user_prompt), + ), + RunLayerSpec( + name="plugin", + type=DIFY_PLUGIN_LAYER_TYPE_ID, + config=DifyPluginLayerConfig( + tenant_id=tenant_id, + plugin_id=plugin_id, + user_id=user_id, + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + deps={"plugin": "plugin"}, + config=DifyPluginLLMLayerConfig( + model_provider=model_provider, + model=model_name, + credentials=model_credentials, + ), + ), + ], + ), + ) + + async with Client(base_url=api_base_url, stream_timeout=None) as client: + run = await client.create_run(request) + print(f"created run: {run.run_id}, status={run.status}") + + async for event in client.stream_events(run.run_id): + print(event.model_dump_json(indent=2)) + + if event.type == "run_succeeded": + print("final output:") + print(json.dumps(event.data.output, ensure_ascii=False, indent=2)) + return 0 + + if event.type == "run_failed": + print(f"run failed: {event.data.error}", file=sys.stderr) + return 2 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) +PY + +chmod +x ./run_dify_agent_client.py +``` + +## Configure the client request and run it + +The server-side `.env` controls how Dify Agent reaches the plugin daemon. The +client request controls which tenant/plugin/provider/model and provider +credentials the run uses. Configure those values before executing the script: + +```bash +export DIFY_AGENT_SERVER_URL=http://127.0.0.1:8000 + +export DIFY_AGENT_TENANT_ID=replace-with-tenant-id +export DIFY_AGENT_PLUGIN_ID=langgenius/openai +export DIFY_AGENT_PROVIDER=openai +export DIFY_AGENT_MODEL_NAME=gpt-4o-mini + +export DIFY_AGENT_MODEL_CREDENTIALS_JSON='{"api_key":"replace-with-provider-key"}' + +export DIFY_AGENT_PROMPT='用一句话介绍 Dify Agent。' + +./run_dify_agent_client.py +``` + +The shape of `DIFY_AGENT_MODEL_CREDENTIALS_JSON` depends on the selected plugin +provider's credential schema. The `{"api_key":"..."}` value above is only an +OpenAI-style example. + +## Troubleshooting + +If the run fails, check these items first: + +1. Redis is running and reachable from the Dify Agent server. +2. The Dify Agent server is listening on `127.0.0.1:8000`. +3. `DIFY_AGENT_PLUGIN_DAEMON_URL` points to the correct plugin daemon. +4. `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` matches the plugin daemon server key. +5. `DIFY_AGENT_PLUGIN_ID`, `DIFY_AGENT_PROVIDER`, and + `DIFY_AGENT_MODEL_NAME` match a provider available through the plugin daemon. +6. `DIFY_AGENT_MODEL_CREDENTIALS_JSON` matches that provider's credential schema. diff --git a/dify-agent/docs/dify-agent/guide/index.md b/dify-agent/docs/dify-agent/guide/index.md new file mode 100644 index 0000000000..012bd3a598 --- /dev/null +++ b/dify-agent/docs/dify-agent/guide/index.md @@ -0,0 +1,140 @@ +# Operating the Dify Agent Run Server + +This guide describes how to run the MVP Dify Agent API server. The server is +implemented in `dify-agent/src/dify_agent/server/app.py` and uses Redis for run +records and per-run event streams only. + +## Default local startup + +Start Redis, then run one FastAPI/uvicorn process: + +```bash +uv run --project dify-agent uvicorn dify_agent.server.app:app --reload +``` + +By default, the FastAPI lifespan creates: + +- one Redis-backed run store used by HTTP routes +- one shared plugin-daemon `httpx.AsyncClient` used by local run tasks +- one process-local scheduler that starts background `asyncio` run tasks + +This means local development needs one uvicorn process plus Redis, and +plugin-backed runs also need a reachable Dify plugin daemon. Run execution still +happens outside request handlers, so client disconnects do not cancel the agent +run. + +## Configuration + +`ServerSettings` loads environment variables with the `DIFY_AGENT_` prefix. It +also reads `.env` and `dify-agent/.env` when present. + +| Environment variable | Default | Description | +| --- | --- | --- | +| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. | +| `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Prefix for Redis record and event keys. | +| `DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` | `30` | Seconds to wait for active local runs during graceful shutdown before cancellation. | +| `DIFY_AGENT_RUN_RETENTION_SECONDS` | `259200` | Seconds to retain Redis run records and per-run event streams; defaults to 3 days. | +| `DIFY_AGENT_PLUGIN_DAEMON_URL` | `http://localhost:5002` | Base URL for the Dify plugin daemon. | +| `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` | empty | API key sent to the Dify plugin daemon. | +| `DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT` | `10` | Plugin-daemon HTTP connect timeout in seconds. | +| `DIFY_AGENT_PLUGIN_DAEMON_READ_TIMEOUT` | `600` | Plugin-daemon HTTP read timeout in seconds. | +| `DIFY_AGENT_PLUGIN_DAEMON_WRITE_TIMEOUT` | `30` | Plugin-daemon HTTP write timeout in seconds. | +| `DIFY_AGENT_PLUGIN_DAEMON_POOL_TIMEOUT` | `10` | Plugin-daemon HTTP connection-pool wait timeout in seconds. | +| `DIFY_AGENT_PLUGIN_DAEMON_MAX_CONNECTIONS` | `100` | Maximum total plugin-daemon HTTP connections. | +| `DIFY_AGENT_PLUGIN_DAEMON_MAX_KEEPALIVE_CONNECTIONS` | `20` | Maximum idle keep-alive plugin-daemon HTTP connections. | +| `DIFY_AGENT_PLUGIN_DAEMON_KEEPALIVE_EXPIRY` | `30` | Keep-alive expiry in seconds for idle plugin-daemon HTTP connections. | + +Example `.env`: + +```env +DIFY_AGENT_REDIS_URL=redis://localhost:6379/0 +DIFY_AGENT_REDIS_PREFIX=dify-agent-dev +DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30 +DIFY_AGENT_RUN_RETENTION_SECONDS=259200 +DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002 +DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-daemon-key +``` + +Run records and event streams use the same retention. Status writes refresh the +record TTL, and event writes refresh both the stream TTL and the corresponding +record TTL so active runs that keep producing events remain observable. + +## Scheduling and shutdown semantics + +`POST /runs` validates the composition, persists a `running` run record, and starts +an `asyncio` task in the same process. There is no Redis job stream, consumer +group, pending reclaim, or automatic retry layer. + +During FastAPI shutdown the scheduler rejects new runs, waits up to +`DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks +and best-effort appends a `run_failed` event plus failed status. A hard process +crash can still leave active runs stuck as `running`; there is no in-service +recovery or worker handoff. + +Horizontal scaling is possible by running multiple API processes against the same +Redis prefix, but each process executes only the runs it accepted. Redis provides +shared status/event visibility, not load balancing or queued-job recovery. + +## Run inputs and session snapshots + +The API does not accept a top-level `user_prompt`. Submit a `RunComposition` +whose Agenton layers provide user input. With the MVP provider set, use +`plain.prompt` and its `config.user` field: + +```json +{ + "composition": { + "schema_version": 1, + "layers": [ + { + "name": "prompt", + "type": "plain.prompt", + "config": { + "prefix": "You are concise.", + "user": "Summarize the current state." + } + } + ] + } +} +``` + +`config.user` can be a string or a list of strings. Empty or whitespace-only +effective prompts are rejected during create-run validation before the run is +persisted or scheduled. + +There is no Pydantic AI history layer. To resume Agenton layer state, pass the +`session_snapshot` from a previous `run_succeeded.data` payload together with a +composition that has the same layer names and order. + +## Observing runs + +Use the HTTP status endpoint for coarse state and the event endpoints for detailed +progress: + +- `POST /runs` creates a running run and schedules it locally. +- `GET /runs/{run_id}` returns `running`, `succeeded`, or `failed`. +- `GET /runs/{run_id}/events` polls the Redis Stream event log with `after` and + `next_cursor` cursors. +- `GET /runs/{run_id}/events/sse` replays and streams events over SSE. The SSE + `id` is the event Redis Stream ID. `after` query cursors take precedence over + `Last-Event-ID` headers. + +Successful runs emit `run_started`, zero or more `pydantic_ai_event`, and +`run_succeeded`. Failed runs end with `run_failed`. Event envelopes retain `id`, +`run_id`, `type`, `data`, and `created_at`; `data` is typed per event type, +including Pydantic AI's `AgentStreamEvent` payload for `pydantic_ai_event` and a +terminal `run_succeeded.data` object containing JSON-safe `output` plus a +`CompositorSessionSnapshot` for resumption. + +## Examples + +The repository includes simple consumers that print observed output/events: + +- `dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py` + creates a run and polls events. +- `dify-agent/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py` + consumes raw SSE frames for an existing run id. + +The create-run examples submit Dify plugin model layers, so they require Redis, +the API server, plugin-daemon settings, and provider credentials. diff --git a/dify-agent/docs/dify-agent/index.md b/dify-agent/docs/dify-agent/index.md new file mode 100644 index 0000000000..39cedf74b9 --- /dev/null +++ b/dify-agent/docs/dify-agent/index.md @@ -0,0 +1,8 @@ +# Dify Agent runtime + +Dify Agent hosts Agenton-composed Pydantic AI runs behind a FastAPI API. Its +source code stays under `src/dify_agent`, while framework-neutral Agenton code +stays under `src/agenton` and `src/agenton_collections`. + +See the [operations guide](guide/index.md) for local server behavior and the +[run API](api/index.md) for request and event schemas. diff --git a/dify-agent/docs/index.md b/dify-agent/docs/index.md new file mode 100644 index 0000000000..0fd13b6cb6 --- /dev/null +++ b/dify-agent/docs/index.md @@ -0,0 +1,11 @@ +# Dify Agent + +This documentation is split by ownership boundary: + +- [Agenton](agenton/index.md) covers the framework-neutral layer compositor and reusable + collection layers. +- [Dify Agent](dify-agent/index.md) covers the Dify runtime, HTTP API, Redis-backed run + storage, and server examples. + +The split mirrors the source tree so Agenton documentation and examples can be +moved together if Agenton is published separately later. diff --git a/dify-agent/examples/agenton/agenton_examples/__init__.py b/dify-agent/examples/agenton/agenton_examples/__init__.py new file mode 100644 index 0000000000..048f5b8e02 --- /dev/null +++ b/dify-agent/examples/agenton/agenton_examples/__init__.py @@ -0,0 +1 @@ +"""Runnable Agenton examples kept separate from Dify Agent runtime examples.""" diff --git a/dify-agent/examples/agenton/agenton_examples/__main__.py b/dify-agent/examples/agenton/agenton_examples/__main__.py new file mode 100644 index 0000000000..d0794fc2f0 --- /dev/null +++ b/dify-agent/examples/agenton/agenton_examples/__main__.py @@ -0,0 +1,50 @@ +"""Small CLI for listing or copying Agenton examples.""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +EXAMPLE_MODULES = ( + "basics", + "pydantic_ai_bridge", + "session_snapshot", +) + + +def cli() -> None: + parser = argparse.ArgumentParser( + prog="agenton_examples", + description="List or copy Agenton examples.", + ) + parser.add_argument("--copy-to", metavar="DEST", help="Copy example files to a new directory") + args = parser.parse_args() + + examples_dir = Path(__file__).parent + if args.copy_to: + copy_to(examples_dir, Path(args.copy_to)) + return + + for module_name in EXAMPLE_MODULES: + print(f"python -m agenton_examples.{module_name}") + + +def copy_to(examples_dir: Path, destination: Path) -> None: + if destination.exists(): + print(f'Error: destination path "{destination}" already exists', file=sys.stderr) + sys.exit(1) + + destination.mkdir(parents=True) + copied = 0 + for source in examples_dir.glob("*.py"): + if source.name == "__init__.py": + continue + shutil.copy2(source, destination / source.name) + copied += 1 + print(f'Copied {copied} Agenton example files to "{destination}"') + + +if __name__ == "__main__": + cli() diff --git a/dify-agent/examples/agenton/agenton_examples/basics.py b/dify-agent/examples/agenton/agenton_examples/basics.py new file mode 100644 index 0000000000..81a3daeb18 --- /dev/null +++ b/dify-agent/examples/agenton/agenton_examples/basics.py @@ -0,0 +1,142 @@ +"""Run with: uv run --project dify-agent python -m agenton_examples.basics.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from inspect import signature +from typing import cast + +from typing_extensions import override + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from agenton.layers import LayerDeps, NoLayerDeps, PlainLayer, PlainToolType +from agenton_collections.layers.plain import DynamicToolsLayer, ObjectLayer, PromptLayer, ToolsLayer, with_object +from agenton_collections.layers.plain.basic import PromptLayerConfig + + +@dataclass(frozen=True, slots=True) +class AgentProfile: + name: str + audience: str + tone: str + + +class ProfilePromptDeps(LayerDeps): + profile: ObjectLayer[AgentProfile] # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class ProfilePromptLayer(PlainLayer[ProfilePromptDeps]): + @property + @override + def prefix_prompts(self) -> list[str]: + profile = self.deps.profile.value + return [ + f"You are {profile.name}, writing for {profile.audience}.", + f"Keep the tone {profile.tone}.", + ] + + +@dataclass(slots=True) +class TraceLayer(PlainLayer[NoLayerDeps]): + events: list[str] = field(default_factory=list) + + @override + async def on_context_create(self) -> None: + self.events.append("create") + + @override + async def on_context_suspend(self) -> None: + self.events.append("suspend") + + @override + async def on_context_resume(self) -> None: + self.events.append("resume") + + @override + async def on_context_delete(self) -> None: + self.events.append("delete") + + +def count_words(text: str) -> int: + return len(text.split()) + + +@with_object(AgentProfile) +def write_tagline(profile: AgentProfile, topic: str) -> str: + return f"{profile.name}: {topic} for {profile.audience}, in a {profile.tone} voice." + + +async def main() -> None: + profile = AgentProfile( + name="Agenton Assistant", + audience="engineers composing agent capabilities", + tone="precise and friendly", + ) + trace_events: list[str] = [] + compositor = Compositor( + [ + LayerNode("base_prompt", PromptLayer), + LayerNode("extra_prompt", PromptLayer), + LayerNode( + "profile", + LayerProvider.from_factory( + layer_type=ObjectLayer, + create=lambda _config: ObjectLayer[AgentProfile](profile), + ), + ), + LayerNode("profile_prompt", ProfilePromptLayer, deps={"profile": "profile"}), + LayerNode( + "tools", + LayerProvider.from_factory( + layer_type=ToolsLayer, + create=lambda _config: ToolsLayer(tool_entries=(count_words,)), + ), + ), + LayerNode( + "dynamic_tools", + LayerProvider.from_factory( + layer_type=DynamicToolsLayer, + create=lambda _config: DynamicToolsLayer[AgentProfile](tool_entries=(write_tagline,)), + ), + deps={"object_layer": "profile"}, + ), + LayerNode( + "trace", + LayerProvider.from_factory(layer_type=TraceLayer, create=lambda _config: TraceLayer(trace_events)), + ), + ] + ) + configs = { + "base_prompt": PromptLayerConfig( + prefix="Use config dicts for serializable layers.", + user="Explain how the composed agent should use its layers.", + suffix="Before finalizing, make the result easy to scan.", + ), + "extra_prompt": PromptLayerConfig(prefix="Use constructed instances for objects, local code, and callables."), + } + + async with compositor.enter(configs=configs) as run: + print("Prompts:") + for prompt in run.prompts: + print(f"- {prompt.value}") + + print("\nUser prompts:") + for prompt in run.user_prompts: + print(f"- {prompt.value}") + + print("\nTools:") + plain_tools = [cast(PlainToolType, tool) for tool in run.tools] + for tool in plain_tools: + print(f"- {tool.value.__name__}{signature(tool.value)}") + print([tool.value("layer composition") for tool in plain_tools]) + run.suspend_on_exit() + + async with compositor.enter(configs=configs, session_snapshot=run.session_snapshot): + pass + print("\nLifecycle:", trace_events) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dify-agent/examples/agenton/agenton_examples/pydantic_ai_bridge.py b/dify-agent/examples/agenton/agenton_examples/pydantic_ai_bridge.py new file mode 100644 index 0000000000..0b6ff588d1 --- /dev/null +++ b/dify-agent/examples/agenton/agenton_examples/pydantic_ai_bridge.py @@ -0,0 +1,133 @@ +"""Pydantic AI bridge example for use from a source checkout. + +`agenton_examples` is not part of the published package, so run this module from +the repository root with: + + PYTHONPATH=dify-agent/src:dify-agent/examples/agenton \ + uv run --project dify-agent python -m agenton_examples.pydantic_ai_bridge +""" + +from __future__ import annotations + +import asyncio +import json +import os +from dataclasses import dataclass + +from pydantic_ai import Agent, RunContext +from pydantic_ai.messages import BuiltinToolCallPart, ModelMessage, ToolCallPart +from pydantic_ai.models.openai import OpenAIChatModel # pyright: ignore[reportDeprecated] +from pydantic_ai.models.test import TestModel + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from agenton_collections.layers.plain import ObjectLayer, PromptLayer, PromptLayerConfig, ToolsLayer +from agenton_collections.layers.pydantic_ai import PydanticAIBridgeLayer +from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS + + +@dataclass(frozen=True, slots=True) +class AgentProfile: + name: str + audience: str + tone: str + + +def count_words(text: str) -> int: + return len(text.split()) + + +def profile_prompt(ctx: RunContext[AgentProfile]) -> str: + profile = ctx.deps + return f"You are {profile.name}, helping {profile.audience}." + + +def tone_prompt(ctx: RunContext[AgentProfile]) -> str: + return f"Keep responses {ctx.deps.tone}." + + +def write_tagline(ctx: RunContext[AgentProfile], topic: str) -> str: + profile = ctx.deps + return f"{profile.name}: {topic} for {profile.audience}, in a {profile.tone} voice." + + +async def main() -> None: + profile = AgentProfile( + name="Agenton Assistant", + audience="engineers composing agent capabilities", + tone="precise and friendly", + ) + compositor = Compositor( + [ + LayerNode("base_prompt", PromptLayer), + LayerNode( + "profile", + LayerProvider.from_factory( + layer_type=ObjectLayer, + create=lambda _config: ObjectLayer[AgentProfile](profile), + ), + ), + LayerNode( + "plain_tools", + LayerProvider.from_factory( + layer_type=ToolsLayer, + create=lambda _config: ToolsLayer(tool_entries=(count_words,)), + ), + ), + LayerNode( + "pydantic_ai_bridge", + LayerProvider.from_factory( + layer_type=PydanticAIBridgeLayer, + create=lambda _config: PydanticAIBridgeLayer[AgentProfile]( + prefix=("Prefer concrete details.", profile_prompt, tone_prompt), + user="Use the tools for 'layer composition'.", + tool_entries=(write_tagline,), + ), + ), + deps={"object_layer": "profile"}, + ), + ], + **PYDANTIC_AI_TRANSFORMERS, + ) + + async with compositor.enter( + configs={ + "base_prompt": PromptLayerConfig( + prefix="Use the available tools before answering.", + suffix="Return concise, inspectable output.", + ) + } + ) as run: + model = ( + OpenAIChatModel("gpt-5.5") # pyright: ignore[reportDeprecated] + if os.getenv("OPENAI_API_KEY") + else TestModel() + ) + agent = Agent[AgentProfile]( + model=model, + deps_type=AgentProfile, + tools=run.tools, + ) + for prompt in run.prompts: + _ = agent.system_prompt(prompt) + + bridge_layer = run.get_layer("pydantic_ai_bridge", PydanticAIBridgeLayer) + result = await agent.run(run.user_prompts, deps=bridge_layer.run_deps) + + for line in _format_messages(result.all_messages()): + print(line) + + +def _format_messages(messages: list[ModelMessage]) -> list[str]: + lines: list[str] = [] + for message in messages: + for part in message.parts: + if isinstance(part, ToolCallPart | BuiltinToolCallPart): + args = json.dumps(part.args, ensure_ascii=False) + lines.append(f"{type(part).__name__}: {part.tool_name}({args})") + else: + lines.append(f"{type(part).__name__}: {part.content}") + return lines + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dify-agent/examples/agenton/agenton_examples/session_snapshot.py b/dify-agent/examples/agenton/agenton_examples/session_snapshot.py new file mode 100644 index 0000000000..4e5a9c3b9f --- /dev/null +++ b/dify-agent/examples/agenton/agenton_examples/session_snapshot.py @@ -0,0 +1,50 @@ +"""Run with: uv run --project dify-agent python -m agenton_examples.session_snapshot.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import ClassVar + +from pydantic import BaseModel, ConfigDict + +from agenton.compositor import Compositor, LayerNode +from agenton.layers import EmptyLayerConfig, NoLayerDeps, PlainLayer + + +class ConnectionState(BaseModel): + connection_id: str = "demo-connection" + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +class ConnectionHandle: + def __init__(self, connection_id: str) -> None: + self.connection_id = connection_id + + +@dataclass(slots=True) +class ConnectionLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ConnectionState]): + runtime_state_type: ClassVar[type[BaseModel]] = ConnectionState + + +async def main() -> None: + compositor = Compositor([LayerNode("connection", ConnectionLayer)]) + async with compositor.enter() as run: + layer = run.get_layer("connection", ConnectionLayer) + connection = ConnectionHandle(layer.runtime_state.connection_id) + print("Active external handle:", connection.connection_id) + run.suspend_on_exit() + + snapshot = run.session_snapshot + assert snapshot is not None + print("Snapshot:", snapshot.model_dump(mode="json")) + + async with compositor.enter(session_snapshot=snapshot) as restored_run: + layer = restored_run.get_layer("connection", ConnectionLayer) + restored_connection = ConnectionHandle(f"restored:{layer.runtime_state.connection_id}") + print("Rehydrated external handle:", restored_connection.connection_id) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/__init__.py b/dify-agent/examples/dify_agent/dify_agent_examples/__init__.py new file mode 100644 index 0000000000..629dd85368 --- /dev/null +++ b/dify-agent/examples/dify_agent/dify_agent_examples/__init__.py @@ -0,0 +1 @@ +"""Runnable Dify Agent runtime examples kept separate from Agenton examples.""" diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/__main__.py b/dify-agent/examples/dify_agent/dify_agent_examples/__main__.py new file mode 100644 index 0000000000..204d2b6b8a --- /dev/null +++ b/dify-agent/examples/dify_agent/dify_agent_examples/__main__.py @@ -0,0 +1,51 @@ +"""Small CLI for listing or copying Dify Agent examples.""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +EXAMPLE_MODULES = ( + "run_pydantic_ai_agent", + "run_server_consumer", + "run_server_sse_consumer", + "run_server_sync_client", +) + + +def cli() -> None: + parser = argparse.ArgumentParser( + prog="dify_agent_examples", + description="List or copy Dify Agent runtime examples.", + ) + parser.add_argument("--copy-to", metavar="DEST", help="Copy example files to a new directory") + args = parser.parse_args() + + examples_dir = Path(__file__).parent + if args.copy_to: + copy_to(examples_dir, Path(args.copy_to)) + return + + for module_name in EXAMPLE_MODULES: + print(f"python -m dify_agent_examples.{module_name}") + + +def copy_to(examples_dir: Path, destination: Path) -> None: + if destination.exists(): + print(f'Error: destination path "{destination}" already exists', file=sys.stderr) + sys.exit(1) + + destination.mkdir(parents=True) + copied = 0 + for source in examples_dir.glob("*.py"): + if source.name == "__init__.py": + continue + shutil.copy2(source, destination / source.name) + copied += 1 + print(f'Copied {copied} Dify Agent example files to "{destination}"') + + +if __name__ == "__main__": + cli() diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_pydantic_ai_agent.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_pydantic_ai_agent.py new file mode 100644 index 0000000000..12eb42dc1b --- /dev/null +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_pydantic_ai_agent.py @@ -0,0 +1,83 @@ +"""Run a Pydantic AI agent through the Dify plugin-daemon adapter. + +Prerequisites: +- Sync the server runtime dependencies first: `uv sync --project dify-agent --extra server`. +- Start the plugin daemon from `dify-aio/dify/docker/docker-compose.middleware.yaml`. +- Run the Dify API with `dify-aio/dify/api/.env` so the daemon can resolve tenants/plugins. +- Fill `dify-agent/.env` with a real tenant, plugin, provider, model, and provider credentials. + +This example is meant to be run from a source checkout because +`dify_agent_examples` is not part of the published package. + +Example from the repository root: + PYTHONPATH=dify-agent/src:dify-agent/examples/dify_agent \ + uv run --project dify-agent python -m dify_agent_examples.run_pydantic_ai_agent +""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +from typing import Any + +from pydantic_ai import Agent + +from dify_agent.adapters.llm import DifyLLMAdapterModel, DifyPluginDaemonProvider + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def load_env_file(path: Path) -> None: + """Load simple KEY=VALUE lines without adding a dotenv dependency.""" + if not path.exists(): + return + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if value: + return value + raise RuntimeError(f"Missing required environment variable: {name}") + + +def load_credentials() -> dict[str, Any]: + raw_credentials = required_env("DIFY_AGENT_MODEL_CREDENTIALS_JSON") + credentials = json.loads(raw_credentials) + if not isinstance(credentials, dict): + raise RuntimeError("DIFY_AGENT_MODEL_CREDENTIALS_JSON must be a JSON object") + return credentials + + +async def main() -> None: + load_env_file(PROJECT_ROOT / ".env") + + model = DifyLLMAdapterModel( + required_env("DIFY_AGENT_MODEL_NAME"), + DifyPluginDaemonProvider( + tenant_id=required_env("DIFY_AGENT_TENANT_ID"), + plugin_id=required_env("DIFY_AGENT_PLUGIN_ID"), + plugin_daemon_url=required_env("PLUGIN_DAEMON_URL"), + plugin_daemon_api_key=required_env("PLUGIN_DAEMON_KEY"), + ), + model_provider=required_env("DIFY_AGENT_PROVIDER"), + credentials=load_credentials(), + ) + agent = Agent(model=model) + async with agent.run_stream("Explain the theory of relativity") as run: + async for piece in run.stream_output(): + print(piece, end="", flush=True) + print(run.usage()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py new file mode 100644 index 0000000000..a3d0474b46 --- /dev/null +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py @@ -0,0 +1,85 @@ +"""Async Python client example for the Dify Agent run server. + +Requires Redis and a running API server. Before starting the server, sync the +server runtime dependencies with `uv sync --project dify-agent --extra server` +or install `dify-agent[server]`. The server schedules runs in-process, for +example: + + uv run --project dify-agent uvicorn dify_agent.server.app:app --reload + +The request carries Dify plugin model configuration in Agenton layers. This +script prints the created run and every event observed through cursor polling. +``Client.create_run`` performs one POST attempt only; use polling or SSE replay to +recover after client-side uncertainty. +""" + +import asyncio + +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig +from dify_agent.client import Client +from dify_agent.layers.dify_plugin import ( + DIFY_PLUGIN_LAYER_TYPE_ID, + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DifyPluginCredentialValue, + DifyPluginLLMLayerConfig, + DifyPluginLayerConfig, +) +from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec + + +API_BASE_URL = "http://localhost:8000" +TENANT_ID = "replace-with-tenant-id" +PLUGIN_ID = "langgenius/openai" +PLUGIN_PROVIDER = "openai" +MODEL_NAME = "gpt-4o-mini" +MODEL_CREDENTIALS: dict[str, DifyPluginCredentialValue] = {"api_key": "replace-with-provider-key"} + + +async def main() -> None: + async with Client(base_url=API_BASE_URL) as client: + run = await client.create_run( + CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type=PLAIN_PROMPT_LAYER_TYPE_ID, + config=PromptLayerConfig( + prefix="You are a concise assistant.", + user="Say hello from the Dify Agent API server example.", + ), + ), + RunLayerSpec( + name="plugin", + type=DIFY_PLUGIN_LAYER_TYPE_ID, + config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + deps={"plugin": "plugin"}, + config=DifyPluginLLMLayerConfig( + model_provider=PLUGIN_PROVIDER, + model=MODEL_NAME, + credentials=MODEL_CREDENTIALS, + ), + ), + ], + ), + ) + ) + print("created run", run) + + cursor = "0-0" + while True: + page = await client.get_events(run.run_id, after=cursor) + cursor = page.next_cursor or cursor + for event in page.events: + print("event", event) + if event.type in {"run_succeeded", "run_failed"}: + return + await asyncio.sleep(0.5) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py new file mode 100644 index 0000000000..8e0979973a --- /dev/null +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sse_consumer.py @@ -0,0 +1,25 @@ +"""Async SSE client example for the Dify Agent run server. + +Create a run with ``run_server_consumer.py`` or any HTTP client, then set RUN_ID +below and run this script while the server is available. The Python client parses +SSE frames into typed protocol events and reconnects with the latest event id by +default. Malformed frames and HTTP 4xx responses fail without reconnecting. +""" + +import asyncio + +from dify_agent.client import Client + + +API_BASE_URL = "http://localhost:8000" +RUN_ID = "replace-with-run-id" + + +async def main() -> None: + async with Client(base_url=API_BASE_URL, stream_timeout=None) as client: + async for event in client.stream_events(RUN_ID): + print(event) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py new file mode 100644 index 0000000000..3c789571f1 --- /dev/null +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py @@ -0,0 +1,70 @@ +"""Synchronous Python client example for the Dify Agent run server. + +Requires the same running FastAPI server as the async examples. Before starting +that server, sync the server runtime dependencies with +`uv sync --project dify-agent --extra server` or install +`dify-agent[server]`. ``create_run_sync`` does not retry ``POST /runs``; if a +timeout occurs, inspect server state or create a new run explicitly rather than +assuming the original request was not accepted. +""" + +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig +from dify_agent.client import Client +from dify_agent.layers.dify_plugin import ( + DIFY_PLUGIN_LAYER_TYPE_ID, + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DifyPluginCredentialValue, + DifyPluginLLMLayerConfig, + DifyPluginLayerConfig, +) +from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec + + +API_BASE_URL = "http://localhost:8000" +TENANT_ID = "replace-with-tenant-id" +PLUGIN_ID = "langgenius/openai" +PLUGIN_PROVIDER = "openai" +MODEL_NAME = "gpt-4o-mini" +MODEL_CREDENTIALS: dict[str, DifyPluginCredentialValue] = {"api_key": "replace-with-provider-key"} + + +def main() -> None: + with Client(base_url=API_BASE_URL) as client: + run = client.create_run_sync( + CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type=PLAIN_PROMPT_LAYER_TYPE_ID, + config=PromptLayerConfig( + prefix="You are a concise assistant.", + user="Say hello from the synchronous Dify Agent client example.", + ), + ), + RunLayerSpec( + name="plugin", + type=DIFY_PLUGIN_LAYER_TYPE_ID, + config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + deps={"plugin": "plugin"}, + config=DifyPluginLLMLayerConfig( + model_provider=PLUGIN_PROVIDER, + model=MODEL_NAME, + credentials=MODEL_CREDENTIALS, + ), + ), + ], + ), + ) + ) + print("created run", run) + terminal = client.wait_run_sync(run.run_id, poll_interval_seconds=0.5) + print("terminal status", terminal) + + +if __name__ == "__main__": + main() diff --git a/dify-agent/mkdocs.yml b/dify-agent/mkdocs.yml new file mode 100644 index 0000000000..9675a5eda2 --- /dev/null +++ b/dify-agent/mkdocs.yml @@ -0,0 +1,65 @@ +site_name: Dify Agent +site_description: Agent runtime and Agenton composition framework documentation +strict: true + +repo_name: langgenius/dify +repo_url: https://github.com/langgenius/dify +edit_uri: edit/main/dify-agent/docs/ + +nav: + - Home: index.md + - Agenton: + - Overview: agenton/index.md + - Guide: agenton/guide/index.md + - API Reference: agenton/api/index.md + - Examples: agenton/examples/index.md + - Dify Agent: + - Overview: dify-agent/index.md + - Get Started: dify-agent/get-started/index.md + - Operations Guide: dify-agent/guide/index.md + - Run API: dify-agent/api/index.md + - Examples: dify-agent/examples/index.md + +theme: + name: material + features: + - content.code.copy + - content.tabs.link + - navigation.indexes + - navigation.sections + - navigation.tracking + - toc.follow + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - pymdownx.details + - pymdownx.highlight: + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - tables + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: + - src + options: + docstring_style: google + members_order: source + separate_signature: true + show_signature_annotations: true + signature_crossrefs: true + +watch: + - src + - examples + +hooks: + - docs/.hooks/main.py diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml new file mode 100644 index 0000000000..d9b2796570 --- /dev/null +++ b/dify-agent/pyproject.toml @@ -0,0 +1,76 @@ +[project] +name = "dify-agent" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.28.1", + "pydantic>=2.12.5,<2.13", + "pydantic-ai-slim>=1.85.1", + "typing-extensions>=4.12.2", +] + +[project.optional-dependencies] +server = [ + "fastapi>=0.136.0", + "graphon~=0.2.2", + "jsonschema>=4.23.0", + "pydantic-ai-slim[anthropic,google,openai]>=1.85.1", + "pydantic-settings>=2.12.0", + "redis>=5", + "uvicorn[standard]>=0.38.0", +] + +[tool.setuptools.packages.find] +where = ["src"] +include = [ + "agenton*", + "agenton_collections*", + "dify_agent*", +] + +[tool.pyright] +include = ["src", "examples", "tests"] +venvPath = "." +venv = ".venv" +pythonVersion = "3.12" +extraPaths = [ + "src", + "examples/agenton", + "examples/dify_agent", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] + +[tool.ruff] +line-length = 120 +target-version = "py312" +include = [ + "src/**/*.py", + "examples/**/*.py", + "tests/**/*.py", + "docs/**/*.py", +] + +[dependency-groups] +dev = [ + "basedpyright>=1.39.3", + "coverage[toml]>=7.10.7", + "pytest>=9.0.3", + "pytest-examples>=0.0.18", + "pytest-mock>=3.14.0", + "ruff>=0.15.11", +] +docs = [ + "mkdocs>=1.6.1,<2", + "mkdocs-glightbox>=0.4.0", + "mkdocs-material>=9.7.0", + "mkdocstrings-python>=2.0.0", +] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" diff --git a/dify-agent/src/agenton/__init__.py b/dify-agent/src/agenton/__init__.py new file mode 100644 index 0000000000..9c9bdfee40 --- /dev/null +++ b/dify-agent/src/agenton/__init__.py @@ -0,0 +1,18 @@ +"""Agenton state-only core. + +Agenton core composes reusable stateless layer graph plans, creates a fresh +``CompositorRun`` for each invocation, hydrates and advances serializable layer +``runtime_state`` through run slots, and emits session snapshots. It intentionally +does not own resources, handles, clients, cleanup callbacks, or any other +non-serializable runtime object. + +Each ``Compositor`` stores only graph nodes and layer providers. Every enter call +creates new layer instances, binds direct dependencies for that run, and writes +the next cross-call state to ``run.session_snapshot`` after exit. To resume a +suspended call, reuse the same compositor plan and pass the prior snapshot to a +new enter call. + +``LifecycleState.ACTIVE`` is internal-only while an entry is running. External +session snapshots and hydrated input must contain only non-active lifecycle +states; ``runtime_state`` is the only mutable layer data captured by snapshots. +""" diff --git a/dify-agent/src/agenton/compositor/__init__.py b/dify-agent/src/agenton/compositor/__init__.py new file mode 100644 index 0000000000..55019d7b61 --- /dev/null +++ b/dify-agent/src/agenton/compositor/__init__.py @@ -0,0 +1,55 @@ +"""Stateless layer graph composition facade for the Agenton core. + +``agenton.compositor`` remains the stable import surface for reusable graph +composition. ``core.py`` defines the state-only ``Compositor`` graph plan and +``LayerNode`` orchestration, ``providers.py`` owns reusable construction plans +and fresh-instance validation, ``run.py`` owns active invocation lifecycle plus +prompt/tool aggregation, ``schemas.py`` owns serializable graph/snapshot DTOs +and boundary revalidation, and ``types.py`` holds shared generic transformer +types. + +``Compositor`` itself stores no live layer instances, run lifecycle state, +session state, resources, or handles. Each ``enter(...)`` call creates a fresh +``CompositorRun`` with new layer instances, direct dependency binding, optional +snapshot hydration, and the next ``session_snapshot`` after exit. +``LifecycleState.ACTIVE`` remains internal-only and session snapshots contain +only ordered layer lifecycle state plus serializable ``runtime_state``. + +The facade also keeps true module-level aliases for ``LayerConfigInput`` and +``LayerProviderInput`` so existing typed direct imports remain supported +without expanding ``__all__``. +""" + +from .core import Compositor, LayerNode +from .providers import LayerConfigInput as _LayerConfigInput +from .providers import LayerFactory, LayerProvider, LayerProviderInput as _LayerProviderInput +from .run import CompositorRun, LayerRunSlot +from .schemas import ( + CompositorConfig, + CompositorConfigValue, + CompositorSessionSnapshot, + CompositorSessionSnapshotValue, + LayerNodeConfig, + LayerSessionSnapshot, +) +from .types import CompositorTransformer, CompositorTransformerKwargs + +type LayerConfigInput = _LayerConfigInput +type LayerProviderInput = _LayerProviderInput + +__all__ = [ + "Compositor", + "CompositorConfig", + "CompositorConfigValue", + "CompositorRun", + "CompositorSessionSnapshot", + "CompositorSessionSnapshotValue", + "CompositorTransformer", + "CompositorTransformerKwargs", + "LayerFactory", + "LayerNode", + "LayerNodeConfig", + "LayerProvider", + "LayerRunSlot", + "LayerSessionSnapshot", +] diff --git a/dify-agent/src/agenton/compositor/core.py b/dify-agent/src/agenton/compositor/core.py new file mode 100644 index 0000000000..355b143356 --- /dev/null +++ b/dify-agent/src/agenton/compositor/core.py @@ -0,0 +1,308 @@ +"""Stateless compositor graph plans and run construction. + +``Compositor`` stores only reusable graph nodes and optional aggregation +transformers. Each ``enter(...)`` call validates node-name keyed configs before +any provider factory runs, optionally validates and hydrates a session snapshot, +creates fresh layer instances, binds direct dependencies, and returns a new +``CompositorRun`` for that invocation only. + +``Compositor.from_config(...)`` resolves serializable provider type ids rather +than import paths. Named ``node_providers`` override type-id providers for the +same graph node without passing graph node data into factories. Session +snapshots must list layer names in compositor order so runtime state can be +hydrated deterministically. +""" + +from collections import OrderedDict +from collections.abc import AsyncIterator, Mapping, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any, Generic, cast + +from pydantic import JsonValue + +from agenton.layers.base import Layer, LayerConfig, LifecycleState + +from .providers import LayerConfigInput, LayerProvider, LayerProviderInput, _as_layer_provider +from .run import CompositorRun, LayerRunSlot +from .schemas import ( + CompositorConfigValue, + CompositorSessionSnapshot, + CompositorSessionSnapshotValue, + _validate_compositor_config_input, + _validate_config_model_input, +) +from .types import ( + CompositorTransformer, + LayerPromptT, + LayerToolT, + LayerUserPromptT, + PromptT, + ToolT, + UserPromptT, +) + + +@dataclass(frozen=True, slots=True, init=False) +class LayerNode: + """Stateless graph node plan for one named layer provider. + + ``implementation`` may be a layer class or an explicit ``LayerProvider``. + ``deps`` maps dependency field names on this node's layer class to other + compositor node names. ``metadata`` is graph description data only; it is + not passed to provider factories and is never included in session snapshots. + """ + + name: str + provider: LayerProvider[Any] + deps: Mapping[str, str] + metadata: Mapping[str, JsonValue] + + def __init__( + self, + name: str, + implementation: LayerProviderInput, + *, + deps: Mapping[str, str] | None = None, + metadata: Mapping[str, JsonValue] | None = None, + ) -> None: + if not name: + raise ValueError("Layer node name must not be empty.") + object.__setattr__(self, "name", name) + object.__setattr__(self, "provider", _as_layer_provider(implementation)) + object.__setattr__(self, "deps", dict(deps or {})) + object.__setattr__(self, "metadata", dict(metadata or {})) + + +class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]): + """Reusable, framework-neutral ordered layer graph plan. + + A compositor stores only immutable graph nodes and provider construction + plans. It is safe to enter repeatedly or concurrently because every entry + creates a separate ``CompositorRun`` with fresh layer instances, run slots, + dependency bindings, and optional hydrated runtime state. Session + continuity is explicit: pass the previous ``CompositorSessionSnapshot`` to + the next ``enter`` call and read the next one from ``run.session_snapshot`` + after exit. + + ``prompt_transformer``, ``user_prompt_transformer``, and + ``tool_transformer`` are post-aggregation hooks on each run. Use two type + arguments for identity aggregation, four when prompt/tool layer item types + differ from exposed item types, or all six when user prompt item types also + differ. + """ + + __slots__ = ("_nodes", "prompt_transformer", "tool_transformer", "user_prompt_transformer") + + _nodes: tuple[LayerNode, ...] + prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None + user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None + tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None + + def __init__( + self, + nodes: Sequence[LayerNode], + *, + prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None = None, + user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None = None, + tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None = None, + ) -> None: + self._nodes = tuple(nodes) + self.prompt_transformer = prompt_transformer + self.user_prompt_transformer = user_prompt_transformer + self.tool_transformer = tool_transformer + self._validate_nodes() + + @property + def nodes(self) -> tuple[LayerNode, ...]: + """Return the stateless graph plan nodes in compositor order.""" + return self._nodes + + @classmethod + def from_config( + cls, + conf: CompositorConfigValue, + *, + providers: Sequence[LayerProviderInput], + node_providers: Mapping[str, LayerProviderInput] | None = None, + prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None = None, + user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None = None, + tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None = None, + ) -> "Compositor[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]": + """Create a reusable compositor plan from serializable graph config. + + ``providers`` resolve graph node ``type`` ids. ``node_providers`` are + keyed by graph node name and take precedence over the type-id provider, + allowing node-specific construction without passing node data to factory + callables. + """ + graph_config = _validate_compositor_config_input(conf) + if graph_config.schema_version != 1: + raise ValueError(f"Unsupported compositor config schema_version: {graph_config.schema_version}.") + + provider_by_type = _build_provider_type_map(providers) + provider_by_node = {name: _as_layer_provider(provider) for name, provider in (node_providers or {}).items()} + graph_node_names = {node.name for node in graph_config.layers} + unknown_node_providers = provider_by_node.keys() - graph_node_names + if unknown_node_providers: + names = ", ".join(sorted(unknown_node_providers)) + raise ValueError(f"node_providers contains unknown layer node names: {names}.") + + nodes: list[LayerNode] = [] + for node_config in graph_config.layers: + provider = provider_by_node.get(node_config.name) + if provider is None: + try: + provider = provider_by_type[node_config.type] + except KeyError as e: + raise KeyError(f"Layer type id '{node_config.type}' is not registered.") from e + nodes.append( + LayerNode( + node_config.name, + provider, + deps=node_config.deps, + metadata=node_config.metadata, + ) + ) + + return cls( + nodes, + prompt_transformer=prompt_transformer, + user_prompt_transformer=user_prompt_transformer, + tool_transformer=tool_transformer, + ) + + @asynccontextmanager + async def enter( + self, + *, + configs: Mapping[str, LayerConfigInput] | None = None, + session_snapshot: CompositorSessionSnapshotValue | None = None, + ) -> AsyncIterator[CompositorRun[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]]: + """Create a fresh run, enter layers in graph order, and yield it. + + Configs are keyed by layer node name and validated before factories run. + The optional session snapshot is validated and hydrated before any hook + runs. Layers exit in reverse graph order, and ``run.session_snapshot`` + is populated after exit with the next non-active lifecycle states. + """ + run = self._create_run(configs=configs, session_snapshot=session_snapshot) + await run._enter_layers() + try: + yield run + finally: + await run._exit_layers() + + def _create_run( + self, + *, + configs: Mapping[str, LayerConfigInput] | None, + session_snapshot: CompositorSessionSnapshotValue | None, + ) -> CompositorRun[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]: + config_by_name = self._validate_run_configs(configs) + typed_config_by_name = self._validate_layer_configs(config_by_name) + snapshot = self._validate_session_snapshot(session_snapshot) if session_snapshot is not None else None + layer_by_name = self._create_layers(typed_config_by_name) + + snapshot_by_name = ( + {layer_snapshot.name: layer_snapshot for layer_snapshot in snapshot.layers} if snapshot else {} + ) + lifecycle_by_name: dict[str, LifecycleState] = {} + for node in self._nodes: + layer = layer_by_name[node.name] + layer_snapshot = snapshot_by_name.get(node.name) + if layer_snapshot is None: + lifecycle_by_name[node.name] = LifecycleState.NEW + continue + layer.runtime_state = cast(Any, layer.runtime_state_type.model_validate(layer_snapshot.runtime_state)) + lifecycle_by_name[node.name] = layer_snapshot.lifecycle_state + + self._bind_deps(layer_by_name) + return CompositorRun( + slots=OrderedDict( + (node.name, LayerRunSlot(layer=layer_by_name[node.name], lifecycle_state=lifecycle_by_name[node.name])) + for node in self._nodes + ), + prompt_transformer=self.prompt_transformer, + user_prompt_transformer=self.user_prompt_transformer, + tool_transformer=self.tool_transformer, + ) + + def _create_layers( + self, + config_by_name: Mapping[str, LayerConfig], + ) -> OrderedDict[str, Layer[Any, Any, Any, Any, Any, Any]]: + return OrderedDict( + (node.name, node.provider.create_layer_from_config(config_by_name[node.name])) for node in self._nodes + ) + + def _validate_layer_configs(self, config_by_name: Mapping[str, LayerConfigInput]) -> dict[str, LayerConfig]: + """Validate every node config before any provider factory is invoked.""" + return {node.name: node.provider.validate_config(config_by_name.get(node.name)) for node in self._nodes} + + def _bind_deps(self, layer_by_name: Mapping[str, Layer[Any, Any, Any, Any, Any, Any]]) -> None: + """Resolve dependency-name mappings and bind direct layer dependencies.""" + for node in self._nodes: + layer = layer_by_name[node.name] + resolved_deps = {dep_name: layer_by_name[target_name] for dep_name, target_name in node.deps.items()} + layer.bind_deps(resolved_deps) + + def _validate_nodes(self) -> None: + layer_names: set[str] = set() + for node in self._nodes: + if node.name in layer_names: + raise ValueError(f"Duplicate layer name '{node.name}'.") + layer_names.add(node.name) + + for node in self._nodes: + declared_deps = node.provider.layer_type.dependency_names() + unknown_dep_keys = set(node.deps) - declared_deps + if unknown_dep_keys: + names = ", ".join(sorted(unknown_dep_keys)) + raise ValueError(f"Layer '{node.name}' declares unknown dependency keys: {names}.") + missing_targets = set(node.deps.values()) - layer_names + if missing_targets: + names = ", ".join(sorted(missing_targets)) + raise ValueError(f"Layer '{node.name}' depends on undefined layer names: {names}.") + + def _validate_run_configs(self, configs: Mapping[str, LayerConfigInput] | None) -> dict[str, LayerConfigInput]: + config_by_name = dict(configs or {}) + known_names = {node.name for node in self._nodes} + unknown_names = config_by_name.keys() - known_names + if unknown_names: + names = ", ".join(sorted(unknown_names)) + raise ValueError(f"Layer configs contain unknown layer node names: {names}.") + return config_by_name + + def _validate_session_snapshot( + self, + snapshot: CompositorSessionSnapshotValue, + ) -> CompositorSessionSnapshot: + resolved_snapshot = _validate_config_model_input(CompositorSessionSnapshot, snapshot) + if resolved_snapshot.schema_version != 1: + raise ValueError( + f"Unsupported compositor session snapshot schema_version: {resolved_snapshot.schema_version}." + ) + expected_layer_names = tuple(node.name for node in self._nodes) + actual_layer_names = tuple(layer.name for layer in resolved_snapshot.layers) + if actual_layer_names != expected_layer_names: + expected = ", ".join(expected_layer_names) + actual = ", ".join(actual_layer_names) + raise ValueError( + "CompositorSessionSnapshot layer names must match compositor layers in order. " + f"Expected [{expected}], got [{actual}]." + ) + return resolved_snapshot + + +def _build_provider_type_map(providers: Sequence[LayerProviderInput]) -> dict[str, LayerProvider[Any]]: + provider_by_type: dict[str, LayerProvider[Any]] = {} + for provider_input in providers: + provider = _as_layer_provider(provider_input) + type_id = provider.type_id + if type_id is None or not type_id: + raise ValueError(f"Layer provider for '{provider.layer_type.__qualname__}' must declare a type_id.") + if type_id in provider_by_type: + raise ValueError(f"Layer type id '{type_id}' is already registered.") + provider_by_type[type_id] = provider + return provider_by_type diff --git a/dify-agent/src/agenton/compositor/providers.py b/dify-agent/src/agenton/compositor/providers.py new file mode 100644 index 0000000000..7f176cec90 --- /dev/null +++ b/dify-agent/src/agenton/compositor/providers.py @@ -0,0 +1,138 @@ +"""Reusable layer providers and fresh-instance validation. + +Providers are construction plans, not live runtime owners. They validate raw +per-enter config into a layer's declared ``config_type`` and then construct a +fresh layer instance for one invocation. Provider factories receive only typed +config, never graph node data; node-specific construction belongs in named +``node_providers`` overrides on ``Compositor.from_config(...)``. + +Fresh-instance enforcement is global by weak reference so ``Compositor`` can +stay stateless while still rejecting reused layer instances before dependency +binding or lifecycle hooks run. +""" + +from collections.abc import Callable, Mapping +from typing import Any, Generic, cast +import weakref + +from agenton.layers.base import Layer, LayerConfig, LayerConfigValue + +from .schemas import _validate_config_model_input +from .types import LayerT + +type LayerConfigInput = LayerConfigValue | Mapping[str, object] | str | bytes | None +type LayerFactory = Callable[[LayerConfig], Layer[Any, Any, Any, Any, Any, Any]] +type LayerProviderInput = type[Layer[Any, Any, Any, Any, Any, Any]] | "LayerProvider[Any]" + + +_USED_LAYER_INSTANCE_REFS: dict[int, weakref.ReferenceType[Layer[Any, Any, Any, Any, Any, Any]]] = {} + + +def _claim_fresh_layer_instance(layer: Layer[Any, Any, Any, Any, Any, Any]) -> None: + """Reject provider factories that return a layer object used before. + + The registry stores weak references, not live resources or run state. It is + intentionally global to keep ``Compositor`` stateless while still enforcing + the fresh-instance boundary before dependencies are bound or hooks run. + """ + layer_identity = id(layer) + existing_ref = _USED_LAYER_INSTANCE_REFS.get(layer_identity) + if existing_ref is not None: + existing_layer = existing_ref() + if existing_layer is not None: + raise ValueError( + "LayerProvider factories must return a fresh layer instance for each invocation; " + f"got reused instance of '{type(layer).__name__}'." + ) + _USED_LAYER_INSTANCE_REFS.pop(layer_identity, None) + + def remove_ref(ref: weakref.ReferenceType[Layer[Any, Any, Any, Any, Any, Any]]) -> None: + if _USED_LAYER_INSTANCE_REFS.get(layer_identity) is ref: + _USED_LAYER_INSTANCE_REFS.pop(layer_identity, None) + + _USED_LAYER_INSTANCE_REFS[layer_identity] = weakref.ref(layer, remove_ref) + + +class LayerProvider(Generic[LayerT]): + """Validated layer factory for one concrete ``Layer`` class. + + Providers are reusable construction plans. They validate per-call config + with ``layer_type.config_type`` before invoking either + ``layer_type.from_config`` or a custom factory. The factory receives only + typed config, never graph node data, and must return a fresh ``layer_type`` + instance; reused instances are rejected before dependencies are bound or + hooks run. + """ + + __slots__ = ("_create", "layer_type") + + layer_type: type[LayerT] + _create: Callable[[LayerConfig], LayerT] + + def __init__(self, *, layer_type: type[LayerT], create: Callable[[LayerConfig], LayerT]) -> None: + self.layer_type = layer_type + self._create = create + + @classmethod + def from_layer_type(cls, layer_type: type[LayerT]) -> "LayerProvider[LayerT]": + """Create a provider that constructs layers via ``layer_type.from_config``.""" + + def create(config: LayerConfig) -> LayerT: + return layer_type.from_config(cast(Any, config)) + + return cls(layer_type=layer_type, create=create) + + @classmethod + def from_factory( + cls, + *, + layer_type: type[LayerT], + create: Callable[[Any], LayerT], + ) -> "LayerProvider[LayerT]": + """Create a provider from a custom typed-config factory. + + ``create`` receives the validated instance of ``layer_type.config_type``. + It does not receive the graph node; node-specific construction should use + a dedicated provider in ``Compositor.from_config(node_providers=...)``. + """ + return cls(layer_type=layer_type, create=cast(Callable[[LayerConfig], LayerT], create)) + + @property + def type_id(self) -> str | None: + """Return the serializable registry type id declared by ``layer_type``.""" + return self.layer_type.type_id + + def create_layer(self, config: LayerConfigInput = None) -> LayerT: + """Validate config, call the factory, and return a fresh layer instance.""" + typed_config = self.validate_config(config) + return self.create_layer_from_config(typed_config) + + def validate_config(self, config: LayerConfigInput = None) -> LayerConfig: + """Return typed config without invoking the layer factory. + + ``Compositor.enter`` calls this for every node before creating any layer + so a later invalid node config cannot leave earlier factory side effects. + """ + raw_config: LayerConfigValue | Mapping[str, object] | str | bytes = {} if config is None else config + return _validate_config_model_input(self.layer_type.config_type, raw_config) + + def create_layer_from_config(self, config: LayerConfig) -> LayerT: + """Call the factory with validated config and enforce fresh instances.""" + typed_config = self.validate_config(config) + layer = self._create(typed_config) + if not isinstance(layer, self.layer_type): + raise TypeError( + f"LayerProvider for '{self.layer_type.__name__}' returned '{type(layer).__name__}', " + f"expected '{self.layer_type.__name__}'." + ) + _claim_fresh_layer_instance(layer) + layer.config = cast(Any, typed_config) + return layer + + +def _as_layer_provider(implementation: LayerProviderInput) -> LayerProvider[Any]: + if isinstance(implementation, LayerProvider): + return implementation + if isinstance(implementation, type) and issubclass(implementation, Layer): + return LayerProvider.from_layer_type(implementation) + raise TypeError("LayerNode implementation must be a Layer subclass or LayerProvider.") diff --git a/dify-agent/src/agenton/compositor/run.py b/dify-agent/src/agenton/compositor/run.py new file mode 100644 index 0000000000..a50e017736 --- /dev/null +++ b/dify-agent/src/agenton/compositor/run.py @@ -0,0 +1,221 @@ +"""Active compositor run lifecycle, snapshots, and aggregation. + +``CompositorRun`` is the only compositor object that exposes live layer +instances. It owns invocation-local lifecycle state, per-layer exit intent, and +the next ``session_snapshot`` after exit. Layers enter in graph order and exit +in reverse graph order. Prompt aggregation preserves graph ordering: prefix +prompts first-to-last, suffix prompts last-to-first, user prompts first-to-last, +and tools in graph order. + +Prompt, user prompt, and tool transformers run only after layer-level wrapping +and run-level aggregation. When no transformer is installed, the wrapped items +are returned unchanged. +""" + +from collections import OrderedDict +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Generic, cast, overload + +from pydantic import JsonValue + +from agenton.layers.base import ExitIntent, Layer, LifecycleState + +from .schemas import CompositorSessionSnapshot, LayerSessionSnapshot +from .types import ( + CompositorTransformer, + LayerPromptT, + LayerT, + LayerToolT, + LayerUserPromptT, + PromptT, + ToolT, + UserPromptT, +) + + +@dataclass(slots=True) +class LayerRunSlot: + """Invocation-local lifecycle and exit state for one fresh layer instance.""" + + layer: Layer[Any, Any, Any, Any, Any, Any] + lifecycle_state: LifecycleState + exit_intent: ExitIntent = ExitIntent.DELETE + + +@dataclass(slots=True) +class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT, LayerUserPromptT]): + """Single-invocation runtime object created by ``Compositor.enter``. + + The run owns ordered ``LayerRunSlot`` objects and the fresh layers inside + them. It is the only object that exposes live layers, lifecycle state, exit + intent, and prompt/user-prompt/tool aggregation for an active invocation. + After context exit, ``session_snapshot`` contains the next cross-call state. + """ + + slots: OrderedDict[str, LayerRunSlot] + prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] | None = None + user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] | None = None + tool_transformer: CompositorTransformer[LayerToolT, ToolT] | None = None + session_snapshot: CompositorSessionSnapshot | None = None + + @overload + def get_layer(self, name: str) -> Layer[Any, Any, Any, Any, Any, Any]: ... + + @overload + def get_layer(self, name: str, layer_type: type[LayerT]) -> LayerT: ... + + def get_layer( + self, + name: str, + layer_type: type[LayerT] | None = None, + ) -> Layer[Any, Any, Any, Any, Any, Any] | LayerT: + """Return a live layer by node name and optionally validate its type.""" + try: + layer = self.slots[name].layer + except KeyError as e: + raise KeyError(f"Layer '{name}' is not defined in this compositor run.") from e + + if layer_type is not None and not isinstance(layer, layer_type): + raise TypeError(f"Layer '{name}' must be {layer_type.__name__}, got {type(layer).__name__}.") + return layer + + def suspend_on_exit(self) -> None: + """Request suspend behavior for every active layer when the run exits.""" + for name in self.slots: + self.suspend_layer_on_exit(name) + + def delete_on_exit(self) -> None: + """Request delete behavior for every active layer when the run exits.""" + for name in self.slots: + self.delete_layer_on_exit(name) + + def suspend_layer_on_exit(self, name: str) -> None: + """Request suspend behavior for one active layer when the run exits.""" + self._set_layer_exit_intent(name, ExitIntent.SUSPEND) + + def delete_layer_on_exit(self, name: str) -> None: + """Request delete behavior for one active layer when the run exits.""" + self._set_layer_exit_intent(name, ExitIntent.DELETE) + + def snapshot_session(self) -> CompositorSessionSnapshot: + """Snapshot non-active layer lifecycle state and runtime state from this run.""" + active_layers = [name for name, slot in self.slots.items() if slot.lifecycle_state is LifecycleState.ACTIVE] + if active_layers: + names = ", ".join(active_layers) + raise RuntimeError(f"Cannot snapshot active compositor run layers: {names}.") + return CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name=name, + lifecycle_state=slot.lifecycle_state, + runtime_state=cast(dict[str, JsonValue], slot.layer.runtime_state.model_dump(mode="json")), + ) + for name, slot in self.slots.items() + ] + ) + + async def _enter_layers(self) -> None: + self._ensure_layers_can_enter() + entered_slots: list[LayerRunSlot] = [] + try: + for slot in self.slots.values(): + await self._enter_slot(slot) + entered_slots.append(slot) + except BaseException as enter_error: + hook_error = await self._exit_slots_reversed(entered_slots) + self.session_snapshot = self.snapshot_session() + if hook_error is not None: + raise hook_error from enter_error + raise + + async def _exit_layers(self) -> None: + hook_error = await self._exit_slots_reversed(list(self.slots.values())) + self.session_snapshot = self.snapshot_session() + if hook_error is not None: + raise hook_error + + async def _enter_slot(self, slot: LayerRunSlot) -> None: + if slot.lifecycle_state is LifecycleState.NEW: + slot.exit_intent = ExitIntent.DELETE + await slot.layer.on_context_create() + slot.lifecycle_state = LifecycleState.ACTIVE + return + if slot.lifecycle_state is LifecycleState.SUSPENDED: + slot.exit_intent = ExitIntent.DELETE + await slot.layer.on_context_resume() + slot.lifecycle_state = LifecycleState.ACTIVE + return + raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.") + + async def _exit_slots_reversed(self, slots: Sequence[LayerRunSlot]) -> BaseException | None: + hook_error: BaseException | None = None + for slot in reversed(slots): + if slot.lifecycle_state is not LifecycleState.ACTIVE: + continue + if slot.exit_intent is ExitIntent.SUSPEND: + try: + await slot.layer.on_context_suspend() + except BaseException as exc: + hook_error = hook_error or exc + finally: + slot.lifecycle_state = LifecycleState.SUSPENDED + else: + try: + await slot.layer.on_context_delete() + except BaseException as exc: + hook_error = hook_error or exc + finally: + slot.lifecycle_state = LifecycleState.CLOSED + + return hook_error + + def _set_layer_exit_intent(self, name: str, intent: ExitIntent) -> None: + try: + slot = self.slots[name] + except KeyError as e: + raise KeyError(f"Layer '{name}' is not defined in this compositor run.") from e + if slot.lifecycle_state is not LifecycleState.ACTIVE: + raise RuntimeError("Layer exit intent can only be changed while the run slot is active.") + slot.exit_intent = intent + + def _ensure_layers_can_enter(self) -> None: + """Reject invalid external lifecycle states before any layer side effects.""" + for name, slot in self.slots.items(): + if slot.lifecycle_state is LifecycleState.ACTIVE: + raise RuntimeError(f"Layer '{name}' is already active; ACTIVE snapshots are not allowed.") + if slot.lifecycle_state is LifecycleState.CLOSED: + raise RuntimeError(f"Layer '{name}' is closed; CLOSED snapshots cannot be entered.") + + @property + def prompts(self) -> list[PromptT]: + result: list[LayerPromptT] = [] + for slot in self.slots.values(): + layer = slot.layer + result.extend(cast(LayerPromptT, layer.wrap_prompt(prompt)) for prompt in layer.prefix_prompts) + for slot in reversed(self.slots.values()): + layer = slot.layer + result.extend(cast(LayerPromptT, layer.wrap_prompt(prompt)) for prompt in layer.suffix_prompts) + if self.prompt_transformer is None: + return cast(list[PromptT], result) + return list(self.prompt_transformer(result)) + + @property + def user_prompts(self) -> list[UserPromptT]: + result: list[LayerUserPromptT] = [] + for slot in self.slots.values(): + layer = slot.layer + result.extend(cast(LayerUserPromptT, layer.wrap_user_prompt(prompt)) for prompt in layer.user_prompts) + if self.user_prompt_transformer is None: + return cast(list[UserPromptT], result) + return list(self.user_prompt_transformer(result)) + + @property + def tools(self) -> list[ToolT]: + result: list[LayerToolT] = [] + for slot in self.slots.values(): + layer = slot.layer + result.extend(cast(LayerToolT, layer.wrap_tool(tool)) for tool in layer.tools) + if self.tool_transformer is None: + return cast(list[ToolT], result) + return list(self.tool_transformer(result)) diff --git a/dify-agent/src/agenton/compositor/schemas.py b/dify-agent/src/agenton/compositor/schemas.py new file mode 100644 index 0000000000..8b58eaf20e --- /dev/null +++ b/dify-agent/src/agenton/compositor/schemas.py @@ -0,0 +1,112 @@ +"""Serializable compositor DTOs and external boundary validation. + +Graph config and session snapshots are separate boundaries on purpose. Graph +config describes only reusable composition state: schema version, ordered node +names, provider type ids, dependency mappings, and metadata. Session snapshots +carry only ordered layer lifecycle state plus serializable ``runtime_state``. + +External DTOs are revalidated even when callers pass an already-constructed +Pydantic model instance. These models are mutable, so dumping and validating +again prevents post-construction mutations from bypassing compositor entry +validators. ``LifecycleState.ACTIVE`` remains internal-only and is rejected in +external session snapshots. +""" + +from collections.abc import Mapping + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator + +from agenton.layers.base import LifecycleState + +type _ConfigModelValue[ModelT: BaseModel] = ModelT | JsonValue | str | bytes + + +def _validate_config_model_input[ModelT: BaseModel]( + model_type: type[ModelT], + value: _ConfigModelValue[ModelT] | Mapping[str, object], +) -> ModelT: + """Validate an external DTO boundary, including existing model instances. + + Pydantic models in this package are generally mutable and do not all enable + assignment validation. Revalidating existing instances through their dumped + data prevents post-construction mutations from bypassing config or snapshot + validators at compositor entry boundaries. + """ + if isinstance(value, BaseModel): + return model_type.model_validate(value.model_dump(mode="python", warnings=False)) + if isinstance(value, str | bytes): + return model_type.model_validate_json(value) + + return model_type.model_validate(value) + + +class LayerNodeConfig(BaseModel): + """Serializable config for one provider-backed layer graph node. + + Nodes intentionally contain no runtime state and no per-call layer config. + Runtime state belongs to session snapshots; layer config belongs to + ``Compositor.enter(configs=...)`` keyed by node name. + """ + + name: str + type: str + deps: Mapping[str, str] = Field(default_factory=dict) + metadata: Mapping[str, JsonValue] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +class CompositorConfig(BaseModel): + """Serializable config for constructing a reusable compositor graph plan.""" + + schema_version: int = 1 + layers: list[LayerNodeConfig] + + model_config = ConfigDict(extra="forbid") + + +type CompositorConfigValue = _ConfigModelValue[CompositorConfig] | Mapping[str, object] + + +def _validate_compositor_config_input(value: CompositorConfigValue) -> CompositorConfig: + """Validate external graph config input for ``Compositor.from_config``.""" + return _validate_config_model_input(CompositorConfig, value) + + +class LayerSessionSnapshot(BaseModel): + """Serializable snapshot for one layer's state-only invocation data. + + ``runtime_state`` is the only snapshotted mutable layer data. ``ACTIVE`` is + rejected here because a running layer cannot be represented safely outside + the active compositor entry. + """ + + name: str + lifecycle_state: LifecycleState + runtime_state: dict[str, JsonValue] + + model_config = ConfigDict(extra="forbid") + + @field_validator("lifecycle_state") + @classmethod + def _reject_active_lifecycle(cls, value: LifecycleState) -> LifecycleState: + if value is LifecycleState.ACTIVE: + raise ValueError("LifecycleState.ACTIVE is internal-only and cannot appear in session snapshots.") + return value + + +class CompositorSessionSnapshot(BaseModel): + """Serializable compositor session snapshot. + + Snapshots include ordered layer lifecycle state and serializable runtime + state only. Live resources, handles, dependencies, prompts, tools, and + config are outside Agenton snapshots and are never captured here. + """ + + schema_version: int = 1 + layers: list[LayerSessionSnapshot] + + model_config = ConfigDict(extra="forbid") + + +type CompositorSessionSnapshotValue = _ConfigModelValue[CompositorSessionSnapshot] | Mapping[str, object] diff --git a/dify-agent/src/agenton/compositor/types.py b/dify-agent/src/agenton/compositor/types.py new file mode 100644 index 0000000000..984dc2014e --- /dev/null +++ b/dify-agent/src/agenton/compositor/types.py @@ -0,0 +1,46 @@ +"""Shared generic compositor types. + +This module contains the generic prompt/tool type variables and transformer +contracts shared by compositor runtime and orchestration modules. It depends +only on layer base/types modules so higher-level compositor modules can import +it without creating cycles. +""" + +from collections.abc import Callable, Sequence +from typing import Any, TypedDict + +from typing_extensions import TypeVar + +from agenton.layers.base import Layer +from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptTypes + +PromptT = TypeVar("PromptT", default=AllPromptTypes) +ToolT = TypeVar("ToolT", default=AllToolTypes) +LayerPromptT = TypeVar("LayerPromptT", default=AllPromptTypes) +LayerToolT = TypeVar("LayerToolT", default=AllToolTypes) +UserPromptT = TypeVar("UserPromptT", default=AllUserPromptTypes) +LayerUserPromptT = TypeVar("LayerUserPromptT", default=AllUserPromptTypes) +LayerT = TypeVar("LayerT", bound=Layer[Any, Any, Any, Any, Any, Any]) + + +type CompositorTransformer[InputT, OutputT] = Callable[[Sequence[InputT]], Sequence[OutputT]] + + +class CompositorTransformerKwargs[ + PromptT, + ToolT, + LayerPromptT, + LayerToolT, + UserPromptT, + LayerUserPromptT, +](TypedDict): + """Keyword arguments that install prompt, user prompt, and tool transformers. + + The required keys intentionally mirror the keyword-only transformer + parameters exposed by ``Compositor.__init__`` and + ``Compositor.from_config(...)``. + """ + + prompt_transformer: CompositorTransformer[LayerPromptT, PromptT] + user_prompt_transformer: CompositorTransformer[LayerUserPromptT, UserPromptT] + tool_transformer: CompositorTransformer[LayerToolT, ToolT] diff --git a/dify-agent/src/agenton/layers/__init__.py b/dify-agent/src/agenton/layers/__init__.py new file mode 100644 index 0000000000..bc02a13f0c --- /dev/null +++ b/dify-agent/src/agenton/layers/__init__.py @@ -0,0 +1,66 @@ +"""Layer base classes and typed layer families. + +``agenton.layers.base`` owns the framework-neutral ``Layer`` abstraction. +``agenton.layers.types`` binds the prompt/tool generic slots to specific layer +families while keeping concrete reusable layers in ``agenton_collections``. +""" + +from agenton.layers.base import ( + EmptyLayerConfig, + EmptyRuntimeState, + ExitIntent, + Layer, + LayerConfig, + LayerConfigValue, + LayerDeps, + LifecycleState, + NoLayerDeps, +) +from agenton.layers.types import ( + AllPromptTypes, + AllToolTypes, + AllUserPromptTypes, + PlainLayer, + PlainPrompt, + PlainPromptType, + PlainTool, + PlainToolType, + PlainUserPrompt, + PlainUserPromptType, + PydanticAILayer, + PydanticAIPrompt, + PydanticAIPromptType, + PydanticAITool, + PydanticAIToolType, + PydanticAIUserPrompt, + PydanticAIUserPromptType, +) + +__all__ = [ + "AllPromptTypes", + "AllToolTypes", + "AllUserPromptTypes", + "Layer", + "LayerConfig", + "LayerConfigValue", + "LayerDeps", + "LifecycleState", + "ExitIntent", + "EmptyLayerConfig", + "EmptyRuntimeState", + "NoLayerDeps", + "PlainLayer", + "PlainPrompt", + "PlainPromptType", + "PlainUserPrompt", + "PlainUserPromptType", + "PlainTool", + "PlainToolType", + "PydanticAILayer", + "PydanticAIPrompt", + "PydanticAIPromptType", + "PydanticAIUserPrompt", + "PydanticAIUserPromptType", + "PydanticAITool", + "PydanticAIToolType", +] diff --git a/dify-agent/src/agenton/layers/base.py b/dify-agent/src/agenton/layers/base.py new file mode 100644 index 0000000000..c6f26a4dbc --- /dev/null +++ b/dify-agent/src/agenton/layers/base.py @@ -0,0 +1,520 @@ +"""Invocation-scoped core layer abstractions and typed dependency binding. + +Agenton core deliberately manages only three concerns: stateless layer graph +composition, serializable ``runtime_state`` lifecycle, and session snapshots. It +does not own live resources, process handles, HTTP clients, cleanup stacks, or +any other non-serializable runtime object. Those belong to application layers or +integration code outside the core. + +Layers declare their dependency shape with +``Layer[DepsT, PromptT, UserPromptT, ToolT, ConfigT, RuntimeStateT]``. +``DepsT`` must be a ``LayerDeps`` subclass whose annotated members are concrete +``Layer`` subclasses or modern optional dependencies such as ``SomeLayer | None``. +Dependencies are direct layer instance relationships bound onto ``self.deps`` +for one compositor invocation; there is no dependency-control lookup API in the +core. + +``LayerConfig`` is the DTO base for config schemas accepted by layer providers. +The provider validates raw node-name keyed configs with a layer's +``config_type`` before constructing the layer and assigning ``self.config``. +``runtime_state_type`` is the only mutable schema managed by Agenton and the only +per-layer data included in session snapshots. The base class infers +``deps_type``, ``config_type``, and ``runtime_state_type`` from generic bases +when possible, while still allowing subclasses to set them explicitly for +unusual inheritance patterns. + +``Layer`` is an invocation-scoped business object. It owns ``config``, direct +``deps``, and serializable ``runtime_state`` plus prompt/tool authoring surfaces, +but it does not own lifecycle state, exit intent, graph owner tokens, entry +stacks, resources, or cleanup callbacks. ``CompositorRun`` owns lifecycle state +and exit intent for one entry. ``SessionSnapshot`` objects are the only supported +cross-call state carrier. + +Lifecycle hooks are no-argument business hooks on the layer instance: +``on_context_create/resume/suspend/delete(self)``. They should read dependencies +from ``self.deps`` and read or mutate serializable invocation state through +``self.runtime_state``. Resource acquisition and deterministic cleanup should be +handled outside Agenton core, for example by integration-specific context +managers that wrap compositor entry. + +``Layer`` is framework-neutral over system prompt, user prompt, and tool item +types. The native ``prefix_prompts``, ``suffix_prompts``, ``user_prompts``, and +``tools`` properties are the layer authoring surface. ``wrap_prompt``, +``wrap_user_prompt``, and ``wrap_tool`` are the compositor aggregation surface; +typed families such as ``agenton.layers.types.PlainLayer`` implement them to tag +native values without changing layer implementations. +""" + +from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import StrEnum +from types import UnionType +from typing import ( + Any, + ClassVar, + Generic, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) + +from pydantic import BaseModel, ConfigDict, JsonValue, SerializeAsAny +from typing_extensions import Self, TypeVar + + +_DepsT = TypeVar("_DepsT", bound="LayerDeps") +_PromptT = TypeVar("_PromptT") +_UserPromptT = TypeVar("_UserPromptT") +_ToolT = TypeVar("_ToolT") + + +class LayerConfig(BaseModel): + """Base DTO for serializable layer configuration. + + Layer providers validate raw config values with concrete ``LayerConfig`` + subclasses before constructing a layer for one invocation. Serializable + compositor graph config references layer type ids and node metadata only; + per-call config travels through ``Compositor.enter(configs=...)``. + """ + + model_config = ConfigDict(extra="forbid") + + +type LayerConfigValue = JsonValue | SerializeAsAny[LayerConfig] + + +_ConfigT = TypeVar("_ConfigT", bound=LayerConfig, default="EmptyLayerConfig") +_RuntimeStateT = TypeVar("_RuntimeStateT", bound=BaseModel, default="EmptyRuntimeState") + + +class LayerDeps: + """Typed dependency container for a layer. + + Subclasses declare dependency members with annotations. Every annotated + member must be a Layer subclass or ``LayerSubclass | None``. Optional deps + are always assigned as attributes; missing optional values become ``None``. + """ + + def __init__(self, **deps: "Layer[Any, Any, Any, Any, Any, Any] | None") -> None: + dep_specs = _get_dep_specs(type(self)) + missing_names = {name for name, spec in dep_specs.items() if not spec.optional} - deps.keys() + if missing_names: + names = ", ".join(sorted(missing_names)) + raise ValueError(f"Missing layer dependencies: {names}.") + + unknown_names = deps.keys() - dep_specs.keys() + if unknown_names: + names = ", ".join(sorted(unknown_names)) + raise ValueError(f"Unknown layer dependencies: {names}.") + + for name, spec in dep_specs.items(): + value = deps.get(name) + if value is None: + if spec.optional: + setattr(self, name, None) + continue + raise ValueError(f"Dependency '{name}' is required but not provided.") + + if not isinstance(value, spec.layer_type): + raise TypeError( + f"Dependency '{name}' should be of type '{spec.layer_type.__name__}', " + f"but got type '{type(value).__name__}'." + ) + setattr(self, name, value) + + +class NoLayerDeps(LayerDeps): + """Dependency container for layers that do not require other layers.""" + + +class EmptyLayerConfig(LayerConfig): + """Default serializable config schema for layers without config.""" + + model_config = ConfigDict(extra="forbid") + + +class EmptyRuntimeState(BaseModel): + """Default serializable invocation runtime state schema.""" + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +class LifecycleState(StrEnum): + """Lifecycle state for one run slot. + + ``ACTIVE`` is internal-only. It is used while an invocation is running and + must never appear in external session snapshots or hydrated input. + """ + + NEW = "new" + ACTIVE = "active" + SUSPENDED = "suspended" + CLOSED = "closed" + + +class ExitIntent(StrEnum): + """Run-slot exit behavior requested during active invocation.""" + + DELETE = "delete" + SUSPEND = "suspend" + + +@dataclass(frozen=True, slots=True) +class LayerDepSpec: + """Runtime dependency specification derived from a deps annotation.""" + + layer_type: type["Layer[Any, Any, Any, Any, Any, Any]"] + optional: bool = False + + +class Layer( + ABC, + Generic[_DepsT, _PromptT, _UserPromptT, _ToolT, _ConfigT, _RuntimeStateT], +): + """Framework-neutral base class for prompt/tool layers. + + A layer instance is invocation-scoped mutable business state, not a reusable + cross-session definition. ``CompositorRun`` creates fresh instances through + layer providers, assigns validated ``config``, binds direct dependency layer + instances to ``deps``, hydrates ``runtime_state`` from an optional session + snapshot, and then runs no-argument lifecycle hooks. The run owns lifecycle + state and exit intent; layers never expose a public entry context manager. + + Live resources and handles are intentionally outside this abstraction. Only + ``runtime_state`` is managed and snapshotted by Agenton core. Lifecycle hooks + should operate on ``self`` and keep any non-serializable cleanup policy in + integration code that wraps the compositor. + """ + + deps_type: type[_DepsT] + config: _ConfigT + deps: _DepsT + runtime_state: _RuntimeStateT + type_id: ClassVar[str | None] = None + config_type: ClassVar[type[LayerConfig]] = EmptyLayerConfig + runtime_state_type: ClassVar[type[BaseModel]] = EmptyRuntimeState + + def __new__(cls, *args: object, **kwargs: object) -> Self: + instance = cast(Self, super().__new__(cls)) + runtime_state_type = getattr(cls, "runtime_state_type", None) + if isinstance(runtime_state_type, type) and issubclass(runtime_state_type, BaseModel): + instance.runtime_state = cast(Any, runtime_state_type.model_validate({})) + return instance + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + is_generic_template = _is_generic_layer_template(cls) + deps_type = cls.__dict__.get("deps_type") + if deps_type is None: + deps_type = _infer_deps_type(cls) or getattr(cls, "deps_type", None) + if deps_type is None and is_generic_template: + return + if deps_type is not None: + cls.deps_type = deps_type # pyright: ignore[reportAttributeAccessIssue] + if deps_type is None: + raise TypeError(f"{cls.__name__} must define deps_type or inherit from Layer[DepsT].") + if not isinstance(deps_type, type) or not issubclass(deps_type, LayerDeps): + raise TypeError(f"{cls.__name__}.deps_type must be a LayerDeps subclass.") + _get_dep_specs(deps_type) + _init_config_type(cls, _infer_config_type(cls)) + _init_schema_type( + cls, + "runtime_state_type", + _infer_schema_type(cls, 5, "runtime_state_type"), + EmptyRuntimeState, + ) + + @classmethod + def from_config(cls: type[Self], config: _ConfigT) -> Self: + """Create a layer from schema-validated serialized config. + + ``LayerProvider.from_layer_type`` validates raw config with + ``config_type`` before calling this method. Layers without config use the + default no-argument construction path. Layers with a concrete config + schema should override this method and consume the typed Pydantic model. + """ + if cls.config_type is not EmptyLayerConfig: + raise TypeError(f"{cls.__name__} cannot be created from config; override from_config or use a provider.") + EmptyLayerConfig.model_validate(config) + try: + return cast(Self, cls()) + except TypeError as e: + raise TypeError(f"{cls.__name__} cannot be created from empty config; use a custom provider.") from e + + @classmethod + def dependency_names(cls) -> frozenset[str]: + """Return dependency field names declared by this layer's deps schema.""" + return frozenset(_get_dep_specs(cls.deps_type)) + + def bind_deps(self, deps: Mapping[str, "Layer[Any, Any, Any, Any, Any, Any] | None"]) -> None: + """Bind this layer's declared dependencies from a name-to-layer mapping. + + The mapping may include more layers than the declared dependency fields. + Only names declared by ``deps_type`` are selected and validated. Missing + optional deps are bound as ``None``. Bound values are direct layer + instances for this invocation graph. + """ + resolved_deps: dict[str, Layer[Any, Any, Any, Any, Any, Any] | None] = {} + for name, spec in _get_dep_specs(self.deps_type).items(): + if name not in deps: + if spec.optional: + resolved_deps[name] = None + continue + raise ValueError(f"Dependency '{name}' is required for layer '{type(self).__name__}' but not provided.") + resolved_deps[name] = deps[name] + self.deps = self.deps_type(**resolved_deps) + + async def on_context_create(self) -> None: + """Run when the run slot enters from ``LifecycleState.NEW``.""" + + async def on_context_delete(self) -> None: + """Run when the run slot exits with ``ExitIntent.DELETE``.""" + + async def on_context_suspend(self) -> None: + """Run when the run slot exits with ``ExitIntent.SUSPEND``.""" + + async def on_context_resume(self) -> None: + """Run when the run slot enters from ``LifecycleState.SUSPENDED``.""" + + @property + def prefix_prompts(self) -> Sequence[_PromptT]: + return [] + + @property + def suffix_prompts(self) -> Sequence[_PromptT]: + return [] + + @property + def user_prompts(self) -> Sequence[_UserPromptT]: + return [] + + @property + def tools(self) -> Sequence[_ToolT]: + return [] + + @abstractmethod + def wrap_prompt(self, prompt: _PromptT) -> object: + """Wrap a native prompt item for run-level aggregation.""" + raise NotImplementedError + + @abstractmethod + def wrap_user_prompt(self, prompt: _UserPromptT) -> object: + """Wrap a native user prompt item for run-level aggregation.""" + raise NotImplementedError + + @abstractmethod + def wrap_tool(self, tool: _ToolT) -> object: + """Wrap a native tool item for run-level aggregation.""" + raise NotImplementedError + + +def _get_dep_specs(deps_type: type[LayerDeps]) -> dict[str, LayerDepSpec]: + dep_specs: dict[str, LayerDepSpec] = {} + for name, annotation in get_type_hints(deps_type).items(): + spec = _as_dep_spec(annotation) + if spec is None: + raise TypeError( + f"{deps_type.__name__}.{name} must be annotated with a Layer subclass or Layer subclass | None." + ) + dep_specs[name] = spec + return dep_specs + + +def _as_dep_spec(annotation: object) -> LayerDepSpec | None: + origin = get_origin(annotation) + args = get_args(annotation) + if origin in (UnionType, Union) and len(args) == 2 and type(None) in args: + layer_annotation = args[0] if args[1] is type(None) else args[1] + layer_type = _as_layer_type(layer_annotation) + if layer_type is None: + return None + return LayerDepSpec(layer_type=layer_type, optional=True) + + layer_type = _as_layer_type(annotation) + if layer_type is None: + return None + return LayerDepSpec(layer_type=layer_type) + + +def _as_layer_type(annotation: object) -> type[Layer[Any, Any, Any, Any, Any, Any]] | None: + runtime_type = get_origin(annotation) or annotation + if isinstance(runtime_type, type) and issubclass(runtime_type, Layer): + return cast(type[Layer[Any, Any, Any, Any, Any, Any]], runtime_type) + return None + + +def _infer_deps_type(layer_type: type[Layer[Any, Any, Any, Any, Any, Any]]) -> type[LayerDeps] | None: + inferred = _infer_layer_generic_arg(layer_type, 0, {}) + if inferred is None: + return None + return _as_deps_type(inferred) + + +def _infer_schema_type( + layer_type: type[Layer[Any, Any, Any, Any, Any, Any]], + index: int, + attr_name: str, +) -> type[BaseModel] | None: + inferred = _infer_schema_generic_arg(layer_type, attr_name, {}) or _infer_layer_generic_arg(layer_type, index, {}) + if inferred is None: + return None + schema_type = _as_model_type(inferred) + if schema_type is None: + raise TypeError(f"{layer_type.__name__}.{attr_name} must be a Pydantic BaseModel subclass.") + return schema_type + + +def _infer_config_type(layer_type: type[Layer[Any, Any, Any, Any, Any, Any]]) -> type[LayerConfig] | None: + inferred = _infer_schema_generic_arg(layer_type, "config_type", {}) or _infer_layer_generic_arg(layer_type, 4, {}) + if inferred is None: + return None + config_type = _as_config_type(inferred) + if config_type is None: + raise TypeError(f"{layer_type.__name__}.config_type must be a LayerConfig subclass.") + return config_type + + +def _infer_schema_generic_arg( + layer_type: type[Layer[Any, Any, Any, Any, Any, Any]], + attr_name: str, + substitutions: Mapping[object, object], +) -> object | None: + """Infer schema type arguments exposed by typed layer family bases.""" + expected_names = { + "config_type": {"ConfigT", "_ConfigT"}, + "runtime_state_type": {"RuntimeStateT", "_RuntimeStateT"}, + }[attr_name] + for base in getattr(layer_type, "__orig_bases__", ()): + origin = get_origin(base) or base + args = tuple(_substitute_type(arg, substitutions) for arg in get_args(base)) + if not isinstance(origin, type) or not issubclass(origin, Layer): + continue + + params = _generic_params(origin) + for param, arg in zip(params, args): + if getattr(param, "__name__", None) in expected_names: + return arg + + next_substitutions = dict(substitutions) + next_substitutions.update(_generic_arg_substitutions(origin, args)) + inferred = _infer_schema_generic_arg(origin, attr_name, next_substitutions) + if inferred is not None: + return inferred + return None + + +def _infer_layer_generic_arg( + layer_type: type[Layer[Any, Any, Any, Any, Any, Any]], + index: int, + substitutions: Mapping[object, object], +) -> object | None: + """Infer one concrete ``Layer`` generic argument through inheritance. + + This walks through intermediate generic base classes so subclasses can omit + explicit class attributes in common cases such as ``class X(Base[YDeps])``. + """ + for base in getattr(layer_type, "__orig_bases__", ()): + origin = get_origin(base) or base + args = tuple(_substitute_type(arg, substitutions) for arg in get_args(base)) + if origin is Layer: + if len(args) <= index: + continue + return args[index] + + if not isinstance(origin, type) or not issubclass(origin, Layer): + continue + + next_substitutions = dict(substitutions) + next_substitutions.update(_generic_arg_substitutions(origin, args)) + inferred = _infer_layer_generic_arg(origin, index, next_substitutions) + if inferred is not None: + return inferred + return None + + +def _init_schema_type( + layer_type: type[Layer[Any, Any, Any, Any, Any, Any]], + attr_name: str, + inferred_schema_type: type[BaseModel] | None, + default_schema_type: type[BaseModel], +) -> None: + schema_type = layer_type.__dict__.get(attr_name) + if schema_type is None: + schema_type = inferred_schema_type or getattr(layer_type, attr_name, default_schema_type) + setattr(layer_type, attr_name, schema_type) + if not isinstance(schema_type, type) or not issubclass(schema_type, BaseModel): + raise TypeError(f"{layer_type.__name__}.{attr_name} must be a Pydantic BaseModel subclass.") + + +def _init_config_type( + layer_type: type[Layer[Any, Any, Any, Any, Any, Any]], + inferred_config_type: type[LayerConfig] | None, +) -> None: + config_type = layer_type.__dict__.get("config_type") + if config_type is None: + config_type = inferred_config_type or getattr(layer_type, "config_type", EmptyLayerConfig) + setattr(layer_type, "config_type", config_type) + if not isinstance(config_type, type) or not issubclass(config_type, LayerConfig): + raise TypeError(f"{layer_type.__name__}.config_type must be a LayerConfig subclass.") + + +def _substitute_type(value: object, substitutions: Mapping[object, object]) -> object: + if value in substitutions: + return substitutions[value] + + origin = get_origin(value) + if origin is None: + return value + + args = get_args(value) + if not args: + return value + + substituted_args = tuple(_substitute_type(arg, substitutions) for arg in args) + if substituted_args == args: + return value + + try: + return origin[substituted_args] + except TypeError: + return value + + +def _generic_arg_substitutions(origin: type[Any], args: Sequence[object]) -> dict[object, object]: + params = _generic_params(origin) + return dict(zip(params, args)) + + +def _generic_params(origin: type[Any]) -> Sequence[object]: + params = getattr(origin, "__type_params__", ()) + if not params: + params = getattr(origin, "__parameters__", ()) + return params + + +def _as_deps_type(value: object) -> type[LayerDeps] | None: + runtime_type = get_origin(value) or value + if isinstance(runtime_type, type) and issubclass(runtime_type, LayerDeps): + return runtime_type + return None + + +def _as_model_type(value: object) -> type[BaseModel] | None: + runtime_type = get_origin(value) or value + if isinstance(runtime_type, type) and issubclass(runtime_type, BaseModel): + return runtime_type + return None + + +def _as_config_type(value: object) -> type[LayerConfig] | None: + runtime_type = get_origin(value) or value + if isinstance(runtime_type, type) and issubclass(runtime_type, LayerConfig): + return runtime_type + return None + + +def _is_generic_layer_template(layer_type: type[Layer[Any, Any, Any, Any, Any, Any]]) -> bool: + return bool(getattr(layer_type, "__type_params__", ())) or bool(getattr(layer_type, "__parameters__", ())) diff --git a/dify-agent/src/agenton/layers/types.py b/dify-agent/src/agenton/layers/types.py new file mode 100644 index 0000000000..e6133dcd55 --- /dev/null +++ b/dify-agent/src/agenton/layers/types.py @@ -0,0 +1,184 @@ +"""Typed layer family definitions. + +``Layer`` itself is framework-neutral. This module defines typed layer families +that bind its system prompt, user prompt, and tool generic slots to concrete +contracts, such as ordinary strings with plain callable tools or pydantic-ai +prompt/tool shapes. The families keep the trailing schema generic slots open so +concrete layers can have ``config_type`` and ``runtime_state_type`` inferred from +type arguments instead of repeated class attributes. Config schemas use +``LayerConfig`` so they can also be embedded as +typed DTOs in serializable compositor config. Agenton core is state-only: +typed layer families do not expose runtime handle schemas or resource ownership. +Tagged aggregate aliases cover code paths that can accept any supported +prompt/tool family without changing the plain and pydantic-ai layer contracts. +Pydantic-ai names are imported for static analysis only, so ``agenton`` can be +imported without loading that optional integration at runtime. +Concrete reusable layers live under ``agenton_collections``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Generic, Literal + +from typing_extensions import TypeVar, final, override + +if TYPE_CHECKING: + from pydantic_ai import Tool + from pydantic_ai.messages import UserContent + from pydantic_ai.tools import SystemPromptFunc + +from pydantic import BaseModel + +from agenton.layers.base import EmptyLayerConfig, EmptyRuntimeState, Layer, LayerConfig, LayerDeps + +type PlainPrompt = str +type PlainUserPrompt = str +type PlainTool = Callable[..., Any] + + +type PydanticAIPrompt[AgentDepsT] = SystemPromptFunc[AgentDepsT] +type PydanticAIUserPrompt = UserContent +type PydanticAITool[AgentDepsT] = Tool[AgentDepsT] + + +@dataclass(frozen=True, slots=True) +class PlainPromptType: + """Tagged plain prompt item for aggregate prompt transformations.""" + + value: PlainPrompt + kind: Literal["plain"] = field(default="plain", init=False) + + +@dataclass(frozen=True, slots=True) +class PlainToolType: + """Tagged plain tool item for aggregate tool transformations.""" + + value: PlainTool + kind: Literal["plain"] = field(default="plain", init=False) + + +@dataclass(frozen=True, slots=True) +class PlainUserPromptType: + """Tagged plain user prompt item for aggregate user prompt transformations.""" + + value: PlainUserPrompt + kind: Literal["plain"] = field(default="plain", init=False) + + +@dataclass(frozen=True, slots=True) +class PydanticAIPromptType[AgentDepsT]: + """Tagged pydantic-ai prompt item for aggregate prompt transformations.""" + + value: PydanticAIPrompt[AgentDepsT] + kind: Literal["pydantic_ai"] = field(default="pydantic_ai", init=False) + + +@dataclass(frozen=True, slots=True) +class PydanticAIUserPromptType: + """Tagged pydantic-ai user prompt item for aggregate user prompts.""" + + value: PydanticAIUserPrompt + kind: Literal["pydantic_ai"] = field(default="pydantic_ai", init=False) + + +@dataclass(frozen=True, slots=True) +class PydanticAIToolType[AgentDepsT]: + """Tagged pydantic-ai tool item for aggregate tool transformations.""" + + value: PydanticAITool[AgentDepsT] + kind: Literal["pydantic_ai"] = field(default="pydantic_ai", init=False) + + +type AllPromptTypes = PlainPromptType | PydanticAIPromptType[Any] +type AllUserPromptTypes = PlainUserPromptType | PydanticAIUserPromptType +type AllToolTypes = PlainToolType | PydanticAIToolType[Any] + + +_DepsT = TypeVar("_DepsT", bound=LayerDeps) +_ConfigT = TypeVar("_ConfigT", bound=LayerConfig, default=EmptyLayerConfig) +_RuntimeStateT = TypeVar("_RuntimeStateT", bound=BaseModel, default=EmptyRuntimeState) +_AgentDepsT = TypeVar("_AgentDepsT") + + +class PlainLayer( + Generic[_DepsT, _ConfigT, _RuntimeStateT], + Layer[ + _DepsT, + PlainPrompt, + PlainUserPrompt, + PlainTool, + _ConfigT, + _RuntimeStateT, + ], +): + """Layer base for ordinary string prompts and plain-callable tools.""" + + @final + @override + def wrap_prompt(self, prompt: PlainPrompt) -> PlainPromptType: + return PlainPromptType(prompt) + + @final + @override + def wrap_user_prompt(self, prompt: PlainUserPrompt) -> PlainUserPromptType: + return PlainUserPromptType(prompt) + + @final + @override + def wrap_tool(self, tool: PlainTool) -> PlainToolType: + return PlainToolType(tool) + + +class PydanticAILayer( + Generic[_DepsT, _AgentDepsT, _ConfigT, _RuntimeStateT], + Layer[ + _DepsT, + PydanticAIPrompt[_AgentDepsT], + PydanticAIUserPrompt, + PydanticAITool[_AgentDepsT], + _ConfigT, + _RuntimeStateT, + ], +): + """Layer base for pydantic-ai prompt and tool adapters.""" + + @final + @override + def wrap_prompt( + self, + prompt: PydanticAIPrompt[_AgentDepsT], + ) -> PydanticAIPromptType[_AgentDepsT]: + return PydanticAIPromptType(prompt) + + @final + @override + def wrap_user_prompt(self, prompt: PydanticAIUserPrompt) -> PydanticAIUserPromptType: + return PydanticAIUserPromptType(prompt) + + @final + @override + def wrap_tool(self, tool: PydanticAITool[_AgentDepsT]) -> PydanticAIToolType[_AgentDepsT]: + return PydanticAIToolType(tool) + + +__all__ = [ + "AllPromptTypes", + "AllUserPromptTypes", + "AllToolTypes", + "PlainLayer", + "PlainPrompt", + "PlainPromptType", + "PlainUserPrompt", + "PlainUserPromptType", + "PlainTool", + "PlainToolType", + "PydanticAILayer", + "PydanticAIPrompt", + "PydanticAIPromptType", + "PydanticAIUserPrompt", + "PydanticAIUserPromptType", + "PydanticAITool", + "PydanticAIToolType", +] diff --git a/dify-agent/src/agenton_collections/__init__.py b/dify-agent/src/agenton_collections/__init__.py new file mode 100644 index 0000000000..b4b52de993 --- /dev/null +++ b/dify-agent/src/agenton_collections/__init__.py @@ -0,0 +1,52 @@ +"""Convenience exports for reusable layer implementations. + +Concrete collection layers live in family subpackages such as +``agenton_collections.layers.plain`` and +``agenton_collections.layers.pydantic_ai``. The package root keeps short +client-safe imports for common plain layers while requiring explicit submodule +imports for pydantic-ai bridge implementations. +""" + +from agenton.layers.types import ( + AllPromptTypes, + AllToolTypes, + PlainLayer, + PlainPrompt, + PlainPromptType, + PlainTool, + PlainToolType, + PydanticAILayer, + PydanticAIPrompt, + PydanticAIPromptType, + PydanticAITool, + PydanticAIToolType, +) +from agenton_collections.layers.plain import ( + DynamicToolsLayer, + DynamicToolsLayerDeps, + ObjectLayer, + PromptLayer, + ToolsLayer, + with_object, +) + +__all__ = [ + "AllPromptTypes", + "AllToolTypes", + "DynamicToolsLayer", + "DynamicToolsLayerDeps", + "ObjectLayer", + "PlainLayer", + "PlainPrompt", + "PlainPromptType", + "PlainTool", + "PlainToolType", + "PromptLayer", + "PydanticAILayer", + "PydanticAIPrompt", + "PydanticAIPromptType", + "PydanticAITool", + "PydanticAIToolType", + "ToolsLayer", + "with_object", +] diff --git a/dify-agent/src/agenton_collections/layers/plain/__init__.py b/dify-agent/src/agenton_collections/layers/plain/__init__.py new file mode 100644 index 0000000000..a3e20cc204 --- /dev/null +++ b/dify-agent/src/agenton_collections/layers/plain/__init__.py @@ -0,0 +1,25 @@ +"""Reusable collection layers for the plain layer family.""" + +from agenton_collections.layers.plain.basic import ( + PLAIN_PROMPT_LAYER_TYPE_ID, + ObjectLayer, + PromptLayer, + PromptLayerConfig, + ToolsLayer, +) +from agenton_collections.layers.plain.dynamic_tools import ( + DynamicToolsLayer, + DynamicToolsLayerDeps, + with_object, +) + +__all__ = [ + "DynamicToolsLayer", + "DynamicToolsLayerDeps", + "ObjectLayer", + "PLAIN_PROMPT_LAYER_TYPE_ID", + "PromptLayer", + "PromptLayerConfig", + "ToolsLayer", + "with_object", +] diff --git a/dify-agent/src/agenton_collections/layers/plain/basic.py b/dify-agent/src/agenton_collections/layers/plain/basic.py new file mode 100644 index 0000000000..aeefd1abf8 --- /dev/null +++ b/dify-agent/src/agenton_collections/layers/plain/basic.py @@ -0,0 +1,103 @@ +"""Basic ready-to-compose layers for common plain use cases. + +These layers are small concrete implementations built on +``agenton.layers.types``. They intentionally stay free of compositor graph +construction so they can be reused from config, examples, and higher-level +dynamic layers. +""" + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import Any, Final + +from pydantic import ConfigDict, Field +from typing_extensions import Self, override + +from agenton.layers.base import LayerConfig, NoLayerDeps +from agenton.layers.types import PlainLayer + + +PLAIN_PROMPT_LAYER_TYPE_ID: Final[str] = "plain.prompt" + + +class PromptLayerConfig(LayerConfig): + """Serializable config schema for ``PromptLayer``.""" + + prefix: list[str] | str = Field(default_factory=list) + user: list[str] | str = Field(default_factory=list) + suffix: list[str] | str = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +@dataclass +class ObjectLayer[ObjectT](PlainLayer[NoLayerDeps]): + """Layer that stores one typed object for downstream dependencies. + + Object layers are instance-only because arbitrary Python objects are not + serializable graph config. Add them with a custom ``LayerProvider`` factory + that creates a fresh object layer for each compositor run. + """ + + value: ObjectT + + +@dataclass +class PromptLayer(PlainLayer[NoLayerDeps, PromptLayerConfig]): + """Layer that contributes configured system and user prompt fragments.""" + + type_id = PLAIN_PROMPT_LAYER_TYPE_ID + + prefix: list[str] | str = field(default_factory=list) + user: list[str] | str = field(default_factory=list) + suffix: list[str] | str = field(default_factory=list) + + @classmethod + @override + def from_config(cls, config: PromptLayerConfig) -> Self: + """Create a prompt layer from validated prompt config.""" + validated_config = PromptLayerConfig.model_validate(config) + return cls(prefix=validated_config.prefix, user=validated_config.user, suffix=validated_config.suffix) + + @property + def prefix_prompts(self) -> list[str]: + if isinstance(self.prefix, str): + return [self.prefix] + return self.prefix + + @property + def suffix_prompts(self) -> list[str]: + if isinstance(self.suffix, str): + return [self.suffix] + return self.suffix + + @property + def user_prompts(self) -> list[str]: + if isinstance(self.user, str): + return [self.user] + return self.user + + +@dataclass +class ToolsLayer(PlainLayer[NoLayerDeps]): + """Layer that contributes configured plain-callable tools. + + Tool layers are instance-only because Python callables are live objects. Add + them with a custom ``LayerProvider`` factory that returns a fresh layer for + each compositor run. + """ + + tool_entries: Sequence[Callable[..., Any]] = () + + @property + def tools(self) -> list[Callable[..., Any]]: + return list(self.tool_entries) + + +__all__ = [ + "ObjectLayer", + "PLAIN_PROMPT_LAYER_TYPE_ID", + "PromptLayerConfig", + "PromptLayer", + "ToolsLayer", +] diff --git a/dify-agent/src/agenton_collections/layers/plain/dynamic_tools.py b/dify-agent/src/agenton_collections/layers/plain/dynamic_tools.py new file mode 100644 index 0000000000..12c78990a3 --- /dev/null +++ b/dify-agent/src/agenton_collections/layers/plain/dynamic_tools.py @@ -0,0 +1,232 @@ +"""Dynamic plain-tool layer with object-bound tool entries. + +This module builds on ``ObjectLayer`` from ``agenton_collections.plain.basic``. +Plain callables are exposed unchanged, while entries wrapped with +``with_object`` bind the current object value into the first callable argument +and expose the remaining parameters as the public tool signature. +""" + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from functools import wraps +from inspect import Parameter, Signature, iscoroutinefunction, signature +from types import UnionType +from typing import ( + Annotated, + Any, + Concatenate, + Union, + get_args, + get_origin, + get_type_hints, +) + +from agenton.layers.base import LayerDeps +from agenton.layers.types import PlainLayer +from agenton_collections.layers.plain.basic import ObjectLayer + +type _ObjectToolCallable[ObjectT] = Callable[Concatenate[ObjectT, ...], Any] + + +@dataclass(frozen=True, slots=True) +class _ObjectToolEntry[ObjectT]: + """Tool entry whose first argument should be filled from ``ObjectLayer``.""" + + tool_entry: _ObjectToolCallable[ObjectT] + object_type: type[ObjectT] | None = None + + +type _DynamicToolEntry[ObjectT] = Callable[..., Any] | _ObjectToolEntry[ObjectT] + + +def with_object[ObjectT]( + object_type: type[ObjectT], + /, +) -> Callable[[_ObjectToolCallable[ObjectT]], _ObjectToolEntry[ObjectT]]: + """Mark a tool as requiring the bound object value as its first argument.""" + + def decorator(tool_entry: _ObjectToolCallable[ObjectT]) -> _ObjectToolEntry[ObjectT]: + _validate_object_tool_annotation(tool_entry, object_type) + return _ObjectToolEntry(tool_entry=tool_entry, object_type=object_type) + + return decorator + + +class DynamicToolsLayerDeps[ObjectT](LayerDeps): + """Dependencies required by ``DynamicToolsLayer``.""" + + object_layer: ObjectLayer[ObjectT] # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass +class DynamicToolsLayer[ObjectT](PlainLayer[DynamicToolsLayerDeps[ObjectT]]): + """Layer that exposes plain tools and object-bound tools.""" + + tool_entries: Sequence[_DynamicToolEntry[ObjectT]] = () + + @property + def tools(self) -> list[Callable[..., Any]]: + object_value = self.deps.object_layer.value + return [ + _bind_object_argument(tool_entry.tool_entry, object_value, tool_entry.object_type) + if isinstance(tool_entry, _ObjectToolEntry) + else tool_entry + for tool_entry in self.tool_entries + ] + + +def _bind_object_argument[ObjectT]( + tool_entry: _ObjectToolCallable[ObjectT], + object_value: ObjectT, + object_type: type[ObjectT] | None, +) -> Callable[..., Any]: + _validate_object_value(tool_entry, object_value, object_type) + if iscoroutinefunction(tool_entry): + wrapped = _async_object_wrapper(tool_entry, object_value) + else: + wrapped = _sync_object_wrapper(tool_entry, object_value) + + public_signature = _public_tool_signature(tool_entry) + if public_signature is not None: + setattr(wrapped, "__signature__", public_signature) + _set_public_annotations(wrapped, tool_entry) + return wrapped + + +def _validate_object_tool_annotation[ObjectT]( + tool_entry: _ObjectToolCallable[ObjectT], + object_type: type[ObjectT], +) -> None: + parameter = _first_object_parameter(tool_entry) + if parameter is None: + return + + annotation = _parameter_annotation(tool_entry, parameter) + if annotation is Parameter.empty: + return + if _annotation_accepts_object_type(annotation, object_type): + return + + raise TypeError( + f"Object-bound tool '{_tool_name(tool_entry)}' first parameter should accept '{_type_name(object_type)}'." + ) + + +def _first_object_parameter(tool_entry: Callable[..., Any]) -> Parameter | None: + try: + tool_signature = signature(tool_entry) + except (TypeError, ValueError): + return None + + parameters = list(tool_signature.parameters.values()) + if not parameters: + raise ValueError("Dynamic tools must accept the object dependency as their first parameter.") + return parameters[0] + + +def _parameter_annotation(tool_entry: Callable[..., Any], parameter: Parameter) -> object: + try: + type_hints = get_type_hints(tool_entry, include_extras=True) + except (AttributeError, NameError, TypeError): + return parameter.annotation + return type_hints.get(parameter.name, parameter.annotation) + + +def _annotation_accepts_object_type(annotation: object, object_type: type[Any]) -> bool: + if annotation is Any or annotation is Parameter.empty: + return True + + origin = get_origin(annotation) + if origin is Annotated: + args = get_args(annotation) + return True if not args else _annotation_accepts_object_type(args[0], object_type) + if origin in (UnionType, Union): + return any( + arg is type(None) or _annotation_accepts_object_type(arg, object_type) for arg in get_args(annotation) + ) + + runtime_type = origin or annotation + if not isinstance(runtime_type, type): + return True + try: + return issubclass(object_type, runtime_type) + except TypeError: + return True + + +def _validate_object_value[ObjectT]( + tool_entry: _ObjectToolCallable[ObjectT], + object_value: ObjectT, + object_type: type[ObjectT] | None, +) -> None: + if object_type is None or isinstance(object_value, object_type): + return + raise TypeError( + f"Object-bound tool '{_tool_name(tool_entry)}' expected object dependency " + f"of type '{_type_name(object_type)}', but got '{type(object_value).__qualname__}'." + ) + + +def _tool_name(tool_entry: Callable[..., Any]) -> str: + return getattr(tool_entry, "__qualname__", getattr(tool_entry, "__name__", repr(tool_entry))) + + +def _type_name(object_type: type[Any]) -> str: + return object_type.__qualname__ + + +def _sync_object_wrapper[ObjectT]( + tool_entry: _ObjectToolCallable[ObjectT], + object_value: ObjectT, +) -> Callable[..., Any]: + @wraps(tool_entry) + def wrapped(*args: Any, **kwargs: Any) -> Any: + return tool_entry(object_value, *args, **kwargs) + + return wrapped + + +def _async_object_wrapper[ObjectT]( + tool_entry: _ObjectToolCallable[ObjectT], + object_value: ObjectT, +) -> Callable[..., Any]: + @wraps(tool_entry) + async def wrapped(*args: Any, **kwargs: Any) -> Any: + return await tool_entry(object_value, *args, **kwargs) + + return wrapped + + +def _public_tool_signature(tool_entry: Callable[..., Any]) -> Signature | None: + try: + tool_signature = signature(tool_entry) + except (TypeError, ValueError): + return None + + parameters = list(tool_signature.parameters.values()) + if not parameters: + raise ValueError("Dynamic tools must accept the object dependency as their first parameter.") + return tool_signature.replace(parameters=parameters[1:]) + + +def _set_public_annotations(wrapper: Callable[..., Any], tool_entry: Callable[..., Any]) -> None: + annotations = getattr(tool_entry, "__annotations__", None) + if not isinstance(annotations, dict): + return + + try: + parameters = list(signature(tool_entry).parameters) + except (TypeError, ValueError): + parameters = [] + + public_annotations = dict(annotations) + if parameters: + public_annotations.pop(parameters[0], None) + wrapper.__annotations__ = public_annotations + + +__all__ = [ + "DynamicToolsLayer", + "DynamicToolsLayerDeps", + "with_object", +] diff --git a/dify-agent/src/agenton_collections/layers/pydantic_ai/__init__.py b/dify-agent/src/agenton_collections/layers/pydantic_ai/__init__.py new file mode 100644 index 0000000000..c8b657cfdb --- /dev/null +++ b/dify-agent/src/agenton_collections/layers/pydantic_ai/__init__.py @@ -0,0 +1,11 @@ +"""Reusable collection layers for the pydantic-ai layer family.""" + +from agenton_collections.layers.pydantic_ai.bridge import ( + PydanticAIBridgeLayer, + PydanticAIBridgeLayerDeps, +) + +__all__ = [ + "PydanticAIBridgeLayer", + "PydanticAIBridgeLayerDeps", +] diff --git a/dify-agent/src/agenton_collections/layers/pydantic_ai/bridge.py b/dify-agent/src/agenton_collections/layers/pydantic_ai/bridge.py new file mode 100644 index 0000000000..c2f9c668db --- /dev/null +++ b/dify-agent/src/agenton_collections/layers/pydantic_ai/bridge.py @@ -0,0 +1,106 @@ +"""Pydantic AI bridge prompt and tool layer. + +This module keeps pydantic-ai's callable shapes intact through +``PydanticAILayer``. The bridge layer depends on ``ObjectLayer`` so callers have +one explicit graph node that provides the object used as +``RunContext[ObjectT].deps`` in pydantic-ai prompt and tool callables. +Bridge construction accepts pydantic-ai's ergonomic input forms and normalizes +them at the layer boundary: string system prompts become zero-arg system prompt +functions, user prompts stay as pydantic-ai ``UserContent`` values, and bare +tool functions become ``Tool`` instances. +""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from pydantic_ai import Tool +from pydantic_ai.messages import UserContent +from pydantic_ai.tools import ToolFuncEither +from typing_extensions import override + +from agenton.layers.base import LayerDeps +from agenton.layers.types import PydanticAILayer, PydanticAIPrompt, PydanticAITool, PydanticAIUserPrompt +from agenton_collections.layers.plain.basic import ObjectLayer + + +class PydanticAIBridgeLayerDeps[ObjectT](LayerDeps): + """Dependencies required by ``PydanticAIBridgeLayer``.""" + + object_layer: ObjectLayer[ObjectT] # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass +class PydanticAIBridgeLayer[ObjectT](PydanticAILayer[PydanticAIBridgeLayerDeps[ObjectT], ObjectT]): + """Bridge layer for pydantic-ai prompts and tools using one object deps.""" + + prefix: str | PydanticAIPrompt[ObjectT] | Sequence[str | PydanticAIPrompt[ObjectT]] = () + user: UserContent | Sequence[UserContent] = () + suffix: str | PydanticAIPrompt[ObjectT] | Sequence[str | PydanticAIPrompt[ObjectT]] = () + tool_entries: Sequence[PydanticAITool[ObjectT] | ToolFuncEither[ObjectT, ...]] = () + + @property + def run_deps(self) -> ObjectT: + """Object to pass as pydantic-ai run deps for this layer.""" + return self.deps.object_layer.value + + @property + @override + def prefix_prompts(self) -> list[PydanticAIPrompt[ObjectT]]: + return _normalize_prompts(self.prefix) + + @property + @override + def suffix_prompts(self) -> list[PydanticAIPrompt[ObjectT]]: + return _normalize_prompts(self.suffix) + + @property + @override + def user_prompts(self) -> list[PydanticAIUserPrompt]: + return _normalize_user_prompts(self.user) + + @property + @override + def tools(self) -> list[PydanticAITool[ObjectT]]: + return [_normalize_tool(tool_entry) for tool_entry in self.tool_entries] + + +def _normalize_prompts[ObjectT]( + prompts: str | PydanticAIPrompt[ObjectT] | Sequence[str | PydanticAIPrompt[ObjectT]], +) -> list[PydanticAIPrompt[ObjectT]]: + if isinstance(prompts, str): + return [_normalize_prompt(prompts)] + if isinstance(prompts, Sequence): + return [_normalize_prompt(prompt) for prompt in prompts] + return [prompts] + + +def _normalize_prompt[ObjectT]( + prompt: str | PydanticAIPrompt[ObjectT], +) -> PydanticAIPrompt[ObjectT]: + if isinstance(prompt, str): + return (lambda value: lambda: value)(prompt) + return prompt + + +def _normalize_user_prompts( + prompts: UserContent | Sequence[UserContent], +) -> list[PydanticAIUserPrompt]: + if isinstance(prompts, str): + return [prompts] + if isinstance(prompts, Sequence): + return list(prompts) + return [prompts] + + +def _normalize_tool[ObjectT]( + tool_entry: PydanticAITool[ObjectT] | ToolFuncEither[ObjectT, ...], +) -> PydanticAITool[ObjectT]: + if isinstance(tool_entry, Tool): + return tool_entry + return Tool(tool_entry) + + +__all__ = [ + "PydanticAIBridgeLayer", + "PydanticAIBridgeLayerDeps", +] diff --git a/dify-agent/src/agenton_collections/transformers/__init__.py b/dify-agent/src/agenton_collections/transformers/__init__.py new file mode 100644 index 0000000000..9767d57fc3 --- /dev/null +++ b/dify-agent/src/agenton_collections/transformers/__init__.py @@ -0,0 +1,8 @@ +"""Transformer package marker for collection integrations. + +Import pydantic-ai transformer presets from +``agenton_collections.transformers.pydantic_ai`` explicitly so default imports do +not pull runtime bridge implementations into client-safe environments. +""" + +__all__: list[str] = [] diff --git a/dify-agent/src/agenton_collections/transformers/pydantic_ai.py b/dify-agent/src/agenton_collections/transformers/pydantic_ai.py new file mode 100644 index 0000000000..81b5393a8c --- /dev/null +++ b/dify-agent/src/agenton_collections/transformers/pydantic_ai.py @@ -0,0 +1,85 @@ +"""Pydantic AI compositor transformer presets. + +This module owns the pydantic-ai runtime dependency for transforming tagged +agenton system prompt, user prompt, and tool items into pydantic-ai-compatible +items. +""" + +from collections.abc import Sequence +from typing import Final + +from pydantic_ai import Tool + +from agenton.compositor import CompositorTransformerKwargs +from agenton.layers.types import ( + AllPromptTypes, + AllToolTypes, + AllUserPromptTypes, + PydanticAIPrompt, + PydanticAITool, + PydanticAIUserPrompt, +) + +type PydanticAICompositorTransformerKwargs = CompositorTransformerKwargs[ + PydanticAIPrompt[object], + PydanticAITool[object], + AllPromptTypes, + AllToolTypes, + PydanticAIUserPrompt, + AllUserPromptTypes, +] + + +def _pydantic_ai_prompt_transformer( + prompts: Sequence[AllPromptTypes], +) -> list[PydanticAIPrompt[object]]: + result: list[PydanticAIPrompt[object]] = [] + for prompt in prompts: + if prompt.kind == "plain": + result.append((lambda value: lambda: value)(prompt.value)) + elif prompt.kind == "pydantic_ai": + result.append(prompt.value) + else: + raise NotImplementedError(f"Unsupported prompt type: {type(prompt).__qualname__}.") + return result + + +def _pydantic_ai_user_prompt_transformer( + prompts: Sequence[AllUserPromptTypes], +) -> list[PydanticAIUserPrompt]: + result: list[PydanticAIUserPrompt] = [] + for prompt in prompts: + if prompt.kind == "plain": + result.append(prompt.value) + elif prompt.kind == "pydantic_ai": + result.append(prompt.value) + else: + raise NotImplementedError(f"Unsupported user prompt type: {type(prompt).__qualname__}.") + return result + + +def _pydantic_ai_tool_transformer( + tools: Sequence[AllToolTypes], +) -> list[PydanticAITool[object]]: + result: list[PydanticAITool[object]] = [] + for tool in tools: + if tool.kind == "plain": + result.append(Tool(tool.value)) + elif tool.kind == "pydantic_ai": + result.append(tool.value) + else: + raise NotImplementedError(f"Unsupported tool type: {type(tool).__qualname__}.") + return result + + +PYDANTIC_AI_TRANSFORMERS: Final[PydanticAICompositorTransformerKwargs] = { + "prompt_transformer": _pydantic_ai_prompt_transformer, + "user_prompt_transformer": _pydantic_ai_user_prompt_transformer, + "tool_transformer": _pydantic_ai_tool_transformer, +} + + +__all__ = [ + "PYDANTIC_AI_TRANSFORMERS", + "PydanticAICompositorTransformerKwargs", +] diff --git a/dify-agent/src/dify_agent/__init__.py b/dify-agent/src/dify_agent/__init__.py new file mode 100644 index 0000000000..b83189b195 --- /dev/null +++ b/dify-agent/src/dify_agent/__init__.py @@ -0,0 +1,10 @@ +"""Client-safe top-level exports for the Dify Agent package. + +Default installs must be able to import ``dify_agent`` without pulling in server +runtime adapters or their optional dependencies. Server-only adapter entry points +remain under ``dify_agent.adapters.llm``. +""" + +from dify_agent.client import Client + +__all__ = ["Client"] diff --git a/dify-agent/src/dify_agent/adapters/__init__.py b/dify-agent/src/dify_agent/adapters/__init__.py new file mode 100644 index 0000000000..ac7a1ae47a --- /dev/null +++ b/dify-agent/src/dify_agent/adapters/__init__.py @@ -0,0 +1 @@ +"""Adapter integrations for Dify agent components.""" diff --git a/dify-agent/src/dify_agent/adapters/llm/__init__.py b/dify-agent/src/dify_agent/adapters/llm/__init__.py new file mode 100644 index 0000000000..b63771ed61 --- /dev/null +++ b/dify-agent/src/dify_agent/adapters/llm/__init__.py @@ -0,0 +1,6 @@ +"""LLM adapters for Dify plugin-daemon integrations.""" + +from .model import DifyLLMAdapterModel +from .provider import DifyPluginDaemonProvider + +__all__ = ["DifyLLMAdapterModel", "DifyPluginDaemonProvider"] diff --git a/dify-agent/src/dify_agent/adapters/llm/model.py b/dify-agent/src/dify_agent/adapters/llm/model.py new file mode 100644 index 0000000000..1863dec138 --- /dev/null +++ b/dify-agent/src/dify_agent/adapters/llm/model.py @@ -0,0 +1,757 @@ +"""Bridge Dify plugin-daemon LLM invocations into Pydantic AI's model interface. + +The API and agent layers are clients of the plugin daemon, not direct hosts of provider SDK +implementations. This adapter therefore targets the plugin-daemon dispatch protocol and maps +Pydantic AI messages into the daemon's Graphon-compatible request and stream response schema. +""" + +from __future__ import annotations + +import base64 +import re +from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence +from contextlib import asynccontextmanager +from dataclasses import KW_ONLY, InitVar, dataclass, field +from datetime import datetime, timezone +from typing import cast + +from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMUsage +from graphon.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + AudioPromptMessageContent, + DocumentPromptMessageContent, + ImagePromptMessageContent, + PromptMessage, + PromptMessageContentUnionTypes, + PromptMessageTool, + SystemPromptMessage, + TextPromptMessageContent, + ToolPromptMessage, + UserPromptMessage, + VideoPromptMessageContent, +) +from typing_extensions import assert_never, override + +from pydantic_ai._parts_manager import ModelResponsePartsManager +from pydantic_ai.exceptions import UnexpectedModelBehavior +from pydantic_ai.messages import ( + AudioUrl, + BinaryContent, + BuiltinToolCallPart, + BuiltinToolReturnPart, + CachePoint, + CompactionPart, + DocumentUrl, + FilePart, + FinishReason, + ImageUrl, + ModelMessage, + ModelRequest, + ModelResponse, + ModelResponsePart, + ModelResponseStreamEvent, + MultiModalContent, + RetryPromptPart, + SystemPromptPart, + TextContent, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UploadedFile, + UserContent, + UserPromptPart, + VideoUrl, +) +from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse +from pydantic_ai.profiles import ModelProfileSpec +from pydantic_ai.settings import ModelSettings +from pydantic_ai.usage import RequestUsage + +from .provider import DifyPluginDaemonLLMClient, DifyPluginDaemonProvider + +_THINK_START = "\n" +_THINK_END = "\n" +_THINK_OPEN_TAG = "" +_THINK_CLOSE_TAG = "" +_THINK_TAG_PATTERN = re.compile(r"(.*?)", re.DOTALL) +_DETAIL_HIGH = "high" + + +@dataclass(slots=True) +class _DifyRequestInput: + credentials: dict[str, object] + prompt_messages: list[PromptMessage] + model_parameters: dict[str, object] + tools: list[PromptMessageTool] | None + stop_sequences: list[str] | None + + +@dataclass(slots=True) +class DifyLLMAdapterModel(Model[DifyPluginDaemonLLMClient]): + """Use a Dify plugin-daemon transport plus request-level model identity.""" + + model: str + daemon_provider: DifyPluginDaemonProvider + _: KW_ONLY + model_provider: str + credentials: dict[str, object] = field(default_factory=dict, repr=False) + model_profile: InitVar[ModelProfileSpec | None] = None + model_settings: InitVar[ModelSettings | None] = None + + def __post_init__( + self, + model_profile: ModelProfileSpec | None, + model_settings: ModelSettings | None, + ) -> None: + Model.__init__( + self, + settings=model_settings, + profile=model_profile or self.daemon_provider.model_profile(self.model), + ) + + @property + @override + def provider(self) -> DifyPluginDaemonProvider: + return self.daemon_provider + + @property + @override + def model_name(self) -> str: + return self.model + + @property + @override + def system(self) -> str: + return self.daemon_provider.name + + @override + async def request( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + ) -> ModelResponse: + prepared_settings, prepared_params = self.prepare_request(model_settings, model_request_parameters) + request_input = self._build_request_input(messages, prepared_settings, prepared_params) + + response = DifyStreamedResponse( + model_request_parameters=prepared_params, + chunks=self.daemon_provider.client.iter_llm_result_chunks( + provider=self.model_provider, + model=self.model_name, + credentials=request_input.credentials, + prompt_messages=request_input.prompt_messages, + model_parameters=request_input.model_parameters, + tools=request_input.tools, + stop=request_input.stop_sequences, + stream=False, + ), + response_model_name=self.model_name, + provider_name_value=self.system, + ) + async for _event in response: + pass + return response.get() + + @asynccontextmanager + @override + async def request_stream( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + run_context: object | None = None, + ) -> AsyncGenerator[StreamedResponse, None]: + del run_context + prepared_settings, prepared_params = self.prepare_request(model_settings, model_request_parameters) + request_input = self._build_request_input(messages, prepared_settings, prepared_params) + + yield DifyStreamedResponse( + model_request_parameters=prepared_params, + chunks=self.daemon_provider.client.iter_llm_result_chunks( + provider=self.model_provider, + model=self.model_name, + credentials=request_input.credentials, + prompt_messages=request_input.prompt_messages, + model_parameters=request_input.model_parameters, + tools=request_input.tools, + stop=request_input.stop_sequences, + stream=True, + ), + response_model_name=self.model_name, + provider_name_value=self.system, + ) + + def _build_request_input( + self, + messages: Sequence[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + ) -> _DifyRequestInput: + return _DifyRequestInput( + credentials=dict(self.credentials), + prompt_messages=_map_messages_to_prompt_messages(messages, model_request_parameters), + model_parameters=_map_model_settings_to_parameters(model_settings), + tools=_map_tool_definitions_to_prompt_tools(model_request_parameters), + stop_sequences=_get_stop_sequences(model_settings), + ) + + +@dataclass +class DifyStreamedResponse(StreamedResponse): + chunks: AsyncIterator[LLMResultChunk] + response_model_name: str + provider_name_value: str + _timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + _embedded_thinking_parser: "_EmbeddedThinkingParser" = field(default_factory=lambda: _EmbeddedThinkingParser()) + + @override + async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]: + async for chunk in self.chunks: + if chunk.delta.usage is not None: + self._usage: RequestUsage = _map_usage(chunk.delta.usage) + if chunk.delta.finish_reason is not None: + self.finish_reason: FinishReason | None = _normalize_finish_reason(chunk.delta.finish_reason) + + for event in _chunk_to_stream_events( + self._parts_manager, + chunk, + self.provider_name_value, + self._embedded_thinking_parser, + ): + yield event + + for event in self._embedded_thinking_parser.flush(self._parts_manager, self.provider_name_value): + yield event + + @property + @override + def model_name(self) -> str: + return self.response_model_name + + @property + @override + def provider_name(self) -> str: + return self.provider_name_value + + @property + @override + def provider_url(self) -> None: + return None + + @property + @override + def timestamp(self) -> datetime: + return self._timestamp + + +def _map_messages_to_prompt_messages( + messages: Sequence[ModelMessage], + model_request_parameters: ModelRequestParameters, +) -> list[PromptMessage]: + prompt_messages: list[PromptMessage] = [] + + for message in messages: + if isinstance(message, ModelRequest): + prompt_messages.extend(_map_model_request_to_prompt_messages(message)) + elif isinstance(message, ModelResponse): + assistant_message = _map_model_response_to_prompt_message(message) + if assistant_message is not None: + prompt_messages.append(assistant_message) + else: + assert_never(message) + + instruction_messages = [ + SystemPromptMessage(content=part.content) + for part in (Model._get_instruction_parts(messages, model_request_parameters) or []) + if part.content.strip() + ] + if instruction_messages: + insert_at = next( + (index for index, message in enumerate(prompt_messages) if not isinstance(message, SystemPromptMessage)), + len(prompt_messages), + ) + prompt_messages[insert_at:insert_at] = instruction_messages + + return prompt_messages + + +def _map_model_request_to_prompt_messages(message: ModelRequest) -> list[PromptMessage]: + prompt_messages: list[PromptMessage] = [] + + for part in message.parts: + if isinstance(part, SystemPromptPart): + prompt_messages.append(SystemPromptMessage(content=part.content)) + elif isinstance(part, UserPromptPart): + prompt_messages.append(UserPromptMessage(content=_map_user_prompt_content(part.content))) + elif isinstance(part, ToolReturnPart): + prompt_messages.append(_map_tool_return_part_to_prompt_message(part)) + elif isinstance(part, RetryPromptPart): + if part.tool_name is None: + prompt_messages.append(UserPromptMessage(content=part.model_response())) + else: + prompt_messages.append( + ToolPromptMessage( + content=part.model_response(), + tool_call_id=part.tool_call_id, + name=part.tool_name, + ) + ) + else: + assert_never(part) + + return prompt_messages + + +def _map_tool_return_part_to_prompt_message(part: ToolReturnPart) -> ToolPromptMessage: + items = part.content_items(mode="str") + if len(items) == 1 and isinstance(items[0], str): + content: str | list[PromptMessageContentUnionTypes] | None = items[0] + else: + content_items: list[PromptMessageContentUnionTypes] = [] + for item in items: + if isinstance(item, str): + content_items.append(TextPromptMessageContent(data=item)) + elif isinstance(item, CachePoint): + continue + elif _is_multi_modal_content(item): + content_items.append(_map_multi_modal_user_content(item)) + else: + raise UnexpectedModelBehavior(f"Unsupported daemon tool message content: {type(item).__name__}") + content = content_items or None + + return ToolPromptMessage(content=content, tool_call_id=part.tool_call_id, name=part.tool_name) + + +def _map_model_response_to_prompt_message( + message: ModelResponse, +) -> AssistantPromptMessage | None: + """Map prior assistant output into daemon prompt history. + + The plugin daemon requires ``PromptMessage.content`` to be present even when + an assistant turn contains only tool calls. Tool-call-only assistant history + therefore uses the empty string instead of ``null`` so the second request in + a tool round trip remains schema-compatible. + """ + content_parts: list[PromptMessageContentUnionTypes] = [] + tool_calls: list[AssistantPromptMessage.ToolCall] = [] + + for part in message.parts: + if isinstance(part, TextPart): + if part.content: + content_parts.append(TextPromptMessageContent(data=part.content)) + elif isinstance(part, ThinkingPart): + if part.content: + content_parts.append(TextPromptMessageContent(data=f"{_THINK_START}{part.content}{_THINK_END}")) + elif isinstance(part, FilePart): + content_parts.append(_map_binary_content_to_prompt_content(part.content)) + elif isinstance(part, ToolCallPart): + tool_calls.append( + AssistantPromptMessage.ToolCall( + id=part.tool_call_id or f"tool-call-{part.tool_name}", + type="function", + function=AssistantPromptMessage.ToolCall.ToolCallFunction( + name=part.tool_name, + arguments=part.args_as_json_str(), + ), + ) + ) + elif isinstance(part, BuiltinToolCallPart | BuiltinToolReturnPart | CompactionPart): + raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}") + else: + assert_never(part) + + content = _normalize_prompt_content(content_parts) + if content is None and not tool_calls: + return None + if content is None: + content = "" + + return AssistantPromptMessage(content=content, tool_calls=tool_calls) + + +def _map_user_prompt_content( + content: str | Sequence[UserContent], +) -> str | list[PromptMessageContentUnionTypes] | None: + if isinstance(content, str): + return content + + prompt_content: list[PromptMessageContentUnionTypes] = [] + for item in content: + if isinstance(item, CachePoint): + continue + if isinstance(item, str): + prompt_content.append(TextPromptMessageContent(data=item)) + elif isinstance(item, TextContent): + prompt_content.append(TextPromptMessageContent(data=item.content)) + elif _is_multi_modal_content(item): + prompt_content.append(_map_multi_modal_user_content(item)) + else: + raise UnexpectedModelBehavior(f"Unsupported user prompt content: {type(item).__name__}") + return _normalize_prompt_content(prompt_content) + + +def _is_multi_modal_content(item: object) -> bool: + return isinstance( + item, + ImageUrl | AudioUrl | DocumentUrl | VideoUrl | BinaryContent | UploadedFile, + ) + + +def _map_multi_modal_user_content( + item: MultiModalContent, +) -> PromptMessageContentUnionTypes: + if isinstance(item, ImageUrl): + detail = ( + ImagePromptMessageContent.DETAIL.HIGH + if _get_detail(item) == _DETAIL_HIGH + else ImagePromptMessageContent.DETAIL.LOW + ) + return ImagePromptMessageContent( + url=item.url, + mime_type=item.media_type, + format=item.format, + filename=f"{item.identifier}.{item.format}", + detail=detail, + ) + if isinstance(item, AudioUrl): + return AudioPromptMessageContent( + url=item.url, + mime_type=item.media_type, + format=item.format, + filename=f"{item.identifier}.{item.format}", + ) + if isinstance(item, VideoUrl): + return VideoPromptMessageContent( + url=item.url, + mime_type=item.media_type, + format=item.format, + filename=f"{item.identifier}.{item.format}", + ) + if isinstance(item, DocumentUrl): + return DocumentPromptMessageContent( + url=item.url, + mime_type=item.media_type, + format=item.format, + filename=f"{item.identifier}.{item.format}", + ) + if isinstance(item, BinaryContent): + return _map_binary_content_to_prompt_content(item) + if isinstance(item, UploadedFile): + raise UnexpectedModelBehavior("UploadedFile content is not supported by the daemon adapter") + assert_never(item) + + +def _map_binary_content_to_prompt_content( + item: BinaryContent, +) -> PromptMessageContentUnionTypes: + filename = f"{item.identifier}.{item.format}" + if item.is_image: + detail = ( + ImagePromptMessageContent.DETAIL.HIGH + if _get_detail(item) == _DETAIL_HIGH + else ImagePromptMessageContent.DETAIL.LOW + ) + return ImagePromptMessageContent( + base64_data=item.base64, + mime_type=item.media_type, + format=item.format, + filename=filename, + detail=detail, + ) + if item.is_audio: + return AudioPromptMessageContent( + base64_data=item.base64, + mime_type=item.media_type, + format=item.format, + filename=filename, + ) + if item.is_video: + return VideoPromptMessageContent( + base64_data=item.base64, + mime_type=item.media_type, + format=item.format, + filename=filename, + ) + if item.is_document: + return DocumentPromptMessageContent( + base64_data=item.base64, + mime_type=item.media_type, + format=item.format, + filename=filename, + ) + raise UnexpectedModelBehavior(f"Unsupported binary media type for daemon adapter: {item.media_type}") + + +def _normalize_prompt_content( + content: list[PromptMessageContentUnionTypes], +) -> str | list[PromptMessageContentUnionTypes] | None: + if not content: + return None + if len(content) == 1 and isinstance(content[0], TextPromptMessageContent): + return content[0].data + return content + + +def _map_tool_definitions_to_prompt_tools( + model_request_parameters: ModelRequestParameters, +) -> list[PromptMessageTool] | None: + tool_definitions = [ + *model_request_parameters.function_tools, + *model_request_parameters.output_tools, + ] + if not tool_definitions: + return None + + return [ + PromptMessageTool( + name=tool_definition.name, + description=tool_definition.description or "", + parameters=cast(dict[str, object], tool_definition.parameters_json_schema), + ) + for tool_definition in tool_definitions + ] + + +def _map_model_settings_to_parameters(model_settings: ModelSettings | None) -> dict[str, object]: + if not model_settings: + return {} + + parameters: dict[str, object] = { + key: value + for key, value in model_settings.items() + if value is not None and key not in {"extra_body", "stop_sequences"} + } + + extra_body = model_settings.get("extra_body") + if isinstance(extra_body, Mapping): + parameters.update(cast(Mapping[str, object], extra_body)) + + return parameters + + +def _get_stop_sequences(model_settings: ModelSettings | None) -> list[str] | None: + if not model_settings: + return None + return list(model_settings.get("stop_sequences") or []) or None + + +def _map_usage(usage: LLMUsage) -> RequestUsage: + return RequestUsage(input_tokens=usage.prompt_tokens, output_tokens=usage.completion_tokens) + + +def _normalize_finish_reason(finish_reason: str) -> FinishReason: + lowered = finish_reason.lower() + if lowered in {"stop", "length", "content_filter", "error", "tool_call"}: + return cast(FinishReason, lowered) + if lowered in {"tool_calls", "function_call", "function_calls"}: + return "tool_call" + return "error" + + +def _chunk_to_stream_events( + parts_manager: ModelResponsePartsManager, + chunk: LLMResultChunk, + provider_name: str, + embedded_thinking_parser: "_EmbeddedThinkingParser", +) -> list[ModelResponseStreamEvent]: + events: list[ModelResponseStreamEvent] = [] + message = chunk.delta.message + + if isinstance(message.content, str): + if message.content: + events.extend(embedded_thinking_parser.parse(parts_manager, message.content, provider_name)) + elif isinstance(message.content, list): + for part in _map_assistant_content_to_response_parts(message.content): + if isinstance(part, TextPart): + events.extend( + parts_manager.handle_text_delta( + vendor_part_id=None, + content=part.content, + provider_name=provider_name, + ) + ) + else: + events.append(parts_manager.handle_part(vendor_part_id=None, part=part)) + + for index, tool_call in enumerate(message.tool_calls): + vendor_id = tool_call.id or f"chunk-{chunk.delta.index}-tool-{index}" + events.append( + parts_manager.handle_tool_call_part( + vendor_part_id=vendor_id, + tool_name=tool_call.function.name, + args=tool_call.function.arguments, + tool_call_id=tool_call.id, + provider_name=provider_name, + ) + ) + + return events + + +def _map_assistant_content_to_response_parts( + content: Sequence[PromptMessageContentUnionTypes], +) -> list[ModelResponsePart]: + response_parts: list[ModelResponsePart] = [] + + for item in content: + if isinstance(item, TextPromptMessageContent): + if item.data: + response_parts.extend(_parse_assistant_text_parts(item.data)) + elif isinstance( + item, + ImagePromptMessageContent + | AudioPromptMessageContent + | VideoPromptMessageContent + | DocumentPromptMessageContent, + ): + if item.url: + raise UnexpectedModelBehavior( + "URL-based assistant multimodal output is not supported by the daemon adapter" + ) + if not item.base64_data: + continue + response_parts.append( + FilePart( + content=BinaryContent( + data=base64.b64decode(item.base64_data), + media_type=item.mime_type, + ), + provider_name=None, + ) + ) + else: + assert_never(item) + + return response_parts + + +def _get_detail(item: ImageUrl | BinaryContent) -> str | None: + metadata = item.vendor_metadata or {} + detail = metadata.get("detail") + return detail if isinstance(detail, str) else None + + +def _parse_assistant_text_parts(content: str) -> list[ModelResponsePart]: + response_parts: list[ModelResponsePart] = [] + cursor = 0 + + for match in _THINK_TAG_PATTERN.finditer(content): + if match.start() > cursor: + response_parts.append(TextPart(content=content[cursor : match.start()], provider_name=None)) + + thinking_content = match.group(1).strip("\n") + if thinking_content: + response_parts.append(ThinkingPart(content=thinking_content, provider_name=None)) + cursor = match.end() + + if cursor < len(content): + response_parts.append(TextPart(content=content[cursor:], provider_name=None)) + + if response_parts: + return response_parts + return [TextPart(content=content, provider_name=None)] + + +@dataclass(slots=True) +class _EmbeddedThinkingParser: + _pending: str = "" + _inside_thinking: bool = False + + def parse( + self, + parts_manager: ModelResponsePartsManager, + content: str, + provider_name: str, + ) -> list[ModelResponseStreamEvent]: + events: list[ModelResponseStreamEvent] = [] + buffer = self._pending + content + self._pending = "" + + while buffer: + if self._inside_thinking: + end_index = buffer.find(_THINK_CLOSE_TAG) + if end_index >= 0: + if end_index > 0: + events.extend( + parts_manager.handle_thinking_delta( + vendor_part_id=None, + content=buffer[:end_index], + provider_name=provider_name, + ) + ) + buffer = buffer[end_index + len(_THINK_CLOSE_TAG) :] + self._inside_thinking = False + continue + + safe_content, self._pending = _split_incomplete_tag_suffix(buffer, _THINK_CLOSE_TAG) + if safe_content: + events.extend( + parts_manager.handle_thinking_delta( + vendor_part_id=None, + content=safe_content, + provider_name=provider_name, + ) + ) + break + + start_index = buffer.find(_THINK_OPEN_TAG) + if start_index >= 0: + if start_index > 0: + events.extend( + parts_manager.handle_text_delta( + vendor_part_id=None, + content=buffer[:start_index], + provider_name=provider_name, + ) + ) + buffer = buffer[start_index + len(_THINK_OPEN_TAG) :] + self._inside_thinking = True + continue + + safe_content, self._pending = _split_incomplete_tag_suffix(buffer, _THINK_OPEN_TAG) + if safe_content: + events.extend( + parts_manager.handle_text_delta( + vendor_part_id=None, + content=safe_content, + provider_name=provider_name, + ) + ) + break + + return events + + def flush( + self, + parts_manager: ModelResponsePartsManager, + provider_name: str, + ) -> list[ModelResponseStreamEvent]: + if not self._pending: + return [] + + pending = self._pending + self._pending = "" + if self._inside_thinking: + return list( + parts_manager.handle_thinking_delta( + vendor_part_id=None, + content=pending, + provider_name=provider_name, + ) + ) + return list( + parts_manager.handle_text_delta( + vendor_part_id=None, + content=pending, + provider_name=provider_name, + ) + ) + + +def _split_incomplete_tag_suffix(content: str, tag: str) -> tuple[str, str]: + for suffix_length in range(len(tag) - 1, 0, -1): + if content.endswith(tag[:suffix_length]): + return content[:-suffix_length], content[-suffix_length:] + return content, "" diff --git a/dify-agent/src/dify_agent/adapters/llm/provider.py b/dify-agent/src/dify_agent/adapters/llm/provider.py new file mode 100644 index 0000000000..6e7b92f646 --- /dev/null +++ b/dify-agent/src/dify_agent/adapters/llm/provider.py @@ -0,0 +1,272 @@ +"""Dify plugin-daemon provider for Pydantic AI LLM adapters. + +The Pydantic AI provider represents daemon/plugin transport identity. Business +model provider names such as ``openai`` are request-level model identity and are +passed by ``DifyLLMAdapterModel`` for each invocation instead of being stored on +this provider. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Callable, Mapping +from dataclasses import dataclass, field +from typing import NoReturn + +import httpx +from graphon.model_runtime.entities.llm_entities import LLMResultChunk +from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool +from pydantic import BaseModel +from typing_extensions import override + +from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior, UserError +from pydantic_ai.providers import Provider + +_DEFAULT_DAEMON_TIMEOUT: float | httpx.Timeout | None = 600.0 + + +class PluginDaemonBasicResponse(BaseModel): + code: int + message: str + data: object | None = None + + +@dataclass(slots=True) +class DifyPluginDaemonLLMClient: + """HTTP client wrapper for plugin-daemon LLM dispatch requests.""" + + plugin_daemon_url: str + plugin_daemon_api_key: str + tenant_id: str + plugin_id: str + user_id: str | None + http_client: httpx.AsyncClient = field(repr=False) + + def __post_init__(self) -> None: + self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/") + + async def iter_llm_result_chunks( + self, + *, + provider: str, + model: str, + credentials: dict[str, object], + prompt_messages: list[PromptMessage], + model_parameters: dict[str, object], + tools: list[PromptMessageTool] | None, + stop: list[str] | None, + stream: bool, + ) -> AsyncIterator[LLMResultChunk]: + async for item in self._iter_stream_response( + model_name=model, + path=f"plugin/{self.tenant_id}/dispatch/llm/invoke", + request_data={ + "provider": provider, + "model_type": "llm", + "model": model, + "credentials": credentials, + "prompt_messages": prompt_messages, + "model_parameters": model_parameters, + "tools": tools, + "stop": stop, + "stream": stream, + }, + response_model=LLMResultChunk, + ): + yield item + + async def _iter_stream_response[T: BaseModel]( + self, + *, + model_name: str, + path: str, + request_data: Mapping[str, object], + response_model: type[T], + ) -> AsyncIterator[T]: + payload: dict[str, object] = {"data": _to_jsonable(request_data)} + if self.user_id is not None: + payload["user_id"] = self.user_id + + headers = { + "X-Api-Key": self.plugin_daemon_api_key, + "X-Plugin-ID": self.plugin_id, + "Content-Type": "application/json", + } + url = f"{self.plugin_daemon_url}/{path}" + + async with self.http_client.stream("POST", url, headers=headers, json=payload) as response: + if response.is_error: + body = (await response.aread()).decode("utf-8", errors="replace") + error = _decode_plugin_daemon_error_payload(body) + if error is not None: + _raise_plugin_daemon_error( + model_name=model_name, + error_type=error["error_type"], + message=error["message"], + status_code=response.status_code, + body=error, + ) + raise ModelHTTPError(response.status_code, model_name, body or None) + + async for raw_line in response.aiter_lines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("data:"): + line = line[5:].strip() + + wrapped = PluginDaemonBasicResponse.model_validate_json(line) + if wrapped.code != 0: + error = _decode_plugin_daemon_error_payload(wrapped.message) + if error is not None: + _raise_plugin_daemon_error( + model_name=model_name, + error_type=error["error_type"], + message=error["message"], + body=error, + ) + raise ModelAPIError( + model_name, + f"Plugin daemon returned error code {wrapped.code}: {wrapped.message}", + ) + if wrapped.data is None: + raise UnexpectedModelBehavior("Plugin daemon returned an empty stream item") + yield response_model.model_validate(wrapped.data) + + +@dataclass(slots=True, kw_only=True) +class DifyPluginDaemonProvider(Provider[DifyPluginDaemonLLMClient]): + """Pydantic AI provider for Dify plugin-daemon dispatch requests. + + The provider ``name`` identifies the daemon/plugin context. The business LLM + provider is supplied by each adapter model request so one daemon provider can + serve different model-provider selections without mutating transport state. + When ``http_client`` is omitted the provider owns an ``AsyncClient`` and the + Pydantic AI provider context manager closes it. When an external client is + supplied, ownership stays with the caller and provider exit leaves it open. + """ + + tenant_id: str + plugin_id: str + plugin_daemon_url: str + plugin_daemon_api_key: str = field(repr=False) + user_id: str | None = None + timeout: float | httpx.Timeout | None = _DEFAULT_DAEMON_TIMEOUT + http_client: httpx.AsyncClient | None = field(default=None, repr=False) + _client: DifyPluginDaemonLLMClient = field(init=False, repr=False) + _own_http_client: httpx.AsyncClient | None = field(init=False, default=None, repr=False) + _http_client_factory: Callable[[], httpx.AsyncClient] | None = field(init=False, default=None, repr=False) + + def __post_init__(self) -> None: + self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/") + if self.http_client is None: + self._http_client_factory = self._make_http_client + http_client = self._make_http_client() + self._own_http_client = http_client + else: + http_client = self.http_client + self._own_http_client = None + self._http_client_factory = None + self._client = DifyPluginDaemonLLMClient( + plugin_daemon_url=self.plugin_daemon_url, + plugin_daemon_api_key=self.plugin_daemon_api_key, + tenant_id=self.tenant_id, + plugin_id=self.plugin_id, + user_id=self.user_id, + http_client=http_client, + ) + + def _make_http_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=self.timeout, trust_env=False) + + @override + def _set_http_client(self, http_client: httpx.AsyncClient) -> None: + self._client.http_client = http_client + + @property + @override + def name(self) -> str: + return f"DifyPlugin/{self.plugin_id}" + + @property + @override + def base_url(self) -> str: + return self.plugin_daemon_url + + @property + @override + def client(self) -> DifyPluginDaemonLLMClient: + return self._client + + +def _to_jsonable(value: object) -> object: + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + if isinstance(value, dict): + return {key: _to_jsonable(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return [_to_jsonable(item) for item in value] + return value + + +def _decode_plugin_daemon_error_payload(raw_message: str) -> dict[str, str] | None: + try: + parsed = json.loads(raw_message) + except json.JSONDecodeError: + return None + + if not isinstance(parsed, dict): + return None + + error_type = parsed.get("error_type") + message = parsed.get("message") + if not isinstance(error_type, str) or not isinstance(message, str): + return None + return {"error_type": error_type, "message": message} + + +def _raise_plugin_daemon_error( + *, + model_name: str, + error_type: str, + message: str, + status_code: int | None = None, + body: object | None = None, +) -> NoReturn: + http_error_body = body or {"error_type": error_type, "message": message} + + match error_type: + case "PluginInvokeError": + nested_error = _decode_plugin_daemon_error_payload(message) + if nested_error is not None: + _raise_plugin_daemon_error( + model_name=model_name, + error_type=nested_error["error_type"], + message=nested_error["message"], + status_code=status_code, + body=nested_error, + ) + raise ModelAPIError(model_name, message) + case "PluginDaemonUnauthorizedError" | "InvokeAuthorizationError": + raise ModelHTTPError(status_code or 401, model_name, http_error_body) + case "PluginPermissionDeniedError": + raise ModelHTTPError(status_code or 403, model_name, http_error_body) + case ( + "PluginDaemonBadRequestError" + | "InvokeBadRequestError" + | "CredentialsValidateFailedError" + | "PluginUniqueIdentifierError" + ): + raise ModelHTTPError(status_code or 400, model_name, http_error_body) + case "EndpointSetupFailedError" | "TriggerProviderCredentialValidationError": + raise UserError(message) + case "PluginDaemonNotFoundError" | "PluginNotFoundError": + raise ModelHTTPError(status_code or 404, model_name, http_error_body) + case "InvokeRateLimitError": + raise ModelHTTPError(status_code or 429, model_name, http_error_body) + case "PluginDaemonInternalServerError" | "PluginDaemonInnerError": + raise ModelHTTPError(status_code or 500, model_name, http_error_body) + case "InvokeConnectionError" | "InvokeServerUnavailableError": + raise ModelHTTPError(status_code or 503, model_name, http_error_body) + case _: + raise ModelAPIError(model_name, f"{error_type}: {message}") diff --git a/dify-agent/src/dify_agent/client/__init__.py b/dify-agent/src/dify_agent/client/__init__.py new file mode 100644 index 0000000000..ff0027b291 --- /dev/null +++ b/dify-agent/src/dify_agent/client/__init__.py @@ -0,0 +1,21 @@ +"""Unified sync and async Python client for the Dify Agent run API.""" + +from ._client import ( + Client, + DifyAgentClientError, + DifyAgentHTTPError, + DifyAgentNotFoundError, + DifyAgentStreamError, + DifyAgentTimeoutError, + DifyAgentValidationError, +) + +__all__ = [ + "Client", + "DifyAgentClientError", + "DifyAgentHTTPError", + "DifyAgentNotFoundError", + "DifyAgentStreamError", + "DifyAgentTimeoutError", + "DifyAgentValidationError", +] diff --git a/dify-agent/src/dify_agent/client/_client.py b/dify-agent/src/dify_agent/client/_client.py new file mode 100644 index 0000000000..7760f356aa --- /dev/null +++ b/dify-agent/src/dify_agent/client/_client.py @@ -0,0 +1,665 @@ +"""HTTPX-based client for Dify Agent runs. + +The client uses the public DTOs from ``dify_agent.protocol.schemas`` for all +normal request and response parsing. It intentionally does not retry +``POST /runs`` because create-run is not idempotent, and create helpers require a +``CreateRunRequest`` instance rather than accepting raw payload dicts. SSE +streams are the only operation with reconnect logic: transient stream, connect, +or read failures, stream timeouts, and HTTP 5xx stream responses reconnect with +the latest observed event id, while HTTP 4xx responses, DTO validation failures, +and malformed SSE frames fail immediately. +""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import AsyncIterator, Iterator +from types import TracebackType +from typing import Self, TypeVar, cast +from urllib.parse import quote + +import httpx +from pydantic import BaseModel, ValidationError + +from dify_agent.protocol.schemas import ( + CreateRunRequest, + CreateRunResponse, + RUN_EVENT_ADAPTER, + RunEvent, + RunEventsResponse, + RunStatusResponse, +) + +_ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel) +_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed"} +_TERMINAL_RUN_STATUSES = {"succeeded", "failed"} + + +class DifyAgentClientError(RuntimeError): + """Base class for errors raised by the Dify Agent Python client.""" + + +class DifyAgentHTTPError(DifyAgentClientError): + """Raised for HTTP 4xx/5xx responses not covered by a narrower subclass.""" + + status_code: int + detail: object + + def __init__(self, status_code: int, detail: object) -> None: + self.status_code = status_code + self.detail = detail + super().__init__(f"Dify Agent HTTP {status_code}: {detail}") + + +class DifyAgentNotFoundError(DifyAgentHTTPError): + """Raised when the server returns ``404`` for a run resource.""" + + +class DifyAgentValidationError(DifyAgentHTTPError): + """Raised for local input validation, invalid DTO responses, or HTTP ``422``.""" + + def __init__(self, detail: object, *, status_code: int = 422) -> None: + super().__init__(status_code=status_code, detail=detail) + + +class DifyAgentTimeoutError(DifyAgentClientError): + """Raised when an HTTPX timeout occurs outside successful SSE reconnects.""" + + +class DifyAgentStreamError(DifyAgentClientError): + """Raised for malformed SSE frames or exhausted SSE reconnect attempts.""" + + +class _ReconnectableStreamError(Exception): + """Internal wrapper for stream failures that may be retried by the caller.""" + + error: DifyAgentClientError + + def __init__(self, error: DifyAgentClientError) -> None: + self.error = error + super().__init__(str(error)) + + +class _SSEDecoder: + """Incrementally decode SSE lines into typed run events. + + The decoder keeps only the fields for the current frame. Comments are ignored, + ``data`` fields are joined with newlines as required by the SSE specification, + and payload JSON is validated by ``RUN_EVENT_ADAPTER``. The frame ``id`` is + copied into the decoded event only when the JSON payload omits ``event.id``. + """ + + _event_id: str | None + _event_type: str | None + _data_lines: list[str] + + def __init__(self) -> None: + self._event_id = None + self._event_type = None + self._data_lines = [] + + def feed_line(self, raw_line: str) -> RunEvent | None: + """Consume one SSE line and return an event when a frame completes. + + Empty lines dispatch the current frame. Comment-only frames and frames + without ``data`` are ignored so server heartbeats do not surface to users. + Malformed event payloads raise ``DifyAgentStreamError`` and must not be + retried because replaying would repeat the same invalid frame. + """ + line = raw_line.rstrip("\r") + if line == "": + return self._dispatch() + if line.startswith(":"): + return None + + field, separator, value = line.partition(":") + if separator and value.startswith(" "): + value = value[1:] + if field == "id": + self._event_id = value + elif field == "event": + self._event_type = value + elif field == "data": + self._data_lines.append(value) + return None + + def _dispatch(self) -> RunEvent | None: + """Validate and return the current frame, then clear decoder state.""" + if not self._data_lines: + self._reset() + return None + + frame_id = self._event_id + frame_event_type = self._event_type + data = "\n".join(self._data_lines) + self._reset() + + try: + event = RUN_EVENT_ADAPTER.validate_json(data) + except ValidationError as exc: + raise DifyAgentStreamError("malformed SSE data frame") from exc + if frame_event_type is not None and frame_event_type != event.type: + raise DifyAgentStreamError( + f"SSE event field {frame_event_type!r} does not match payload type {event.type!r}" + ) + if frame_id is not None and event.id is None: + return event.model_copy(update={"id": frame_id}) + return event + + def _reset(self) -> None: + """Clear the current frame without changing decoder configuration.""" + self._event_id = None + self._event_type = None + self._data_lines = [] + + +class Client: + """Unified synchronous and asynchronous client for Dify Agent runs. + + The instance is intentionally small and stateful: it stores base URL, default + headers, timeout settings, optional external HTTPX clients, and lazy-owned + clients for whichever sync/async side is used. External clients are never + closed by this wrapper. Owned sync clients close via ``close_sync`` or the + sync context manager; owned async clients close via ``aclose`` or the async + context manager. + """ + + _base_url: str + _timeout: float | httpx.Timeout + _stream_timeout: float | httpx.Timeout | None + _headers: dict[str, str] + _sync_http_client: httpx.Client | None + _async_http_client: httpx.AsyncClient | None + _owns_sync_http_client: bool + _owns_async_http_client: bool + _sync_closed: bool + _async_closed: bool + + def __init__( + self, + *, + base_url: str, + timeout: float | httpx.Timeout = 30.0, + stream_timeout: float | httpx.Timeout | None = None, + headers: dict[str, str] | None = None, + sync_http_client: httpx.Client | None = None, + async_http_client: httpx.AsyncClient | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._stream_timeout = stream_timeout + self._headers = dict(headers or {}) + self._sync_http_client = sync_http_client + self._async_http_client = async_http_client + self._owns_sync_http_client = sync_http_client is None + self._owns_async_http_client = async_http_client is None + self._sync_closed = False + self._async_closed = False + + def __enter__(self) -> Self: + """Enter a sync context and return this client without opening the network.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Close the owned sync HTTP client when leaving a sync context.""" + del exc_type, exc_value, traceback + self.close_sync() + + async def __aenter__(self) -> Self: + """Enter an async context and return this client without opening the network.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Close owned async resources when leaving an async context.""" + del exc_type, exc_value, traceback + await self.aclose() + + def close_sync(self) -> None: + """Close the owned synchronous HTTPX client if it was created.""" + if self._sync_closed: + return + if self._owns_sync_http_client and self._sync_http_client is not None: + self._sync_http_client.close() + self._sync_closed = True + + async def aclose(self) -> None: + """Close owned asynchronous resources and any owned sync client already opened.""" + if not self._async_closed: + if self._owns_async_http_client and self._async_http_client is not None: + await self._async_http_client.aclose() + self._async_closed = True + if self._owns_sync_http_client and self._sync_http_client is not None: + self.close_sync() + + async def create_run(self, request: CreateRunRequest) -> CreateRunResponse: + """Create one run and return its accepted status response. + + ``request`` must already be a public ``CreateRunRequest`` DTO. This + method performs exactly one ``POST /runs`` attempt and maps HTTPX + timeouts to ``DifyAgentTimeoutError``. + """ + request_model = _validate_create_run_request(request) + try: + response = await self._get_async_http_client().post( + self._url("/runs"), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("create_run timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"create_run request failed: {exc}") from exc + return _parse_model_response(response, CreateRunResponse) + + def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse: + """Synchronous variant of ``create_run`` with the same no-retry contract.""" + request_model = _validate_create_run_request(request) + try: + response = self._get_sync_http_client().post( + self._url("/runs"), + content=request_model.model_dump_json(), + headers=self._merged_headers({"Content-Type": "application/json"}), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("create_run_sync timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"create_run_sync request failed: {exc}") from exc + return _parse_model_response(response, CreateRunResponse) + + async def get_run(self, run_id: str) -> RunStatusResponse: + """Return the current status for ``run_id`` or raise a mapped client error.""" + try: + response = await self._get_async_http_client().get( + self._url(f"/runs/{quote(run_id, safe='')}"), + headers=self._merged_headers(), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("get_run timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"get_run request failed: {exc}") from exc + return _parse_model_response(response, RunStatusResponse) + + def get_run_sync(self, run_id: str) -> RunStatusResponse: + """Synchronous variant of ``get_run``.""" + try: + response = self._get_sync_http_client().get( + self._url(f"/runs/{quote(run_id, safe='')}"), + headers=self._merged_headers(), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("get_run_sync timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"get_run_sync request failed: {exc}") from exc + return _parse_model_response(response, RunStatusResponse) + + async def get_events(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse: + """Return one cursor-paginated page of events for ``run_id``.""" + try: + response = await self._get_async_http_client().get( + self._url(f"/runs/{quote(run_id, safe='')}/events"), + params={"after": after, "limit": str(limit)}, + headers=self._merged_headers(), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("get_events timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"get_events request failed: {exc}") from exc + return _parse_model_response(response, RunEventsResponse) + + def get_events_sync(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse: + """Synchronous variant of ``get_events``.""" + try: + response = self._get_sync_http_client().get( + self._url(f"/runs/{quote(run_id, safe='')}/events"), + params={"after": after, "limit": str(limit)}, + headers=self._merged_headers(), + timeout=self._timeout, + ) + except httpx.TimeoutException as exc: + raise DifyAgentTimeoutError("get_events_sync timed out") from exc + except httpx.RequestError as exc: + raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc + return _parse_model_response(response, RunEventsResponse) + + async def stream_events( + self, + run_id: str, + *, + after: str | None = None, + reconnect: bool = True, + max_reconnects: int | None = None, + reconnect_delay_seconds: float = 1.0, + until_terminal: bool = True, + ) -> AsyncIterator[RunEvent]: + """Yield typed events from SSE with cursor-based reconnect. + + The initial cursor is ``after`` or ``"0-0"``. After every yielded event + with an id, reconnects resume from that id using the ``after`` query + parameter. HTTP 5xx stream responses are retried, but HTTP 4xx responses, + DTO validation failures, and malformed SSE frames are not retried. By + default iteration stops after ``run_succeeded`` or ``run_failed``. + """ + _validate_stream_options(max_reconnects, reconnect_delay_seconds) + cursor = after or "0-0" + reconnect_attempts = 0 + while True: + try: + async for event in self._stream_events_once(run_id, after=cursor): + if event.id is not None: + cursor = event.id + yield event + if until_terminal and event.type in _TERMINAL_EVENT_TYPES: + return + except _ReconnectableStreamError as exc: + if not reconnect: + raise exc.error from exc + reconnect_attempts = _next_reconnect_attempt( + reconnect_attempts, + max_reconnects=max_reconnects, + error=exc.error, + ) + await _sleep_async(reconnect_delay_seconds) + continue + if not reconnect: + return + reconnect_attempts = _next_reconnect_attempt( + reconnect_attempts, + max_reconnects=max_reconnects, + error=DifyAgentStreamError("SSE stream ended before a terminal event"), + ) + await _sleep_async(reconnect_delay_seconds) + + def stream_events_sync( + self, + run_id: str, + *, + after: str | None = None, + reconnect: bool = True, + max_reconnects: int | None = None, + reconnect_delay_seconds: float = 1.0, + until_terminal: bool = True, + ) -> Iterator[RunEvent]: + """Synchronous variant of ``stream_events`` with the same reconnect rules.""" + _validate_stream_options(max_reconnects, reconnect_delay_seconds) + cursor = after or "0-0" + reconnect_attempts = 0 + while True: + try: + for event in self._stream_events_once_sync(run_id, after=cursor): + if event.id is not None: + cursor = event.id + yield event + if until_terminal and event.type in _TERMINAL_EVENT_TYPES: + return + except _ReconnectableStreamError as exc: + if not reconnect: + raise exc.error from exc + reconnect_attempts = _next_reconnect_attempt( + reconnect_attempts, + max_reconnects=max_reconnects, + error=exc.error, + ) + _sleep_sync(reconnect_delay_seconds) + continue + if not reconnect: + return + reconnect_attempts = _next_reconnect_attempt( + reconnect_attempts, + max_reconnects=max_reconnects, + error=DifyAgentStreamError("SSE stream ended before a terminal event"), + ) + _sleep_sync(reconnect_delay_seconds) + + async def wait_run( + self, + run_id: str, + *, + poll_interval_seconds: float = 1.0, + timeout_seconds: float | None = None, + ) -> RunStatusResponse: + """Poll run status until it becomes terminal and return the final status.""" + _validate_wait_options(poll_interval_seconds, timeout_seconds) + deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None + while True: + status = await self.get_run(run_id) + if status.status in _TERMINAL_RUN_STATUSES: + return status + sleep_for = _next_sleep_seconds(poll_interval_seconds, deadline) + if sleep_for is None: + raise DifyAgentTimeoutError(f"run {run_id!r} did not finish before timeout") + await _sleep_async(sleep_for) + + def wait_run_sync( + self, + run_id: str, + *, + poll_interval_seconds: float = 1.0, + timeout_seconds: float | None = None, + ) -> RunStatusResponse: + """Synchronous variant of ``wait_run``.""" + _validate_wait_options(poll_interval_seconds, timeout_seconds) + deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None + while True: + status = self.get_run_sync(run_id) + if status.status in _TERMINAL_RUN_STATUSES: + return status + sleep_for = _next_sleep_seconds(poll_interval_seconds, deadline) + if sleep_for is None: + raise DifyAgentTimeoutError(f"run {run_id!r} did not finish before timeout") + _sleep_sync(sleep_for) + + async def _stream_events_once(self, run_id: str, *, after: str) -> AsyncIterator[RunEvent]: + """Open one SSE connection and yield events until it ends or fails.""" + try: + async with self._get_async_http_client().stream( + "GET", + self._url(f"/runs/{quote(run_id, safe='')}/events/sse"), + params={"after": after}, + headers=self._merged_headers(), + timeout=self._stream_timeout, + ) as response: + if response.status_code >= 400: + _ = await response.aread() + _raise_for_stream_status(response) + decoder = _SSEDecoder() + async for line in response.aiter_lines(): + event = decoder.feed_line(line) + if event is not None: + yield event + except DifyAgentHTTPError: + raise + except DifyAgentStreamError: + raise + except httpx.TimeoutException as exc: + raise _ReconnectableStreamError(DifyAgentTimeoutError("SSE stream timed out")) from exc + except httpx.TransportError as exc: + raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc + except httpx.StreamError as exc: + raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc + + def _stream_events_once_sync(self, run_id: str, *, after: str) -> Iterator[RunEvent]: + """Open one synchronous SSE connection and yield events until it ends or fails.""" + try: + with self._get_sync_http_client().stream( + "GET", + self._url(f"/runs/{quote(run_id, safe='')}/events/sse"), + params={"after": after}, + headers=self._merged_headers(), + timeout=self._stream_timeout, + ) as response: + if response.status_code >= 400: + _ = response.read() + _raise_for_stream_status(response) + decoder = _SSEDecoder() + for line in response.iter_lines(): + event = decoder.feed_line(line) + if event is not None: + yield event + except DifyAgentHTTPError: + raise + except DifyAgentStreamError: + raise + except httpx.TimeoutException as exc: + raise _ReconnectableStreamError(DifyAgentTimeoutError("SSE stream timed out")) from exc + except httpx.TransportError as exc: + raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc + except httpx.StreamError as exc: + raise _ReconnectableStreamError(DifyAgentStreamError(f"SSE stream failed: {exc}")) from exc + + def _get_sync_http_client(self) -> httpx.Client: + """Return an open sync HTTPX client, creating an owned one lazily.""" + if self._sync_closed: + raise DifyAgentClientError("sync client is closed") + if self._sync_http_client is None: + self._sync_http_client = httpx.Client(timeout=self._timeout, headers=self._headers) + return self._sync_http_client + + def _get_async_http_client(self) -> httpx.AsyncClient: + """Return an open async HTTPX client, creating an owned one lazily.""" + if self._async_closed: + raise DifyAgentClientError("async client is closed") + if self._async_http_client is None: + self._async_http_client = httpx.AsyncClient(timeout=self._timeout, headers=self._headers) + return self._async_http_client + + def _url(self, path: str) -> str: + """Build an absolute URL from the configured base and API path.""" + return f"{self._base_url}{path}" + + def _merged_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + """Return per-request headers without mutating client defaults.""" + headers = dict(self._headers) + if extra is not None: + headers.update(extra) + return headers + + +def _validate_create_run_request(request: CreateRunRequest) -> CreateRunRequest: + """Reject raw payloads so create-run uses the public request DTO boundary.""" + if isinstance(request, CreateRunRequest): + return request + raise DifyAgentValidationError(detail="request must be a CreateRunRequest") + + +def _parse_model_response(response: httpx.Response, model_type: type[_ResponseModelT]) -> _ResponseModelT: + """Map HTTP errors and parse a Pydantic response DTO.""" + _raise_for_status(response) + try: + return model_type.model_validate_json(response.content) + except ValidationError as exc: + raise DifyAgentValidationError( + detail=exc.errors(include_url=False), + status_code=response.status_code, + ) from exc + + +def _raise_for_status(response: httpx.Response) -> None: + """Raise the configured client exception for HTTP 4xx/5xx responses.""" + if response.status_code < 400: + return + detail = _extract_error_detail(response) + if response.status_code == 404: + raise DifyAgentNotFoundError(status_code=response.status_code, detail=detail) + if response.status_code == 422: + raise DifyAgentValidationError(status_code=response.status_code, detail=detail) + raise DifyAgentHTTPError(status_code=response.status_code, detail=detail) + + +def _raise_for_stream_status(response: httpx.Response) -> None: + """Raise terminal 4xx errors or wrap retryable SSE 5xx responses.""" + try: + _raise_for_status(response) + except DifyAgentHTTPError as exc: + if response.status_code >= 500: + raise _ReconnectableStreamError( + DifyAgentStreamError(f"SSE stream HTTP {response.status_code}: {exc.detail}") + ) from exc + raise + + +def _extract_error_detail(response: httpx.Response) -> object: + """Extract FastAPI's ``detail`` field when present, falling back to text.""" + try: + payload = cast(object, response.json()) + except (ValueError, httpx.ResponseNotRead): + return response.text or response.reason_phrase + if isinstance(payload, dict) and "detail" in payload: + return cast(object, payload["detail"]) + return cast(object, payload) + + +def _next_reconnect_attempt( + reconnect_attempts: int, + *, + max_reconnects: int | None, + error: DifyAgentClientError, +) -> int: + """Increment reconnect attempts or raise when the configured budget is spent.""" + if max_reconnects is not None and reconnect_attempts >= max_reconnects: + raise DifyAgentStreamError("SSE stream reconnect attempts exhausted") from error + return reconnect_attempts + 1 + + +def _validate_stream_options(max_reconnects: int | None, reconnect_delay_seconds: float) -> None: + """Reject stream options that cannot produce deterministic reconnect behavior.""" + if max_reconnects is not None and max_reconnects < 0: + raise DifyAgentValidationError(detail="max_reconnects must be non-negative") + if reconnect_delay_seconds < 0: + raise DifyAgentValidationError(detail="reconnect_delay_seconds must be non-negative") + + +def _validate_wait_options(poll_interval_seconds: float, timeout_seconds: float | None) -> None: + """Reject wait options that would make polling ambiguous.""" + if poll_interval_seconds < 0: + raise DifyAgentValidationError(detail="poll_interval_seconds must be non-negative") + if timeout_seconds is not None and timeout_seconds < 0: + raise DifyAgentValidationError(detail="timeout_seconds must be non-negative") + + +def _next_sleep_seconds(poll_interval_seconds: float, deadline: float | None) -> float | None: + """Return the next polling sleep duration, or ``None`` when timed out.""" + if deadline is None: + return poll_interval_seconds + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + return min(poll_interval_seconds, remaining) + + +async def _sleep_async(seconds: float) -> None: + """Sleep asynchronously, skipping the call for zero-second test delays.""" + if seconds > 0: + await asyncio.sleep(seconds) + + +def _sleep_sync(seconds: float) -> None: + """Sleep synchronously, skipping the call for zero-second test delays.""" + if seconds > 0: + time.sleep(seconds) + + +__all__ = [ + "Client", + "DifyAgentClientError", + "DifyAgentHTTPError", + "DifyAgentNotFoundError", + "DifyAgentStreamError", + "DifyAgentTimeoutError", + "DifyAgentValidationError", +] diff --git a/dify-agent/src/dify_agent/layers/__init__.py b/dify-agent/src/dify_agent/layers/__init__.py new file mode 100644 index 0000000000..518fbf9bd9 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/__init__.py @@ -0,0 +1,3 @@ +"""Dify-owned Agenton layer packages.""" + +__all__: list[str] = [] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py b/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py new file mode 100644 index 0000000000..5b2f1dccce --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py @@ -0,0 +1,21 @@ +"""Client-safe exports for Dify plugin DTOs and public layer type ids. + +Implementation layers live in sibling modules and require server-side runtime +dependencies. Keep this package root import-safe for client-only installs. +""" + +from dify_agent.layers.dify_plugin.configs import ( + DIFY_PLUGIN_LAYER_TYPE_ID, + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DifyPluginCredentialValue, + DifyPluginLLMLayerConfig, + DifyPluginLayerConfig, +) + +__all__ = [ + "DIFY_PLUGIN_LAYER_TYPE_ID", + "DIFY_PLUGIN_LLM_LAYER_TYPE_ID", + "DifyPluginCredentialValue", + "DifyPluginLLMLayerConfig", + "DifyPluginLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py new file mode 100644 index 0000000000..5fff7dde51 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py @@ -0,0 +1,50 @@ +"""Client-safe DTOs for Dify plugin-backed Agenton layers. + +This module intentionally contains only public config schemas and scalar type +aliases plus stable layer type identifiers. Runtime objects such as HTTP +clients, server settings, and adapter implementations live in sibling +implementation modules so clients can build run requests without importing +server-only dependencies. +""" + +from typing import ClassVar, Final, TypeAlias + +from pydantic import ConfigDict, Field +from pydantic_ai.settings import ModelSettings + +from agenton.layers import LayerConfig + + +DifyPluginCredentialValue: TypeAlias = str | int | float | bool | None +DIFY_PLUGIN_LAYER_TYPE_ID: Final[str] = "dify.plugin" +DIFY_PLUGIN_LLM_LAYER_TYPE_ID: Final[str] = "dify.plugin.llm" + + +class DifyPluginLayerConfig(LayerConfig): + """Public config for the plugin daemon tenant/plugin context layer.""" + + tenant_id: str + plugin_id: str + user_id: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +class DifyPluginLLMLayerConfig(LayerConfig): + """Public config for selecting a business provider/model from a plugin.""" + + model_provider: str + model: str + credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict) + model_settings: ModelSettings | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True) + + +__all__ = [ + "DIFY_PLUGIN_LAYER_TYPE_ID", + "DIFY_PLUGIN_LLM_LAYER_TYPE_ID", + "DifyPluginCredentialValue", + "DifyPluginLLMLayerConfig", + "DifyPluginLayerConfig", +] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py new file mode 100644 index 0000000000..4ac053df3f --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py @@ -0,0 +1,55 @@ +"""Dify plugin LLM model layer. + +This layer owns model capability resolution for Dify plugin-backed LLMs. It +depends on ``DifyPluginLayer`` for daemon identity through Agenton's direct +dependency binding and returns a Pydantic AI model adapter configured from the +public LLM layer DTO. Runtime code supplies the FastAPI lifespan-owned shared +HTTP client to ``get_model``; the layer does not own or discover live resources. +The daemon provider carries plugin transport identity, while the DTO's +``model_provider`` is passed to the adapter as request-level model identity. +""" + +from dataclasses import dataclass + +import httpx +from typing_extensions import Self, override + +from agenton.layers import LayerDeps, PlainLayer +from dify_agent.adapters.llm import DifyLLMAdapterModel +from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig +from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer + + +class DifyPluginLLMDeps(LayerDeps): + """Dependencies required by ``DifyPluginLLMLayer``.""" + + plugin: DifyPluginLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]): + """Layer that creates the Dify plugin-daemon Pydantic AI model.""" + + type_id = DIFY_PLUGIN_LLM_LAYER_TYPE_ID + + config: DifyPluginLLMLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyPluginLLMLayerConfig) -> Self: + """Create the LLM layer from validated public config.""" + return cls(config=config) + + def get_model(self, *, http_client: httpx.AsyncClient) -> DifyLLMAdapterModel: + """Return the configured model using the directly bound plugin dependency.""" + provider = self.deps.plugin.create_daemon_provider(http_client=http_client) + return DifyLLMAdapterModel( + model=self.config.model, + daemon_provider=provider, + model_provider=self.config.model_provider, + credentials=dict(self.config.credentials), + model_settings=self.config.model_settings, + ) + + +__all__ = ["DifyPluginLLMDeps", "DifyPluginLLMLayer"] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py new file mode 100644 index 0000000000..71c649b6de --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py @@ -0,0 +1,69 @@ +"""Runtime Dify plugin context layer. + +The public config identifies tenant/plugin/user context only. Plugin daemon URL +and API key are server-side settings injected by the provider factory. The layer +is intentionally config/settings-only under Agenton's state-only core: it does +not open, cache, close, or snapshot HTTP clients, and its lifecycle hooks remain +the inherited no-op hooks. Runtime code passes the FastAPI lifespan-owned shared +``httpx.AsyncClient`` into ``create_daemon_provider`` for each model adapter. +Business model-provider names belong to the LLM layer/model request, not this +daemon context layer. +""" + +from dataclasses import dataclass + +import httpx +from typing_extensions import Self, override + +from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer +from dify_agent.adapters.llm import DifyPluginDaemonProvider +from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig + + +@dataclass(slots=True) +class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntimeState]): + """Layer that carries plugin daemon identity without owning live resources.""" + + type_id = DIFY_PLUGIN_LAYER_TYPE_ID + + config: DifyPluginLayerConfig + daemon_url: str + daemon_api_key: str + + @classmethod + @override + def from_config(cls, config: DifyPluginLayerConfig) -> Self: + """Reject construction without server-injected daemon settings.""" + del config + raise TypeError("DifyPluginLayer requires server-side daemon settings and must use a provider factory.") + + @classmethod + def from_config_with_settings( + cls, + config: DifyPluginLayerConfig, + *, + daemon_url: str, + daemon_api_key: str, + ) -> Self: + """Create a plugin layer from public config plus server-only daemon settings.""" + return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key) + + def create_daemon_provider(self, *, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider: + """Return a daemon provider backed by the shared plugin daemon client. + + Raises: + RuntimeError: if ``http_client`` has already been closed. + """ + if http_client.is_closed: + raise RuntimeError("DifyPluginLayer.create_daemon_provider() requires an open shared HTTP client.") + return DifyPluginDaemonProvider( + tenant_id=self.config.tenant_id, + plugin_id=self.config.plugin_id, + plugin_daemon_url=self.daemon_url, + plugin_daemon_api_key=self.daemon_api_key, + user_id=self.config.user_id, + http_client=http_client, + ) + + +__all__ = ["DifyPluginLayer"] diff --git a/dify-agent/src/dify_agent/layers/output/__init__.py b/dify-agent/src/dify_agent/layers/output/__init__.py new file mode 100644 index 0000000000..076fd00b1a --- /dev/null +++ b/dify-agent/src/dify_agent/layers/output/__init__.py @@ -0,0 +1,10 @@ +"""Client-safe exports for the Dify structured output layer DTOs. + +The runtime layer implementation lives in ``output_layer.py`` and imports +server-side execution dependencies. Keep this package root import-safe for +client code that only needs to build run requests. +""" + +from dify_agent.layers.output.configs import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig + +__all__ = ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"] diff --git a/dify-agent/src/dify_agent/layers/output/configs.py b/dify-agent/src/dify_agent/layers/output/configs.py new file mode 100644 index 0000000000..bead8a354f --- /dev/null +++ b/dify-agent/src/dify_agent/layers/output/configs.py @@ -0,0 +1,62 @@ +"""Client-safe DTOs for Dify Agent structured output configuration. + +This module contains only the public config schema and stable layer type id for +the optional structured output layer. Runtime conversion into pydantic-ai output +specifications happens in ``dify_agent.layers.output.output_layer`` so client +imports do not pull in server execution code. +""" + +from __future__ import annotations + +import re +from typing import ClassVar, Final + +from pydantic import ConfigDict, JsonValue, field_validator + +from agenton.layers import LayerConfig + + +DIFY_OUTPUT_LAYER_TYPE_ID: Final[str] = "dify.output" +_OUTPUT_TOOL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + + +class DifyOutputLayerConfig(LayerConfig): + """Public config for the conventionally named structured output layer. + + The runtime only reads the layer named by + ``dify_agent.protocol.DIFY_AGENT_OUTPUT_LAYER_ID``. ``json_schema`` must + therefore describe the final top-level object output, not a provider-specific + response wrapper. First-version support is intentionally limited to object + schemas plus local ``#/$defs/...`` references so the same caller-provided + schema can drive both runtime validation and model-facing tool exposure; the + exposure copy may inline supported ``$defs`` refs as needed for the + Pydantic/Pydantic AI integration. ``name`` becomes the structured-output + tool name exposed to pydantic-ai, defaults to ``final_result``, and must be + 1-64 ASCII letters, numbers, underscores, or hyphens so downstream model + providers accept it consistently. ``description`` and ``strict`` are passed + through to the generated structured-output tool definition. + """ + + json_schema: dict[str, JsonValue] + name: str = "final_result" + description: str | None = None + strict: bool | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @field_validator("json_schema") + @classmethod + def _ensure_object_schema(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + if value.get("type") != "object": + raise ValueError("Schema must declare an object output.") + return value + + @field_validator("name") + @classmethod + def _ensure_safe_tool_name(cls, value: str) -> str: + if not _OUTPUT_TOOL_NAME_PATTERN.fullmatch(value): + raise ValueError("name must be 1-64 characters of letters, numbers, underscores, or hyphens.") + return value + + +__all__ = ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"] diff --git a/dify-agent/src/dify_agent/layers/output/output_layer.py b/dify-agent/src/dify_agent/layers/output/output_layer.py new file mode 100644 index 0000000000..f07dc309da --- /dev/null +++ b/dify-agent/src/dify_agent/layers/output/output_layer.py @@ -0,0 +1,319 @@ +"""Runtime layer for Dify Agent structured final output contracts. + +``DifyOutputLayer`` is intentionally state-free and does not participate in +prompt, user prompt, or tool aggregation. Instead, the scheduler and runner read +the conventionally named layer after ``Compositor.enter(...)`` and convert its +top-level object JSON Schema into a ``ToolOutput(...)`` whose inner dynamic type +both exposes the model-facing schema and validates runtime output. ``jsonschema`` +performs the real content validation inside that custom Pydantic-compatible +dict-like type, so Pydantic AI's normal output validation flow can request +retries without a separate Dify-owned output-validator callback. Keeping both +steps here lets request validation and execution reuse the same schema checks +without teaching Agenton core about output aggregation. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Final, cast + +from jsonschema import SchemaError +from jsonschema.exceptions import ValidationError as JsonSchemaValidationError +from jsonschema.protocols import Validator as JsonSchemaValidator +from jsonschema.validators import validator_for +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler, JsonValue +from pydantic.json_schema import JsonSchemaValue +from pydantic_ai.output import OutputSpec, ToolOutput +from pydantic_core import PydanticCustomError, core_schema +from typing_extensions import Self, assert_never, override + + +from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer +from dify_agent.layers.output.configs import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig + + +_LOCAL_DEFS_REF_PREFIX: Final[str] = "#/$defs/" +_NON_SCHEMA_VALUE_KEYWORDS: Final[frozenset[str]] = frozenset({"const", "default", "enum", "example", "examples"}) + + +@dataclass(frozen=True, slots=True) +class DifyOutputContract: + """Resolved pydantic-ai output spec for one run. + + ``output_type`` controls both the model-facing output tool schema and the + runtime validation behavior because the type inside ``ToolOutput`` carries + custom Pydantic hooks for JSON Schema exposure plus output validation. + """ + + output_type: OutputSpec[object] + + +@dataclass(slots=True) +class DifyOutputLayer(PlainLayer[NoLayerDeps, DifyOutputLayerConfig, EmptyRuntimeState]): + """State-free layer that stores the final structured output contract.""" + + type_id = DIFY_OUTPUT_LAYER_TYPE_ID + + config: DifyOutputLayerConfig + + @classmethod + @override + def from_config(cls, config: DifyOutputLayerConfig) -> Self: + """Create the output layer from validated public config.""" + return cls(config=DifyOutputLayerConfig.model_validate(config)) + + def build_output_contract(self) -> DifyOutputContract: + """Return the pydantic-ai output contract for this layer. + + The returned contract always keeps model-facing schema exposure plus + runtime validation inside the same dynamically generated dict-like type. + First-version support is intentionally limited to top-level object JSON + Schemas so the same schema can be validated with ``jsonschema`` and then + exposed to Pydantic AI without any wrapper/unwrapper translation. + + Raises: + ValueError: If the JSON Schema is invalid, contains non-local + references, or cannot be represented as a supported structured + output tool schema. + """ + user_schema = deepcopy(self.config.json_schema) + _reject_non_local_refs(user_schema) + validated_output_type = _build_validated_output_type( + user_schema, + name=self.config.name, + description=self.config.description, + ) + + return DifyOutputContract( + output_type=cast( + OutputSpec[object], + ToolOutput( + validated_output_type, + name=self.config.name, + strict=self.config.strict, + ), + ), + ) + + +def _build_json_schema_validator(schema: dict[str, JsonValue]) -> JsonSchemaValidator: + """Build a reusable validator after checking the schema itself.""" + validator_class = validator_for(schema) + try: + validator_class.check_schema(schema) + except SchemaError as exc: + raise ValueError(str(exc)) from exc + return validator_class(schema) + + +def _build_validated_output_type( + schema: dict[str, JsonValue], + *, + name: str, + description: str | None, +) -> type[dict[str, object]]: + """Create a dict-like output type with custom JSON schema and validation hooks. + + The generated type is unique per output layer config. Its Pydantic core + schema performs real ``jsonschema`` validation, while its JSON schema hook + exposes a model-facing schema that Pydantic AI can turn into an output tool. + """ + validator = _build_json_schema_validator(schema) + exposed_schema = _build_exposed_json_schema(schema, name=name, description=description) + type_name = _build_output_type_name(name) + + def _validate_output(value: dict[str, object]) -> object: + errors = sorted(validator.iter_errors(cast(JsonValue, value)), key=lambda error: _sort_error_path(error.path)) + if errors: + message = _format_json_schema_error(errors[0]) + raise PydanticCustomError( + "json_schema_validation", + "Output does not match JSON Schema: {message}", + {"message": message}, + ) + return validated_output_type(value) + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + del source_type, handler + return core_schema.no_info_after_validator_function( + _validate_output, + core_schema.dict_schema( + keys_schema=core_schema.str_schema(), + values_schema=core_schema.any_schema(), + ), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, + schema_: core_schema.CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + del cls, schema_, handler + return deepcopy(exposed_schema) + + namespace = { + "__module__": __name__, + "__is_model_like__": True, + "__get_pydantic_core_schema__": __get_pydantic_core_schema__, + "__get_pydantic_json_schema__": __get_pydantic_json_schema__, + } + validated_output_type = cast(type[dict[str, object]], type(type_name, (dict,), namespace)) + return validated_output_type + + +def _build_exposed_json_schema( + schema: dict[str, JsonValue], + *, + name: str, + description: str | None, +) -> dict[str, JsonValue]: + """Return the schema exposed to the model through Pydantic AI. + + Pydantic's JSON schema generation cannot safely emit custom schemas that keep + root-level ``$defs`` references intact, so the exposure copy is inlined for + supported local ``#/$defs/...`` refs before the final title/description is + attached. + """ + exposed_schema = _inline_local_defs_refs(schema) + exposed_schema["title"] = name + if description is not None: + exposed_schema["description"] = description + return exposed_schema + + +def _build_output_type_name(name: str) -> str: + """Return a deterministic debug-friendly class name for one output schema.""" + sanitized = "".join(character if character.isalnum() else "_" for character in name).strip("_") or "final_result" + return f"DifyValidatedOutput_{sanitized}" + + +def _reject_non_local_refs(schema: JsonValue) -> None: + """Reject references that would require external fetching or non-local state. + + JSON Schema allows ordinary instance values under keywords such as ``const`` + and ``examples``. Those values may themselves contain ``{"$ref": ...}`` as + plain data, so the traversal only inspects positions that still represent + schema documents rather than blindly recursing into every JSON object. This + first version only supports local refs under ``#/$defs/`` so every schema + accepted here can be materialized into the dynamic output type's exposed JSON + schema without needing general-purpose root rewriting. + """ + for ref in _iter_schema_refs(schema): + if not ref.startswith("#"): + raise ValueError( + "Remote $ref values are not supported; only local fragment refs beginning with '#' are allowed." + ) + if not ref.startswith(_LOCAL_DEFS_REF_PREFIX): + raise ValueError( + f"Only local refs under '#/$defs/' are supported in this version; got unsupported local ref {ref!r}." + ) + + +def _inline_local_defs_refs(schema: dict[str, JsonValue]) -> dict[str, JsonValue]: + """Inline supported root ``$defs`` refs for Pydantic schema exposure. + + ``jsonschema`` can validate a schema that keeps local refs, but Pydantic's + JSON schema generation cannot safely preserve custom root ``$defs`` in this + integration. Inlining keeps the model-facing schema equivalent while still + letting runtime validation use the original schema document. + """ + root_defs = schema.get("$defs") + if not isinstance(root_defs, dict): + return deepcopy(schema) + + def resolve(node: JsonValue, *, ref_stack: tuple[str, ...]) -> JsonValue: + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + ref_name = ref.removeprefix(_LOCAL_DEFS_REF_PREFIX) + if ref_name in ref_stack: + raise ValueError("Recursive $defs refs are not supported for structured output exposure.") + target = root_defs.get(ref_name) + if not isinstance(target, dict): + raise ValueError(f"Local $ref {ref!r} could not be resolved from $defs.") + resolved_target = resolve(deepcopy(target), ref_stack=(*ref_stack, ref_name)) + merged = cast(dict[str, JsonValue], deepcopy(resolved_target)) + for key, value in node.items(): + if key == "$ref": + continue + if key in _NON_SCHEMA_VALUE_KEYWORDS: + merged[key] = deepcopy(value) + else: + merged[key] = _resolve_or_copy(value, ref_stack=ref_stack) + return merged + + resolved_node: dict[str, JsonValue] = {} + for key, value in node.items(): + if key == "$defs": + continue + if key in _NON_SCHEMA_VALUE_KEYWORDS: + resolved_node[key] = deepcopy(value) + else: + resolved_node[key] = _resolve_or_copy(value, ref_stack=ref_stack) + return resolved_node + + if isinstance(node, list): + return [_resolve_or_copy(item, ref_stack=ref_stack) for item in node] + + return deepcopy(node) + + def _resolve_or_copy(value: JsonValue, *, ref_stack: tuple[str, ...]) -> JsonValue: + if isinstance(value, dict | list): + return resolve(value, ref_stack=ref_stack) + return deepcopy(value) + + resolved_schema = resolve(deepcopy(schema), ref_stack=()) + return cast(dict[str, JsonValue], resolved_schema) + + +def _iter_schema_refs(schema: JsonValue) -> Iterable[str]: + if isinstance(schema, dict): + for key, value in schema.items(): + if key == "$ref" and isinstance(value, str): + yield value + elif key in _NON_SCHEMA_VALUE_KEYWORDS: + continue + elif isinstance(value, dict | list): + yield from _iter_schema_refs(value) + elif isinstance(schema, list): + for item in schema: + if isinstance(item, dict | list): + yield from _iter_schema_refs(item) + elif isinstance(schema, str | int | float | bool) or schema is None: + return + else: # pragma: no cover - JsonValue exhaustiveness guard + assert_never(schema) + + +def _format_json_schema_error(error: JsonSchemaValidationError) -> str: + return f"{_format_json_path(list(error.path))}: {error.message}" + + +def _format_json_path(path: Sequence[object]) -> str: + if not path: + return "$" + + formatted_path = "$" + for segment in path: + if isinstance(segment, int): + formatted_path += f"[{segment}]" + elif isinstance(segment, str): + formatted_path += f".{segment}" + else: # pragma: no cover - jsonschema paths are strings or integers + formatted_path += f"[{segment!r}]" + return formatted_path + + +def _sort_error_path(path: Sequence[object]) -> tuple[str, ...]: + return tuple(str(segment) for segment in path) + + +__all__ = ["DifyOutputContract", "DifyOutputLayer"] diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py new file mode 100644 index 0000000000..7ab78a5e4d --- /dev/null +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -0,0 +1,53 @@ +"""Public protocol exports shared by the Dify Agent server and clients.""" + +from .schemas import ( + DIFY_AGENT_MODEL_LAYER_ID, + DIFY_AGENT_OUTPUT_LAYER_ID, + RUN_EVENT_ADAPTER, + BaseRunEvent, + CreateRunRequest, + CreateRunResponse, + EmptyRunEventData, + LayerExitSignals, + PydanticAIStreamRunEvent, + RunEvent, + RunComposition, + RunEventType, + RunEventsResponse, + RunFailedEvent, + RunFailedEventData, + RunLayerSpec, + RunStartedEvent, + RunStatus, + RunStatusResponse, + RunSucceededEvent, + RunSucceededEventData, + normalize_composition, + utc_now, +) + +__all__ = [ + "BaseRunEvent", + "CreateRunRequest", + "CreateRunResponse", + "DIFY_AGENT_MODEL_LAYER_ID", + "DIFY_AGENT_OUTPUT_LAYER_ID", + "EmptyRunEventData", + "LayerExitSignals", + "PydanticAIStreamRunEvent", + "RUN_EVENT_ADAPTER", + "RunComposition", + "RunEvent", + "RunEventType", + "RunEventsResponse", + "RunFailedEvent", + "RunFailedEventData", + "RunLayerSpec", + "RunStartedEvent", + "RunStatus", + "RunStatusResponse", + "RunSucceededEvent", + "RunSucceededEventData", + "normalize_composition", + "utc_now", +] diff --git a/dify-agent/src/dify_agent/protocol/schemas.py b/dify-agent/src/dify_agent/protocol/schemas.py new file mode 100644 index 0000000000..430c4052a3 --- /dev/null +++ b/dify-agent/src/dify_agent/protocol/schemas.py @@ -0,0 +1,277 @@ +"""Public HTTP protocol schemas for the Dify Agent run API. + +This module is the shared wire contract for the FastAPI server, runtime event +producers, storage adapters, and Python client. Create-run requests expose a +Dify-friendly ``composition.layers[].config`` shape so callers can describe one +layer in one place; the server normalizes that public DTO into Agenton's +state-only ``CompositorConfig`` plus node-name keyed per-run configs before +calling ``Compositor.enter(configs=...)``. Session snapshots and ``on_exit`` stay +top-level because they are per-run resume state and exit policy, not graph node +definition. + +The server still constructs layers only from explicit provider type ids, keeping +HTTP input data-only and preventing unsafe import-path construction. Run events +are append-only records; Redis stream ids (or in-memory equivalents in tests) are +the public cursors used by polling and SSE replay. Event envelopes keep the +public ``id``/``run_id``/``type``/``data``/``created_at`` shape, while each +``type`` has a typed ``data`` model so OpenAPI, Redis replay, and clients parse +the same payload contract. Model/provider selection is part of the submitted +composition, not a top-level run field; the runtime reads the model layer named +by ``DIFY_AGENT_MODEL_LAYER_ID`` and the optional structured output layer named +by ``DIFY_AGENT_OUTPUT_LAYER_ID``. Request-level ``on_exit`` signals decide +whether each active layer is suspended or deleted when the run exits, with +suspend as the default so successful terminal events can include resumable +snapshots. Successful runs publish the final JSON-safe agent output and the +resumable Agenton session snapshot together on the terminal ``run_succeeded`` +event so consumers can treat terminal events as complete run summaries. Session +snapshots carry only layer lifecycle/runtime state in compositor order; they do +not persist output-layer config. Resumed structured-output runs therefore must +resubmit the same ``output`` layer in ``composition.layers[]`` so snapshot layer +name/order still matches the composition and the runtime can rebuild the same +structured output contract. +""" + +from datetime import datetime, timezone +from typing import Annotated, ClassVar, Final, Literal, TypeAlias + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter +from pydantic_ai.messages import AgentStreamEvent + +from agenton.compositor import CompositorConfig, CompositorSessionSnapshot, LayerConfigInput, LayerNodeConfig +from agenton.layers import ExitIntent + + +DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm" +DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output" +RunStatus = Literal["running", "succeeded", "failed"] +RunEventType = Literal[ + "run_started", + "pydantic_ai_event", + "run_succeeded", + "run_failed", +] + + +def utc_now() -> datetime: + """Return the timezone-aware timestamp format used by public schemas.""" + return datetime.now(timezone.utc) + + +class LayerExitSignals(BaseModel): + """Requested per-layer lifecycle behavior for the top-level ``on_exit`` field.""" + + default: ExitIntent = ExitIntent.SUSPEND + layers: dict[str, ExitIntent] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunLayerSpec(BaseModel): + """Public graph node plus per-run layer config for one Dify Agent layer. + + ``name``/``type``/``deps``/``metadata`` are normalized into Agenton's + provider-backed graph config. ``config`` is kept separate at the Agenton + boundary and passed to ``Compositor.enter(configs=...)`` keyed by ``name``; + existing layer config DTO instances are preserved so client code can stay + DTO-first without being forced into raw dictionaries. + """ + + name: str + type: str + deps: dict[str, str] = Field(default_factory=dict) + metadata: dict[str, JsonValue] = Field(default_factory=dict) + config: LayerConfigInput = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunComposition(BaseModel): + """Public create-run composition DTO. + + The public shape intentionally differs from Agenton's internal + ``CompositorConfig`` by carrying each layer's per-run config next to its graph + node fields. Use ``normalize_composition`` at server/runtime boundaries before + constructing a ``Compositor``. + """ + + schema_version: int = 1 + layers: list[RunLayerSpec] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class CreateRunRequest(BaseModel): + """Request body for creating one async agent run. + + Model/provider configuration must be supplied through the composition layer + named by ``DIFY_AGENT_MODEL_LAYER_ID``. Structured output may be supplied + through the optional composition layer named by + ``DIFY_AGENT_OUTPUT_LAYER_ID``. ``on_exit`` defaults every active layer to + suspend so callers receive a resumable success snapshot unless they + explicitly request delete for one or more layers. Session snapshots do not + preserve output-layer config, so resume requests that rely on structured + output must include the same ``output`` layer in ``composition.layers[]`` to + keep snapshot compatibility and rebuild the output schema. + """ + + composition: RunComposition + session_snapshot: CompositorSessionSnapshot | None = None + on_exit: LayerExitSignals = Field(default_factory=LayerExitSignals) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +def normalize_composition(composition: RunComposition) -> tuple[CompositorConfig, dict[str, LayerConfigInput]]: + """Split public Dify composition into Agenton's graph config and layer configs. + + Returns: + A ``CompositorConfig`` containing only graph fields and a node-name keyed + config mapping suitable for ``Compositor.enter(configs=...)``. + + The helper is the stable public-to-Agenton boundary: it preserves concrete + ``LayerConfig`` DTO inputs where possible, does not accept legacy + ``LayerNodeConfig(config=...)`` payloads, and keeps session snapshots plus + exit signals out of graph normalization. + """ + + graph_config = CompositorConfig( + schema_version=composition.schema_version, + layers=[ + LayerNodeConfig( + name=layer.name, + type=layer.type, + deps=dict(layer.deps), + metadata=dict(layer.metadata), + ) + for layer in composition.layers + ], + ) + layer_configs = {layer.name: layer.config for layer in composition.layers} + return graph_config, layer_configs + + +class CreateRunResponse(BaseModel): + """Response returned after a run has been persisted and scheduled locally.""" + + run_id: str + status: RunStatus + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunStatusResponse(BaseModel): + """Current server-side status for one run.""" + + run_id: str + status: RunStatus + created_at: datetime + updated_at: datetime + error: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class EmptyRunEventData(BaseModel): + """Typed empty payload for lifecycle events that carry no extra data.""" + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunSucceededEventData(BaseModel): + """Terminal success payload for final output and resumable session state.""" + + output: JsonValue + session_snapshot: CompositorSessionSnapshot + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunFailedEventData(BaseModel): + """Terminal failure payload shown to polling and SSE consumers.""" + + error: str + reason: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class BaseRunEvent(BaseModel): + """Shared append-only event envelope visible through polling and SSE.""" + + id: str | None = None + run_id: str + created_at: datetime = Field(default_factory=utc_now) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class RunStartedEvent(BaseRunEvent): + """Run lifecycle event emitted before runtime execution starts.""" + + type: Literal["run_started"] = "run_started" + data: EmptyRunEventData = Field(default_factory=EmptyRunEventData) + + +class PydanticAIStreamRunEvent(BaseRunEvent): + """Pydantic AI stream event using the upstream typed event model.""" + + type: Literal["pydantic_ai_event"] = "pydantic_ai_event" + data: AgentStreamEvent + + +class RunSucceededEvent(BaseRunEvent): + """Terminal success event carrying the complete successful run result.""" + + type: Literal["run_succeeded"] = "run_succeeded" + data: RunSucceededEventData + + +class RunFailedEvent(BaseRunEvent): + """Terminal failure event emitted before the run status becomes failed.""" + + type: Literal["run_failed"] = "run_failed" + data: RunFailedEventData + + +RunEvent: TypeAlias = Annotated[ + RunStartedEvent | PydanticAIStreamRunEvent | RunSucceededEvent | RunFailedEvent, + Field(discriminator="type"), +] +RUN_EVENT_ADAPTER: TypeAdapter[RunEvent] = TypeAdapter(RunEvent) + + +class RunEventsResponse(BaseModel): + """Cursor-paginated event log response.""" + + run_id: str + events: list[RunEvent] + next_cursor: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +__all__ = [ + "BaseRunEvent", + "CreateRunRequest", + "CreateRunResponse", + "DIFY_AGENT_MODEL_LAYER_ID", + "DIFY_AGENT_OUTPUT_LAYER_ID", + "EmptyRunEventData", + "LayerExitSignals", + "PydanticAIStreamRunEvent", + "RUN_EVENT_ADAPTER", + "RunComposition", + "RunEvent", + "RunEventType", + "RunEventsResponse", + "RunFailedEvent", + "RunFailedEventData", + "RunStartedEvent", + "RunStatus", + "RunStatusResponse", + "RunSucceededEvent", + "RunSucceededEventData", + "RunLayerSpec", + "normalize_composition", + "utc_now", +] diff --git a/dify-agent/src/dify_agent/runtime/agent_factory.py b/dify-agent/src/dify_agent/runtime/agent_factory.py new file mode 100644 index 0000000000..95c79f3086 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/agent_factory.py @@ -0,0 +1,52 @@ +"""Pydantic AI agent construction for models supplied by Agenton layers. + +The run request carries model/provider selection in the layer graph. This helper +keeps Agent construction details out of ``AgentRunRunner`` while accepting an +already resolved Pydantic AI model from the configured model layer. Prompt and +tool values arriving here are already transformed by Agenton's +``PYDANTIC_AI_TRANSFORMERS`` preset; this module registers those pydantic-ai +objects without reimplementing plain/pydantic-ai conversion logic. The caller +also passes the already resolved ``output_type`` so legacy text output and the +optional JSON Schema output layer share the same ``Agent`` construction path. +""" + +from collections.abc import Sequence +from typing import Any, cast + +from pydantic_ai import Agent +from pydantic_ai.messages import UserContent +from pydantic_ai.models import Model +from pydantic_ai.output import OutputSpec + +from agenton.layers.types import PydanticAIPrompt, PydanticAITool + + +def create_agent( + model: Model[Any], + *, + system_prompts: Sequence[PydanticAIPrompt[object]], + tools: Sequence[PydanticAITool[object]], + output_type: OutputSpec[object] = str, +) -> Agent[None, object]: + """Create the pydantic-ai agent for one run. + + ``output_type`` is resolved by the runtime after entering the Agenton run so + validation and execution both honor the same optional structured output + contract. For structured output runs the type inside ``output_type`` already + carries the Pydantic hooks needed for schema exposure and runtime validation, + so agent construction does not need to register a separate validator. + """ + agent = cast(Agent[None, object], Agent(model, output_type=output_type, tools=tools)) + for prompt in system_prompts: + _ = agent.system_prompt(cast(Any, prompt)) + return agent + + +def normalize_user_input(user_prompts: Sequence[UserContent]) -> str | Sequence[UserContent]: + """Return the pydantic-ai run input while preserving multi-part prompts.""" + if len(user_prompts) == 1 and isinstance(user_prompts[0], str): + return user_prompts[0] + return list(user_prompts) + + +__all__ = ["create_agent", "normalize_user_input"] diff --git a/dify-agent/src/dify_agent/runtime/agenton_validation.py b/dify-agent/src/dify_agent/runtime/agenton_validation.py new file mode 100644 index 0000000000..3522e16b08 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/agenton_validation.py @@ -0,0 +1,28 @@ +"""Shared validation helpers for Agenton-backed request boundaries. + +Most bad Dify Agent inputs surface from Agenton as ``KeyError``, ``TypeError``, +or ``ValueError`` while graph config, per-run layer config, and session snapshot +DTOs are being validated. One smaller class of request-shaped failures appears a +bit later, during ``Compositor.enter(...)`` before the body of the entered run +executes: session snapshots may contain lifecycle states such as ``CLOSED`` that +are serializable but not enterable. Agenton reports those as ``RuntimeError``. + +Dify Agent intentionally translates only these known enter-time runtime errors +into public request-validation errors. Other runtime failures still represent +execution bugs or infrastructure problems and must not be downgraded to client +input errors. +""" + +_ENTER_VALIDATION_RUNTIME_ERROR_FRAGMENTS = ( + "ACTIVE snapshots are not allowed.", + "CLOSED snapshots cannot be entered.", +) + + +def is_agenton_enter_validation_runtime_error(exc: RuntimeError) -> bool: + """Return whether ``exc`` is a known Agenton enter-time input failure.""" + message = str(exc) + return any(fragment in message for fragment in _ENTER_VALIDATION_RUNTIME_ERROR_FRAGMENTS) + + +__all__ = ["is_agenton_enter_validation_runtime_error"] diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py new file mode 100644 index 0000000000..4fc221f91c --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -0,0 +1,90 @@ +"""Safe Agenton compositor construction for API-submitted configs. + +Only explicitly allowed provider type ids are constructible here. The default +provider set contains prompt layers, the state-free Dify structured output +layer, plus Dify plugin LLM layers. Public DTOs provide tenant/plugin/model +data, while server-only plugin daemon settings are injected through the provider +factory for ``DifyPluginLayer``. The resulting ``Compositor`` remains Agenton +state-only: live resources such as the plugin daemon HTTP client are supplied +later by the runtime and never enter providers, layers, or session snapshots. +""" + +from collections.abc import Mapping, Sequence +from typing import Any, cast + +from pydantic_ai.messages import UserContent + +from agenton.compositor import Compositor, CompositorConfig, LayerProvider, LayerProviderInput +from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptTypes, PydanticAIPrompt, PydanticAITool +from agenton_collections.layers.plain.basic import PromptLayer +from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS +from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig +from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer +from dify_agent.layers.output.output_layer import DifyOutputLayer + + +type DifyAgentLayerProvider = LayerProvider[Any] + + +def create_default_layer_providers( + *, + plugin_daemon_url: str = "http://localhost:5002", + plugin_daemon_api_key: str = "", +) -> tuple[DifyAgentLayerProvider, ...]: + """Return the server provider set of safe config-constructible layers.""" + return ( + LayerProvider.from_layer_type(PromptLayer), + LayerProvider.from_layer_type(DifyOutputLayer), + LayerProvider.from_factory( + layer_type=DifyPluginLayer, + create=lambda config: DifyPluginLayer.from_config_with_settings( + DifyPluginLayerConfig.model_validate(config), + daemon_url=plugin_daemon_url, + daemon_api_key=plugin_daemon_api_key, + ), + ), + LayerProvider.from_layer_type(DifyPluginLLMLayer), + ) + + +def build_pydantic_ai_compositor( + config: CompositorConfig, + *, + providers: Sequence[LayerProviderInput], + node_providers: Mapping[str, LayerProviderInput] | None = None, +) -> Compositor[ + PydanticAIPrompt[object], + PydanticAITool[object], + AllPromptTypes, + AllToolTypes, + UserContent, + AllUserPromptTypes, +]: + """Build a Pydantic AI-ready compositor from a validated graph config. + + Prompt, user prompt, and tool conversion is delegated to Agenton's shared + pydantic-ai transformer preset so Dify Agent does not duplicate conversion + logic for plain and pydantic-ai layer families. Callers must pass the already + selected provider set explicitly so provider defaulting stays at outer runtime + boundaries rather than being duplicated here. + """ + return cast( + Compositor[ + PydanticAIPrompt[object], + PydanticAITool[object], + AllPromptTypes, + AllToolTypes, + UserContent, + AllUserPromptTypes, + ], + Compositor.from_config( + config, + providers=providers, + node_providers=node_providers, + **PYDANTIC_AI_TRANSFORMERS, # pyright: ignore[reportArgumentType] + ), + ) + + +__all__ = ["DifyAgentLayerProvider", "build_pydantic_ai_compositor", "create_default_layer_providers"] diff --git a/dify-agent/src/dify_agent/runtime/event_sink.py b/dify-agent/src/dify_agent/runtime/event_sink.py new file mode 100644 index 0000000000..6567189c69 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/event_sink.py @@ -0,0 +1,134 @@ +"""Event sink contracts used by the runner and storage adapters. + +The runner only needs append-only event writes and status transitions, so tests +can use ``InMemoryRunEventSink`` without Redis. Production storage implements the +same protocol with Redis streams in ``dify_agent.storage.redis_run_store``. The +terminal success helper writes the final JSON-safe output and session snapshot in +one event so event consumers can stop at ``run_succeeded`` without correlating +separate payload events. +""" + +from collections import defaultdict +from typing import Protocol + +from pydantic import JsonValue +from pydantic_ai.messages import AgentStreamEvent + +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol.schemas import ( + EmptyRunEventData, + PydanticAIStreamRunEvent, + RunEvent, + RunFailedEvent, + RunFailedEventData, + RunStartedEvent, + RunStatus, + RunSucceededEvent, + RunSucceededEventData, + utc_now, +) + + +class RunEventSink(Protocol): + """Boundary used by runtime code to publish observable run progress.""" + + async def append_event(self, event: RunEvent) -> str: + """Persist ``event`` and return its cursor id.""" + ... + + async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None: + """Persist the current run status.""" + ... + + +class InMemoryRunEventSink: + """Small async-compatible sink for local unit tests and examples.""" + + events: dict[str, list[RunEvent]] + statuses: dict[str, RunStatus] + errors: dict[str, str | None] + + def __init__(self) -> None: + self.events = defaultdict(list) + self.statuses = {} + self.errors = {} + + async def append_event(self, event: RunEvent) -> str: + """Store an event and assign a monotonic per-run cursor.""" + event_id = str(len(self.events[event.run_id]) + 1) + stored = event.model_copy(update={"id": event_id}) + self.events[event.run_id].append(stored) + return event_id + + async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None: + """Record the latest status; timestamps are owned by run stores.""" + self.statuses[run_id] = status + self.errors[run_id] = error + + +async def emit_run_event( + sink: RunEventSink, + *, + event: RunEvent, +) -> str: + """Append an already typed public run event.""" + return await sink.append_event(event) + + +async def emit_run_started(sink: RunEventSink, *, run_id: str) -> str: + """Emit the first lifecycle event for one run.""" + return await emit_run_event( + sink, + event=RunStartedEvent(run_id=run_id, data=EmptyRunEventData(), created_at=utc_now()), + ) + + +async def emit_pydantic_ai_event(sink: RunEventSink, *, run_id: str, data: AgentStreamEvent) -> str: + """Emit one typed Pydantic AI stream event.""" + return await emit_run_event( + sink, + event=PydanticAIStreamRunEvent(run_id=run_id, data=data, created_at=utc_now()), + ) + + +async def emit_run_succeeded( + sink: RunEventSink, + *, + run_id: str, + output: JsonValue, + session_snapshot: CompositorSessionSnapshot, +) -> str: + """Emit the terminal success event with output and resumable state.""" + return await emit_run_event( + sink, + event=RunSucceededEvent( + run_id=run_id, + data=RunSucceededEventData(output=output, session_snapshot=session_snapshot), + created_at=utc_now(), + ), + ) + + +async def emit_run_failed( + sink: RunEventSink, + *, + run_id: str, + error: str, + reason: str | None = None, +) -> str: + """Emit the terminal failure lifecycle event.""" + return await emit_run_event( + sink, + event=RunFailedEvent(run_id=run_id, data=RunFailedEventData(error=error, reason=reason), created_at=utc_now()), + ) + + +__all__ = [ + "InMemoryRunEventSink", + "RunEventSink", + "emit_pydantic_ai_event", + "emit_run_event", + "emit_run_failed", + "emit_run_started", + "emit_run_succeeded", +] diff --git a/dify-agent/src/dify_agent/runtime/layer_exit_signals.py b/dify-agent/src/dify_agent/runtime/layer_exit_signals.py new file mode 100644 index 0000000000..3636b89836 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/layer_exit_signals.py @@ -0,0 +1,46 @@ +"""Validation and application of request-level Agenton layer exit signals. + +HTTP requests carry data-only lifecycle intent in the top-level ``on_exit`` +field. The runtime validates signal keys against the built compositor before a +run is persisted or entered, then applies the resolved intent to the active +``CompositorRun`` after entry because Agenton initializes each run slot with a +delete-on-exit intent. +""" + +from typing import Any + +from agenton.compositor import Compositor, CompositorRun +from agenton.layers import ExitIntent +from dify_agent.protocol.schemas import LayerExitSignals + + +def validate_layer_exit_signals( + compositor: Compositor[Any, Any, Any, Any, Any, Any], + signals: LayerExitSignals, +) -> None: + """Raise ``ValueError`` when ``signals`` mention layers absent from ``compositor``.""" + known_layer_ids = {node.name for node in compositor.nodes} + unknown_layer_ids = set(signals.layers) - known_layer_ids + if not unknown_layer_ids: + return + + names = ", ".join(sorted(unknown_layer_ids)) + raise ValueError(f"on_exit.layers references unknown layer ids: {names}.") + + +def apply_layer_exit_signals( + run: CompositorRun[Any, Any, Any, Any, Any, Any], + signals: LayerExitSignals, +) -> None: + """Apply ``signals`` to active run slots for the current compositor entry.""" + for layer_id in run.slots: + intent = signals.layers.get(layer_id, signals.default) + if intent is ExitIntent.SUSPEND: + run.suspend_layer_on_exit(layer_id) + elif intent is ExitIntent.DELETE: + run.delete_layer_on_exit(layer_id) + else: + raise ValueError(f"Unsupported layer exit intent: {intent!r}.") + + +__all__ = ["apply_layer_exit_signals", "validate_layer_exit_signals"] diff --git a/dify-agent/src/dify_agent/runtime/output_type.py b/dify-agent/src/dify_agent/runtime/output_type.py new file mode 100644 index 0000000000..a8e12cfd6f --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/output_type.py @@ -0,0 +1,92 @@ +"""Validate and resolve the optional structured output layer. + +Dify Agent keeps structured output outside Agenton's prompt/tool aggregation. +That means the request boundary must validate the public composition shape +before entering Agenton, while the entered-run helper resolves a complete output +contract containing the model-facing output type. The type itself carries the +custom Pydantic hooks needed for schema exposure and runtime validation, so the +runtime does not need a separate validator callback. Missing output layers +preserve the legacy plain-text ``str`` output contract. +""" + +from typing import Protocol + +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID +from dify_agent.layers.output.output_layer import DifyOutputContract, DifyOutputLayer +from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID +from dify_agent.protocol.schemas import RunComposition + + +class SupportsOutputLayerLookup(Protocol): + """Minimal run surface needed to resolve the optional output layer.""" + + def get_layer(self, name: str, layer_type: type[DifyOutputLayer]) -> DifyOutputLayer: + """Return a typed layer instance or raise lookup/type errors.""" + ... + + +def validate_output_layer_composition(composition: RunComposition) -> None: + """Reject unsupported public output-layer graph shapes. + + The proposal reserves the node name ``output`` for the single supported + ``dify.output`` layer. Validating this directly from + ``CreateRunRequest.composition.layers`` keeps bad requests from silently + falling back to text output after entering Agenton. + + Raises: + ValueError: If more than one ``dify.output`` layer is declared or if the + single ``dify.output`` layer does not use the reserved node name + ``output``. + """ + output_layer_names = [layer.name for layer in composition.layers if layer.type == DIFY_OUTPUT_LAYER_TYPE_ID] + if not output_layer_names: + return + + if len(output_layer_names) > 1: + names = ", ".join(output_layer_names) + raise ValueError( + f"Only one '{DIFY_OUTPUT_LAYER_TYPE_ID}' layer is supported, named '{DIFY_AGENT_OUTPUT_LAYER_ID}'. " + f"Found layers: {names}." + ) + + output_layer_name = output_layer_names[0] + if output_layer_name != DIFY_AGENT_OUTPUT_LAYER_ID: + raise ValueError( + f"Layer type '{DIFY_OUTPUT_LAYER_TYPE_ID}' must use reserved layer name " + f"'{DIFY_AGENT_OUTPUT_LAYER_ID}', got '{output_layer_name}'." + ) + + +_TEXT_OUTPUT_CONTRACT = DifyOutputContract(output_type=str) + + +def resolve_run_output_contract(run: SupportsOutputLayerLookup) -> DifyOutputContract: + """Return the run's configured pydantic-ai output contract. + + When the conventionally named output layer is absent, the runtime keeps the + existing plain-text behavior by returning a contract whose ``output_type`` is + ``str``. This helper assumes the + public composition has already passed ``validate_output_layer_composition``; + its remaining responsibility is to type-check the reserved ``output`` slot + after compositor entry and build the validated output type from the layer + config. + + Raises: + TypeError: If a layer named ``output`` exists but is not a + ``DifyOutputLayer``. + ValueError: If the output layer exists but its JSON Schema cannot be + converted into a supported pydantic-ai structured output contract. + """ + try: + output_layer = run.get_layer(DIFY_AGENT_OUTPUT_LAYER_ID, DifyOutputLayer) + except KeyError: + return _TEXT_OUTPUT_CONTRACT + return output_layer.build_output_contract() + + +__all__ = [ + "DifyOutputContract", + "SupportsOutputLayerLookup", + "resolve_run_output_contract", + "validate_output_layer_composition", +] diff --git a/dify-agent/src/dify_agent/runtime/run_scheduler.py b/dify-agent/src/dify_agent/runtime/run_scheduler.py new file mode 100644 index 0000000000..8b1a5a2699 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/run_scheduler.py @@ -0,0 +1,206 @@ +"""In-process scheduling for Dify Agent runs. + +The scheduler is intentionally process-local: it persists a run record, starts an +``asyncio.Task`` for ``AgentRunRunner.run()``, and keeps only a transient active +task registry. Redis remains the durable source for status and event streams, but +there is no Redis job queue or cross-process handoff. If the process crashes, +currently active runs are lost until an external operator marks or retries them. +Create-run validation enters a lightweight Agenton run before persistence so the +same transformed user prompts, optional structured output contract, and +top-level ``on_exit`` policy used by execution are checked without relying on +removed session/control APIs; Dify's default layers keep lifecycle hooks +side-effect free so this validation does not open plugin daemon clients. +""" + +import asyncio +import logging +from collections.abc import Callable +from typing import Protocol + +import httpx + +from agenton.compositor import LayerProviderInput +from dify_agent.protocol.schemas import CreateRunRequest, normalize_composition +from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error +from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers +from dify_agent.runtime.event_sink import RunEventSink, emit_run_failed +from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals +from dify_agent.runtime.output_type import resolve_run_output_contract, validate_output_layer_composition +from dify_agent.runtime.runner import AgentRunRunner +from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt +from dify_agent.server.schemas import RunRecord + +logger = logging.getLogger(__name__) + + +class SchedulerStoppingError(RuntimeError): + """Raised when a create-run request arrives after shutdown has started.""" + + +class RunRequestValidationError(ValueError): + """Raised when a create-run request cannot produce an executable Agenton run.""" + + +class RunStore(RunEventSink, Protocol): + """Persistence boundary needed by the scheduler.""" + + async def create_run(self) -> RunRecord: + """Persist a new run record and return it with status ``running``.""" + ... + + +class RunnableRun(Protocol): + """Executable unit for one scheduled run.""" + + async def run(self) -> None: + """Run until terminal status/events have been written or cancellation occurs.""" + ... + + +type RunRunnerFactory = Callable[[RunRecord, CreateRunRequest], RunnableRun] + + +class RunScheduler: + """Owns process-local run tasks and best-effort graceful shutdown. + + ``active_tasks`` is mutated only on the event loop that calls ``create_run`` + and ``shutdown``. The task registry is not durable; it exists so the lifespan + hook can wait for in-flight work and mark cancelled runs failed before Redis is + closed. A lock guards the stopping flag, lightweight request validation, run + persistence, and task registration so shutdown cannot begin after a request is + admitted and no validation runs once stopping has been set. + """ + + store: RunStore + shutdown_grace_seconds: float + active_tasks: dict[str, asyncio.Task[None]] + stopping: bool + runner_factory: RunRunnerFactory + layer_providers: tuple[LayerProviderInput, ...] + plugin_daemon_http_client: httpx.AsyncClient + _lifecycle_lock: asyncio.Lock + + def __init__( + self, + *, + store: RunStore, + plugin_daemon_http_client: httpx.AsyncClient, + shutdown_grace_seconds: float = 30, + layer_providers: tuple[LayerProviderInput, ...] | None = None, + runner_factory: RunRunnerFactory | None = None, + ) -> None: + self.store = store + self.shutdown_grace_seconds = shutdown_grace_seconds + self.active_tasks = {} + self.stopping = False + self.plugin_daemon_http_client = plugin_daemon_http_client + self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() + self.runner_factory = runner_factory or self._default_runner_factory + self._lifecycle_lock = asyncio.Lock() + + async def create_run(self, request: CreateRunRequest) -> RunRecord: + """Validate, persist, and schedule one run in the current process. + + The returned record is already ``running``. The background task is removed + from ``active_tasks`` when it finishes, regardless of success or failure. + """ + async with self._lifecycle_lock: + if self.stopping: + raise SchedulerStoppingError("run scheduler is shutting down") + await validate_run_request(request, layer_providers=self.layer_providers) + record = await self.store.create_run() + task = asyncio.create_task(self._run_record(record, request), name=f"dify-agent-run-{record.run_id}") + self.active_tasks[record.run_id] = task + task.add_done_callback(lambda _task, run_id=record.run_id: self.active_tasks.pop(run_id, None)) + return record + + async def shutdown(self) -> None: + """Stop accepting runs, wait briefly, then cancel and fail unfinished runs.""" + async with self._lifecycle_lock: + self.stopping = True + if not self.active_tasks: + return + tasks_by_run_id = dict(self.active_tasks) + done, pending = await asyncio.wait(tasks_by_run_id.values(), timeout=self.shutdown_grace_seconds) + del done + if not pending: + return + + pending_run_ids = [run_id for run_id, task in tasks_by_run_id.items() if task in pending] + for task in pending: + _ = task.cancel() + _ = await asyncio.gather(*pending, return_exceptions=True) + for run_id in pending_run_ids: + await self._mark_cancelled_run_failed(run_id) + + async def _run_record(self, record: RunRecord, request: CreateRunRequest) -> None: + """Execute a stored run and log failures already reflected in events.""" + try: + await self.runner_factory(record, request).run() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("scheduled run failed", extra={"run_id": record.run_id}) + + def _default_runner_factory(self, record: RunRecord, request: CreateRunRequest) -> RunnableRun: + """Create the production runner for a stored run record.""" + return AgentRunRunner( + sink=self.store, + request=request, + run_id=record.run_id, + plugin_daemon_http_client=self.plugin_daemon_http_client, + layer_providers=self.layer_providers, + ) + + async def _mark_cancelled_run_failed(self, run_id: str) -> None: + """Best-effort failure event/status for shutdown-cancelled runs.""" + message = "run cancelled during server shutdown" + try: + _ = await emit_run_failed(self.store, run_id=run_id, error=message, reason="shutdown") + await self.store.update_status(run_id, "failed", message) + except Exception: + logger.exception("failed to mark cancelled run failed", extra={"run_id": run_id}) + + +async def validate_run_request( + request: CreateRunRequest, + *, + layer_providers: tuple[LayerProviderInput, ...] | None = None, +) -> None: + """Validate create-run semantics that require an entered Agenton run. + + This boundary rejects unsupported output-layer graph shapes, unknown + ``on_exit`` layer ids, effectively empty transformed user prompts, and known + enter-time snapshot lifecycle errors before the scheduler persists a run + record. It also exercises provider config validation, structured output + contract construction, and snapshot hydration without touching external + services because Dify plugin daemon clients are owned by the FastAPI + lifespan, not Agenton lifecycle hooks. + """ + resolved_layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() + entered_run = False + try: + validate_output_layer_composition(request.composition) + graph_config, layer_configs = normalize_composition(request.composition) + compositor = build_pydantic_ai_compositor( + graph_config, + providers=resolved_layer_providers, + ) + validate_layer_exit_signals(compositor, request.on_exit) + async with compositor.enter(configs=layer_configs, session_snapshot=request.session_snapshot) as run: + entered_run = True + apply_layer_exit_signals(run, request.on_exit) + if not has_non_blank_user_prompt(run.user_prompts): + raise RunRequestValidationError(EMPTY_USER_PROMPTS_ERROR) + _ = resolve_run_output_contract(run) + except RunRequestValidationError: + raise + except RuntimeError as exc: + if not entered_run and is_agenton_enter_validation_runtime_error(exc): + raise RunRequestValidationError(str(exc)) from exc + raise + except (KeyError, TypeError, ValueError) as exc: + raise RunRequestValidationError(str(exc)) from exc + + +__all__ = ["RunRequestValidationError", "RunScheduler", "SchedulerStoppingError", "validate_run_request"] diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py new file mode 100644 index 0000000000..becb538e7b --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -0,0 +1,164 @@ +"""Runtime execution for one scheduled Dify Agent run. + +The runner is storage-agnostic: it normalizes the public Dify composition into +Agenton's graph/config split, enters a fresh ``CompositorRun`` (or resumes one +from a snapshot), runs pydantic-ai with ``run.user_prompts`` as the user input, +emits stream events, applies request-level ``on_exit`` signals, and then +publishes a terminal success or failure event. The Pydantic AI model is resolved +from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID``. An +optional structured output layer named by ``DIFY_AGENT_OUTPUT_LAYER_ID`` is read +after entry and resolved into an output contract whose type both exposes the +output schema to the model and performs runtime JSON Schema validation through +custom Pydantic hooks. Invalid structured outputs therefore trigger Pydantic +AI's normal output-validation retry behavior before Dify Agent emits +``run_succeeded``. Layers still never own the FastAPI lifespan-owned plugin +daemon HTTP client. Successful terminal events contain both the JSON-safe final +output and session snapshot; there are no separate output or snapshot events to +correlate. +""" + +from collections.abc import AsyncIterable +from typing import cast + +import httpx +from pydantic import JsonValue, TypeAdapter +from pydantic_ai.messages import AgentStreamEvent + +from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput +from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.protocol.schemas import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, normalize_composition +from dify_agent.runtime.agent_factory import create_agent, normalize_user_input +from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error +from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers +from dify_agent.runtime.event_sink import ( + RunEventSink, + emit_pydantic_ai_event, + emit_run_failed, + emit_run_started, + emit_run_succeeded, +) +from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals +from dify_agent.runtime.output_type import resolve_run_output_contract, validate_output_layer_composition +from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt + + +_AGENT_OUTPUT_ADAPTER = TypeAdapter(object) + + +class AgentRunValidationError(ValueError): + """Raised when a run request is valid JSON but cannot execute.""" + + +class AgentRunRunner: + """Executes one run and writes only public run events to its sink.""" + + sink: RunEventSink + + request: CreateRunRequest + run_id: str + layer_providers: tuple[LayerProviderInput, ...] + plugin_daemon_http_client: httpx.AsyncClient + + def __init__( + self, + *, + sink: RunEventSink, + request: CreateRunRequest, + run_id: str, + plugin_daemon_http_client: httpx.AsyncClient, + layer_providers: tuple[LayerProviderInput, ...] | None = None, + ) -> None: + self.sink = sink + self.request = request + self.run_id = run_id + self.plugin_daemon_http_client = plugin_daemon_http_client + self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() + + async def run(self) -> None: + """Execute the run and emit the documented event sequence.""" + await self.sink.update_status(self.run_id, "running") + _ = await emit_run_started(self.sink, run_id=self.run_id) + + try: + output, session_snapshot = await self._run_agent() + except Exception as exc: + message = str(exc) or type(exc).__name__ + _ = await emit_run_failed(self.sink, run_id=self.run_id, error=message) + await self.sink.update_status(self.run_id, "failed", message) + raise + + _ = await emit_run_succeeded( + self.sink, + run_id=self.run_id, + output=output, + session_snapshot=session_snapshot, + ) + await self.sink.update_status(self.run_id, "succeeded") + + async def _run_agent(self) -> tuple[JsonValue, CompositorSessionSnapshot]: + """Run pydantic-ai inside an entered Agenton run. + + Known input-shaped Agenton enter-time runtime errors, such as trying to + resume a ``CLOSED`` snapshot layer, are normalized to + ``AgentRunValidationError``. Output-layer graph invariants are validated + from the public composition before entering Agenton so misnamed or extra + ``dify.output`` layers never silently degrade to text output. Later + runtime failures still propagate as execution errors so they become + terminal failed runs rather than client validation responses. Structured + output uses a resolved contract whose type itself encodes both the + model-facing schema and the runtime validation hooks, so invalid model + outputs can be corrected before Dify Agent emits success. + """ + try: + validate_output_layer_composition(self.request.composition) + graph_config, layer_configs = normalize_composition(self.request.composition) + compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers) + validate_layer_exit_signals(compositor, self.request.on_exit) + except (KeyError, TypeError, ValueError) as exc: + raise AgentRunValidationError(str(exc)) from exc + + entered_run = False + try: + async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run: + entered_run = True + apply_layer_exit_signals(run, self.request.on_exit) + user_prompts = run.user_prompts + if not has_non_blank_user_prompt(user_prompts): + raise AgentRunValidationError(EMPTY_USER_PROMPTS_ERROR) + + async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None: + async for event in events: + _ = await emit_pydantic_ai_event(self.sink, run_id=self.run_id, data=event) + + try: + output_contract = resolve_run_output_contract(run) + llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer) + model = llm_layer.get_model(http_client=self.plugin_daemon_http_client) + except (KeyError, TypeError, RuntimeError, ValueError) as exc: + raise AgentRunValidationError(str(exc)) from exc + + agent = create_agent( + model, + system_prompts=run.prompts, + tools=run.tools, + output_type=output_contract.output_type, + ) + result = await agent.run(normalize_user_input(user_prompts), event_stream_handler=handle_events) + output = _serialize_agent_output(result.output) + except RuntimeError as exc: + if not entered_run and is_agenton_enter_validation_runtime_error(exc): + raise AgentRunValidationError(str(exc)) from exc + raise + + if run.session_snapshot is None: + raise RuntimeError("Agenton run did not produce a session snapshot after exit.") + + return output, run.session_snapshot + + +def _serialize_agent_output(output: object) -> JsonValue: + """Convert arbitrary pydantic-ai output into the public JSON-safe payload type.""" + return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json")) + + +__all__ = ["AgentRunRunner", "AgentRunValidationError"] diff --git a/dify-agent/src/dify_agent/runtime/user_prompt_validation.py b/dify-agent/src/dify_agent/runtime/user_prompt_validation.py new file mode 100644 index 0000000000..8e8602c864 --- /dev/null +++ b/dify-agent/src/dify_agent/runtime/user_prompt_validation.py @@ -0,0 +1,29 @@ +"""Validation for effective user prompts produced by Agenton runs. + +Validation happens after safe compositor construction and run entry so scheduler +and runner paths use the same transformed prompts as the actual pydantic-ai +input. Blank string fragments do not count as meaningful input; non-string +``UserContent`` is treated as intentional content because rich media/message +parts do not have a universal whitespace representation. +""" + +from collections.abc import Sequence + +from pydantic_ai.messages import UserContent + + +EMPTY_USER_PROMPTS_ERROR = "run.user_prompts must not be empty" + + +def has_non_blank_user_prompt(user_prompts: Sequence[UserContent]) -> bool: + """Return whether composed user prompts contain meaningful input.""" + for prompt in user_prompts: + if isinstance(prompt, str): + if prompt.strip(): + return True + else: + return True + return False + + +__all__ = ["EMPTY_USER_PROMPTS_ERROR", "has_non_blank_user_prompt"] diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py new file mode 100644 index 0000000000..9b5cf2a9c4 --- /dev/null +++ b/dify-agent/src/dify_agent/server/app.py @@ -0,0 +1,97 @@ +"""FastAPI application factory for the Dify Agent run server. + +The HTTP process owns Redis clients, one shared plugin daemon ``httpx.AsyncClient``, +route wiring, and a process-local scheduler. Run execution happens in background +``asyncio`` tasks rather than request handlers, so client disconnects do not +cancel the agent runtime. Redis persists run records and per-run event streams +with configured retention only; it is not used as a job queue. Agenton layers and +providers stay state-only: they borrow the lifespan-owned plugin daemon client +through the runner and never create or close it themselves. +""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +import httpx +from fastapi import FastAPI +from redis.asyncio import Redis + +from dify_agent.runtime.compositor_factory import create_default_layer_providers +from dify_agent.runtime.run_scheduler import RunScheduler +from dify_agent.server.routes.runs import create_runs_router +from dify_agent.server.settings import ServerSettings +from dify_agent.storage.redis_run_store import RedisRunStore + + +def create_app(settings: ServerSettings | None = None) -> FastAPI: + """Build the FastAPI app with one shared Redis store and local scheduler.""" + resolved_settings = settings or ServerSettings() + layer_providers = create_default_layer_providers( + plugin_daemon_url=resolved_settings.plugin_daemon_url, + plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key, + ) + state: dict[str, object] = {} + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: + redis = Redis.from_url(resolved_settings.redis_url) + plugin_daemon_http_client = create_plugin_daemon_http_client(resolved_settings) + store = RedisRunStore( + redis, + prefix=resolved_settings.redis_prefix, + run_retention_seconds=resolved_settings.run_retention_seconds, + ) + scheduler = RunScheduler( + store=store, + plugin_daemon_http_client=plugin_daemon_http_client, + shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds, + layer_providers=layer_providers, + ) + state["store"] = store + state["scheduler"] = scheduler + try: + yield + finally: + await scheduler.shutdown() + await plugin_daemon_http_client.aclose() + await redis.aclose() + + app = FastAPI(title="Dify Agent Run Server", version="0.1.0", lifespan=lifespan) + + def get_store() -> RedisRunStore: + return state["store"] # pyright: ignore[reportReturnType] + + def get_scheduler() -> RunScheduler: + return state["scheduler"] # pyright: ignore[reportReturnType] + + app.include_router(create_runs_router(get_store, get_scheduler)) + return app + + +def create_plugin_daemon_http_client(settings: ServerSettings) -> httpx.AsyncClient: + """Create the lifespan-owned plugin daemon HTTP client with configured limits. + + The returned client is shared by all local background runs in this FastAPI + process and must be closed by the app lifespan after the scheduler has stopped + using it. + """ + return httpx.AsyncClient( + timeout=httpx.Timeout( + connect=settings.plugin_daemon_connect_timeout, + read=settings.plugin_daemon_read_timeout, + write=settings.plugin_daemon_write_timeout, + pool=settings.plugin_daemon_pool_timeout, + ), + limits=httpx.Limits( + max_connections=settings.plugin_daemon_max_connections, + max_keepalive_connections=settings.plugin_daemon_max_keepalive_connections, + keepalive_expiry=settings.plugin_daemon_keepalive_expiry, + ), + trust_env=False, + ) + + +app = create_app() + + +__all__ = ["app", "create_app", "create_plugin_daemon_http_client"] diff --git a/dify-agent/src/dify_agent/server/routes/runs.py b/dify-agent/src/dify_agent/server/routes/runs.py new file mode 100644 index 0000000000..9375b1f5b7 --- /dev/null +++ b/dify-agent/src/dify_agent/server/routes/runs.py @@ -0,0 +1,92 @@ +"""FastAPI routes for asynchronous agent runs. + +Controllers translate known validation and shutdown errors into HTTP status codes. +Unexpected scheduler or storage failures are intentionally left for FastAPI's +server-error handling so infrastructure problems are not reported as client input +errors. Created runs are scheduled in the current process and observed through +status polling or SSE replay backed by Redis event streams. +""" + +from collections.abc import Callable +from typing import Annotated + +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from fastapi.responses import StreamingResponse + +from dify_agent.protocol.schemas import CreateRunRequest, CreateRunResponse, RunEventsResponse, RunStatusResponse +from dify_agent.runtime.run_scheduler import RunRequestValidationError, RunScheduler, SchedulerStoppingError +from dify_agent.server.sse import sse_event_stream +from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError + + +def create_runs_router( + get_store: Callable[[], RedisRunStore], + get_scheduler: Callable[[], RunScheduler], +) -> APIRouter: + """Create routes bound to the application's store dependency provider.""" + router = APIRouter(prefix="/runs", tags=["runs"]) + + async def store_dep() -> RedisRunStore: + return get_store() + + async def scheduler_dep() -> RunScheduler: + return get_scheduler() + + @router.post("", response_model=CreateRunResponse, status_code=202) + async def create_run( + request: CreateRunRequest, + scheduler: Annotated[RunScheduler, Depends(scheduler_dep)], + ) -> CreateRunResponse: + try: + record = await scheduler.create_run(request) + except RunRequestValidationError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except SchedulerStoppingError as exc: + raise HTTPException(status_code=503, detail="run scheduler is shutting down") from exc + return CreateRunResponse(run_id=record.run_id, status=record.status) + + @router.get("/{run_id}", response_model=RunStatusResponse) + async def get_run_status(run_id: str, store: Annotated[RedisRunStore, Depends(store_dep)]) -> RunStatusResponse: + try: + record = await store.get_run(run_id) + except RunNotFoundError as exc: + raise HTTPException(status_code=404, detail="run not found") from exc + return RunStatusResponse( + run_id=record.run_id, + status=record.status, + created_at=record.created_at, + updated_at=record.updated_at, + error=record.error, + ) + + @router.get("/{run_id}/events", response_model=RunEventsResponse) + async def get_run_events( + run_id: str, + store: Annotated[RedisRunStore, Depends(store_dep)], + after: str = Query(default="0-0"), + limit: int = Query(default=100, ge=1, le=500), + ) -> RunEventsResponse: + try: + return await store.get_events(run_id, after=after, limit=limit) + except RunNotFoundError as exc: + raise HTTPException(status_code=404, detail="run not found") from exc + + @router.get("/{run_id}/events/sse") + async def stream_run_events( + run_id: str, + store: Annotated[RedisRunStore, Depends(store_dep)], + last_event_id: Annotated[str | None, Header(alias="Last-Event-ID")] = None, + after: str | None = Query(default=None), + ) -> StreamingResponse: + cursor = after or last_event_id or "0-0" + try: + _ = await store.get_run(run_id) + events = store.iter_events(run_id, after=cursor) + return StreamingResponse(sse_event_stream(events), media_type="text/event-stream") + except RunNotFoundError as exc: + raise HTTPException(status_code=404, detail="run not found") from exc + + return router + + +__all__ = ["create_runs_router"] diff --git a/dify-agent/src/dify_agent/server/schemas.py b/dify-agent/src/dify_agent/server/schemas.py new file mode 100644 index 0000000000..21e8e624a0 --- /dev/null +++ b/dify-agent/src/dify_agent/server/schemas.py @@ -0,0 +1,48 @@ +"""Server-only schemas and helpers for persisted run records. + +Public HTTP DTOs and run events live in ``dify_agent.protocol.schemas`` and are +intentionally not re-exported here. Keeping this module server-only prevents old +imports from silently depending on implementation modules while preserving the +internal ``RunRecord`` model used by schedulers and Redis storage. +""" + +from datetime import datetime +from typing import ClassVar +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from dify_agent.protocol import schemas as _protocol_schemas + + +def new_run_id() -> str: + """Return a stable external run id for newly persisted server records.""" + return str(uuid4()) + + +class RunRecord(BaseModel): + """Internal representation persisted for status reads. + + Only status metadata is persisted. Create-run requests can contain model + credentials in layer config and must remain in scheduler memory rather than + being written to Redis. + """ + + run_id: str + status: _protocol_schemas.RunStatus + created_at: datetime = Field(default_factory=_protocol_schemas.utc_now) + updated_at: datetime = Field(default_factory=_protocol_schemas.utc_now) + error: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @field_validator("updated_at") + @classmethod + def updated_at_must_be_timezone_aware(cls, value: datetime) -> datetime: + """Reject naive timestamps before they become JSON API values.""" + if value.tzinfo is None: + raise ValueError("updated_at must be timezone-aware") + return value + + +__all__ = ["RunRecord", "new_run_id"] diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py new file mode 100644 index 0000000000..18c0ce7747 --- /dev/null +++ b/dify-agent/src/dify_agent/server/settings.py @@ -0,0 +1,41 @@ +"""Configuration for the FastAPI run server. + +Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned +``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do +not own that client, so these settings are process resource limits rather than +per-run lifecycle knobs. +""" + +from typing import ClassVar + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60 + + +class ServerSettings(BaseSettings): + """Environment-backed settings for Redis, scheduling, and plugin daemon access.""" + + redis_url: str = "redis://localhost:6379/0" + redis_prefix: str = "dify-agent" + shutdown_grace_seconds: float = 30 + run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1) + plugin_daemon_url: str = "http://localhost:5002" + plugin_daemon_api_key: str = "" + plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0) + plugin_daemon_read_timeout: float = Field(default=600.0, ge=0) + plugin_daemon_write_timeout: float = Field(default=30.0, ge=0) + plugin_daemon_pool_timeout: float = Field(default=10.0, ge=0) + plugin_daemon_max_connections: int = Field(default=100, ge=1) + plugin_daemon_max_keepalive_connections: int = Field(default=20, ge=0) + plugin_daemon_keepalive_expiry: float = Field(default=30.0, ge=0) + + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( + env_prefix="DIFY_AGENT_", + env_file=(".env", "dify-agent/.env"), + extra="ignore", + ) + + +__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"] diff --git a/dify-agent/src/dify_agent/server/sse.py b/dify-agent/src/dify_agent/server/sse.py new file mode 100644 index 0000000000..72a880ab0f --- /dev/null +++ b/dify-agent/src/dify_agent/server/sse.py @@ -0,0 +1,29 @@ +"""Server-sent event formatting for run event replay. + +SSE frames use the run event id as ``id`` and the run event type as ``event`` so +browsers can resume with ``Last-Event-ID`` while clients can subscribe by event +name. Payload data is the full public ``RunEvent`` JSON object. +""" + +from collections.abc import AsyncIterable, AsyncIterator + +from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, RunEvent + + +def format_sse_event(event: RunEvent) -> str: + """Serialize one event as an SSE frame.""" + lines: list[str] = [] + if event.id is not None: + lines.append(f"id: {event.id}") + lines.append(f"event: {event.type}") + lines.append(f"data: {RUN_EVENT_ADAPTER.dump_json(event).decode()}") + return "\n".join(lines) + "\n\n" + + +async def sse_event_stream(events: AsyncIterable[RunEvent]) -> AsyncIterator[str]: + """Yield formatted SSE frames from public run events.""" + async for event in events: + yield format_sse_event(event) + + +__all__ = ["format_sse_event", "sse_event_stream"] diff --git a/dify-agent/src/dify_agent/storage/redis_keys.py b/dify-agent/src/dify_agent/storage/redis_keys.py new file mode 100644 index 0000000000..c93e69ba82 --- /dev/null +++ b/dify-agent/src/dify_agent/storage/redis_keys.py @@ -0,0 +1,14 @@ +"""Redis key helpers for run records and per-run event streams.""" + + +def run_record_key(prefix: str, run_id: str) -> str: + """Return the Redis string key holding one serialized run record.""" + return f"{prefix}:runs:{run_id}:record" + + +def run_events_key(prefix: str, run_id: str) -> str: + """Return the Redis stream key holding one run's event log.""" + return f"{prefix}:runs:{run_id}:events" + + +__all__ = ["run_events_key", "run_record_key"] diff --git a/dify-agent/src/dify_agent/storage/redis_run_store.py b/dify-agent/src/dify_agent/storage/redis_run_store.py new file mode 100644 index 0000000000..72091dcc1e --- /dev/null +++ b/dify-agent/src/dify_agent/storage/redis_run_store.py @@ -0,0 +1,137 @@ +"""Redis-backed run records and per-run event streams. + +The store writes status-only run records as JSON strings and events as Redis +streams. HTTP event cursors are Redis stream ids; ``0-0`` means replay from the +beginning for polling and SSE. Records and streams share one retention window +that is refreshed when status or event data is written. Execution is scheduled +in-process by ``dify_agent.runtime.run_scheduler``; Redis is not a job queue, and +create-run payloads are never persisted because layer config may include model +credentials. +""" + +from collections.abc import AsyncIterator +from typing import cast + +from redis.asyncio import Redis + +from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, RunEvent, RunEventsResponse, RunStatus, utc_now +from dify_agent.runtime.event_sink import RunEventSink +from dify_agent.server.schemas import RunRecord, new_run_id +from dify_agent.server.settings import DEFAULT_RUN_RETENTION_SECONDS +from dify_agent.storage.redis_keys import run_events_key, run_record_key + + +class RunNotFoundError(LookupError): + """Raised when a requested run record does not exist.""" + + +class RedisRunStore(RunEventSink): + """Async Redis implementation for run records and event logs. + + ``run_retention_seconds`` is applied to both the run record key and the + per-run Redis stream. Event writes also refresh the record TTL so long-running + runs that keep producing events do not lose their status record mid-run. + """ + + redis: Redis + prefix: str + run_retention_seconds: int + + def __init__( + self, + redis: Redis, + *, + prefix: str = "dify-agent", + run_retention_seconds: int = DEFAULT_RUN_RETENTION_SECONDS, + ) -> None: + if run_retention_seconds <= 0: + raise ValueError("run_retention_seconds must be positive") + self.redis = redis + self.prefix = prefix + self.run_retention_seconds = run_retention_seconds + + async def create_run(self) -> RunRecord: + """Persist a running run record without storing the create request.""" + run_id = new_run_id() + record = RunRecord(run_id=run_id, status="running") + await self.redis.set( + run_record_key(self.prefix, run_id), + record.model_dump_json(), + ex=self.run_retention_seconds, + ) + return record + + async def get_run(self, run_id: str) -> RunRecord: + """Return one run record or raise ``RunNotFoundError``.""" + value = await self.redis.get(run_record_key(self.prefix, run_id)) + if value is None: + raise RunNotFoundError(run_id) + if isinstance(value, bytes): + value = value.decode() + return RunRecord.model_validate_json(value) + + async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None: + """Update the status fields of an existing run record.""" + record = await self.get_run(run_id) + updated = record.model_copy(update={"status": status, "updated_at": utc_now(), "error": error}) + await self.redis.set( + run_record_key(self.prefix, run_id), + updated.model_dump_json(), + ex=self.run_retention_seconds, + ) + + async def append_event(self, event: RunEvent) -> str: + """Append an event JSON payload to the run's Redis stream.""" + events_key = run_events_key(self.prefix, event.run_id) + payload = RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode() + event_id = await self.redis.xadd( + events_key, + {"payload": payload}, + ) + await self.redis.expire(events_key, self.run_retention_seconds) + await self.redis.expire(run_record_key(self.prefix, event.run_id), self.run_retention_seconds) + return event_id.decode() if isinstance(event_id, bytes) else str(event_id) + + async def get_events(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse: + """Read a bounded page of events after ``after`` cursor.""" + await self.get_run(run_id) + raw_events = await self.redis.xrange(run_events_key(self.prefix, run_id), min=f"({after}", count=limit) + events = [self._decode_event(run_id, raw_id, fields) for raw_id, fields in raw_events] + next_cursor = events[-1].id if events else after + return RunEventsResponse(run_id=run_id, events=events, next_cursor=next_cursor) + + async def iter_events(self, run_id: str, *, after: str = "0-0") -> AsyncIterator[RunEvent]: + """Yield replayed and future events for SSE clients.""" + await self.get_run(run_id) + cursor = after + while True: + page = await self.get_events(run_id, after=cursor, limit=100) + for event in page.events: + if event.id is not None: + cursor = event.id + yield event + if not page.events: + break + while True: + response = await self.redis.xread({run_events_key(self.prefix, run_id): cursor}, block=30_000, count=100) + if not response: + continue + for _stream_name, entries in response: + for raw_id, fields in entries: + event = self._decode_event(run_id, raw_id, fields) + if event.id is not None: + cursor = event.id + yield event + + @staticmethod + def _decode_event(run_id: str, raw_id: object, fields: dict[object, object]) -> RunEvent: + """Decode one Redis stream entry into a public event.""" + payload = fields.get(b"payload") or fields.get("payload") + if isinstance(payload, bytes): + payload = payload.decode() + event_id = raw_id.decode() if isinstance(raw_id, bytes) else str(raw_id) + event = RUN_EVENT_ADAPTER.validate_json(cast(str, payload)) + return event.model_copy(update={"id": event_id, "run_id": run_id}) + + +__all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "RedisRunStore", "RunNotFoundError"] diff --git a/dify-agent/tests/__init__.py b/dify-agent/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/docs/test_examples.py b/dify-agent/tests/docs/test_examples.py new file mode 100644 index 0000000000..83280724b2 --- /dev/null +++ b/dify-agent/tests/docs/test_examples.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +from _pytest.mark import ParameterSet +from pytest_examples import CodeExample, EvalExample, find_examples +from pytest_examples.config import ExamplesConfig as BaseExamplesConfig + + +@dataclass +class ExamplesConfig(BaseExamplesConfig): + known_first_party: list[str] = field(default_factory=list[str]) + + def ruff_config(self) -> tuple[str, ...]: + config = super().ruff_config() + if self.known_first_party: + config = (*config, "--config", f"lint.isort.known-first-party = {self.known_first_party}") + return config + + +def find_doc_examples() -> Iterable[ParameterSet]: + root_dir = Path(__file__).resolve().parents[2] + for example in find_examples( + root_dir / "docs", + root_dir / "src", + root_dir / "examples" / "agenton", + root_dir / "examples" / "dify_agent", + ): + path = example.path.relative_to(root_dir) + yield pytest.param(example, id=f"{path}:{example.start_line}") + + +@pytest.mark.parametrize("example", find_doc_examples()) +def test_documentation_examples(example: CodeExample, eval_example: EvalExample) -> None: + prefix_settings = example.prefix_settings() + opt_test = prefix_settings.get("test", "") + opt_lint = prefix_settings.get("lint", "") + line_length = int(prefix_settings.get("line_length", "120")) + + eval_example.config = ExamplesConfig( + ruff_ignore=["D", "Q001"], + target_version="py312", # pyright: ignore[reportArgumentType] + line_length=line_length, + isort=True, + upgrade=True, + quotes="double", + known_first_party=["agenton", "agenton_collections", "dify_agent"], + ) + + if not opt_lint.startswith("skip"): + if eval_example.update_examples: # pragma: no cover + eval_example.format_ruff(example) + else: + eval_example.lint_ruff(example) + + if opt_test.startswith("skip"): + pytest.skip(opt_test[4:].lstrip(" -") or "running code skipped") + + if eval_example.update_examples: # pragma: no cover + eval_example.run_print_update(example, module_globals={"__name__": "__main__"}) + else: + eval_example.run_print_check(example, module_globals={"__name__": "__main__"}) diff --git a/dify-agent/tests/docs/test_snippets.py b/dify-agent/tests/docs/test_snippets.py new file mode 100644 index 0000000000..89428323d0 --- /dev/null +++ b/dify-agent/tests/docs/test_snippets.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +HOOKS_DIR = PROJECT_ROOT / "docs" / ".hooks" +sys.path.append(str(HOOKS_DIR)) + +from snippets import inject_snippets, parse_file_sections, parse_snippet_directive # pyright: ignore[reportMissingImports] # noqa: E402 + + +def test_parse_snippet_directive() -> None: + directive = parse_snippet_directive('```snippet {path="demo.py" fragment="main" hl="1"}\n```') + + assert directive is not None + assert directive.path == "demo.py" + assert directive.fragment == "main" + assert directive.extra_attrs == {"hl": "1"} + + +def test_parse_file_sections_and_inject_snippet(tmp_path: Path) -> None: + source = tmp_path / "demo.py" + source.write_text( + """import asyncio + +### [main] +async def main() -> None: + print("hello") +### [/main] + +if __name__ == "__main__": + asyncio.run(main()) +""", + encoding="utf-8", + ) + + parsed = parse_file_sections(source) + assert "main" in parsed.sections + + markdown = '```snippet {path="/examples/agenton/agenton_examples/session_snapshot.py"}\n```' + rendered = inject_snippets(markdown, PROJECT_ROOT / "docs") + + assert rendered.startswith('```py {title="examples/agenton/agenton_examples/session_snapshot.py"}') + assert "async def main() -> None:" in rendered + assert "asyncio.run(main())" in rendered diff --git a/dify-agent/tests/local/__init__.py b/dify-agent/tests/local/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/agenton/__init__.py b/dify-agent/tests/local/agenton/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/dify-agent/tests/local/agenton/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/agenton/compositor/__init__.py b/dify-agent/tests/local/agenton/compositor/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/dify-agent/tests/local/agenton/compositor/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/agenton/compositor/test_builder_snapshot.py b/dify-agent/tests/local/agenton/compositor/test_builder_snapshot.py new file mode 100644 index 0000000000..b43b0d1e71 --- /dev/null +++ b/dify-agent/tests/local/agenton/compositor/test_builder_snapshot.py @@ -0,0 +1,337 @@ +import asyncio +from dataclasses import dataclass + +import pytest +from pydantic import BaseModel, ConfigDict, ValidationError +from typing_extensions import override + +import agenton.compositor as compositor_module +import agenton.layers as layers_module +from agenton.compositor import ( + Compositor, + LayerConfigInput, + LayerNode, + LayerNodeConfig, + LayerProvider, + LayerProviderInput, +) +from agenton.layers import EmptyLayerConfig, Layer, LayerDeps, NoLayerDeps, PlainLayer +from agenton_collections.layers.plain import ObjectLayer, PromptLayer, PromptLayerConfig + + +EXPECTED_FACADE_EXPORTS = [ + "Compositor", + "CompositorConfig", + "CompositorConfigValue", + "CompositorRun", + "CompositorSessionSnapshot", + "CompositorSessionSnapshotValue", + "CompositorTransformer", + "CompositorTransformerKwargs", + "LayerFactory", + "LayerNode", + "LayerNodeConfig", + "LayerProvider", + "LayerRunSlot", + "LayerSessionSnapshot", +] + +EXPECTED_DIRECT_IMPORT_COMPAT_NAMES = ["LayerConfigInput", "LayerProviderInput"] + + +@dataclass(slots=True) +class InstanceOnlyLayer(PlainLayer[NoLayerDeps]): + pass + + +@dataclass(slots=True) +class RequiredConstructorLayer(PlainLayer[NoLayerDeps]): + value: str + + +def test_layer_provider_from_layer_type_uses_declared_schema_and_type_id() -> None: + provider = LayerProvider.from_layer_type(PromptLayer) + + assert provider.type_id == "plain.prompt" + assert provider.layer_type is PromptLayer + + layer = provider.create_layer(PromptLayerConfig(prefix="hello", user="ask politely")) + + assert isinstance(layer, PromptLayer) + assert layer.config == PromptLayerConfig(prefix="hello", user="ask politely") + assert layer.prefix_prompts == ["hello"] + + with pytest.raises(TypeError, match="cannot be created from empty config"): + LayerProvider.from_layer_type(RequiredConstructorLayer).create_layer() + + +def test_compositor_from_config_uses_providers_and_enter_configs_by_node_name() -> None: + compositor = Compositor.from_config( + {"layers": [{"name": "prompt", "type": "plain.prompt"}]}, + providers=[PromptLayer], + ) + + async def run() -> None: + async with compositor.enter( + configs={"prompt": {"prefix": "hello", "user": "ask politely", "suffix": ["bye"]}} + ) as active_run: + assert [prompt.value for prompt in active_run.prompts] == ["hello", "bye"] + assert [prompt.value for prompt in active_run.user_prompts] == ["ask politely"] + + asyncio.run(run()) + + with pytest.raises(ValidationError): + asyncio.run(_enter_once(compositor, configs={"prompt": {"unknown": "field"}})) + + +def test_layer_node_config_has_no_runtime_state_or_layer_config() -> None: + node = LayerNodeConfig( + name="prompt", + type="plain.prompt", + deps={"source": "other"}, + metadata={"label": "Prompt"}, + ) + + assert node.model_dump(mode="json") == { + "name": "prompt", + "type": "plain.prompt", + "deps": {"source": "other"}, + "metadata": {"label": "Prompt"}, + } + assert "runtime_state" not in LayerNodeConfig.model_fields + assert "config" not in LayerNodeConfig.model_fields + + +def test_node_providers_override_type_id_providers_for_serializable_graphs() -> None: + override_provider = LayerProvider.from_factory( + layer_type=PromptLayer, + create=lambda config: PromptLayer(prefix="override"), + ) + compositor = Compositor.from_config( + {"layers": [{"name": "prompt", "type": "plain.prompt"}]}, + providers=[PromptLayer], + node_providers={"prompt": override_provider}, + ) + + async def run() -> None: + async with compositor.enter(configs={"prompt": {"prefix": "ignored"}}) as active_run: + assert [prompt.value for prompt in active_run.prompts] == ["override"] + + asyncio.run(run()) + + +def test_from_config_rejects_missing_duplicate_and_unknown_providers() -> None: + with pytest.raises(KeyError, match="Layer type id 'missing' is not registered"): + Compositor.from_config({"layers": [{"name": "node", "type": "missing"}]}, providers=[]) + + with pytest.raises(ValueError, match="already registered"): + Compositor.from_config( + {"layers": [{"name": "prompt", "type": "plain.prompt"}]}, + providers=[PromptLayer, PromptLayer], + ) + + with pytest.raises(ValueError, match="must declare a type_id"): + Compositor.from_config( + {"layers": [{"name": "node", "type": "instance.only"}]}, + providers=[InstanceOnlyLayer], + ) + + with pytest.raises(ValueError, match="unknown layer node names: other"): + Compositor.from_config( + {"layers": [{"name": "prompt", "type": "plain.prompt"}]}, + providers=[PromptLayer], + node_providers={"other": PromptLayer}, + ) + + +def test_compositor_run_get_layer_returns_named_layer_and_validates_type() -> None: + compositor = Compositor([LayerNode("obj", _object_provider("value"))]) + + async def run() -> None: + async with compositor.enter() as active_run: + layer = active_run.get_layer("obj", ObjectLayer) + assert active_run.get_layer("obj") is layer + assert layer.value == "value" + + with pytest.raises(KeyError, match="Layer 'missing' is not defined"): + active_run.get_layer("missing") + + with pytest.raises(TypeError, match="Layer 'obj' must be PromptLayer, got ObjectLayer"): + active_run.get_layer("obj", PromptLayer) + + asyncio.run(run()) + + +class ObjectConsumerDeps(LayerDeps): + obj: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class ObjectConsumerLayer(PlainLayer[ObjectConsumerDeps]): + @property + @override + def prefix_prompts(self) -> list[str]: + return [self.deps.obj.value] + + +def test_python_native_construction_mixes_layer_classes_and_providers() -> None: + compositor = Compositor( + [ + LayerNode("prompt", PromptLayer), + LayerNode("obj", _object_provider("instance")), + LayerNode("consumer", ObjectConsumerLayer, deps={"obj": "obj"}), + ] + ) + + async def run() -> None: + async with compositor.enter(configs={"prompt": {"prefix": "cfg"}}) as active_run: + assert [prompt.value for prompt in active_run.prompts] == ["cfg", "instance"] + + asyncio.run(run()) + + +class SerializableState(BaseModel): + resource_id: str = "" + created: bool = False + resumed: bool = False + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +@dataclass(slots=True) +class StateLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, SerializableState]): + created_hooks: int = 0 + resumed_hooks: int = 0 + + @override + async def on_context_create(self) -> None: + self.created_hooks += 1 + self.runtime_state.created = True + + @override + async def on_context_resume(self) -> None: + self.resumed_hooks += 1 + self.runtime_state.resumed = True + + +def test_snapshot_contains_runtime_state_only_not_config_deps_or_resources() -> None: + compositor = Compositor([LayerNode("state", StateLayer)]) + + async def get_snapshot() -> dict[str, object]: + async with compositor.enter() as active_run: + state_layer = active_run.get_layer("state", StateLayer) + state_layer.runtime_state.resource_id = "abc" + assert active_run.session_snapshot is not None + return active_run.session_snapshot.model_dump(mode="json") + + dumped = asyncio.run(get_snapshot()) + assert dumped == { + "schema_version": 1, + "layers": [ + { + "name": "state", + "lifecycle_state": "closed", + "runtime_state": {"resource_id": "abc", "created": True, "resumed": False}, + } + ], + } + + +def test_hydrate_validates_runtime_state_and_resume_mutates_layer_self() -> None: + compositor = Compositor([LayerNode("state", StateLayer)]) + + bad_snapshot = {"layers": [{"name": "state", "lifecycle_state": "suspended", "runtime_state": {"wrong": "field"}}]} + with pytest.raises(ValidationError): + asyncio.run(_enter_once(compositor, session_snapshot=bad_snapshot)) + + good_snapshot = { + "layers": [ + { + "name": "state", + "lifecycle_state": "suspended", + "runtime_state": {"resource_id": "abc", "created": True, "resumed": False}, + } + ] + } + + async def run() -> None: + async with compositor.enter(session_snapshot=good_snapshot) as active_run: + layer = active_run.get_layer("state", StateLayer) + assert layer.runtime_state.resource_id == "abc" + assert layer.runtime_state.resumed is True + assert layer.resumed_hooks == 1 + + asyncio.run(run()) + + +def test_hydrate_rejects_mismatched_snapshot_layer_names() -> None: + compositor = Compositor([LayerNode("state", StateLayer)]) + + with pytest.raises(ValueError, match=r"Expected \[state\], got \[other\]"): + asyncio.run( + _enter_once( + compositor, + session_snapshot={"layers": [{"name": "other", "lifecycle_state": "new", "runtime_state": {}}]}, + ) + ) + + +def test_removed_lifecycle_and_resource_apis_are_not_public_exports() -> None: + assert not hasattr(compositor_module, "CompositorBuilder") + assert not hasattr(compositor_module, "LayerRegistry") + assert not hasattr(compositor_module, "LayerDescriptor") + assert not hasattr(layers_module, "LayerControl") + assert not hasattr(layers_module, "EmptyRuntimeHandles") + assert not hasattr(Layer, "enter") + assert not hasattr(Layer, "hydrate_session_state") + assert not hasattr(Layer, "suspend_on_exit") + assert not hasattr(Layer, "delete_on_exit") + assert not hasattr(Layer, "runtime_handles") + assert not hasattr(Layer, "require_control") + assert not hasattr(Layer, "control_for") + assert not hasattr(Layer, "enter_async_resource") + assert not hasattr(Layer, "add_async_cleanup") + + +def test_facade_keeps_direct_import_type_aliases_without_expanding___all__() -> None: + assert compositor_module.LayerConfigInput is LayerConfigInput + assert compositor_module.LayerProviderInput is LayerProviderInput + assert LayerConfigInput.__module__ == "agenton.compositor" + assert LayerProviderInput.__module__ == "agenton.compositor" + assert LayerConfigInput.__name__ == "LayerConfigInput" + assert LayerProviderInput.__name__ == "LayerProviderInput" + assert "LayerConfigInput" not in compositor_module.__all__ + assert "LayerProviderInput" not in compositor_module.__all__ + + +def test_facade_export_surface_matches_split_contract() -> None: + compatibility_names = [*EXPECTED_FACADE_EXPORTS, *EXPECTED_DIRECT_IMPORT_COMPAT_NAMES] + + assert compositor_module.__all__ == EXPECTED_FACADE_EXPORTS + + namespace: dict[str, object] = {} + exec( + "from agenton.compositor import " + ", ".join(compatibility_names), + {}, + namespace, + ) + + for name in compatibility_names: + assert namespace[name] is getattr(compositor_module, name) + + +def _object_provider(value: str) -> LayerProvider[ObjectLayer[str]]: + return LayerProvider.from_factory(layer_type=ObjectLayer, create=lambda config: ObjectLayer(value)) + + +async def _enter_once( + compositor: Compositor, + *, + configs: dict[str, object] | None = None, + session_snapshot: object | None = None, +) -> None: + async with compositor.enter( + configs=configs, # pyright: ignore[reportArgumentType] + session_snapshot=session_snapshot, # pyright: ignore[reportArgumentType] + ): + pass diff --git a/dify-agent/tests/local/agenton/compositor/test_direct_deps.py b/dify-agent/tests/local/agenton/compositor/test_direct_deps.py new file mode 100644 index 0000000000..d89d4bb7e2 --- /dev/null +++ b/dify-agent/tests/local/agenton/compositor/test_direct_deps.py @@ -0,0 +1,128 @@ +import asyncio +from dataclasses import dataclass + +import pytest +from typing_extensions import override + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from agenton.layers import EmptyLayerConfig, LayerDeps, PlainLayer +from agenton_collections.layers.plain import ObjectLayer + + +class RenamedObjectDeps(LayerDeps): + renamed: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class RenamedConsumerLayer(PlainLayer[RenamedObjectDeps]): + @property + @override + def prefix_prompts(self) -> list[str]: + return [self.deps.renamed.value] + + +class SameNameObjectDeps(LayerDeps): + same: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class SameNameConsumerLayer(PlainLayer[SameNameObjectDeps]): + @property + @override + def prefix_prompts(self) -> list[str]: + return [self.deps.same.value] + + +class OptionalObjectDeps(LayerDeps): + maybe: ObjectLayer[str] | None # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class OptionalConsumerLayer(PlainLayer[OptionalObjectDeps]): + pass + + +def _object_provider(value: str) -> LayerProvider[ObjectLayer[str]]: + return LayerProvider.from_factory( + layer_type=ObjectLayer, + create=lambda config: ObjectLayer(value), + ) + + +def test_direct_deps_access_uses_explicit_dependency_rename() -> None: + compositor = Compositor( + [ + LayerNode("actual", _object_provider("target")), + LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "actual"}), + ] + ) + + async def run() -> None: + async with compositor.enter() as active_run: + target = active_run.get_layer("actual", ObjectLayer) + consumer = active_run.get_layer("consumer", RenamedConsumerLayer) + assert consumer.deps.renamed is target + assert [prompt.value for prompt in active_run.prompts] == ["target"] + + asyncio.run(run()) + + +def test_direct_deps_access_uses_explicit_same_name_dependency() -> None: + compositor = Compositor( + [ + LayerNode("same", _object_provider("target")), + LayerNode("consumer", SameNameConsumerLayer, deps={"same": "same"}), + ] + ) + + async def run() -> None: + async with compositor.enter() as active_run: + target = active_run.get_layer("same", ObjectLayer) + consumer = active_run.get_layer("consumer", SameNameConsumerLayer) + assert consumer.deps.same is target + assert [prompt.value for prompt in active_run.prompts] == ["target"] + + asyncio.run(run()) + + +def test_optional_missing_dependency_is_bound_to_none() -> None: + compositor = Compositor([LayerNode("consumer", OptionalConsumerLayer)]) + + async def run() -> None: + async with compositor.enter() as active_run: + consumer = active_run.get_layer("consumer", OptionalConsumerLayer) + assert consumer.deps.maybe is None + + asyncio.run(run()) + + +def test_missing_required_dependency_is_rejected_before_hooks() -> None: + compositor = Compositor([LayerNode("consumer", SameNameConsumerLayer)]) + + with pytest.raises(ValueError, match="Dependency 'same' is required"): + asyncio.run(_enter_once(compositor)) + + +def test_unknown_dependency_mapping_is_rejected_for_compositor_construction() -> None: + with pytest.raises(ValueError, match="unknown dependency keys: missing"): + Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"missing": "target"})]) + + +def test_undefined_dependency_target_is_rejected_for_compositor_construction() -> None: + with pytest.raises(ValueError, match="depends on undefined layer names: missing_target"): + Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "missing_target"})]) + + +def test_duplicate_layer_node_name_is_rejected() -> None: + with pytest.raises(ValueError, match="Duplicate layer name 'same'"): + Compositor( + [ + LayerNode("same", _object_provider("first")), + LayerNode("same", _object_provider("second")), + ] + ) + + +async def _enter_once(compositor: Compositor) -> None: + async with compositor.enter(configs={"consumer": EmptyLayerConfig()}): + pass diff --git a/dify-agent/tests/local/agenton/compositor/test_enter.py b/dify-agent/tests/local/agenton/compositor/test_enter.py new file mode 100644 index 0000000000..154bc8b0d7 --- /dev/null +++ b/dify-agent/tests/local/agenton/compositor/test_enter.py @@ -0,0 +1,401 @@ +import asyncio +from collections.abc import Iterator +from dataclasses import dataclass, field +from itertools import count + +import pytest +from pydantic import BaseModel, ConfigDict, ValidationError +from typing_extensions import override + +from agenton.compositor import Compositor, CompositorSessionSnapshot, LayerNode, LayerProvider +from agenton.layers import ( + EmptyLayerConfig, + ExitIntent, + LayerConfig, + LifecycleState, + NoLayerDeps, + PlainLayer, +) + + +@dataclass(slots=True) +class TraceLayer(PlainLayer[NoLayerDeps]): + """Layer that records no-arg lifecycle events observable to tests.""" + + events: list[str] = field(default_factory=list) + + @override + async def on_context_create(self) -> None: + self.events.append("create") + + @override + async def on_context_suspend(self) -> None: + self.events.append("suspend") + + @override + async def on_context_resume(self) -> None: + self.events.append("resume") + + @override + async def on_context_delete(self) -> None: + self.events.append("delete") + + +def _compositor(*layer_names: str) -> Compositor: + return Compositor([LayerNode(layer_name, TraceLayer) for layer_name in layer_names]) + + +def test_same_compositor_enters_multiple_times_with_fresh_layers_and_snapshot_resume() -> None: + compositor = _compositor("first", "second") + runs = [] + + async def run() -> None: + async with compositor.enter() as first_run: + assert [slot.lifecycle_state for slot in first_run.slots.values()] == [ + LifecycleState.ACTIVE, + LifecycleState.ACTIVE, + ] + first_run.suspend_on_exit() + assert [slot.exit_intent for slot in first_run.slots.values()] == [ + ExitIntent.SUSPEND, + ExitIntent.SUSPEND, + ] + runs.append(first_run) + + assert first_run.session_snapshot is not None + async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run: + assert resumed_run.get_layer("first", TraceLayer).events == ["resume"] + assert resumed_run.get_layer("second", TraceLayer).events == ["resume"] + runs.append(resumed_run) + + asyncio.run(run()) + + first_layer = runs[0].get_layer("first", TraceLayer) + resumed_layer = runs[1].get_layer("first", TraceLayer) + assert first_layer is not resumed_layer + assert first_layer.events == ["create", "suspend"] + assert resumed_layer.events == ["resume", "delete"] + assert runs[1].session_snapshot is not None + assert [layer.lifecycle_state for layer in runs[1].session_snapshot.layers] == [ + LifecycleState.CLOSED, + LifecycleState.CLOSED, + ] + + +def test_concurrent_enters_do_not_share_layer_instances() -> None: + compositor = _compositor("trace") + + async def enter_once() -> tuple[int, list[str]]: + async with compositor.enter() as run: + layer = run.get_layer("trace", TraceLayer) + await asyncio.sleep(0) + return id(layer), layer.events + + async def run_concurrently() -> list[tuple[int, list[str]]]: + return list(await asyncio.gather(enter_once(), enter_once())) + + results = asyncio.run(run_concurrently()) + + assert results[0][0] != results[1][0] + assert results[0][1] == ["create", "delete"] + assert results[1][1] == ["create", "delete"] + + +class ConfiguredLayerConfig(LayerConfig): + value: str + + model_config = ConfigDict(extra="forbid") + + +@dataclass(slots=True) +class ConfiguredLayer(PlainLayer[NoLayerDeps, ConfiguredLayerConfig]): + type_id = "test.configured" + + value: str + + hooks: list[str] = field(default_factory=list) + + @classmethod + @override + def from_config(cls, config: ConfiguredLayerConfig) -> "ConfiguredLayer": + return cls(value=config.value) + + @override + async def on_context_create(self) -> None: + self.hooks.append(f"create:{self.config.value}") + + +def test_custom_factory_is_called_each_enter_with_typed_config() -> None: + calls: list[str] = [] + + def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer: + calls.append(config.value) + return ConfiguredLayer(value=f"factory:{config.value}") + + compositor = Compositor( + [LayerNode("configured", LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer))] + ) + + async def run() -> None: + async with compositor.enter(configs={"configured": {"value": "one"}}) as first_run: + first_layer = first_run.get_layer("configured", ConfiguredLayer) + assert first_layer.value == "factory:one" + assert first_layer.config.value == "one" + async with compositor.enter(configs={"configured": ConfiguredLayerConfig(value="two")}) as second_run: + second_layer = second_run.get_layer("configured", ConfiguredLayer) + assert second_layer.value == "factory:two" + assert second_layer.config.value == "two" + assert second_layer is not first_layer + + asyncio.run(run()) + + assert calls == ["one", "two"] + + +def test_provider_rejects_reused_layer_instance_before_hooks_run() -> None: + shared_layer = TraceLayer() + compositor = Compositor( + [ + LayerNode( + "trace", + LayerProvider.from_factory(layer_type=TraceLayer, create=lambda config: shared_layer), + ) + ] + ) + + async def run() -> None: + async with compositor.enter(): + pass + + with pytest.raises(ValueError, match="fresh layer instance"): + async with compositor.enter(): + pass + + asyncio.run(run()) + + assert shared_layer.events == ["create", "delete"] + + +def test_configs_are_validated_by_node_name_before_factory_call() -> None: + calls: list[str] = [] + + def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer: + calls.append(config.value) + return ConfiguredLayer(value=config.value) + + compositor = Compositor( + [LayerNode("configured", LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer))] + ) + + with pytest.raises(ValueError, match="unknown layer node names: missing"): + asyncio.run(_enter_once(compositor, configs={"missing": {}})) + + with pytest.raises(ValidationError): + asyncio.run(_enter_once(compositor, configs={"configured": {"unknown": "field"}})) + + assert calls == [] + + +def test_all_node_configs_are_validated_before_any_factory_runs() -> None: + calls: list[str] = [] + + def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer: + calls.append(config.value) + return ConfiguredLayer(value=config.value) + + provider = LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer) + compositor = Compositor([LayerNode("first", provider), LayerNode("second", provider)]) + + with pytest.raises(ValidationError): + asyncio.run( + _enter_once( + compositor, + configs={"first": {"value": "valid"}, "second": {"unknown": "field"}}, + ) + ) + + assert calls == [] + + +def test_existing_config_model_instances_are_revalidated_before_factory_runs() -> None: + calls: list[str] = [] + + def create_layer(config: ConfiguredLayerConfig) -> ConfiguredLayer: + calls.append(config.value) + return ConfiguredLayer(value=config.value) + + compositor = Compositor( + [LayerNode("configured", LayerProvider.from_factory(layer_type=ConfiguredLayer, create=create_layer))] + ) + config = ConfiguredLayerConfig(value="valid") + config.value = 123 # pyright: ignore[reportAttributeAccessIssue] + + with pytest.raises(ValidationError): + asyncio.run(_enter_once(compositor, configs={"configured": config})) + + assert calls == [] + + +def test_existing_snapshot_model_instances_are_revalidated_before_factory_runs() -> None: + calls = 0 + + def create_layer(config: EmptyLayerConfig) -> TraceLayer: + nonlocal calls + calls += 1 + return TraceLayer() + + compositor = Compositor( + [LayerNode("trace", LayerProvider.from_factory(layer_type=TraceLayer, create=create_layer))] + ) + snapshot = CompositorSessionSnapshot.model_validate( + {"layers": [{"name": "trace", "lifecycle_state": "suspended", "runtime_state": {}}]} + ) + snapshot.layers[0].lifecycle_state = LifecycleState.ACTIVE + + with pytest.raises(ValidationError, match="ACTIVE is internal-only"): + asyncio.run(_enter_once(compositor, session_snapshot=snapshot)) + + assert calls == 0 + + +class RuntimeState(BaseModel): + runtime_id: int | None = None + resumed_runtime_id: int | None = None + deleted_runtime_id: int | None = None + body_value: str | None = None + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +@dataclass(slots=True) +class RuntimeStateLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, RuntimeState]): + next_id: Iterator[int] = field(default_factory=lambda: count(1)) + + @override + async def on_context_create(self) -> None: + self.runtime_state.runtime_id = next(self.next_id) + + @override + async def on_context_resume(self) -> None: + self.runtime_state.resumed_runtime_id = self.runtime_state.runtime_id + + @override + async def on_context_delete(self) -> None: + self.runtime_state.deleted_runtime_id = self.runtime_state.runtime_id + + +def test_snapshot_hydrates_runtime_state_and_exit_snapshots_from_layer_self() -> None: + compositor = Compositor([LayerNode("state", RuntimeStateLayer)]) + + async def create_suspend_resume_delete() -> tuple[CompositorSessionSnapshot, CompositorSessionSnapshot]: + async with compositor.enter() as first_run: + first_run.suspend_on_exit() + assert first_run.session_snapshot is not None + + async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run: + resumed_layer = resumed_run.get_layer("state", RuntimeStateLayer) + assert isinstance(resumed_layer.runtime_state, RuntimeState) + assert resumed_layer.runtime_state.runtime_id == 1 + assert resumed_layer.runtime_state.resumed_runtime_id == 1 + resumed_layer.runtime_state.body_value = "mutated on self" + assert resumed_run.session_snapshot is not None + return first_run.session_snapshot, resumed_run.session_snapshot + + suspended_snapshot, closed_snapshot = asyncio.run(create_suspend_resume_delete()) + + assert suspended_snapshot.model_dump(mode="json") == { + "schema_version": 1, + "layers": [ + { + "name": "state", + "lifecycle_state": "suspended", + "runtime_state": { + "runtime_id": 1, + "resumed_runtime_id": None, + "deleted_runtime_id": None, + "body_value": None, + }, + } + ], + } + assert closed_snapshot.model_dump(mode="json") == { + "schema_version": 1, + "layers": [ + { + "name": "state", + "lifecycle_state": "closed", + "runtime_state": { + "runtime_id": 1, + "resumed_runtime_id": 1, + "deleted_runtime_id": 1, + "body_value": "mutated on self", + }, + } + ], + } + + +def test_run_snapshot_rejects_active_layers() -> None: + compositor = _compositor("trace") + + async def run() -> None: + async with compositor.enter() as active_run: + with pytest.raises(RuntimeError, match="Cannot snapshot active compositor run layers: trace"): + active_run.snapshot_session() + + asyncio.run(run()) + + +def test_active_snapshot_input_is_rejected_before_factories_run() -> None: + calls = 0 + + def create_layer(config: EmptyLayerConfig) -> TraceLayer: + nonlocal calls + calls += 1 + return TraceLayer() + + compositor = Compositor( + [LayerNode("trace", LayerProvider.from_factory(layer_type=TraceLayer, create=create_layer))] + ) + active_snapshot = {"layers": [{"name": "trace", "lifecycle_state": "active", "runtime_state": {}}]} + + with pytest.raises(ValidationError, match="ACTIVE is internal-only"): + CompositorSessionSnapshot.model_validate(active_snapshot) + + with pytest.raises(ValidationError, match="ACTIVE is internal-only"): + asyncio.run(_enter_once(compositor, session_snapshot=active_snapshot)) + + assert calls == 0 + + +def test_closed_snapshot_enter_is_rejected_before_hooks_run() -> None: + created_layers: list[TraceLayer] = [] + + def create_layer(config: EmptyLayerConfig) -> TraceLayer: + layer = TraceLayer() + created_layers.append(layer) + return layer + + compositor = Compositor( + [LayerNode("trace", LayerProvider.from_factory(layer_type=TraceLayer, create=create_layer))] + ) + closed_snapshot = {"layers": [{"name": "trace", "lifecycle_state": "closed", "runtime_state": {}}]} + + with pytest.raises(RuntimeError, match="CLOSED snapshots cannot be entered"): + asyncio.run(_enter_once(compositor, session_snapshot=closed_snapshot)) + + assert len(created_layers) == 1 + assert created_layers[0].events == [] + + +async def _enter_once( + compositor: Compositor, + *, + configs: dict[str, object] | None = None, + session_snapshot: object | None = None, +) -> None: + async with compositor.enter( + configs=configs, # pyright: ignore[reportArgumentType] + session_snapshot=session_snapshot, # pyright: ignore[reportArgumentType] + ): + pass diff --git a/dify-agent/tests/local/agenton/compositor/test_transformers.py b/dify-agent/tests/local/agenton/compositor/test_transformers.py new file mode 100644 index 0000000000..56a41d9fe0 --- /dev/null +++ b/dify-agent/tests/local/agenton/compositor/test_transformers.py @@ -0,0 +1,156 @@ +import asyncio +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from inspect import Parameter, signature + +from typing_extensions import override + +from agenton.compositor import Compositor, CompositorTransformerKwargs, LayerNode, LayerProvider +from agenton.layers import NoLayerDeps, PlainLayer, PlainPromptType, PlainToolType, PlainUserPromptType + +type ToolCallable = Callable[..., object] +type WrappedPrompt = tuple[str, str] +type WrappedUserPrompt = tuple[str, str] + + +@dataclass(slots=True) +class PromptAndToolLayer(PlainLayer[NoLayerDeps]): + prefix: list[str] + user: list[str] + suffix: list[str] + tool_entries: list[ToolCallable] + + @property + @override + def prefix_prompts(self) -> list[str]: + return self.prefix + + @property + @override + def suffix_prompts(self) -> list[str]: + return self.suffix + + @property + @override + def user_prompts(self) -> list[str]: + return self.user + + @property + @override + def tools(self) -> list[ToolCallable]: + return self.tool_entries + + +def base_tool() -> str: + return "base" + + +def wrapped_tool() -> str: + return "wrapped" + + +def wrap_prompts(prompts: Sequence[PlainPromptType]) -> list[WrappedPrompt]: + return [("wrapped", prompt.value) for prompt in prompts] + + +def wrap_user_prompts(prompts: Sequence[PlainUserPromptType]) -> list[WrappedUserPrompt]: + return [("wrapped-user", prompt.value) for prompt in prompts] + + +def describe_tools(tools: Sequence[PlainToolType]) -> list[str]: + return [tool.value.__name__ for tool in tools] + + +def prompt_tool_provider( + *, + prefix: list[str] | None = None, + user: list[str] | None = None, + suffix: list[str] | None = None, + tool_entries: list[ToolCallable] | None = None, +) -> LayerProvider[PromptAndToolLayer]: + return LayerProvider.from_factory( + layer_type=PromptAndToolLayer, + create=lambda config: PromptAndToolLayer( + prefix=list(prefix or []), + user=list(user or []), + suffix=list(suffix or []), + tool_entries=list(tool_entries or []), + ), + ) + + +def test_compositor_transformer_kwargs_keys_match_constructor_parameters() -> None: + transformer_kwargs = set(CompositorTransformerKwargs.__required_keys__) + parameters = signature(Compositor).parameters + + assert CompositorTransformerKwargs.__optional_keys__ == frozenset() + assert transformer_kwargs == {name for name in parameters if name.endswith("_transformer")} + assert all(parameters[name].kind is Parameter.KEYWORD_ONLY for name in transformer_kwargs) + + +def test_compositor_transformer_kwargs_keys_match_from_config_parameters() -> None: + transformer_kwargs = set(CompositorTransformerKwargs.__required_keys__) + parameters = signature(Compositor.from_config).parameters + + assert transformer_kwargs == {name for name in parameters if name.endswith("_transformer")} + assert all(parameters[name].kind is Parameter.KEYWORD_ONLY for name in transformer_kwargs) + + +def test_compositor_transforms_prompts_to_another_type_after_layer_ordering() -> None: + compositor: Compositor[WrappedPrompt, PlainToolType, PlainPromptType, PlainToolType] = Compositor( + [ + LayerNode("first", prompt_tool_provider(prefix=["first-prefix"], suffix=["first-suffix"])), + LayerNode("second", prompt_tool_provider(prefix=["second-prefix"], suffix=["second-suffix"])), + ], + prompt_transformer=wrap_prompts, + ) + + async def run() -> None: + async with compositor.enter() as active_run: + assert active_run.prompts == [ + ("wrapped", "first-prefix"), + ("wrapped", "second-prefix"), + ("wrapped", "second-suffix"), + ("wrapped", "first-suffix"), + ] + + asyncio.run(run()) + + +def test_compositor_transforms_tools_to_another_type_after_layer_aggregation() -> None: + compositor: Compositor[PlainPromptType, str, PlainPromptType, PlainToolType] = Compositor( + [LayerNode("tools", prompt_tool_provider(tool_entries=[base_tool, wrapped_tool]))], + tool_transformer=describe_tools, + ) + + async def run() -> None: + async with compositor.enter() as active_run: + assert active_run.tools == ["base_tool", "wrapped_tool"] + + asyncio.run(run()) + + +def test_compositor_transforms_user_prompts_after_layer_ordering() -> None: + compositor: Compositor[ + PlainPromptType, + PlainToolType, + PlainPromptType, + PlainToolType, + WrappedUserPrompt, + PlainUserPromptType, + ] = Compositor( + [ + LayerNode("first", prompt_tool_provider(user=["first-user"])), + LayerNode("second", prompt_tool_provider(user=["second-user"])), + ], + user_prompt_transformer=wrap_user_prompts, + ) + + async def run() -> None: + async with compositor.enter() as active_run: + assert active_run.user_prompts == [ + ("wrapped-user", "first-user"), + ("wrapped-user", "second-user"), + ] + + asyncio.run(run()) diff --git a/dify-agent/tests/local/agenton/layers/__init__.py b/dify-agent/tests/local/agenton/layers/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/dify-agent/tests/local/agenton/layers/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/agenton/layers/test_layer_deps.py b/dify-agent/tests/local/agenton/layers/test_layer_deps.py new file mode 100644 index 0000000000..1ab0c543e2 --- /dev/null +++ b/dify-agent/tests/local/agenton/layers/test_layer_deps.py @@ -0,0 +1,15 @@ +import pytest + +from agenton.layers import LayerDeps +from agenton_collections.layers.plain import ObjectLayer, PromptLayer + + +class ObjectLayerDeps(LayerDeps): + """Deps container used to exercise runtime dependency validation.""" + + object_layer: ObjectLayer[str] # pyright: ignore[reportUninitializedInstanceVariable] + + +def test_layer_deps_rejects_mismatched_runtime_layer_class() -> None: + with pytest.raises(TypeError, match="should be of type 'ObjectLayer'"): + ObjectLayerDeps(object_layer=PromptLayer()) diff --git a/dify-agent/tests/local/agenton/layers/test_schema_inference.py b/dify-agent/tests/local/agenton/layers/test_schema_inference.py new file mode 100644 index 0000000000..55af512e54 --- /dev/null +++ b/dify-agent/tests/local/agenton/layers/test_schema_inference.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict + +from agenton.compositor import LayerProvider +from agenton.layers import ( + EmptyLayerConfig, + EmptyRuntimeState, + LayerConfig, + NoLayerDeps, + PlainLayer, +) + + +class InferredConfig(LayerConfig): + value: str = "configured" + + model_config = ConfigDict(extra="forbid") + + +class InferredState(BaseModel): + count: int = 0 + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + +@dataclass(slots=True) +class GenericSchemaLayer(PlainLayer[NoLayerDeps, InferredConfig, InferredState]): + type_id = "test.generic-schema" + + @classmethod + def from_config(cls, config: InferredConfig) -> "GenericSchemaLayer": + return cls() + + async def on_context_create(self) -> None: + self.runtime_state.count += 1 + + +@dataclass(slots=True) +class DefaultSchemaLayer(PlainLayer[NoLayerDeps]): + type_id = "test.default-schema" + + +def test_layer_infers_config_and_runtime_state_from_generics() -> None: + layer = GenericSchemaLayer() + layer.runtime_state = InferredState(count=3) + + assert GenericSchemaLayer.config_type is InferredConfig + assert GenericSchemaLayer.runtime_state_type is InferredState + assert isinstance(layer.runtime_state, InferredState) + assert layer.runtime_state.count == 3 + + +def test_layer_uses_empty_schema_defaults_when_omitted() -> None: + layer = DefaultSchemaLayer() + + assert DefaultSchemaLayer.config_type is EmptyLayerConfig + assert DefaultSchemaLayer.runtime_state_type is EmptyRuntimeState + assert isinstance(layer.runtime_state, EmptyRuntimeState) + + +def test_invalid_declared_schema_type_is_rejected_clearly() -> None: + try: + + class InvalidSchemaLayer(PlainLayer[NoLayerDeps]): + config_type = dict # pyright: ignore[reportAssignmentType] + + except TypeError as e: + assert str(e) == "InvalidSchemaLayer.config_type must be a LayerConfig subclass." + else: + raise AssertionError("Expected TypeError.") + + try: + + class InvalidGenericSchemaLayer(PlainLayer[NoLayerDeps, dict[str, object]]): # pyright: ignore[reportInvalidTypeArguments] + pass + + except TypeError as e: + assert str(e) == "InvalidGenericSchemaLayer.config_type must be a LayerConfig subclass." + else: + raise AssertionError("Expected TypeError.") + + +def test_layer_provider_uses_inferred_schema_types() -> None: + provider = LayerProvider.from_layer_type(GenericSchemaLayer) + + layer = provider.create_layer({"value": "configured"}) + + assert provider.type_id == "test.generic-schema" + assert provider.layer_type.config_type is InferredConfig + assert provider.layer_type.runtime_state_type is InferredState + assert isinstance(layer.config, InferredConfig) diff --git a/dify-agent/tests/local/agenton_collections/__init__.py b/dify-agent/tests/local/agenton_collections/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/dify-agent/tests/local/agenton_collections/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/agenton_collections/layers/plain/__init__.py b/dify-agent/tests/local/agenton_collections/layers/plain/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/dify-agent/tests/local/agenton_collections/layers/plain/__init__.py @@ -0,0 +1 @@ + diff --git a/dify-agent/tests/local/agenton_collections/layers/plain/test_basic.py b/dify-agent/tests/local/agenton_collections/layers/plain/test_basic.py new file mode 100644 index 0000000000..45928d079b --- /dev/null +++ b/dify-agent/tests/local/agenton_collections/layers/plain/test_basic.py @@ -0,0 +1,6 @@ +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayer + + +def test_prompt_layer_type_id_constant_matches_implementation_class() -> None: + assert PLAIN_PROMPT_LAYER_TYPE_ID == "plain.prompt" + assert PromptLayer.type_id == PLAIN_PROMPT_LAYER_TYPE_ID diff --git a/dify-agent/tests/local/agenton_collections/layers/plain/test_dynamic_tools.py b/dify-agent/tests/local/agenton_collections/layers/plain/test_dynamic_tools.py new file mode 100644 index 0000000000..213d7ff992 --- /dev/null +++ b/dify-agent/tests/local/agenton_collections/layers/plain/test_dynamic_tools.py @@ -0,0 +1,75 @@ +import asyncio + +import pytest +from pydantic_ai import Tool + +from agenton_collections.layers.plain import DynamicToolsLayer, ObjectLayer, with_object + + +class Profile: + """Profile object used by object-bound tool tests.""" + + name: str + + def __init__(self, name: str) -> None: + self.name = name + + +class OtherProfile: + """Different runtime object used to trigger object mismatch checks.""" + + +@with_object(Profile) +def greet(profile: Profile, topic: str) -> str: + return f"{profile.name}: {topic}" + + +def test_with_object_rejects_tool_without_object_parameter() -> None: + def tool() -> str: + return "unused" + + with pytest.raises(ValueError, match="must accept the object dependency"): + with_object(Profile)(tool) # pyright: ignore[reportArgumentType] + + +def test_with_object_rejects_first_parameter_annotation_mismatch() -> None: + def tool(profile: OtherProfile) -> str: + return repr(profile) + + with pytest.raises(TypeError, match="first parameter should accept 'Profile'"): + with_object(Profile)(tool) # pyright: ignore[reportArgumentType] + + +def test_dynamic_tools_layer_rejects_mismatched_runtime_object_value() -> None: + layer = DynamicToolsLayer[Profile](tool_entries=(greet,)) + layer.bind_deps({"object_layer": ObjectLayer[OtherProfile](OtherProfile())}) + + with pytest.raises(TypeError, match="expected object dependency of type 'Profile'"): + layer.tools + + +def public_greet(topic: str) -> str: + return f"Ada: {topic}" + + +def test_dynamic_tools_layer_binds_object_as_pydantic_ai_equivalent_tool() -> None: + layer = DynamicToolsLayer[Profile](tool_entries=(greet,)) + layer.bind_deps({"object_layer": ObjectLayer[Profile](Profile("Ada"))}) + + expected_tool = Tool(public_greet, name="greet") + dynamic_tool = Tool(layer.tools[0], name="greet") + dynamic_result = asyncio.run( + dynamic_tool.function_schema.call( + {"topic": "layer composition"}, + None, # pyright: ignore[reportArgumentType] + ) + ) + expected_result = asyncio.run( + expected_tool.function_schema.call( + {"topic": "layer composition"}, + None, # pyright: ignore[reportArgumentType] + ) + ) + + assert dynamic_tool.tool_def == expected_tool.tool_def + assert dynamic_result == expected_result diff --git a/dify-agent/tests/local/agenton_collections/layers/pydantic_ai/test_bridge.py b/dify-agent/tests/local/agenton_collections/layers/pydantic_ai/test_bridge.py new file mode 100644 index 0000000000..12a93959de --- /dev/null +++ b/dify-agent/tests/local/agenton_collections/layers/pydantic_ai/test_bridge.py @@ -0,0 +1,56 @@ +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from pydantic_ai import RunContext, Tool + +from agenton_collections.layers.pydantic_ai import PydanticAIBridgeLayer + + +@dataclass(frozen=True, slots=True) +class Profile: + name: str + + +def profile_prompt(ctx: RunContext[Profile]) -> str: + return f"Profile: {ctx.deps.name}" + + +def existing_tool(ctx: RunContext[Profile]) -> str: + return ctx.deps.name + + +def raw_tool(ctx: RunContext[Profile], topic: str) -> str: + return f"{ctx.deps.name}: {topic}" + + +def test_pydantic_ai_bridge_layer_accepts_mixed_string_and_function_prompts() -> None: + layer = PydanticAIBridgeLayer[Profile]( + prefix=("plain prefix", profile_prompt), + user=("first user", "second user"), + suffix="plain suffix", + ) + + prefix_prompts = layer.prefix_prompts + user_prompts = layer.user_prompts + suffix_prompts = layer.suffix_prompts + + plain_prefix = cast(Callable[[], str], prefix_prompts[0]) + plain_suffix = cast(Callable[[], str], suffix_prompts[0]) + assert plain_prefix() == "plain prefix" + assert prefix_prompts[1] is profile_prompt + assert user_prompts == ["first user", "second user"] + assert plain_suffix() == "plain suffix" + + +def test_pydantic_ai_bridge_layer_accepts_mixed_tool_and_tool_function_entries() -> None: + pydantic_ai_tool = Tool(existing_tool) + layer = PydanticAIBridgeLayer[Profile]( + tool_entries=(pydantic_ai_tool, raw_tool), + ) + + tools = layer.tools + + assert tools[0] is pydantic_ai_tool + assert isinstance(tools[1], Tool) + assert tools[1].function is raw_tool diff --git a/dify-agent/tests/local/agenton_collections/transformers/test_pydantic_ai.py b/dify-agent/tests/local/agenton_collections/transformers/test_pydantic_ai.py new file mode 100644 index 0000000000..4ff49b1e1a --- /dev/null +++ b/dify-agent/tests/local/agenton_collections/transformers/test_pydantic_ai.py @@ -0,0 +1,85 @@ +from collections.abc import Callable +from typing import cast + +from pydantic_ai import Tool + +from agenton.layers.types import ( + PlainPromptType, + PlainToolType, + PlainUserPromptType, + PydanticAIPromptType, + PydanticAIToolType, + PydanticAIUserPromptType, +) +from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS + + +def plain_tool(name: str) -> str: + return f"hello {name}" + + +def dynamic_prompt() -> str: + return "dynamic prompt" + + +def test_pydantic_ai_transformers_wrap_tagged_plain_prompts() -> None: + prompts = [PlainPromptType("plain prompt")] + + result = PYDANTIC_AI_TRANSFORMERS["prompt_transformer"](prompts) + + assert len(result) == 1 + prompt_func = cast(Callable[[], str], result[0]) + assert prompt_func() == "plain prompt" + + +def test_pydantic_ai_transformers_preserve_tagged_existing_prompt_functions() -> None: + result = PYDANTIC_AI_TRANSFORMERS["prompt_transformer"]([PydanticAIPromptType(dynamic_prompt)]) + + assert result == [dynamic_prompt] + + +def test_pydantic_ai_transformers_accept_mixed_tagged_prompt_types() -> None: + result = PYDANTIC_AI_TRANSFORMERS["prompt_transformer"]( + [PlainPromptType("plain prompt"), PydanticAIPromptType(dynamic_prompt)] + ) + + plain_prompt = cast(Callable[[], str], result[0]) + assert plain_prompt() == "plain prompt" + assert result[1] is dynamic_prompt + + +def test_pydantic_ai_transformers_accept_tagged_user_prompt_types() -> None: + result = PYDANTIC_AI_TRANSFORMERS["user_prompt_transformer"]( + [PlainUserPromptType("plain user"), PydanticAIUserPromptType("pydantic user")] + ) + + assert result == ["plain user", "pydantic user"] + + +def test_pydantic_ai_transformers_wrap_tagged_plain_tools() -> None: + result = PYDANTIC_AI_TRANSFORMERS["tool_transformer"]([PlainToolType(plain_tool)]) + + assert len(result) == 1 + tool = result[0] + assert isinstance(tool, Tool) + assert tool.function is plain_tool + + +def test_pydantic_ai_transformers_preserve_tagged_existing_tools() -> None: + pydantic_ai_tool = Tool(plain_tool) + + result = PYDANTIC_AI_TRANSFORMERS["tool_transformer"]([PydanticAIToolType(pydantic_ai_tool)]) + + assert result == [pydantic_ai_tool] + + +def test_pydantic_ai_transformers_accept_tagged_tool_types() -> None: + pydantic_ai_tool = Tool(plain_tool) + + result = PYDANTIC_AI_TRANSFORMERS["tool_transformer"]( + [PlainToolType(plain_tool), PydanticAIToolType(pydantic_ai_tool)] + ) + + assert isinstance(result[0], Tool) + assert result[0].function is plain_tool + assert result[1] is pydantic_ai_tool diff --git a/dify-agent/tests/local/dify_agent/__init__.py b/dify-agent/tests/local/dify_agent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/adapters/__init__.py b/dify-agent/tests/local/dify_agent/adapters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/__init__.py b/dify-agent/tests/local/dify_agent/adapters/llm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/_test_support.py b/dify-agent/tests/local/dify_agent/adapters/llm/_test_support.py new file mode 100644 index 0000000000..20bb7d734b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/adapters/llm/_test_support.py @@ -0,0 +1,83 @@ +import json +from decimal import Decimal + +import httpx +from graphon.model_runtime.entities.llm_entities import ( + LLMResultChunk, + LLMResultChunkDelta, + LLMUsage, +) +from graphon.model_runtime.entities.message_entities import ( + AssistantPromptMessage, +) +from pydantic import BaseModel + + +def make_usage(prompt_tokens: int = 3, completion_tokens: int = 5) -> LLMUsage: + return LLMUsage( + prompt_tokens=prompt_tokens, + prompt_unit_price=Decimal("0"), + prompt_price_unit=Decimal("0"), + prompt_price=Decimal("0"), + completion_tokens=completion_tokens, + completion_unit_price=Decimal("0"), + completion_price_unit=Decimal("0"), + completion_price=Decimal("0"), + total_tokens=prompt_tokens + completion_tokens, + total_price=Decimal("0"), + currency="USD", + latency=0.1, + ) + + +def single_text_chunk( + text: str, + *, + prompt_tokens: int = 3, + completion_tokens: int = 5, +) -> list[LLMResultChunk]: + return [ + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content=text, tool_calls=[]), + usage=make_usage(prompt_tokens=prompt_tokens, completion_tokens=completion_tokens), + ), + ) + ] + + +def wrap_plugin_daemon_stream_item(item: object) -> str: + if isinstance(item, BaseModel): + data = item.model_dump(mode="json") + else: + data = item + return f"data: {json.dumps({'code': 0, 'message': '', 'data': data})}\n\n" + + +def build_stream_response(*items: object, status_code: int = 200) -> httpx.Response: + body = "".join(wrap_plugin_daemon_stream_item(item) for item in items) + return httpx.Response( + status_code=status_code, + headers={"content-type": "text/event-stream"}, + content=body.encode("utf-8"), + ) + + +def build_error_response(error_type: str, message: str, *, status_code: int) -> httpx.Response: + return httpx.Response( + status_code=status_code, + headers={"content-type": "application/json"}, + content=json.dumps({"error_type": error_type, "message": message}).encode("utf-8"), + ) + + +def build_stream_error(error_type: str, message: str, *, code: int = -500) -> httpx.Response: + return httpx.Response( + status_code=200, + headers={"content-type": "text/event-stream"}, + content=( + f"data: {json.dumps({'code': code, 'message': json.dumps({'error_type': error_type, 'message': message}), 'data': None})}\n\n" + ).encode("utf-8"), + ) diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py new file mode 100644 index 0000000000..c68142ce57 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py @@ -0,0 +1,481 @@ +import json +import unittest +from contextlib import asynccontextmanager +from typing import cast +from unittest.mock import patch + +import httpx +from pydantic_ai.exceptions import ModelHTTPError, UserError +from pydantic_ai.messages import ( + InstructionPart, + ModelRequest, + ModelResponse, + RetryPromptPart, + SystemPromptPart, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) +from pydantic_ai.models import ModelRequestParameters +from pydantic_ai.tools import ToolDefinition + +from dify_agent.adapters.llm import DifyLLMAdapterModel, DifyPluginDaemonProvider + +from ._test_support import ( + AssistantPromptMessage, + LLMResultChunk, + LLMResultChunkDelta, + build_error_response, + build_stream_error, + build_stream_response, + make_usage, + single_text_chunk, +) + + +class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase): + def make_provider( + self, + *, + user_id: str | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> DifyPluginDaemonProvider: + return DifyPluginDaemonProvider( + tenant_id="tenant-1", + plugin_id="langgenius/openai", + plugin_daemon_url="http://plugin-daemon", + plugin_daemon_api_key="daemon-secret", + user_id=user_id, + http_client=http_client, + ) + + @asynccontextmanager + async def mock_daemon_stream(self, handler: httpx.MockTransport): + @asynccontextmanager + async def mock_stream( + client: httpx.AsyncClient, + method: str, + url: str, + **kwargs: object, + ): + request = client.build_request( + method, + url, + headers=cast(dict[str, str] | None, kwargs.get("headers")), + json=kwargs.get("json"), + ) + yield handler.handle_request(request) + + with patch.object(httpx.AsyncClient, "stream", new=mock_stream): + yield + + async def test_request_uses_plugin_daemon_dispatch_contract(self) -> None: + messages = [ + ModelRequest( + parts=[ + SystemPromptPart("request system"), + UserPromptPart("hello"), + ToolReturnPart( + tool_name="lookup", + content={"city": "Paris"}, + tool_call_id="tool-1", + ), + RetryPromptPart(content="try again", tool_name="lookup", tool_call_id="tool-1"), + ] + ), + ModelResponse( + parts=[ + TextPart(content="previous answer"), + ToolCallPart( + tool_name="lookup", + args='{"city":"Paris"}', + tool_call_id="tool-1", + ), + ] + ), + ] + request_parameters = ModelRequestParameters( + function_tools=[ + ToolDefinition( + name="weather", + description="Look up the weather", + parameters_json_schema={ + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + ) + ], + output_mode="tool", + output_tools=[ + ToolDefinition( + name="incident_summary", + description="Return the final structured incident summary", + parameters_json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + }, + ) + ], + allow_text_output=False, + instruction_parts=[InstructionPart(content="be concise")], + ) + + def handler(request: httpx.Request) -> httpx.Response: + self.assertEqual(request.method, "POST") + self.assertEqual(request.url.path, "/plugin/tenant-1/dispatch/llm/invoke") + self.assertEqual(request.headers["X-Api-Key"], "daemon-secret") + self.assertEqual(request.headers["X-Plugin-ID"], "langgenius/openai") + + payload = json.loads(request.content.decode("utf-8")) + self.assertEqual(payload["user_id"], "user-123") + data = payload["data"] + self.assertEqual(data["provider"], "openai") + self.assertEqual(data["model_type"], "llm") + self.assertEqual(data["model"], "demo-model") + self.assertEqual(data["credentials"], {"api_key": "secret"}) + self.assertEqual( + data["model_parameters"], + {"temperature": 0.2, "max_tokens": 128, "logit_bias": {"1": 2}}, + ) + self.assertEqual(data["stop"], ["END"]) + self.assertFalse(data["stream"]) + tools_by_name = {tool["name"]: tool for tool in data["tools"]} + self.assertEqual(set(tools_by_name), {"weather", "incident_summary"}) + self.assertEqual(tools_by_name["incident_summary"]["parameters"]["required"], ["title"]) + self.assertEqual(data["prompt_messages"][0]["role"], "system") + self.assertEqual(data["prompt_messages"][0]["content"], "request system") + self.assertEqual(data["prompt_messages"][1]["content"], "be concise") + self.assertEqual(data["prompt_messages"][2]["content"], "hello") + self.assertEqual(data["prompt_messages"][3]["role"], "tool") + self.assertEqual(data["prompt_messages"][4]["role"], "tool") + self.assertEqual(data["prompt_messages"][5]["role"], "assistant") + return build_stream_response( + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content="adapter response", tool_calls=[]), + usage=make_usage(prompt_tokens=11, completion_tokens=7), + ), + ) + ) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(user_id="user-123"), + model_provider="openai", + credentials={"api_key": "secret"}, + model_settings={"temperature": 0.2, "stop_sequences": ["DEFAULT_STOP"]}, + ) + + response = await adapter.request( + messages, + model_settings={"max_tokens": 128, "logit_bias": {"1": 2}, "stop_sequences": ["END"]}, + model_request_parameters=request_parameters, + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(response.provider_name, "DifyPlugin/langgenius/openai") + self.assertEqual(response.usage.input_tokens, 11) + self.assertEqual(response.usage.output_tokens, 7) + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + + async def test_request_maps_tool_call_only_assistant_history_to_empty_string_content(self) -> None: + messages = [ + ModelRequest(parts=[SystemPromptPart("request system"), UserPromptPart("hello")]), + ModelResponse( + parts=[ + ToolCallPart( + tool_name="weather", + args='{"city":"Paris"}', + tool_call_id="tool-1", + ) + ] + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="weather", + content={"temperature": "18C"}, + tool_call_id="tool-1", + ) + ] + ), + ] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + prompt_messages = payload["data"]["prompt_messages"] + + self.assertEqual([message["role"] for message in prompt_messages], ["system", "user", "assistant", "tool"]) + self.assertEqual(prompt_messages[2]["content"], "") + self.assertEqual(prompt_messages[2]["tool_calls"][0]["id"], "tool-1") + self.assertEqual(prompt_messages[2]["tool_calls"][0]["type"], "function") + self.assertEqual(prompt_messages[2]["tool_calls"][0]["function"]["name"], "weather") + self.assertEqual(prompt_messages[2]["tool_calls"][0]["function"]["arguments"], '{"city":"Paris"}') + self.assertEqual(prompt_messages[3]["tool_call_id"], "tool-1") + + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + response = await adapter.request( + messages, + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + + async def test_request_omits_empty_assistant_history_when_response_has_no_content_or_tool_calls(self) -> None: + messages = [ + ModelRequest(parts=[SystemPromptPart("request system"), UserPromptPart("hello")]), + ModelResponse(parts=[]), + ModelRequest(parts=[UserPromptPart("follow up")]), + ] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + prompt_messages = payload["data"]["prompt_messages"] + + self.assertEqual([message["role"] for message in prompt_messages], ["system", "user", "user"]) + self.assertEqual(prompt_messages[2]["content"], "follow up") + + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + response = await adapter.request( + messages, + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + + async def test_provider_does_not_close_external_http_client(self) -> None: + http_client = httpx.AsyncClient() + provider = self.make_provider(http_client=http_client) + + self.assertEqual(provider.name, "DifyPlugin/langgenius/openai") + self.assertIs(provider.client.http_client, http_client) + async with provider: + pass + + self.assertFalse(http_client.is_closed) + await http_client.aclose() + + async def test_request_returns_a_response(self) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + response = await adapter.request( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + self.assertEqual(response.usage.input_tokens, 11) + self.assertEqual(response.usage.output_tokens, 7) + + async def test_request_stream_yields_response_parts_and_usage(self) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_stream_response( + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage(content="hello ", tool_calls=[]), + ), + ), + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=1, + message=AssistantPromptMessage( + content="", + tool_calls=[ + AssistantPromptMessage.ToolCall( + id="call-1", + type="function", + function=AssistantPromptMessage.ToolCall.ToolCallFunction( + name="weather", + arguments='{"city":"Paris"}', + ), + ) + ], + ), + ), + ), + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=2, + message=AssistantPromptMessage(content="world", tool_calls=[]), + usage=make_usage(prompt_tokens=6, completion_tokens=4), + finish_reason="tool_calls", + ), + ), + ) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + async with adapter.request_stream( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) as stream: + events = [event async for event in stream] + response = stream.get() + + self.assertTrue(events) + self.assertEqual(response.usage.input_tokens, 6) + self.assertEqual(response.usage.output_tokens, 4) + self.assertEqual(response.finish_reason, "tool_call") + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "hello ") + self.assertEqual(response.parts[1].part_kind, "tool-call") + self.assertEqual(cast(ToolCallPart, response.parts[1]).tool_name, "weather") + self.assertEqual(response.parts[2].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[2]).content, "world") + + async def test_request_splits_embedded_thinking_tags_into_parts(self) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_stream_response(*single_text_chunk("beforereasoningafter")) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + response = await adapter.request( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual([part.part_kind for part in response.parts], ["text", "thinking", "text"]) + self.assertEqual(cast(TextPart, response.parts[0]).content, "before") + self.assertEqual(cast(ThinkingPart, response.parts[1]).content, "reasoning") + self.assertEqual(cast(TextPart, response.parts[2]).content, "after") + + async def test_request_maps_stream_envelope_rate_limit_error_to_http_error( + self, + ) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_stream_error( + "PluginInvokeError", + json.dumps({"error_type": "InvokeRateLimitError", "message": "too many"}), + ) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + with self.assertRaises(ModelHTTPError) as context: + await adapter.request( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(context.exception.status_code, 429) + self.assertEqual( + context.exception.body, + {"error_type": "InvokeRateLimitError", "message": "too many"}, + ) + + async def test_request_maps_http_error_payload_to_http_error(self) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_error_response("PluginDaemonUnauthorizedError", "invalid api key", status_code=401) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + with self.assertRaises(ModelHTTPError) as context: + await adapter.request( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(context.exception.status_code, 401) + self.assertEqual( + context.exception.body, + { + "error_type": "PluginDaemonUnauthorizedError", + "message": "invalid api key", + }, + ) + + async def test_request_maps_endpoint_setup_error_to_user_error(self) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_stream_error("EndpointSetupFailedError", "missing endpoint config") + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + with self.assertRaises(UserError) as context: + await adapter.request( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(str(context.exception), "missing endpoint config") diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py new file mode 100644 index 0000000000..990475909d --- /dev/null +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import Iterator +from datetime import UTC, datetime +from typing import cast, override + +import httpx +import pytest + +from agenton.compositor import CompositorSessionSnapshot +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID +from dify_agent.client import ( + Client, + DifyAgentHTTPError, + DifyAgentNotFoundError, + DifyAgentStreamError, + DifyAgentTimeoutError, + DifyAgentValidationError, +) +from dify_agent.protocol.schemas import ( + CreateRunRequest, + RUN_EVENT_ADAPTER, + RunEvent, + RunEventsResponse, + RunStartedEvent, + RunSucceededEvent, + RunSucceededEventData, +) + + +def _create_run_payload() -> dict[str, object]: + return { + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "config": {"user": "hello"}}], + } + } + + +def _event_frame(event: RunEvent, *, event_id: str | None = None, exclude_id: bool = False) -> str: + payload = RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"} if exclude_id else None).decode() + lines: list[str] = [] + if event_id is not None: + lines.append(f"id: {event_id}") + lines.append(f"data: {payload}") + return "\n".join(lines) + "\n\n" + + +def _run_succeeded_event(*, event_id: str = "2-0", run_id: str = "run-1") -> RunSucceededEvent: + return RunSucceededEvent( + id=event_id, + run_id=run_id, + data=RunSucceededEventData(output="done", session_snapshot=CompositorSessionSnapshot(layers=[])), + ) + + +def _run_status_json(status: str) -> dict[str, object]: + now = datetime(2026, 5, 11, tzinfo=UTC).isoformat() + return {"run_id": "run-1", "status": status, "created_at": now, "updated_at": now, "error": None} + + +class DisconnectingSyncStream(httpx.SyncByteStream): + chunks: list[bytes] + + def __init__(self, *chunks: str) -> None: + self.chunks = [chunk.encode() for chunk in chunks] + + @override + def __iter__(self) -> Iterator[bytes]: + yield from self.chunks + raise httpx.ReadError("stream disconnected") + + +def test_sync_methods_parse_protocol_dtos_and_send_create_request_dto() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/runs": + payload = cast(dict[str, object], json.loads(request.content)) + composition = cast(dict[str, object], payload["composition"]) + layers = cast(list[dict[str, object]], composition["layers"]) + assert layers[0]["config"] == {"user": "hello"} + assert "compositor" not in payload + assert "agent_profile" not in payload + return httpx.Response(202, json={"run_id": "run-1", "status": "running"}) + if request.method == "GET" and request.url.path == "/runs/run-1": + return httpx.Response(200, json=_run_status_json("running")) + if request.method == "GET" and request.url.path == "/runs/run-1/events": + assert request.url.params["after"] == "0-0" + assert request.url.params["limit"] == "10" + event = RunStartedEvent(id="1-0", run_id="run-1") + return httpx.Response( + 200, + json={ + "run_id": "run-1", + "events": [cast(object, json.loads(RUN_EVENT_ADAPTER.dump_json(event)))], + "next_cursor": "1-0", + }, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = Client(base_url="http://testserver", sync_http_client=http_client) + + created = client.create_run_sync(CreateRunRequest.model_validate(_create_run_payload())) + status = client.get_run_sync(created.run_id) + events = client.get_events_sync(created.run_id, after="0-0", limit=10) + + assert created.status == "running" + assert status.status == "running" + assert isinstance(events, RunEventsResponse) + assert [event.type for event in events.events] == ["run_started"] + + +def test_async_methods_and_wait_run_parse_protocol_dtos() -> None: + statuses = iter(["running", "succeeded"]) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST" and request.url.path == "/runs": + return httpx.Response(202, json={"run_id": "run-1", "status": "running"}) + if request.method == "GET" and request.url.path == "/runs/run-1": + return httpx.Response(200, json=_run_status_json(next(statuses))) + if request.method == "GET" and request.url.path == "/runs/run-1/events": + return httpx.Response(200, json={"run_id": "run-1", "events": [], "next_cursor": "0-0"}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + async def scenario() -> None: + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = Client(base_url="http://testserver", async_http_client=http_client) + request = CreateRunRequest.model_validate(_create_run_payload()) + + created = await client.create_run(request) + events = await client.get_events(created.run_id) + terminal = await client.wait_run(created.run_id, poll_interval_seconds=0) + + assert created.run_id == "run-1" + assert events.events == [] + assert terminal.status == "succeeded" + await http_client.aclose() + + asyncio.run(scenario()) + + +def test_error_mapping_and_create_run_input_validation() -> None: + responses = iter( + [ + httpx.Response(404, json={"detail": "run not found"}), + httpx.Response(422, json={"detail": "invalid"}), + httpx.Response(500, json={"detail": "boom"}), + ] + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return next(responses) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DifyAgentNotFoundError) as not_found: + _ = client.get_run_sync("missing") + assert not_found.value.status_code == 404 + assert not_found.value.detail == "run not found" + + with pytest.raises(DifyAgentValidationError) as validation: + _ = client.get_run_sync("bad") + assert validation.value.status_code == 422 + + with pytest.raises(DifyAgentHTTPError) as server_error: + _ = client.get_run_sync("bad") + assert server_error.value.status_code == 500 + + with pytest.raises(DifyAgentValidationError): + _ = client.create_run_sync({"unknown": "field"}) # pyright: ignore[reportArgumentType] + + +def test_http_timeout_maps_to_client_timeout_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("slow", request=request) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DifyAgentTimeoutError): + _ = client.get_run_sync("run-1") + + +def test_create_run_is_not_retried_after_timeout() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ConnectTimeout("cannot connect", request=request) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DifyAgentTimeoutError): + _ = client.create_run_sync(CreateRunRequest.model_validate(_create_run_payload())) + assert attempts == 1 + + +def test_sync_sse_parser_supports_comments_multiline_data_and_id_fill() -> None: + payload = RUN_EVENT_ADAPTER.dump_json(RunStartedEvent(run_id="run-1"), exclude={"id"}).decode() + before_type, after_type = payload.split('"type"', maxsplit=1) + body = f': keepalive\nid: 5-0\nevent: run_started\ndata: {before_type}\ndata: "type"{after_type}\n\n' + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["after"] == "0-0" + return httpx.Response(200, content=body) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + events = list(client.stream_events_sync("run-1", until_terminal=False, reconnect=False)) + + assert [event.id for event in events] == ["5-0"] + assert [event.type for event in events] == ["run_started"] + + +def test_stream_events_stops_after_terminal_event() -> None: + calls = 0 + body = "".join( + [ + _event_frame(RunStartedEvent(id="1-0", run_id="run-1")), + _event_frame(_run_succeeded_event()), + ] + ) + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, content=body) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + events = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0)) + + assert [event.type for event in events] == ["run_started", "run_succeeded"] + assert calls == 1 + + +def test_stream_events_reconnects_from_latest_event_id() -> None: + seen_after: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_after.append(request.url.params["after"]) + if len(seen_after) == 1: + return httpx.Response( + 200, + stream=DisconnectingSyncStream(_event_frame(RunStartedEvent(id="1-0", run_id="run-1"))), + ) + return httpx.Response(200, content=_event_frame(_run_succeeded_event())) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + events = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0)) + + assert seen_after == ["0-0", "1-0"] + assert [event.type for event in events] == ["run_started", "run_succeeded"] + + +def test_stream_events_reconnects_after_http_5xx_response() -> None: + seen_after: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_after.append(request.url.params["after"]) + if len(seen_after) == 1: + return httpx.Response(503, json={"detail": "temporarily unavailable"}) + return httpx.Response(200, content=_event_frame(_run_succeeded_event())) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + events = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0)) + + assert seen_after == ["0-0", "0-0"] + assert [event.type for event in events] == ["run_succeeded"] + + +def test_stream_events_raises_when_reconnects_are_exhausted() -> None: + calls = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, stream=DisconnectingSyncStream()) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DifyAgentStreamError): + _ = list(client.stream_events_sync("run-1", max_reconnects=1, reconnect_delay_seconds=0)) + assert calls == 2 + + +def test_malformed_sse_frame_does_not_reconnect() -> None: + calls = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, content="data: not-json\n\n") + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + with pytest.raises(DifyAgentStreamError): + _ = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0)) + assert calls == 1 + + +def test_async_stream_events_yields_terminal_event() -> None: + body = _event_frame(_run_succeeded_event()) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=body) + + async def scenario() -> None: + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = Client(base_url="http://testserver", async_http_client=http_client) + + events = [event async for event in client.stream_events("run-1")] + + assert [event.type for event in events] == ["run_succeeded"] + await http_client.aclose() + + asyncio.run(scenario()) + + +def test_async_stream_events_reconnects_after_http_5xx_response() -> None: + seen_after: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_after.append(request.url.params["after"]) + if len(seen_after) == 1: + return httpx.Response(502, json={"detail": "bad gateway"}) + return httpx.Response(200, content=_event_frame(_run_succeeded_event())) + + async def scenario() -> None: + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = Client(base_url="http://testserver", async_http_client=http_client) + + events = [event async for event in client.stream_events("run-1", reconnect_delay_seconds=0)] + + assert seen_after == ["0-0", "0-0"] + assert [event.type for event in events] == ["run_succeeded"] + await http_client.aclose() + + asyncio.run(scenario()) + + +def test_stream_timeout_can_reconnect_until_terminal() -> None: + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + raise httpx.ReadTimeout("stream stalled", request=request) + return httpx.Response(200, content=_event_frame(_run_succeeded_event())) + + client = Client( + base_url="http://testserver", + sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + + events = list(client.stream_events_sync("run-1", reconnect_delay_seconds=0)) + + assert calls == 2 + assert [event.type for event in events] == ["run_succeeded"] diff --git a/dify-agent/tests/local/dify_agent/layers/__init__.py b/dify-agent/tests/local/dify_agent/layers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/__init__.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py new file mode 100644 index 0000000000..f6f84772ba --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py @@ -0,0 +1,71 @@ +import pytest +from pydantic import ValidationError + +import dify_agent.layers.dify_plugin as dify_plugin_exports +from dify_agent.layers.dify_plugin import ( + DifyPluginCredentialValue, + DifyPluginLLMLayerConfig, + DifyPluginLayerConfig, +) + + +def test_dify_plugin_package_exports_client_safe_config_symbols_only() -> None: + assert dify_plugin_exports.__all__ == [ + "DIFY_PLUGIN_LAYER_TYPE_ID", + "DIFY_PLUGIN_LLM_LAYER_TYPE_ID", + "DifyPluginCredentialValue", + "DifyPluginLLMLayerConfig", + "DifyPluginLayerConfig", + ] + assert dify_plugin_exports.DIFY_PLUGIN_LAYER_TYPE_ID == "dify.plugin" + assert dify_plugin_exports.DIFY_PLUGIN_LLM_LAYER_TYPE_ID == "dify.plugin.llm" + assert not hasattr(dify_plugin_exports, "DifyPluginLayer") + assert not hasattr(dify_plugin_exports, "DifyPluginLLMLayer") + + +def test_dify_plugin_layer_config_forbids_runtime_settings() -> None: + config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1", user_id="user-1") + + assert config.tenant_id == "tenant-1" + assert config.plugin_id == "plugin-1" + assert config.user_id == "user-1" + with pytest.raises(ValidationError): + _ = DifyPluginLayerConfig.model_validate( + { + "tenant_id": "tenant-1", + "plugin_id": "plugin-1", + "daemon_url": "http://daemon", + } + ) + + +def test_dify_plugin_llm_config_accepts_scalar_credentials_and_model_settings() -> None: + credential: DifyPluginCredentialValue = "secret" + config = DifyPluginLLMLayerConfig( + model_provider="openai", + model="gpt-4o-mini", + credentials={"api_key": credential, "enabled": True, "retries": 2, "ratio": 0.5, "empty": None}, + model_settings={"temperature": 0.2, "max_tokens": 64}, + ) + + assert config.model_provider == "openai" + assert config.credentials == {"api_key": "secret", "enabled": True, "retries": 2, "ratio": 0.5, "empty": None} + assert config.model_settings == {"temperature": 0.2, "max_tokens": 64} + with pytest.raises(ValidationError): + _ = DifyPluginLLMLayerConfig.model_validate( + { + "model_provider": "openai", + "model": "gpt-4o-mini", + "credentials": {"nested": {"not": "allowed"}}, + } + ) + + +def test_dify_plugin_llm_config_rejects_old_provider_field() -> None: + with pytest.raises(ValidationError): + _ = DifyPluginLLMLayerConfig.model_validate( + { + "provider": "openai", + "model": "gpt-4o-mini", + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py new file mode 100644 index 0000000000..78c833d946 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py @@ -0,0 +1,130 @@ +import asyncio + +import httpx +import pytest + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from dify_agent.adapters.llm import DifyLLMAdapterModel +from dify_agent.layers.dify_plugin.configs import ( + DIFY_PLUGIN_LAYER_TYPE_ID, + DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + DifyPluginLLMLayerConfig, + DifyPluginLayerConfig, +) +from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer + + +def _plugin_config() -> DifyPluginLayerConfig: + return DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai", user_id="user-1") + + +def _llm_config() -> DifyPluginLLMLayerConfig: + return DifyPluginLLMLayerConfig( + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + model_settings={"temperature": 0.2}, + ) + + +def _plugin_layer() -> DifyPluginLayer: + return DifyPluginLayer.from_config_with_settings( + _plugin_config(), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ) + + +def _plugin_provider() -> LayerProvider[DifyPluginLayer]: + return LayerProvider.from_factory( + layer_type=DifyPluginLayer, + create=lambda config: DifyPluginLayer.from_config_with_settings( + DifyPluginLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + + +def test_dify_plugin_type_id_constants_match_implementation_classes() -> None: + assert DIFY_PLUGIN_LAYER_TYPE_ID == DifyPluginLayer.type_id + assert DIFY_PLUGIN_LLM_LAYER_TYPE_ID == DifyPluginLLMLayer.type_id + + +def test_dify_plugin_layer_creates_daemon_provider_from_shared_http_client() -> None: + async def scenario() -> None: + plugin = _plugin_layer() + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + provider = plugin.create_daemon_provider(http_client=client) + + assert provider.name == "DifyPlugin/langgenius/openai" + assert provider.client.http_client is client + assert provider.client.tenant_id == "tenant-1" + assert provider.client.plugin_id == "langgenius/openai" + assert provider.client.user_id == "user-1" + + async with provider: + pass + assert client.is_closed is False + + asyncio.run(scenario()) + + +def test_dify_plugin_layer_rejects_closed_shared_http_client() -> None: + async def scenario() -> None: + plugin = _plugin_layer() + client = httpx.AsyncClient() + await client.aclose() + + with pytest.raises(RuntimeError, match="open shared HTTP client"): + _ = plugin.create_daemon_provider(http_client=client) + + asyncio.run(scenario()) + + +def test_dify_plugin_llm_layer_builds_adapter_model_from_direct_dependency() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("renamed-plugin", _plugin_provider()), + LayerNode("llm", DifyPluginLLMLayer, deps={"plugin": "renamed-plugin"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + async with compositor.enter( + configs={ + "renamed-plugin": _plugin_config(), + "llm": _llm_config(), + } + ) as run: + plugin = run.get_layer("renamed-plugin", DifyPluginLayer) + llm = run.get_layer("llm", DifyPluginLLMLayer) + + model = llm.get_model(http_client=client) + + assert llm.deps.plugin is plugin + assert isinstance(model, DifyLLMAdapterModel) + assert model.model_name == "demo-model" + assert model.model_provider == "openai" + assert model.credentials == {"api_key": "secret"} + assert model.provider.name == "DifyPlugin/langgenius/openai" + assert model.provider.client.http_client is client + + asyncio.run(scenario()) + + +def test_dify_plugin_layer_lifecycle_does_not_manage_http_client() -> None: + async def scenario() -> None: + compositor = Compositor([LayerNode("plugin", _plugin_provider())]) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client: + async with compositor.enter(configs={"plugin": _plugin_config()}) as run: + plugin = run.get_layer("plugin", DifyPluginLayer) + provider = plugin.create_daemon_provider(http_client=client) + run.suspend_layer_on_exit("plugin") + + assert run.session_snapshot is not None + assert provider.client.http_client is client + assert client.is_closed is False + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/output/__init__.py b/dify-agent/tests/local/dify_agent/layers/output/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dify-agent/tests/local/dify_agent/layers/output/test_configs.py b/dify-agent/tests/local/dify_agent/layers/output/test_configs.py new file mode 100644 index 0000000000..608c5f5a6f --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/output/test_configs.py @@ -0,0 +1,270 @@ +import pytest +from pydantic import JsonValue, TypeAdapter, ValidationError +from pydantic_ai.output import ToolOutput + +import dify_agent.layers.output as output_exports +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig +from dify_agent.layers.output.output_layer import DifyOutputLayer + + +def _json_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "title": {"type": "string"}, + "severity": {"type": "string", "enum": ["low", "medium", "high"]}, + "actions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "severity", "actions"], + "additionalProperties": False, + } + + +def _recursive_json_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": {"node": {"$ref": "#/$defs/node"}}, + "$defs": { + "node": { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/node"}}, + "additionalProperties": False, + } + }, + "additionalProperties": False, + } + + +def _remote_ref_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "title": {"$ref": "https://example.com/schema.json"}, + }, + } + + +def _literal_dollar_ref_value_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "payload": { + "const": { + "$ref": "https://example.com/literal", + "kind": "literal", + }, + }, + "metadata": { + "type": "object", + "examples": [ + { + "$ref": "https://example.com/example", + "note": "example value", + } + ], + }, + }, + "required": ["payload", "metadata"], + "additionalProperties": False, + } + + +def _object_local_definitions_ref_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "items": {"$ref": "#/definitions/itemArray"}, + }, + "required": ["items"], + "definitions": { + "itemArray": { + "type": "array", + "items": {"type": "string"}, + }, + }, + } + + +def _object_local_defs_ref_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "items": {"$ref": "#/$defs/itemArray"}, + }, + "required": ["items"], + "$defs": { + "itemArray": { + "type": "array", + "items": {"type": "string"}, + }, + }, + } + + +def _invalid_json_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "title": {"type": "wat"}, + }, + } + + +def _validated_output_type(output_spec: object) -> object: + assert isinstance(output_spec, ToolOutput) + return output_spec.output + + +def test_output_package_exports_client_safe_config_symbols_only() -> None: + assert output_exports.__all__ == ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"] + assert output_exports.DIFY_OUTPUT_LAYER_TYPE_ID == "dify.output" + assert not hasattr(output_exports, "DifyOutputLayer") + + +def test_output_layer_config_accepts_valid_object_schema_and_defaults_name() -> None: + config = DifyOutputLayerConfig(json_schema=_json_schema()) + + assert DIFY_OUTPUT_LAYER_TYPE_ID == "dify.output" + assert config.name == "final_result" + assert config.description is None + assert config.strict is None + + +def test_output_layer_config_rejects_non_object_top_level_json_schema() -> None: + with pytest.raises(ValidationError, match="Schema must declare an object output"): + _ = DifyOutputLayerConfig(json_schema={"type": "array", "items": {"type": "string"}}) + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + ({"json_schema": _json_schema(), "name": "bad name"}, "letters, numbers, underscores, or hyphens"), + ({"json_schema": _json_schema(), "unknown": True}, "Extra inputs are not permitted"), + ], +) +def test_output_layer_config_rejects_invalid_input(payload: dict[str, object], message: str) -> None: + with pytest.raises(ValidationError, match=message): + _ = DifyOutputLayerConfig.model_validate(payload) + + +def test_output_layer_builds_validated_output_contract_for_object_schema() -> None: + config = DifyOutputLayerConfig( + json_schema=_json_schema(), + name="incident_summary", + description="Structured incident summary.", + strict=True, + ) + + layer = DifyOutputLayer.from_config(config) + output_contract = layer.build_output_contract() + output_type = output_contract.output_type + output_schema = TypeAdapter(output_type.output).json_schema() if isinstance(output_type, ToolOutput) else {} + valid_output = {"title": "Database outage", "severity": "high", "actions": ["page on-call"]} + output_adapter = TypeAdapter(_validated_output_type(output_contract.output_type)) + + assert isinstance(output_type, ToolOutput) + assert output_type.name == "incident_summary" + assert output_type.description is None + assert output_type.strict is True + assert output_schema["type"] == "object" + assert output_schema["title"] == "incident_summary" + assert output_schema["description"] == "Structured incident summary." + assert output_adapter.validate_python(valid_output) == valid_output + + +@pytest.mark.parametrize( + ("invalid_output", "message"), + [ + ( + {"title": "Database outage", "severity": "high", "actions": "page on-call"}, + "Output does not match JSON Schema", + ), + ({"title": "Database outage", "actions": []}, "Output does not match JSON Schema"), + ({"title": "Database outage", "severity": "urgent", "actions": []}, "Output does not match JSON Schema"), + ( + {"title": "Database outage", "severity": "high", "actions": [], "extra": True}, + "Output does not match JSON Schema", + ), + ], +) +def test_output_layer_object_contract_retries_invalid_model_output(invalid_output: JsonValue, message: str) -> None: + output_contract = DifyOutputLayer.from_config( + DifyOutputLayerConfig(json_schema=_json_schema()) + ).build_output_contract() + output_adapter = TypeAdapter(_validated_output_type(output_contract.output_type)) + + with pytest.raises(ValidationError, match=message): + _ = output_adapter.validate_python(invalid_output) + + +def test_output_layer_rejects_non_defs_local_ref_in_direct_object_schema() -> None: + layer = DifyOutputLayer.from_config(DifyOutputLayerConfig(json_schema=_object_local_definitions_ref_schema())) + + with pytest.raises(ValueError, match=r"Only local refs under '#/\$defs/' are supported"): + _ = layer.build_output_contract() + + +def test_output_layer_keeps_local_defs_ref_working_in_direct_object_schema() -> None: + output_contract = DifyOutputLayer.from_config( + DifyOutputLayerConfig(json_schema=_object_local_defs_ref_schema(), name="direct_defs_result") + ).build_output_contract() + output_adapter = TypeAdapter(_validated_output_type(output_contract.output_type)) + output_schema = output_adapter.json_schema() + + assert isinstance(output_contract.output_type, ToolOutput) + assert output_schema == { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["items"], + "title": "direct_defs_result", + } + assert output_adapter.validate_python({"items": ["a", "b"]}) == {"items": ["a", "b"]} + + +def test_output_layer_rejects_recursive_json_schema_during_contract_build() -> None: + layer = DifyOutputLayer.from_config(DifyOutputLayerConfig(json_schema=_recursive_json_schema())) + + with pytest.raises(ValueError): + _ = layer.build_output_contract() + + +def test_output_layer_rejects_invalid_json_schema_during_contract_build() -> None: + layer = DifyOutputLayer.from_config(DifyOutputLayerConfig(json_schema=_invalid_json_schema())) + + with pytest.raises(ValueError): + _ = layer.build_output_contract() + + +def test_output_layer_rejects_remote_ref_during_contract_build() -> None: + layer = DifyOutputLayer.from_config(DifyOutputLayerConfig(json_schema=_remote_ref_schema())) + + with pytest.raises(ValueError, match=r"Remote \$ref values are not supported"): + _ = layer.build_output_contract() + + +def test_output_layer_allows_literal_dollar_ref_values_under_const_and_examples() -> None: + layer = DifyOutputLayer.from_config(DifyOutputLayerConfig(json_schema=_literal_dollar_ref_value_schema())) + + output_contract = layer.build_output_contract() + output_adapter = TypeAdapter(_validated_output_type(output_contract.output_type)) + + assert output_adapter.validate_python( + { + "payload": { + "$ref": "https://example.com/literal", + "kind": "literal", + }, + "metadata": {"note": "runtime value"}, + } + ) == { + "payload": { + "$ref": "https://example.com/literal", + "kind": "literal", + }, + "metadata": {"note": "runtime value"}, + } diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py new file mode 100644 index 0000000000..ffdad4207b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -0,0 +1,174 @@ +import pytest +from pydantic import ValidationError +from pydantic_ai.messages import FinalResultEvent + +from agenton.compositor import CompositorSessionSnapshot +from agenton.layers import ExitIntent +from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig +import dify_agent.protocol as protocol_exports +from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig +from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID +from dify_agent.protocol.schemas import ( + RUN_EVENT_ADAPTER, + CreateRunRequest, + LayerExitSignals, + PydanticAIStreamRunEvent, + RunComposition, + RunFailedEvent, + RunFailedEventData, + RunLayerSpec, + RunStartedEvent, + RunSucceededEvent, + RunSucceededEventData, + normalize_composition, +) +from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig + + +def test_run_event_adapter_round_trips_typed_variants() -> None: + events = [ + RunStartedEvent(run_id="run-1"), + PydanticAIStreamRunEvent(run_id="run-1", data=FinalResultEvent(tool_name=None, tool_call_id=None)), + RunSucceededEvent( + run_id="run-1", + data=RunSucceededEventData( + output={"answer": ["done"]}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ), + RunFailedEvent(run_id="run-1", data=RunFailedEventData(error="boom", reason="shutdown")), + ] + + for event in events: + payload = RUN_EVENT_ADAPTER.dump_json(event) + decoded = RUN_EVENT_ADAPTER.validate_json(payload) + + assert decoded.type == event.type + assert decoded.run_id == event.run_id + + +def test_pydantic_ai_event_data_uses_agent_stream_event_model() -> None: + event = RUN_EVENT_ADAPTER.validate_python( + { + "run_id": "run-1", + "type": "pydantic_ai_event", + "data": {"event_kind": "final_result", "tool_name": None, "tool_call_id": None}, + } + ) + + assert isinstance(event, PydanticAIStreamRunEvent) + assert isinstance(event.data, FinalResultEvent) + + +def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is_public() -> None: + assert DIFY_AGENT_MODEL_LAYER_ID == "llm" + assert DIFY_AGENT_OUTPUT_LAYER_ID == "output" + with pytest.raises(ValidationError): + _ = CreateRunRequest.model_validate( + { + "compositor": {"layers": []}, + } + ) + + +def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_graph_config() -> None: + prompt_config = PromptLayerConfig(prefix="system", user="hello") + plugin_config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai") + llm_config = DifyPluginLLMLayerConfig( + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ) + output_config = DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ) + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="prompt", type=PLAIN_PROMPT_LAYER_TYPE_ID, config=prompt_config), + RunLayerSpec(name="plugin", type=DIFY_PLUGIN_LAYER_TYPE_ID, config=plugin_config), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + deps={"plugin": "plugin"}, + config=llm_config, + ), + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=output_config, + ), + ] + ) + ) + + graph_config, layer_configs = normalize_composition(request.composition) + payload = request.model_dump(mode="json") + + assert payload["composition"]["layers"][0]["config"] == {"prefix": "system", "user": "hello", "suffix": []} + assert [layer.model_dump(mode="json") for layer in graph_config.layers] == [ + {"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "deps": {}, "metadata": {}}, + {"name": "plugin", "type": DIFY_PLUGIN_LAYER_TYPE_ID, "deps": {}, "metadata": {}}, + { + "name": DIFY_AGENT_MODEL_LAYER_ID, + "type": DIFY_PLUGIN_LLM_LAYER_TYPE_ID, + "deps": {"plugin": "plugin"}, + "metadata": {}, + }, + { + "name": DIFY_AGENT_OUTPUT_LAYER_ID, + "type": DIFY_OUTPUT_LAYER_TYPE_ID, + "deps": {}, + "metadata": {}, + }, + ] + assert layer_configs == { + "prompt": prompt_config, + "plugin": plugin_config, + DIFY_AGENT_MODEL_LAYER_ID: llm_config, + DIFY_AGENT_OUTPUT_LAYER_ID: output_config, + } + + +def test_on_exit_default_to_suspend_and_are_public() -> None: + assert protocol_exports.LayerExitSignals is LayerExitSignals + assert protocol_exports.RunComposition is RunComposition + assert protocol_exports.RunLayerSpec is RunLayerSpec + assert protocol_exports.normalize_composition is normalize_composition + assert protocol_exports.DIFY_AGENT_OUTPUT_LAYER_ID == DIFY_AGENT_OUTPUT_LAYER_ID + request = CreateRunRequest.model_validate({"composition": {"layers": []}}) + + assert request.on_exit.default is ExitIntent.SUSPEND + assert request.on_exit.layers == {} + + +def test_on_exit_accept_layer_overrides() -> None: + request = CreateRunRequest.model_validate( + { + "composition": {"layers": []}, + "on_exit": { + "default": "delete", + "layers": {"prompt": "suspend", "llm": "delete"}, + }, + } + ) + + assert request.on_exit.default is ExitIntent.DELETE + assert request.on_exit.layers == {"prompt": ExitIntent.SUSPEND, "llm": ExitIntent.DELETE} + + +def test_layer_exit_signals_reject_extra_fields() -> None: + with pytest.raises(ValidationError): + _ = LayerExitSignals.model_validate({"default": "suspend", "unknown": "value"}) + + +@pytest.mark.parametrize("event_type", ["agent_output", "session_snapshot"]) +def test_removed_non_terminal_payload_events_are_rejected(event_type: str) -> None: + with pytest.raises(ValidationError): + _ = RUN_EVENT_ADAPTER.validate_python({"run_id": "run-1", "type": event_type, "data": {}}) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py new file mode 100644 index 0000000000..2aedacc703 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py @@ -0,0 +1,546 @@ +import asyncio +from collections import defaultdict +from collections.abc import Mapping + +import httpx +import pytest + +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import ExitIntent, LifecycleState +from agenton_collections.layers.plain import PromptLayerConfig +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig +from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID +from dify_agent.protocol.schemas import ( + CreateRunRequest, + LayerExitSignals, + RunComposition, + RunEvent, + RunLayerSpec, + RunStatus, +) +from dify_agent.runtime.run_scheduler import ( + RunRequestValidationError, + RunScheduler, + SchedulerStoppingError, + validate_run_request, +) +from dify_agent.server.schemas import RunRecord + + +def _request( + user: str | list[str] = "hello", + *, + output_config: Mapping[str, object] | DifyOutputLayerConfig | None = None, +) -> CreateRunRequest: + layers = [RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user=user))] + if output_config is not None: + layers.append( + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=output_config, + ) + ) + + return CreateRunRequest(composition=RunComposition(layers=layers)) + + +def _recursive_output_schema() -> dict[str, object]: + return { + "type": "object", + "properties": {"node": {"$ref": "#/$defs/node"}}, + "$defs": { + "node": { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/node"}}, + "additionalProperties": False, + } + }, + "additionalProperties": False, + } + + +class FakeStore: + records: dict[str, RunRecord] + events: dict[str, list[RunEvent]] + statuses: dict[str, RunStatus] + errors: dict[str, str | None] + + def __init__(self) -> None: + self.records = {} + self.events = defaultdict(list) + self.statuses = {} + self.errors = {} + + async def create_run(self) -> RunRecord: + run_id = f"run-{len(self.records) + 1}" + record = RunRecord(run_id=run_id, status="running") + self.records[run_id] = record + self.statuses[run_id] = "running" + return record + + async def append_event(self, event: RunEvent) -> str: + event_id = str(len(self.events[event.run_id]) + 1) + self.events[event.run_id].append(event.model_copy(update={"id": event_id})) + return event_id + + async def update_status(self, run_id: str, status: RunStatus, error: str | None = None) -> None: + self.statuses[run_id] = status + self.errors[run_id] = error + + +class SlowCreateStore(FakeStore): + create_started: asyncio.Event + release_create: asyncio.Event + + def __init__(self, *, create_started: asyncio.Event, release_create: asyncio.Event) -> None: + super().__init__() + self.create_started = create_started + self.release_create = release_create + + async def create_run(self) -> RunRecord: + _ = self.create_started.set() + await self.release_create.wait() + return await super().create_run() + + +class ControlledRunner: + started: asyncio.Event + release: asyncio.Event + + def __init__(self, *, started: asyncio.Event, release: asyncio.Event) -> None: + self.started = started + self.release = release + + async def run(self) -> None: + _ = self.started.set() + await self.release.wait() + + +def test_create_run_starts_background_task_and_returns_running() -> None: + async def scenario() -> None: + store = FakeStore() + started = asyncio.Event() + release = asyncio.Event() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler( + store=store, + plugin_daemon_http_client=client, + runner_factory=lambda _record, _request: ControlledRunner(started=started, release=release), + ) + + record = await scheduler.create_run(_request()) + await asyncio.wait_for(started.wait(), timeout=1) + + assert record.status == "running" + assert list(scheduler.active_tasks) == [record.run_id] + _ = release.set() + await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) + await asyncio.sleep(0) + assert scheduler.active_tasks == {} + + asyncio.run(scenario()) + + +def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None: + async def scenario() -> None: + store = FakeStore() + started = asyncio.Event() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler( + store=store, + plugin_daemon_http_client=client, + shutdown_grace_seconds=0, + runner_factory=lambda _record, _request: ControlledRunner(started=started, release=asyncio.Event()), + ) + record = await scheduler.create_run(_request()) + await asyncio.wait_for(started.wait(), timeout=1) + + await scheduler.shutdown() + + assert scheduler.stopping is True + assert scheduler.active_tasks == {} + assert store.statuses[record.run_id] == "failed" + assert store.errors[record.run_id] == "run cancelled during server shutdown" + assert [event.type for event in store.events[record.run_id]] == ["run_failed"] + + asyncio.run(scenario()) + + +def test_create_run_rejects_blank_prompt_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + with pytest.raises(ValueError, match="run.user_prompts must not be empty"): + await scheduler.create_run(_request(["", " "])) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_invalid_output_schema_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + with pytest.raises(ValueError, match=r"Recursive \$defs refs are not supported"): + await scheduler.create_run( + _request( + output_config={ + "name": "incident_summary", + "json_schema": _recursive_output_schema(), + } + ) + ) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_remote_ref_output_schema_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + with pytest.raises(ValueError, match=r"Remote \$ref values are not supported"): + await scheduler.create_run( + _request( + output_config={ + "name": "incident_summary", + "json_schema": { + "type": "object", + "properties": { + "title": {"$ref": "https://example.com/schema.json"}, + }, + }, + } + ) + ) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_non_object_output_schema_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + with pytest.raises(ValueError, match="Schema must declare an object output"): + await scheduler.create_run( + _request( + output_config={ + "name": "incident_actions", + "json_schema": { + "type": "array", + "items": {"type": "string"}, + }, + } + ) + ) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_non_defs_local_ref_in_direct_object_schema_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + with pytest.raises(ValueError, match=r"Only local refs under '#/\$defs/' are supported"): + await scheduler.create_run( + _request( + output_config={ + "name": "incident_summary", + "json_schema": { + "type": "object", + "properties": { + "items": {"$ref": "#/definitions/itemArray"}, + }, + "required": ["items"], + "definitions": { + "itemArray": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, + } + ) + ) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_misnamed_output_layer_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), + RunLayerSpec( + name="structured-output", + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ), + ), + ] + ) + ) + + with pytest.raises(ValueError, match="must use reserved layer name 'output'"): + await scheduler.create_run(request) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_multiple_output_layers_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ), + ), + RunLayerSpec( + name="secondary-output", + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + "additionalProperties": False, + } + ), + ), + ] + ) + ) + + with pytest.raises(ValueError, match="Only one 'dify.output' layer is supported"): + await scheduler.create_run(request) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_reserved_output_name_with_wrong_layer_type_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, type="plain.prompt", config=PromptLayerConfig(user="hi") + ), + ] + ) + ) + + with pytest.raises(ValueError, match=r"Layer 'output' must be DifyOutputLayer, got PromptLayer"): + await scheduler.create_run(request) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_validate_run_request_honors_explicit_empty_layer_providers() -> None: + async def scenario() -> None: + with pytest.raises(RunRequestValidationError, match="plain.prompt"): + await validate_run_request(_request(), layer_providers=()) + + asyncio.run(scenario()) + + +def test_validate_run_request_rejects_misnamed_output_layer_before_provider_checks() -> None: + async def scenario() -> None: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")), + RunLayerSpec( + name="structured-output", + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ), + ), + ] + ) + ) + + with pytest.raises(RunRequestValidationError, match="must use reserved layer name 'output'"): + await validate_run_request(request, layer_providers=()) + + asyncio.run(scenario()) + + +def test_create_run_rejects_unknown_layer_exit_signal_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + request = _request() + request.on_exit = LayerExitSignals(layers={"missing": ExitIntent.DELETE}) + + with pytest.raises(ValueError, match="missing"): + await scheduler.create_run(request) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_honors_explicit_empty_layer_providers_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, layer_providers=()) + + with pytest.raises(RunRequestValidationError, match="plain.prompt"): + await scheduler.create_run(_request()) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_closed_session_snapshot_before_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + request = _request() + request.session_snapshot = CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="prompt", + lifecycle_state=LifecycleState.CLOSED, + runtime_state={}, + ) + ] + ) + + with pytest.raises(ValueError, match="CLOSED snapshots cannot be entered"): + _ = await scheduler.create_run(request) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_create_run_rejects_after_shutdown_starts() -> None: + async def scenario() -> None: + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=FakeStore(), plugin_daemon_http_client=client) + await scheduler.shutdown() + + with pytest.raises(SchedulerStoppingError): + await scheduler.create_run(_request()) + + asyncio.run(scenario()) + + +def test_create_run_rejects_invalid_request_after_shutdown_without_persisting() -> None: + async def scenario() -> None: + store = FakeStore() + async with httpx.AsyncClient() as client: + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + await scheduler.shutdown() + + with pytest.raises(SchedulerStoppingError): + _ = await scheduler.create_run(_request(["", " "])) + + assert store.records == {} + + asyncio.run(scenario()) + + +def test_shutdown_waits_for_in_flight_create_to_register_before_cancelling() -> None: + async def scenario() -> None: + create_started = asyncio.Event() + release_create = asyncio.Event() + runner_started = asyncio.Event() + store = SlowCreateStore(create_started=create_started, release_create=release_create) + async with httpx.AsyncClient() as client: + scheduler = RunScheduler( + store=store, + plugin_daemon_http_client=client, + shutdown_grace_seconds=0, + runner_factory=lambda _record, _request: ControlledRunner( + started=runner_started, release=asyncio.Event() + ), + ) + + create_task = asyncio.create_task(scheduler.create_run(_request())) + await asyncio.wait_for(create_started.wait(), timeout=1) + shutdown_task = asyncio.create_task(scheduler.shutdown()) + await asyncio.sleep(0) + + assert shutdown_task.done() is False + assert scheduler.stopping is False + + _ = release_create.set() + record = await asyncio.wait_for(create_task, timeout=1) + await asyncio.wait_for(shutdown_task, timeout=1) + + assert scheduler.stopping is True + assert scheduler.active_tasks == {} + assert store.statuses[record.run_id] == "failed" + assert [event.type for event in store.events[record.run_id]] == ["run_failed"] + + with pytest.raises(SchedulerStoppingError): + await scheduler.create_run(_request()) + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py new file mode 100644 index 0000000000..8469139327 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -0,0 +1,834 @@ +import asyncio +from collections.abc import Mapping +from typing import Any + +import httpx +import pytest +from pydantic_ai.exceptions import UnexpectedModelBehavior +from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart +from pydantic_ai.models import ModelRequestParameters +from pydantic_ai.models.test import TestModel +from pydantic_ai.settings import ModelSettings + +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import ExitIntent, LifecycleState +from agenton_collections.layers.plain import PromptLayerConfig +from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig +from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig +from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID +from dify_agent.protocol.schemas import ( + CreateRunRequest, + LayerExitSignals, + RunComposition, + RunLayerSpec, + RunSucceededEvent, +) +from dify_agent.runtime.event_sink import InMemoryRunEventSink +from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError + + +def _request( + user: str | list[str] = "hello", + *, + llm_layer_name: str = DIFY_AGENT_MODEL_LAYER_ID, + plugin_layer_name: str = "plugin", + on_exit: LayerExitSignals | None = None, + output_config: Mapping[str, object] | DifyOutputLayerConfig | None = None, +) -> CreateRunRequest: + layers = [ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user=user), + ), + RunLayerSpec( + name=plugin_layer_name, + type="dify.plugin", + config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + ), + RunLayerSpec( + name=llm_layer_name, + type="dify.plugin.llm", + deps={"plugin": plugin_layer_name}, + config=DifyPluginLLMLayerConfig( + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + ] + if output_config is not None: + layers.append( + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=output_config, + ) + ) + + return CreateRunRequest( + composition=RunComposition(layers=layers), + on_exit=on_exit or LayerExitSignals(), + ) + + +def _recursive_output_schema() -> dict[str, object]: + return { + "type": "object", + "properties": {"node": {"$ref": "#/$defs/node"}}, + "$defs": { + "node": { + "type": "object", + "properties": {"child": {"$ref": "#/$defs/node"}}, + "additionalProperties": False, + } + }, + "additionalProperties": False, + } + + +class SequenceOutputTestModel(TestModel): + outputs: list[str | dict[str, Any] | None] + request_count: int + + def __init__(self, outputs: list[str | dict[str, Any] | None]) -> None: + super().__init__(call_tools=[]) + self.outputs = outputs + self.request_count = 0 + + def _request( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + ) -> ModelResponse: + if not model_request_parameters.output_tools: + return super()._request(messages, model_settings, model_request_parameters) + + output_tool = model_request_parameters.output_tools[0] + next_index = min(self.request_count, len(self.outputs) - 1) + output_args = self.outputs[next_index] + self.request_count += 1 + return ModelResponse( + parts=[ + ToolCallPart( + output_tool.name, + output_args, + tool_call_id=f"pyd_ai_tool_call_id__{output_tool.name}_{self.request_count}", + ) + ], + model_name=self.model_name, + ) + + +def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: + seen_clients: list[httpx.AsyncClient] = [] + + def fake_get_model(self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert self.config.model == "demo-model" + assert self.deps.plugin.config.plugin_id == "langgenius/openai" + seen_clients.append(http_client) + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = _request(plugin_layer_name="renamed-plugin") + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-1", + plugin_daemon_http_client=client, + ).run() + assert seen_clients == [client] + assert client.is_closed is False + + asyncio.run(scenario()) + + event_types = [event.type for event in sink.events["run-1"]] + assert event_types[0] == "run_started" + assert "pydantic_ai_event" in event_types + assert "agent_output" not in event_types + assert "session_snapshot" not in event_types + assert event_types[-1:] == ["run_succeeded"] + terminal = sink.events["run-1"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert terminal.data.output == "done" + assert [layer.name for layer in terminal.data.session_snapshot.layers] == [ + "prompt", + "renamed-plugin", + DIFY_AGENT_MODEL_LAYER_ID, + ] + assert [layer.lifecycle_state for layer in terminal.data.session_snapshot.layers] == [ + LifecycleState.SUSPENDED, + LifecycleState.SUSPENDED, + LifecycleState.SUSPENDED, + ] + assert sink.statuses["run-1"] == "succeeded" + + +def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = _request( + on_exit=LayerExitSignals( + default=ExitIntent.SUSPEND, + layers={"prompt": ExitIntent.DELETE, DIFY_AGENT_MODEL_LAYER_ID: ExitIntent.DELETE}, + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-exit", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + terminal = sink.events["run-exit"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert {layer.name: layer.lifecycle_state for layer in terminal.data.session_snapshot.layers} == { + "prompt": LifecycleState.CLOSED, + "plugin": LifecycleState.SUSPENDED, + DIFY_AGENT_MODEL_LAYER_ID: LifecycleState.CLOSED, + } + + +def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model = TestModel( + custom_output_args={ + "title": "Database outage", + "severity": "high", + "actions": ["page on-call", "open incident bridge"], + } + ) + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return model # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = _request( + output_config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": { + "title": {"type": "string"}, + "severity": {"type": "string", "enum": ["low", "medium", "high"]}, + "actions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "severity", "actions"], + "additionalProperties": False, + }, + name="incident_summary", + description="Structured incident summary returned by the agent.", + strict=True, + ) + ) + sink = InMemoryRunEventSink() + expected_snapshot_layer_names = ["prompt", "plugin", DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID] + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-structured-output", + plugin_daemon_http_client=client, + ).run() + + first_terminal = sink.events["run-structured-output"][-1] + assert isinstance(first_terminal, RunSucceededEvent) + + resumed_request = request.model_copy(deep=True) + resumed_request.session_snapshot = first_terminal.data.session_snapshot + + await AgentRunRunner( + sink=sink, + request=resumed_request, + run_id="run-structured-output-resume", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert model.last_model_request_parameters is not None + assert len(model.last_model_request_parameters.output_tools) == 1 + output_tool = model.last_model_request_parameters.output_tools[0] + assert output_tool.name == "incident_summary" + assert output_tool.description == "Structured incident summary returned by the agent." + assert output_tool.parameters_json_schema["type"] == "object" + assert output_tool.parameters_json_schema["title"] == "incident_summary" + assert output_tool.parameters_json_schema["properties"] == { + "title": {"type": "string"}, + "severity": {"type": "string", "enum": ["low", "medium", "high"]}, + "actions": {"type": "array", "items": {"type": "string"}}, + } + assert output_tool.parameters_json_schema["required"] == ["title", "severity", "actions"] + assert output_tool.parameters_json_schema["additionalProperties"] is False + terminal = sink.events["run-structured-output"][-1] + resumed_terminal = sink.events["run-structured-output-resume"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert isinstance(resumed_terminal, RunSucceededEvent) + assert terminal.data.output == { + "title": "Database outage", + "severity": "high", + "actions": ["page on-call", "open incident bridge"], + } + assert resumed_terminal.data.output == terminal.data.output + assert [layer.name for layer in terminal.data.session_snapshot.layers] == expected_snapshot_layer_names + assert [layer.name for layer in resumed_terminal.data.session_snapshot.layers] == expected_snapshot_layer_names + assert all(layer.lifecycle_state is LifecycleState.SUSPENDED for layer in terminal.data.session_snapshot.layers) + assert all( + layer.lifecycle_state is LifecycleState.SUSPENDED for layer in resumed_terminal.data.session_snapshot.layers + ) + + +def test_runner_retries_invalid_structured_output_and_eventually_succeeds(monkeypatch: pytest.MonkeyPatch) -> None: + model = SequenceOutputTestModel( + outputs=[ + {"title": "Database outage", "severity": "high", "actions": "page on-call"}, + {"title": "Database outage", "severity": "high", "actions": ["page on-call"]}, + ] + ) + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return model # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = _request( + output_config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": { + "title": {"type": "string"}, + "severity": {"type": "string", "enum": ["low", "medium", "high"]}, + "actions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "severity", "actions"], + "additionalProperties": False, + }, + name="incident_summary", + description="Structured incident summary returned by the agent.", + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-output-retry-success", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + terminal = sink.events["run-output-retry-success"][-1] + assert isinstance(terminal, RunSucceededEvent) + assert terminal.data.output == { + "title": "Database outage", + "severity": "high", + "actions": ["page on-call"], + } + assert model.request_count == 2 + + +def test_runner_fails_when_invalid_structured_output_exhausts_retries(monkeypatch: pytest.MonkeyPatch) -> None: + model = TestModel( + custom_output_args={ + "title": "Database outage", + "severity": "high", + "actions": "page on-call", + } + ) + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + assert http_client.is_closed is False + return model # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = _request( + output_config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": { + "title": {"type": "string"}, + "severity": {"type": "string", "enum": ["low", "medium", "high"]}, + "actions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["title", "severity", "actions"], + "additionalProperties": False, + }, + name="incident_summary", + description="Structured incident summary returned by the agent.", + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(UnexpectedModelBehavior): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-output-retry-failed", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + event_types = [event.type for event in sink.events["run-output-retry-failed"]] + assert event_types[0] == "run_started" + assert event_types[-1] == "run_failed" + assert "run_succeeded" not in event_types + assert sink.statuses["run-output-retry-failed"] == "failed" + + +def test_runner_rejects_invalid_output_layer_before_model_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + model_requested = False + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + del http_client + nonlocal model_requested + model_requested = True + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = _request( + output_config={ + "name": "incident_summary", + "json_schema": _recursive_output_schema(), + } + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match=r"Recursive \$defs refs are not supported"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-invalid-output", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert model_requested is False + assert [event.type for event in sink.events["run-invalid-output"]] == ["run_started", "run_failed"] + assert sink.statuses["run-invalid-output"] == "failed" + + +def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + model_requested = False + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="plugin", + type="dify.plugin", + config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"plugin": "plugin"}, + config=DifyPluginLLMLayerConfig( + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="structured-output", + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ), + ), + ] + ), + on_exit=LayerExitSignals(), + ) + sink = InMemoryRunEventSink() + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + del http_client + nonlocal model_requested + model_requested = True + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="must use reserved layer name 'output'"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-misnamed-output", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert model_requested is False + assert [event.type for event in sink.events["run-misnamed-output"]] == ["run_started", "run_failed"] + assert sink.statuses["run-misnamed-output"] == "failed" + + +def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + model_requested = False + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="plugin", + type="dify.plugin", + config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"plugin": "plugin"}, + config=DifyPluginLLMLayerConfig( + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ), + ), + RunLayerSpec( + name="secondary-output", + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + "additionalProperties": False, + } + ), + ), + ] + ), + on_exit=LayerExitSignals(), + ) + sink = InMemoryRunEventSink() + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + del http_client + nonlocal model_requested + model_requested = True + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="Only one 'dify.output' layer is supported"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-duplicate-output", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert model_requested is False + assert [event.type for event in sink.events["run-duplicate-output"]] == ["run_started", "run_failed"] + assert sink.statuses["run-duplicate-output"] == "failed" + + +def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_resolution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + model_requested = False + + def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient): + del http_client + nonlocal model_requested + model_requested = True + return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="plugin", + type="dify.plugin", + config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"plugin": "plugin"}, + config=DifyPluginLLMLayerConfig( + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name=DIFY_AGENT_OUTPUT_LAYER_ID, + type="plain.prompt", + config=PromptLayerConfig(user="not structured output"), + ), + ] + ), + on_exit=LayerExitSignals(), + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises( + AgentRunValidationError, match=r"Layer 'output' must be DifyOutputLayer, got PromptLayer" + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-wrong-output-type", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert model_requested is False + assert [event.type for event in sink.events["run-wrong-output-type"]] == ["run_started", "run_failed"] + assert sink.statuses["run-wrong-output-type"] == "failed" + + +def test_runner_rejects_misnamed_output_layer_before_provider_checks() -> None: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="structured-output", + type=DIFY_OUTPUT_LAYER_TYPE_ID, + config=DifyOutputLayerConfig( + json_schema={ + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + } + ), + ), + ] + ), + on_exit=LayerExitSignals(), + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="must use reserved layer name 'output'"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-misnamed-output-before-providers", + plugin_daemon_http_client=client, + layer_providers=(), + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-misnamed-output-before-providers"]] == [ + "run_started", + "run_failed", + ] + assert sink.statuses["run-misnamed-output-before-providers"] == "failed" + + +def test_runner_rejects_unknown_on_exit_layer_id() -> None: + request = _request(on_exit=LayerExitSignals(layers={"missing": ExitIntent.DELETE})) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="missing"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-unknown-signal", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-unknown-signal"]] == ["run_started", "run_failed"] + assert sink.statuses["run-unknown-signal"] == "failed" + + +def test_runner_honors_explicit_empty_layer_providers() -> None: + request = _request() + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="plain.prompt"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-empty-providers", + plugin_daemon_http_client=client, + layer_providers=(), + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-empty-providers"]] == ["run_started", "run_failed"] + assert sink.statuses["run-empty-providers"] == "failed" + + +def test_runner_fails_empty_user_prompts() -> None: + request = _request("") + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-2", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-2"]] == ["run_started", "run_failed"] + assert sink.statuses["run-2"] == "failed" + + +def test_runner_fails_blank_string_user_prompt_list() -> None: + request = _request(["", " "]) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-3", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-3"]] == ["run_started", "run_failed"] + assert sink.statuses["run-3"] == "failed" + + +def test_runner_requires_llm_layer_id() -> None: + request = _request(llm_layer_name="not-llm") + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="llm"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-4", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-4"]] == ["run_started", "run_failed"] + assert sink.statuses["run-4"] == "failed" + + +def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None: + request = _request() + request.session_snapshot = CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="prompt", + lifecycle_state=LifecycleState.CLOSED, + runtime_state={}, + ), + LayerSessionSnapshot( + name="plugin", + lifecycle_state=LifecycleState.NEW, + runtime_state={}, + ), + LayerSessionSnapshot( + name=DIFY_AGENT_MODEL_LAYER_ID, + lifecycle_state=LifecycleState.NEW, + runtime_state={}, + ), + ] + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="CLOSED snapshots cannot be entered"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-closed-snapshot", + plugin_daemon_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-closed-snapshot"]] == ["run_started", "run_failed"] + assert sink.statuses["run-closed-snapshot"] == "failed" diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py new file mode 100644 index 0000000000..73bfde69bd --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from typing import ClassVar + +import pytest +from fastapi.testclient import TestClient + +import dify_agent.server.app as app_module +from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider +from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig +from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer +from dify_agent.server.app import create_app, create_plugin_daemon_http_client +from dify_agent.server.settings import ServerSettings +from dify_agent.storage.redis_run_store import RedisRunStore + + +class FakeRedis: + closed: bool + + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + +class FakeRunScheduler: + created: list["FakeRunScheduler"] = [] + + store: object + shutdown_grace_seconds: float + layer_providers: tuple[DifyAgentLayerProvider, ...] + plugin_daemon_http_client: FakePluginDaemonHttpClient + shutdown_called: bool + + def __init__( + self, + *, + store: object, + plugin_daemon_http_client: FakePluginDaemonHttpClient, + shutdown_grace_seconds: float, + layer_providers: tuple[DifyAgentLayerProvider, ...], + ) -> None: + self.store = store + self.shutdown_grace_seconds = shutdown_grace_seconds + self.layer_providers = layer_providers + self.plugin_daemon_http_client = plugin_daemon_http_client + self.shutdown_called = False + self.created.append(self) + + async def shutdown(self) -> None: + self.shutdown_called = True + + +class FakePluginDaemonHttpClient: + timeout: object | None + limits: object | None + trust_env: bool | None + is_closed: bool + + def __init__( + self, + *, + timeout: object | None = None, + limits: object | None = None, + trust_env: bool | None = None, + ) -> None: + self.timeout = timeout + self.limits = limits + self.trust_env = trust_env + self.is_closed = False + + async def aclose(self) -> None: + self.is_closed = True + + +class FakeTimeout: + connect: float + read: float + write: float + pool: float + + def __init__(self, *, connect: float, read: float, write: float, pool: float) -> None: + self.connect = connect + self.read = read + self.write = write + self.pool = pool + + +class FakeLimits: + max_connections: int + max_keepalive_connections: int + keepalive_expiry: float + + def __init__(self, *, max_connections: int, max_keepalive_connections: int, keepalive_expiry: float) -> None: + self.max_connections = max_connections + self.max_keepalive_connections = max_keepalive_connections + self.keepalive_expiry = keepalive_expiry + + +class FakeRedisModule: + fake_redis: ClassVar[FakeRedis | None] = None + + @staticmethod + def from_url(_url: str) -> FakeRedis: + assert FakeRedisModule.fake_redis is not None + return FakeRedisModule.fake_redis + + +class FakeHttpxModule: + Timeout: ClassVar[type[FakeTimeout]] = FakeTimeout + Limits: ClassVar[type[FakeLimits]] = FakeLimits + AsyncClient: ClassVar[type[FakePluginDaemonHttpClient]] = FakePluginDaemonHttpClient + + +def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: + fake_redis = FakeRedis() + fake_http_client = FakePluginDaemonHttpClient() + FakeRunScheduler.created.clear() + FakeRedisModule.fake_redis = fake_redis + monkeypatch.setattr(app_module, "Redis", FakeRedisModule) + monkeypatch.setattr(app_module, "RunScheduler", FakeRunScheduler) + + def fake_create_plugin_daemon_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: + return fake_http_client + + monkeypatch.setattr(app_module, "create_plugin_daemon_http_client", fake_create_plugin_daemon_http_client) + + settings = ServerSettings( + redis_url="redis://example.invalid/0", + redis_prefix="test", + shutdown_grace_seconds=5, + run_retention_seconds=7, + plugin_daemon_url="http://plugin-daemon", + plugin_daemon_api_key="daemon-secret", + plugin_daemon_connect_timeout=1, + plugin_daemon_read_timeout=2, + plugin_daemon_write_timeout=3, + plugin_daemon_pool_timeout=4, + plugin_daemon_max_connections=5, + plugin_daemon_max_keepalive_connections=3, + plugin_daemon_keepalive_expiry=6, + ) + + with TestClient(create_app(settings)): + assert len(FakeRunScheduler.created) == 1 + scheduler = FakeRunScheduler.created[0] + assert scheduler.shutdown_grace_seconds == 5 + layer_providers = scheduler.layer_providers + assert isinstance(layer_providers, tuple) + plugin_provider = next(provider for provider in layer_providers if provider.type_id == "dify.plugin") + plugin_layer = plugin_provider.create_layer(DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1")) + assert isinstance(plugin_layer, DifyPluginLayer) + assert plugin_layer.daemon_url == "http://plugin-daemon" + assert plugin_layer.daemon_api_key == "daemon-secret" + http_client = scheduler.plugin_daemon_http_client + assert http_client is fake_http_client + assert http_client.is_closed is False + store = scheduler.store + assert isinstance(store, RedisRunStore) + assert store.run_retention_seconds == 7 + + assert FakeRunScheduler.created[0].shutdown_called is True + assert FakeRunScheduler.created[0].plugin_daemon_http_client.is_closed is True + assert fake_redis.closed is True + + +def test_create_plugin_daemon_http_client_uses_configured_httpx_construction_args( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(app_module, "httpx", FakeHttpxModule) + + client = create_plugin_daemon_http_client(ServerSettings()) + + assert isinstance(client, FakePluginDaemonHttpClient) + assert isinstance(client.timeout, FakeTimeout) + assert client.timeout.connect == 10 + assert client.timeout.read == 600 + assert client.timeout.write == 30 + assert client.timeout.pool == 10 + assert isinstance(client.limits, FakeLimits) + assert client.limits.max_connections == 100 + assert client.limits.max_keepalive_connections == 20 + assert client.limits.keepalive_expiry == 30 + assert client.trust_env is False diff --git a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py new file mode 100644 index 0000000000..c173816a51 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py @@ -0,0 +1,235 @@ +from fastapi.testclient import TestClient + +from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID +from dify_agent.runtime.run_scheduler import RunRequestValidationError, SchedulerStoppingError +from dify_agent.server.routes.runs import create_runs_router +from dify_agent.server.schemas import RunRecord + + +class FakeScheduler: + async def create_run(self, request: object) -> object: + del request + raise RunRequestValidationError("run.user_prompts must not be empty") + + +class FakeStore: + pass + + +def test_create_run_rejects_effectively_blank_user_prompt_list() -> None: + from fastapi import FastAPI + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: FakeScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": "plain.prompt", "config": {"user": ["", " "]}}], + } + }, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "run.user_prompts must not be empty" + + +def test_create_run_returns_running_from_scheduler() -> None: + from fastapi import FastAPI + + class CapturingScheduler: + async def create_run(self, request: object) -> RunRecord: + del request + return RunRecord(run_id="run-1", status="running") + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: CapturingScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}], + } + }, + ) + + assert response.status_code == 202 + assert response.json() == {"run_id": "run-1", "status": "running"} + + +def test_create_run_accepts_valid_full_plugin_graph() -> None: + from fastapi import FastAPI + + class CapturingScheduler: + async def create_run(self, request: object) -> RunRecord: + del request + return RunRecord(run_id="run-1", status="running") + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: CapturingScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [ + {"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}, + { + "name": "plugin-renamed", + "type": "dify.plugin", + "config": {"tenant_id": "tenant-1", "plugin_id": "langgenius/openai"}, + }, + { + "name": DIFY_AGENT_MODEL_LAYER_ID, + "type": "dify.plugin.llm", + "deps": {"plugin": "plugin-renamed"}, + "config": { + "model_provider": "openai", + "model": "gpt-4o-mini", + "credentials": {"api_key": "secret"}, + "model_settings": {"temperature": 0.2}, + }, + }, + ], + } + }, + ) + + assert response.status_code == 202 + assert response.json() == {"run_id": "run-1", "status": "running"} + + +def test_create_run_rejects_unknown_layer_exit_signal_before_scheduling() -> None: + from fastapi import FastAPI + + class UnknownSignalScheduler: + async def create_run(self, request: object) -> RunRecord: + del request + raise RunRequestValidationError("on_exit.layers references unknown layer ids: missing.") + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: UnknownSignalScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}], + }, + "on_exit": {"layers": {"missing": "delete"}}, + }, + ) + + assert response.status_code == 422 + assert "missing" in response.json()["detail"] + + +def test_create_run_rejects_closed_session_snapshot_with_422() -> None: + from fastapi import FastAPI + + class ClosedSnapshotScheduler: + async def create_run(self, request: object) -> RunRecord: + del request + raise RunRequestValidationError("Layer 'prompt' is closed; CLOSED snapshots cannot be entered.") + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: ClosedSnapshotScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}], + }, + "session_snapshot": { + "schema_version": 1, + "layers": [ + { + "name": "prompt", + "lifecycle_state": "closed", + "runtime_state": {}, + } + ], + }, + }, + ) + + assert response.status_code == 422 + assert "CLOSED snapshots cannot be entered" in response.json()["detail"] + + +def test_create_run_returns_503_when_scheduler_is_stopping() -> None: + from fastapi import FastAPI + + class StoppingScheduler: + async def create_run(self, request: object) -> RunRecord: + del request + raise SchedulerStoppingError("run scheduler is shutting down") + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: StoppingScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}], + } + }, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "run scheduler is shutting down" + + +def test_create_run_does_not_map_infrastructure_failure_to_422() -> None: + from fastapi import FastAPI + + class FailingScheduler: + async def create_run(self, request: object) -> RunRecord: + del request + raise RuntimeError("redis unavailable") + + app = FastAPI() + app.include_router( + create_runs_router(lambda: FakeStore(), lambda: FailingScheduler()) # pyright: ignore[reportArgumentType] + ) + client = TestClient(app, raise_server_exceptions=False) + + response = client.post( + "/runs", + json={ + "composition": { + "schema_version": 1, + "layers": [{"name": "prompt", "type": "plain.prompt", "config": {"user": "hello"}}], + } + }, + ) + + assert response.status_code == 500 diff --git a/dify-agent/tests/local/dify_agent/server/test_schemas.py b/dify-agent/tests/local/dify_agent/server/test_schemas.py new file mode 100644 index 0000000000..e627c9e53f --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_schemas.py @@ -0,0 +1,12 @@ +import dify_agent.server.schemas as server_schemas + + +def test_server_schemas_do_not_reexport_public_protocol_dtos() -> None: + assert server_schemas.__all__ == ["RunRecord", "new_run_id"] + assert not hasattr(server_schemas, "CreateRunRequest") + assert not hasattr(server_schemas, "RunStartedEvent") + + +def test_server_schemas_keep_server_only_run_helpers() -> None: + assert isinstance(server_schemas.new_run_id(), str) + assert hasattr(server_schemas, "RunRecord") diff --git a/dify-agent/tests/local/dify_agent/server/test_sse.py b/dify-agent/tests/local/dify_agent/server/test_sse.py new file mode 100644 index 0000000000..64201a8080 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_sse.py @@ -0,0 +1,12 @@ +from dify_agent.protocol.schemas import RunStartedEvent +from dify_agent.server.sse import format_sse_event + + +def test_format_sse_event_uses_id_event_and_json_data() -> None: + event = RunStartedEvent(id="7-0", run_id="run-1") + + frame = format_sse_event(event) + + assert frame.startswith("id: 7-0\nevent: run_started\ndata: ") + assert '"run_id":"run-1"' in frame + assert frame.endswith("\n\n") diff --git a/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py b/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py new file mode 100644 index 0000000000..f835652536 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/storage/test_redis_run_store.py @@ -0,0 +1,144 @@ +import asyncio +from collections.abc import Mapping +from typing import cast + +from pydantic import JsonValue + +from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot +from agenton.layers import LifecycleState +from dify_agent.protocol.schemas import RunStartedEvent, RunSucceededEvent, RunSucceededEventData +from dify_agent.storage.redis_run_store import DEFAULT_RUN_RETENTION_SECONDS, RedisRunStore + + +class FakeRedis: + commands: list[tuple[object, ...]] + values: dict[str, object] + streams: dict[str, list[tuple[str, dict[str, object]]]] + + def __init__(self) -> None: + self.commands = [] + self.values = {} + self.streams = {} + + async def set(self, key: str, value: object, *, ex: int | None = None) -> None: + self.commands.append(("set", key, value, ex)) + self.values[key] = value + + async def get(self, key: str) -> object | None: + self.commands.append(("get", key)) + return self.values.get(key) + + async def xadd(self, key: str, fields: Mapping[str, object]) -> str: + self.commands.append(("xadd", key, dict(fields))) + entries = self.streams.setdefault(key, []) + event_id = f"{len(entries) + 1}-0" + entries.append((event_id, dict(fields))) + return event_id + + async def xrange( + self, key: str, *, min: str = "-", count: int | None = None + ) -> list[tuple[str, dict[str, object]]]: + self.commands.append(("xrange", key, min, count)) + entries = [entry for entry in self.streams.get(key, []) if self._is_after_min(entry[0], min)] + if count is not None: + return entries[:count] + return entries + + async def expire(self, key: str, seconds: int) -> bool: + self.commands.append(("expire", key, seconds)) + return True + + @staticmethod + def _is_after_min(event_id: str, min_id: str) -> bool: + if min_id == "-": + return True + is_exclusive = min_id.startswith("(") + cursor = min_id[1:] if is_exclusive else min_id + event_value = FakeRedis._stream_id_value(event_id) + cursor_value = FakeRedis._stream_id_value(cursor) + return event_value > cursor_value if is_exclusive else event_value >= cursor_value + + @staticmethod + def _stream_id_value(event_id: str) -> tuple[int, int]: + timestamp, sequence = event_id.split("-", maxsplit=1) + return int(timestamp), int(sequence) + + +def test_create_run_writes_running_record_without_job_queue_and_with_retention() -> None: + redis = FakeRedis() + store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType] + + record = asyncio.run(store.create_run()) + + assert record.status == "running" + assert [command[0] for command in redis.commands] == ["set"] + assert redis.commands[0][1] == f"test:runs:{record.run_id}:record" + assert redis.commands[0][3] == DEFAULT_RUN_RETENTION_SECONDS + assert "request" not in str(redis.commands[0][2]) + + +def test_update_status_refreshes_record_retention() -> None: + redis = FakeRedis() + store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType] + record = asyncio.run(store.create_run()) + redis.commands.clear() + + asyncio.run(store.update_status(record.run_id, "succeeded")) + + assert [command[0] for command in redis.commands] == ["get", "set"] + assert redis.commands[1][1] == f"test:runs:{record.run_id}:record" + assert redis.commands[1][3] == 60 + + +def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() -> None: + redis = FakeRedis() + store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType] + + event_id = asyncio.run(store.append_event(RunStartedEvent(id="local", run_id="run-1"))) + + assert event_id == "1-0" + assert redis.commands[0][0] == "xadd" + fields = redis.commands[0][2] + assert isinstance(fields, dict) + assert '"id"' not in str(fields["payload"]) + assert '"type":"run_started"' in str(fields["payload"]) + assert redis.commands[1:] == [ + ("expire", "test:runs:run-1:events", 60), + ("expire", "test:runs:run-1:record", 60), + ] + + +def test_get_events_round_trips_run_succeeded_output_and_session_snapshot() -> None: + redis = FakeRedis() + store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType] + output = cast(JsonValue, {"answer": ["done", 1], "ok": True}) + session_snapshot = CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="prompt", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={"resource_id": "abc"}, + ) + ] + ) + + async def scenario() -> tuple[str, RunSucceededEvent]: + record = await store.create_run() + event_id = await store.append_event( + RunSucceededEvent( + id="local-only", + run_id=record.run_id, + data=RunSucceededEventData(output=output, session_snapshot=session_snapshot), + ) + ) + page = await store.get_events(record.run_id, after="0-0", limit=10) + decoded = page.events[0] + assert isinstance(decoded, RunSucceededEvent) + assert page.next_cursor == event_id + return event_id, decoded + + event_id, decoded = asyncio.run(scenario()) + + assert decoded.id == event_id + assert decoded.data.output == output + assert decoded.data.session_snapshot == session_snapshot diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py new file mode 100644 index 0000000000..7ff55b167b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def _run_import_check(*, blocked_imports: list[str], imports: list[str], assertions: list[str]) -> None: + python_path = os.pathsep.join([str(PROJECT_ROOT / "src"), os.environ.get("PYTHONPATH", "")]) + module_aliases = {module_name: module_name.replace(".", "_") for module_name in imports} + script = "\n".join( + [ + "import builtins", + "import importlib", + f"blocked_imports = {blocked_imports!r}", + f"imports = {imports!r}", + f"module_aliases = {module_aliases!r}", + f"assertions = {assertions!r}", + "original_import = builtins.__import__", + "def guarded_import(name, globals=None, locals=None, fromlist=(), level=0):", + " for blocked in blocked_imports:", + " if name == blocked or name.startswith(f'{blocked}.'):", + " raise ModuleNotFoundError(f'blocked import: {name}')", + " return original_import(name, globals, locals, fromlist, level)", + "builtins.__import__ = guarded_import", + "namespace = {}", + "for module_name in imports:", + " namespace[module_aliases[module_name]] = importlib.import_module(module_name)", + "for statement in assertions:", + " exec(statement, namespace)", + ] + ) + env = os.environ.copy() + env["PYTHONPATH"] = python_path + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_dify_agent_root_import_is_client_safe() -> None: + _run_import_check( + blocked_imports=[ + "anthropic", + "dify_agent.adapters.llm", + "dify_agent.runtime", + "dify_agent.server", + "fastapi", + "google", + "graphon", + "openai", + "pydantic_settings", + "redis", + ], + imports=["dify_agent"], + assertions=[ + "from dify_agent import Client", + "assert dify_agent.__all__ == ['Client']", + "assert dify_agent.Client is Client", + "assert not hasattr(dify_agent, 'DifyLLMAdapterModel')", + "assert not hasattr(dify_agent, 'DifyPluginDaemonProvider')", + ], + ) + + +def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> None: + _run_import_check( + blocked_imports=[ + "anthropic", + "dify_agent.adapters.llm", + "dify_agent.layers.dify_plugin.llm_layer", + "dify_agent.layers.dify_plugin.plugin_layer", + "dify_agent.layers.output.output_layer", + "dify_agent.runtime", + "dify_agent.server", + "fastapi", + "google", + "graphon", + "openai", + "pydantic_settings", + "redis", + ], + imports=["dify_agent.protocol", "dify_agent.layers.dify_plugin", "dify_agent.layers.output"], + assertions=[ + "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", + "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LAYER_TYPE_ID', 'DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginLayerConfig']", + "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", + ], + ) + + +def test_agenton_collection_roots_do_not_eagerly_import_pydantic_ai_implementations() -> None: + _run_import_check( + blocked_imports=[ + "agenton_collections.layers.pydantic_ai", + "agenton_collections.transformers.pydantic_ai", + ], + imports=["agenton_collections", "agenton_collections.transformers"], + assertions=[ + "assert 'PydanticAIBridgeLayer' not in agenton_collections.__all__", + "assert agenton_collections_transformers.__all__ == []", + ], + ) diff --git a/dify-agent/tests/local/examples/test_agenton_examples.py b/dify-agent/tests/local/examples/test_agenton_examples.py new file mode 100644 index 0000000000..545f5e6ef5 --- /dev/null +++ b/dify-agent/tests/local/examples/test_agenton_examples.py @@ -0,0 +1,59 @@ +import os +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def _run_example(path: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + _ = env.pop("OPENAI_API_KEY", None) + python_path = os.pathsep.join( + [ + str(PROJECT_ROOT / "src"), + str(PROJECT_ROOT / "examples" / "agenton"), + str(PROJECT_ROOT / "examples" / "dify_agent"), + env.get("PYTHONPATH", ""), + ] + ) + env["PYTHONPATH"] = python_path + + return subprocess.run( + [sys.executable, "-m", path], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + +def test_agenton_basics_example_smoke() -> None: + result = _run_example("agenton_examples.basics") + + assert result.returncode == 0, result.stderr + assert "Prompts:" in result.stdout + assert "User prompts:" in result.stdout + assert "Tools:" in result.stdout + assert "Lifecycle: ['create', 'suspend', 'resume', 'delete']" in result.stdout + + +def test_agenton_pydantic_ai_example_smoke() -> None: + result = _run_example("agenton_examples.pydantic_ai_bridge") + + assert result.returncode == 0, result.stderr + assert "SystemPromptPart: Prefer concrete details." in result.stdout + assert "UserPromptPart: [\"Use the tools for 'layer composition'.\"]" in result.stdout + assert "ToolCallPart: count_words(" in result.stdout + assert "ToolCallPart: write_tagline(" in result.stdout + assert "TextPart:" in result.stdout + + +def test_agenton_session_snapshot_example_smoke() -> None: + result = _run_example("agenton_examples.session_snapshot") + + assert result.returncode == 0, result.stderr + assert "Snapshot:" in result.stdout + assert "Rehydrated external handle: restored:demo-connection" in result.stdout diff --git a/dify-agent/tests/local/examples/test_dify_agent_examples.py b/dify-agent/tests/local/examples/test_dify_agent_examples.py new file mode 100644 index 0000000000..d0530181c7 --- /dev/null +++ b/dify-agent/tests/local/examples/test_dify_agent_examples.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def test_dify_agent_examples_are_importable_from_repo_checkout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.syspath_prepend(str(PROJECT_ROOT / "examples" / "dify_agent")) + + for module_name in [ + "dify_agent_examples.run_pydantic_ai_agent", + "dify_agent_examples.run_server_consumer", + "dify_agent_examples.run_server_sse_consumer", + "dify_agent_examples.run_server_sync_client", + ]: + importlib.import_module(module_name) diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py new file mode 100644 index 0000000000..a1a7b024e0 --- /dev/null +++ b/dify-agent/tests/local/test_packaging.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +CLIENT_SHARED_DTO_DEPENDENCIES = { + "httpx>=0.28.1", + "pydantic>=2.13.3", + "pydantic-ai-slim>=1.85.1", + "typing-extensions>=4.12.2", +} + +SERVER_RUNTIME_DEPENDENCIES = { + "fastapi>=0.136.0", + "graphon~=0.2.2", + "jsonschema>=4.23.0", + "pydantic-ai-slim[anthropic,google,openai]>=1.85.1", + "pydantic-settings>=2.12.0", + "redis>=5", + "uvicorn[standard]>=0.38.0", +} + + +def _read_pyproject(): + return tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + +def test_project_dependencies_split_client_and_server_requirements() -> None: + pyproject = _read_pyproject() + project = pyproject["project"] + + assert set(project["dependencies"]) == CLIENT_SHARED_DTO_DEPENDENCIES + assert set(project["optional-dependencies"]["server"]) == SERVER_RUNTIME_DEPENDENCIES + + +def test_default_package_discovery_excludes_example_packages() -> None: + pyproject = _read_pyproject() + find_config = pyproject["tool"]["setuptools"]["packages"]["find"] + + assert find_config["where"] == ["src"] + assert "agenton_examples*" not in find_config["include"] + assert "dify_agent_examples*" not in find_config["include"] diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock new file mode 100644 index 0000000000..d3b4b09ba0 --- /dev/null +++ b/dify-agent/uv.lock @@ -0,0 +1,3716 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.96.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/7e/672f533dee813028d2c699bfd2a7f52c9118d7353680d9aa44b9e23f717f/anthropic-0.96.0.tar.gz", hash = "sha256:9de947b737f39452f68aa520f1c2239d44119c9b73b0fb6d4e6ca80f00279ee6", size = 658210, upload-time = "2026-04-16T14:28:02.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/5a/72f33204064b6e87601a71a6baf8d855769f8a0c1eaae8d06a1094872371/anthropic-0.96.0-py3-none-any.whl", hash = "sha256:9a6e335a354602a521cd9e777e92bfd46ba6e115bf9bbfe6135311e8fb2015b2", size = 635930, upload-time = "2026-04-16T14:28:01.436Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "blis" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d0/d8cc8c9a4488a787e7fa430f6055e5bd1ddb22c340a751d9e901b82e2efe/blis-1.3.3.tar.gz", hash = "sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd", size = 2644873, upload-time = "2025-11-17T12:28:30.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/d1/429cf0cf693d4c7dc2efed969bd474e315aab636e4a95f66c4ed7264912d/blis-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a1c74e100665f8e918ebdbae2794576adf1f691680b5cdb8b29578432f623ef", size = 6929663, upload-time = "2025-11-17T12:27:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/11/69/363c8df8d98b3cc97be19aad6aabb2c9c53f372490d79316bdee92d476e7/blis-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f6c595185176ce021316263e1a1d636a3425b6c48366c1fd712d08d0b71849a", size = 1230939, upload-time = "2025-11-17T12:27:46.19Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/fbf65d906d823d839076c5150a6f8eb5ecbc5f9135e0b6510609bda1e6b7/blis-1.3.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d734b19fba0be7944f272dfa7b443b37c61f9476d9ab054a9ac53555ceadd2e0", size = 2818835, upload-time = "2025-11-17T12:27:48.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/58deaa3ad856dd3cc96493e40ffd2ed043d18d4d304f85a65cde1ccbf644/blis-1.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ef6d6e2b599a3a2788eb6d9b443533961265aa4ec49d574ed4bb846e548dcdb", size = 11366550, upload-time = "2025-11-17T12:27:49.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/82/816a7adfe1f7acc8151f01ec86ef64467a3c833932d8f19f8e06613b8a4e/blis-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8c888438ae99c500422d50698e3028b65caa8ebb44e24204d87fda2df64058f7", size = 3023686, upload-time = "2025-11-17T12:27:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/0e93b865f648b5519360846669a35f28ee8f4e1d93d054f6850d8afbabde/blis-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8177879fd3590b5eecdd377f9deafb5dc8af6d684f065bd01553302fb3fcf9a7", size = 14250939, upload-time = "2025-11-17T12:27:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/20/07/fb43edc2ff0a6a367e4a94fc39eb3b85aa1e55e24cc857af2db145ce9f0d/blis-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:f20f7ad69aaffd1ce14fe77de557b6df9b61e0c9e582f75a843715d836b5c8af", size = 6192759, upload-time = "2025-11-17T12:27:56.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f7/d26e62d9be3d70473a63e0a5d30bae49c2fe138bebac224adddcdef8a7ce/blis-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1e647341f958421a86b028a2efe16ce19c67dba2a05f79e8f7e80b1ff45328aa", size = 6928322, upload-time = "2025-11-17T12:27:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/750d12da388f714958eb2f2fd177652323bbe7ec528365c37129edd6eb84/blis-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d563160f874abb78a57e346f07312c5323f7ad67b6370052b6b17087ef234a8e", size = 1229635, upload-time = "2025-11-17T12:28:00.118Z" }, + { url = "https://files.pythonhosted.org/packages/e8/36/eac4199c5b200a5f3e93cad197da8d26d909f218eb444c4f552647c95240/blis-1.3.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30b8a5b90cb6cb81d1ada9ae05aa55fb8e70d9a0ae9db40d2401bb9c1c8f14c4", size = 2815650, upload-time = "2025-11-17T12:28:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/bf/51/472e7b36a6bedb5242a9757e7486f702c3619eff76e256735d0c8b1679c6/blis-1.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9f5c53b277f6ac5b3ca30bc12ebab7ea16c8f8c36b14428abb56924213dc127", size = 11359008, upload-time = "2025-11-17T12:28:04.589Z" }, + { url = "https://files.pythonhosted.org/packages/84/da/d0dfb6d6e6321ae44df0321384c32c322bd07b15740d7422727a1a49fc5d/blis-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6297e7616c158b305c9a8a4e47ca5fc9b0785194dd96c903b1a1591a7ca21ddf", size = 3011959, upload-time = "2025-11-17T12:28:06.862Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2b0b5e556fa0364ed671051ea078a6d6d7b979b1cfef78d64ad3ca5f0c7f/blis-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f966ca74f89f8a33e568b9a1d71992fc9a0d29a423e047f0a212643e21b5458", size = 14232456, upload-time = "2025-11-17T12:28:08.779Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/4cdc81a47bf862c0b06d91f1bc6782064e8b69ac9b5d4ff51d97e4ff03da/blis-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97", size = 6192624, upload-time = "2025-11-17T12:28:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8a/80f7c68fbc24a76fc9c18522c46d6d69329c320abb18e26a707a5d874083/blis-1.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415", size = 6934081, upload-time = "2025-11-17T12:28:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/e5/52/d1aa3a51a7fc299b0c89dcaa971922714f50b1202769eebbdaadd1b5cff7/blis-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b", size = 1231486, upload-time = "2025-11-17T12:28:18.008Z" }, + { url = "https://files.pythonhosted.org/packages/99/4f/badc7bd7f74861b26c10123bba7b9d16f99cd9535ad0128780360713820f/blis-1.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0", size = 2814944, upload-time = "2025-11-17T12:28:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/72/a6/f62a3bd814ca19ec7e29ac889fd354adea1217df3183e10217de51e2eb8b/blis-1.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74", size = 11345825, upload-time = "2025-11-17T12:28:21.354Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/671af79ee42bc4c968cae35c091ac89e8721c795bfa4639100670dc59139/blis-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990", size = 3008771, upload-time = "2025-11-17T12:28:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/7cd7f8490da7c98ee01557f2105885cc597217b0e7fd2eeb9e22cdd4ef23/blis-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb", size = 14219213, upload-time = "2025-11-17T12:28:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/0a/de/acae8e9f9a1f4bb393d41c8265898b0f29772e38eac14e9f69d191e2c006/blis-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf", size = 6324695, upload-time = "2025-11-17T12:28:28.401Z" }, +] + +[[package]] +name = "catalogue" +version = "2.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/244d58127e1cdf04cf2dc7d9566f0d24ef01d5ce21811bab088ecc62b5ea/catalogue-2.0.10.tar.gz", hash = "sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15", size = 19561, upload-time = "2023-09-25T06:29:24.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/96/d32b941a501ab566a16358d68b6eb4e4acc373fab3c3c4d7d9e649f7b4bb/catalogue-2.0.10-py3-none-any.whl", hash = "sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f", size = 17325, upload-time = "2023-09-25T06:29:23.337Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "cloudpathlib" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/18/2ac35d6b3015a0c74e923d94fc69baf8307f7c3233de015d69f99e17afa8/cloudpathlib-0.23.0.tar.gz", hash = "sha256:eb38a34c6b8a048ecfd2b2f60917f7cbad4a105b7c979196450c2f541f4d6b4b", size = 53126, upload-time = "2025-10-07T22:47:56.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8a/c4bb04426d608be4a3171efa2e233d2c59a5c8937850c10d098e126df18e/cloudpathlib-0.23.0-py3-none-any.whl", hash = "sha256:8520b3b01468fee77de37ab5d50b1b524ea6b4a8731c35d1b7407ac0cd716002", size = 62755, upload-time = "2025-10-07T22:47:54.905Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "confection" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/65/efd0fe8a936fc8ca2978cb7b82581fb20d901c6039e746a808f746b7647b/confection-1.3.3.tar.gz", hash = "sha256:f0f6810d567ff73993fe74d218ca5e1ffb6a44fb03f391257fc5d033546cbfaa", size = 54895, upload-time = "2026-03-24T18:45:24.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e4/d66708bdf0d92fb4d49b22cdff4b10cec38aca5dcd7e81d909bb55c65cd7/confection-1.3.3-py3-none-any.whl", hash = "sha256:b9fef9ee84b237ef4611ec3eb5797b70e13063e6310ad9f15536373f5e313c82", size = 35902, upload-time = "2026-03-24T18:45:22.664Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, +] + +[[package]] +name = "cymem" +version = "2.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/2f0fbb32535c3731b7c2974c569fb9325e0a38ed5565a08e1139a3b71e82/cymem-2.0.13.tar.gz", hash = "sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588", size = 12320, upload-time = "2025-11-14T14:58:36.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/52/478a2911ab5028cb710b4900d64aceba6f4f882fcb13fd8d40a456a1b6dc/cymem-2.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8afbc5162a0fe14b6463e1c4e45248a1b2fe2cbcecc8a5b9e511117080da0eb", size = 43745, upload-time = "2025-11-14T14:57:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/f9/71/f0f8adee945524774b16af326bd314a14a478ed369a728a22834e6785a18/cymem-2.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9251d889348fe79a75e9b3e4d1b5fa651fca8a64500820685d73a3acc21b6a8", size = 42927, upload-time = "2025-11-14T14:57:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/159780fe162ff715d62b809246e5fc20901cef87ca28b67d255a8d741861/cymem-2.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:742fc19764467a49ed22e56a4d2134c262d73a6c635409584ae3bf9afa092c33", size = 258346, upload-time = "2025-11-14T14:57:34.917Z" }, + { url = "https://files.pythonhosted.org/packages/eb/12/678d16f7aa1996f947bf17b8cfb917ea9c9674ef5e2bd3690c04123d5680/cymem-2.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f190a92fe46197ee64d32560eb121c2809bb843341733227f51538ce77b3410d", size = 260843, upload-time = "2025-11-14T14:57:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/0dd8c167c08cd85e70d274b7235cfe1e31b3cebc99221178eaf4bbb95c6f/cymem-2.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d670329ee8dbbbf241b7c08069fe3f1d3a1a3e2d69c7d05ea008a7010d826298", size = 254607, upload-time = "2025-11-14T14:57:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/d6514a412a1160aa65db539836b3d47f9b59f6675f294ec34ae32f867c82/cymem-2.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a84ba3178d9128b9ffb52ce81ebab456e9fe959125b51109f5b73ebdfc6b60d6", size = 262421, upload-time = "2025-11-14T14:57:39.265Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fe/3ee37d02ca4040f2fb22d34eb415198f955862b5dd47eee01df4c8f5454c/cymem-2.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:2ff1c41fd59b789579fdace78aa587c5fc091991fa59458c382b116fc36e30dc", size = 40176, upload-time = "2025-11-14T14:57:40.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/fb/1b681635bfd5f2274d0caa8f934b58435db6c091b97f5593738065ddb786/cymem-2.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:6bbd701338df7bf408648191dff52472a9b334f71bcd31a21a41d83821050f67", size = 35959, upload-time = "2025-11-14T14:57:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/95a4d1e3bebfdfa7829252369357cf9a764f67569328cd9221f21e2c952e/cymem-2.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:891fd9030293a8b652dc7fb9fdc79a910a6c76fc679cd775e6741b819ffea476", size = 43478, upload-time = "2025-11-14T14:57:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/8fc929cc29ae466b7b4efc23ece99cbd3ea34992ccff319089c624d667fd/cymem-2.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89c4889bd16513ce1644ccfe1e7c473ba7ca150f0621e66feac3a571bde09e7e", size = 42695, upload-time = "2025-11-14T14:57:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b3/deeb01354ebaf384438083ffe0310209ef903db3e7ba5a8f584b06d28387/cymem-2.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45dcaba0f48bef9cc3d8b0b92058640244a95a9f12542210b51318da97c2cf28", size = 250573, upload-time = "2025-11-14T14:57:44.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/36/bc980b9a14409f3356309c45a8d88d58797d02002a9d794dd6c84e809d3a/cymem-2.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e96848faaafccc0abd631f1c5fb194eac0caee4f5a8777fdbb3e349d3a21741c", size = 254572, upload-time = "2025-11-14T14:57:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dd/a12522952624685bd0f8968e26d2ed6d059c967413ce6eb52292f538f1b0/cymem-2.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e02d3e2c3bfeb21185d5a4a70790d9df40629a87d8d7617dc22b4e864f665fa3", size = 248060, upload-time = "2025-11-14T14:57:47.605Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/5dc933ddfeb2dfea747a0b935cb965b9a7580b324d96fc5f5a1b5ff8df29/cymem-2.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fece5229fd5ecdcd7a0738affb8c59890e13073ae5626544e13825f26c019d3c", size = 254601, upload-time = "2025-11-14T14:57:48.861Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/d23b06166864fa94e13a98e5922986ce774832936473578febce64448d75/cymem-2.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:38aefeb269597c1a0c2ddf1567dd8605489b661fa0369c6406c1acd433b4c7ba", size = 40103, upload-time = "2025-11-14T14:57:50.396Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9e/c7b21271ab88a21760f3afdec84d2bc09ffa9e6c8d774ad9d4f1afab0416/cymem-2.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:717270dcfd8c8096b479c42708b151002ff98e434a7b6f1f916387a6c791e2ad", size = 36016, upload-time = "2025-11-14T14:57:51.611Z" }, + { url = "https://files.pythonhosted.org/packages/7f/28/d3b03427edc04ae04910edf1c24b993881c3ba93a9729a42bcbb816a1808/cymem-2.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7e1a863a7f144ffb345397813701509cfc74fc9ed360a4d92799805b4b865dd1", size = 46429, upload-time = "2025-11-14T14:57:52.582Z" }, + { url = "https://files.pythonhosted.org/packages/35/a9/7ed53e481f47ebfb922b0b42e980cec83e98ccb2137dc597ea156642440c/cymem-2.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c16cb80efc017b054f78998c6b4b013cef509c7b3d802707ce1f85a1d68361bf", size = 46205, upload-time = "2025-11-14T14:57:53.64Z" }, + { url = "https://files.pythonhosted.org/packages/61/39/a3d6ad073cf7f0fbbb8bbf09698c3c8fac11be3f791d710239a4e8dd3438/cymem-2.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d78a27c88b26c89bd1ece247d1d5939dba05a1dae6305aad8fd8056b17ddb51", size = 296083, upload-time = "2025-11-14T14:57:55.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/20697c8bc19f624a595833e566f37d7bcb9167b0ce69de896eba7cfc9c2d/cymem-2.0.13-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d36710760f817194dacb09d9fc45cb6a5062ed75e85f0ef7ad7aeeb13d80cc3", size = 286159, upload-time = "2025-11-14T14:57:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/82/d4/9326e3422d1c2d2b4a8fb859bdcce80138f6ab721ddafa4cba328a505c71/cymem-2.0.13-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c8f30971cadd5dcf73bcfbbc5849b1f1e1f40db8cd846c4aa7d3b5e035c7b583", size = 288186, upload-time = "2025-11-14T14:57:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/68da7dd749b72884dc22e898562f335002d70306069d496376e5ff3b6153/cymem-2.0.13-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9d441d0e45798ec1fd330373bf7ffa6b795f229275f64016b6a193e6e2a51522", size = 290353, upload-time = "2025-11-14T14:58:00.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/dbf2ad6ecd19b99b3aab6203b1a06608bbd04a09c522d836b854f2f30f73/cymem-2.0.13-cp313-cp313t-win_amd64.whl", hash = "sha256:d1c950eebb9f0f15e3ef3591313482a5a611d16fc12d545e2018cd607f40f472", size = 44764, upload-time = "2025-11-14T14:58:01.793Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/35701c13e1fc7b0895198c8b20068c569a841e0daf8e0b14d1dc0816b28f/cymem-2.0.13-cp313-cp313t-win_arm64.whl", hash = "sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f", size = 38964, upload-time = "2025-11-14T14:58:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2e/f0e1596010a9a57fa9ebd124a678c07c5b2092283781ae51e79edcf5cb98/cymem-2.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1", size = 43812, upload-time = "2025-11-14T14:58:04.227Z" }, + { url = "https://files.pythonhosted.org/packages/bc/45/8ccc21df08fcbfa6aa3efeb7efc11a1c81c90e7476e255768bb9c29ba02a/cymem-2.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b", size = 42951, upload-time = "2025-11-14T14:58:05.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/8c/fe16531631f051d3d1226fa42e2d76fd2c8d5cfa893ec93baee90c7a9d90/cymem-2.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f", size = 249878, upload-time = "2025-11-14T14:58:06.95Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/39d67b80ffb260457c05fcc545de37d82e9e2dbafc93dd6b64f17e09b933/cymem-2.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef", size = 252571, upload-time = "2025-11-14T14:58:08.232Z" }, + { url = "https://files.pythonhosted.org/packages/53/0e/76f6531f74dfdfe7107899cce93ab063bb7ee086ccd3910522b31f623c08/cymem-2.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705", size = 248555, upload-time = "2025-11-14T14:58:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/eee56757db81f0aefc2615267677ae145aff74228f529838425057003c0d/cymem-2.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e", size = 254177, upload-time = "2025-11-14T14:58:10.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/a4b58ec9e53c836dce07ef39837a64a599f4a21a134fc7ca57a3a8f9a4b5/cymem-2.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed", size = 40853, upload-time = "2025-11-14T14:58:12.116Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/9931d1f83e5aeba175440af0b28f0c2e6f71274a5a7b688bc3e907669388/cymem-2.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1", size = 36970, upload-time = "2025-11-14T14:58:13.114Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/af447c2184dec6dec973be14614df8ccb4d16d1c74e0784ab4f02538433c/cymem-2.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042", size = 46804, upload-time = "2025-11-14T14:58:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/e10f33a8d4fc17f9b933d451038218437f9326c2abb15a3e7f58ce2a06ec/cymem-2.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612", size = 46254, upload-time = "2025-11-14T14:58:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/5efeb2d2ea6ebad2745301ad33a4fa9a8f9a33b66623ee4d9185683007a6/cymem-2.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431", size = 296061, upload-time = "2025-11-14T14:58:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/2a3f65842cc8443c2c0650cf23d525be06c8761ab212e0a095a88627be1b/cymem-2.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6", size = 285784, upload-time = "2025-11-14T14:58:17.412Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/dd5f9729398f0108c2e71d942253d0d484d299d08b02e474d7cfc43ed0b0/cymem-2.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e", size = 288062, upload-time = "2025-11-14T14:58:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/5a/01/ffe51729a8f961a437920560659073e47f575d4627445216c1177ecd4a41/cymem-2.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc", size = 290465, upload-time = "2025-11-14T14:58:21.815Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/c9e7d68607f71ef978c81e334ab2898b426944c71950212b1467186f69f9/cymem-2.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1", size = 46665, upload-time = "2025-11-14T14:58:23.512Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/150e406a2db5535533aa3c946de58f0371f2e412e23f050c704588023e6e/cymem-2.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f", size = 39715, upload-time = "2025-11-14T14:58:24.773Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dify-agent" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-ai-slim" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +server = [ + { name = "fastapi" }, + { name = "graphon" }, + { name = "jsonschema" }, + { name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] }, + { name = "pydantic-settings" }, + { name = "redis" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "basedpyright" }, + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-examples" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-glightbox" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings-python" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" }, + { name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" }, + { name = "pydantic", specifier = ">=2.12.5,<2.13" }, + { name = "pydantic-ai-slim", specifier = ">=1.85.1" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" }, + { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" }, + { name = "redis", marker = "extra == 'server'", specifier = ">=5" }, + { name = "typing-extensions", specifier = ">=4.12.2" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" }, +] +provides-extras = ["server"] + +[package.metadata.requires-dev] +dev = [ + { name = "basedpyright", specifier = ">=1.39.3" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.10.7" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-examples", specifier = ">=0.0.18" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "ruff", specifier = ">=0.15.11" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1,<2" }, + { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, + { name = "mkdocs-material", specifier = ">=9.7.0" }, + { name = "mkdocstrings-python", specifier = ">=2.0.0" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "emoji" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + +[[package]] +name = "genai-prices" +version = "0.0.57" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/30/11f3d683cf3b1d9612475ad8bfffe3423ce9f50fc617733109033e73a038/genai_prices-0.0.57.tar.gz", hash = "sha256:6e101e9c53975557ceffa237b0995787d81fe75aac12410f2898504188bcad89", size = 66555, upload-time = "2026-04-21T13:42:52.554Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/fe/d0095040c120d97cb63d055224ecd4e913dc5655315c203c8e83bf13aa86/genai_prices-0.0.57-py3-none-any.whl", hash = "sha256:14e50fb69cdc5a06ddb2a6df5a7fe06741b9e44304ce3f1728f56abdf1856cca", size = 69654, upload-time = "2026-04-21T13:42:51.236Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.73.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, +] + +[[package]] +name = "graphon" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "httpx" }, + { name = "json-repair" }, + { name = "jsonschema" }, + { name = "orjson" }, + { name = "pandas", extra = ["excel"] }, + { name = "pydantic" }, + { name = "pydantic-extra-types" }, + { name = "pypandoc" }, + { name = "pypdfium2" }, + { name = "python-docx" }, + { name = "pyyaml" }, + { name = "tiktoken" }, + { name = "transformers" }, + { name = "typing-extensions" }, + { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, + { name = "webvtt-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/50/e745a79c5f742f88f6011a1f7c9ba2c2f9cc1beedd982f0b192f1ab8c748/graphon-0.2.2.tar.gz", hash = "sha256:141f0de536171850f1af6f738dc66f0285aadd3c097f1dad2a038636789e0aa5", size = 236360, upload-time = "2026-04-17T08:52:28.047Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, + { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, +] + +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/89/e7aa12d8a6b9259bed10671abb25ae6fa437c0f88a86ecbf59617bae7759/huggingface_hub-1.11.0.tar.gz", hash = "sha256:15fb3713c7f9cdff7b808a94fd91664f661ab142796bb48c9cd9493e8d166278", size = 761749, upload-time = "2026-04-16T13:07:39.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/02/4f3f8997d1ea7fe0146b343e5e14bd065fa87af790d07e5576d31b31cc18/huggingface_hub-1.11.0-py3-none-any.whl", hash = "sha256:42a6de0afbfeb5e022222d36398f029679db4eb4778801aafda32257ae9131ab", size = 645499, upload-time = "2026-04-16T13:07:37.716Z" }, +] + +[[package]] +name = "idna" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "installer" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/18/ceeb4e3ab3aa54495775775b38ae42b10a92f42ce42dfa44da684289b8c8/installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631", size = 474349, upload-time = "2023-03-17T20:39:38.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", size = 453838, upload-time = "2023-03-17T20:39:36.219Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + +[[package]] +name = "json-repair" +version = "0.59.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "langdetect" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "logfire-api" +version = "4.32.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/1b/0c74ad85f977743ba4c589e46e0cb138d6a6e69487830f4e86ebbdb145a3/logfire_api-4.32.1.tar.gz", hash = "sha256:5e8714b2bb5fb5d1f4a4a833941e4ca711b75d2c1f98e76c5ad680fe6991af6a", size = 78788, upload-time = "2026-04-15T14:11:58.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ab/d5adeab6253c7ecd5904fc5ef3265859f218610caf4e1e55efe9aff6ac49/logfire_api-4.32.1-py3-none-any.whl", hash = "sha256:4b4c27cf6e27e8e26ef4b22a77f2a2988dd1d07e2d24ee70673ef34b234fb8a5", size = 124394, upload-time = "2026-04-15T14:11:56.157Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-glightbox" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "selectolax" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/26/c793459622da8e31f954c6f5fb51e8f098143fdfc147b1e3c25bf686f4aa/mkdocs_glightbox-0.5.2.tar.gz", hash = "sha256:c7622799347c32310878e01ccf14f70648445561010911c80590cec0353370ac", size = 510586, upload-time = "2025-10-23T14:55:18.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/ca/03624e017e5ee2d7ce8a08d89f81c1e535eb3c30d7b2dc4a435ea3fbbeae/mkdocs_glightbox-0.5.2-py3-none-any.whl", hash = "sha256:23a431ea802b60b1030c73323db2eed6ba859df1a0822ce575afa43e0ea3f47e", size = 26458, upload-time = "2025-10-23T14:55:17.43Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + +[[package]] +name = "murmurhash" +version = "1.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/2e/88c147931ea9725d634840d538622e94122bceaf346233349b7b5c62964b/murmurhash-1.0.15.tar.gz", hash = "sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01", size = 13291, upload-time = "2025-11-14T09:51:15.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/46/be8522d3456fdccf1b8b049c6d82e7a3c1114c4fc2cfe14b04cba4b3e701/murmurhash-1.0.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d37e3ae44746bca80b1a917c2ea625cf216913564ed43f69d2888e5df97db0cb", size = 27884, upload-time = "2025-11-14T09:50:13.133Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/630449bf4f6178d7daf948ce46ad00b25d279065fc30abd8d706be3d87e0/murmurhash-1.0.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0861cb11039409eaf46878456b7d985ef17b6b484103a6fc367b2ecec846891d", size = 27855, upload-time = "2025-11-14T09:50:14.859Z" }, + { url = "https://files.pythonhosted.org/packages/ff/30/ea8f601a9bf44db99468696efd59eb9cff1157cd55cb586d67116697583f/murmurhash-1.0.15-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5a301decfaccfec70fe55cb01dde2a012c3014a874542eaa7cc73477bb749616", size = 134088, upload-time = "2025-11-14T09:50:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/c9/de/c40ce8c0877d406691e735b8d6e9c815f36a82b499d358313db5dbe219d7/murmurhash-1.0.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32c6fde7bd7e9407003370a07b5f4addacabe1556ad3dc2cac246b7a2bba3400", size = 133978, upload-time = "2025-11-14T09:50:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/bd49963ecd84ebab2fe66595e2d1ed41d5e8b5153af5dc930f0bd827007c/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d8b43a7011540dc3c7ce66f2134df9732e2bc3bbb4a35f6458bc755e48bde26", size = 132956, upload-time = "2025-11-14T09:50:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/2530769c545074417c862583f05f4245644599f1e9ff619b3dfe2969aafc/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43bf4541892ecd95963fcd307bf1c575fc0fee1682f41c93007adee71ca2bb40", size = 134184, upload-time = "2025-11-14T09:50:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/b249b042f5afe34d14ada2dc4afc777e883c15863296756179652e081c44/murmurhash-1.0.15-cp312-cp312-win_amd64.whl", hash = "sha256:f4ac15a2089dc42e6eb0966622d42d2521590a12c92480aafecf34c085302cca", size = 25647, upload-time = "2025-11-14T09:50:21.049Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/028179259aebc18fd4ba5cae2601d1d47517427a537ab44336446431a215/murmurhash-1.0.15-cp312-cp312-win_arm64.whl", hash = "sha256:4a70ca4ae19e600d9be3da64d00710e79dde388a4d162f22078d64844d0ebdda", size = 23338, upload-time = "2025-11-14T09:50:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/ba300b5f04dae0409202d6285668b8a9d3ade43a846abee3ef611cb388d5/murmurhash-1.0.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe50dc70e52786759358fd1471e309b94dddfffb9320d9dfea233c7684c894ba", size = 27861, upload-time = "2025-11-14T09:50:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/29c19d268e6f4ea1ed2a462c901eed1ed35b454e2cbc57da592fad663ac6/murmurhash-1.0.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1349a7c23f6092e7998ddc5bd28546cc31a595afc61e9fdb3afc423feec3d7ad", size = 27840, upload-time = "2025-11-14T09:50:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/e2/63/58e2de2b5232cd294c64092688c422196e74f9fa8b3958bdf02d33df24b9/murmurhash-1.0.15-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ba6d05de2613535b5a9227d4ad8ef40a540465f64660d4a8800634ae10e04f", size = 133080, upload-time = "2025-11-14T09:50:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/d13e2e9f8ba1ced06840921a50f7cece0a475453284158a3018b72679761/murmurhash-1.0.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa1b70b3cc2801ab44179c65827bbd12009c68b34e9d9ce7125b6a0bd35af63c", size = 132648, upload-time = "2025-11-14T09:50:27.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/47994f1813fa205c84977b0ff51ae6709f8539af052c7491a5f863d82bdc/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:213d710fb6f4ef3bc11abbfad0fa94a75ffb675b7dc158c123471e5de869f9af", size = 131502, upload-time = "2025-11-14T09:50:29.339Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/90c1fd00b4aeb704fb5e84cd666b33ffd7f245155048071ffbb51d2bb57d/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b65a5c4e7f5d71f7ccac2d2b60bdf7092d7976270878cfec59d5a66a533db823", size = 132736, upload-time = "2025-11-14T09:50:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/da73462dbfa77f6433b128d2120ba7ba300f8c06dc4f4e022c38d240a5f5/murmurhash-1.0.15-cp313-cp313-win_amd64.whl", hash = "sha256:9aba94c5d841e1904cd110e94ceb7f49cfb60a874bbfb27e0373622998fb7c7c", size = 25682, upload-time = "2025-11-14T09:50:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/bb/83/032729ef14971b938fbef41ee125fc8800020ee229bd35178b6ede8ee934/murmurhash-1.0.15-cp313-cp313-win_arm64.whl", hash = "sha256:263807eca40d08c7b702413e45cca75ecb5883aa337237dc5addb660f1483378", size = 23370, upload-time = "2025-11-14T09:50:33.264Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/7547d9205e9bd2f8e5dfd0b682cc9277594f98909f228eb359489baec1df/murmurhash-1.0.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:694fd42a74b7ce257169d14c24aa616aa6cd4ccf8abe50eca0557e08da99d055", size = 29955, upload-time = "2025-11-14T09:50:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c7/3afd5de7a5b3ae07fe2d3a3271b327ee1489c58ba2b2f2159bd31a25edb9/murmurhash-1.0.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a2ea4546ba426390beff3cd10db8f0152fdc9072c4f2583ec7d8aa9f3e4ac070", size = 30108, upload-time = "2025-11-14T09:50:35.53Z" }, + { url = "https://files.pythonhosted.org/packages/02/69/d6637ee67d78ebb2538c00411f28ea5c154886bbe1db16c49435a8a4ab16/murmurhash-1.0.15-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:34e5a91139c40b10f98d0b297907f5d5267b4b1b2e5dd2eb74a021824f751b98", size = 164054, upload-time = "2025-11-14T09:50:36.591Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4c/89e590165b4c7da6bf941441212a721a270195332d3aacfdfdf527d466ca/murmurhash-1.0.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35606868a5961cf42e79314ca0bddf5a400ce377b14d83192057928d6252ec", size = 168153, upload-time = "2025-11-14T09:50:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/95c42df0c21d2e413b9fcd17317a7587351daeb264dc29c6aec1fdbd26f8/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:43cc6ac3b91ca0f7a5ae9c063ba4d6c26972c97fd7c25280ecc666413e4c5535", size = 164345, upload-time = "2025-11-14T09:50:39.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/22/9d02c880a88b83bb3ce7d6a38fb727373ab78d82e5f3d8d9fc5612219f90/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:847d712136cb462f0e4bd6229ee2d9eb996d8854eb8312dff3d20c8f5181fda5", size = 161990, upload-time = "2025-11-14T09:50:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/750232524e0dc262e8dcede6536dafc766faadd9a52f1d23746b02948ad8/murmurhash-1.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:2680851af6901dbe66cc4aa7ef8e263de47e6e1b425ae324caa571bdf18f8d58", size = 28812, upload-time = "2025-11-14T09:50:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/4ad9d215ef6ade89f27a72dc4e86b98ef1a43534cc3e6a6900a362a0bf0a/murmurhash-1.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:189a8de4d657b5da9efd66601b0636330b08262b3a55431f2379097c986995d0", size = 25398, upload-time = "2025-11-14T09:50:43.023Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/726df275edf07688146966e15eaaa23168100b933a2e1a29b37eb56c6db8/murmurhash-1.0.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b", size = 28029, upload-time = "2025-11-14T09:50:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/24ecf9061bc2b20933df8aba47c73e904274ea8811c8300cab92f6f82372/murmurhash-1.0.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68", size = 27912, upload-time = "2025-11-14T09:50:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/ba/26/fff3caba25aa3c0622114e03c69fb66c839b22335b04d7cce91a3a126d44/murmurhash-1.0.15-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e", size = 131847, upload-time = "2025-11-14T09:50:46.819Z" }, + { url = "https://files.pythonhosted.org/packages/df/e4/0f2b9fc533467a27afb4e906c33f32d5f637477de87dd94690e0c44335a6/murmurhash-1.0.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724", size = 132267, upload-time = "2025-11-14T09:50:48.298Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/9d1c107989728ec46e25773d503aa54070b32822a18cfa7f9d5f41bc17a5/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba", size = 131894, upload-time = "2025-11-14T09:50:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/0d/81/dcf27c71445c0e993b10e33169a098ca60ee702c5c58fcbde205fa6332a6/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322", size = 132054, upload-time = "2025-11-14T09:50:50.747Z" }, + { url = "https://files.pythonhosted.org/packages/bc/32/e874a14b2d2246bd2d16f80f49fad393a3865d4ee7d66d2cae939a67a29a/murmurhash-1.0.15-cp314-cp314-win_amd64.whl", hash = "sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22", size = 26579, upload-time = "2025-11-14T09:50:52.278Z" }, + { url = "https://files.pythonhosted.org/packages/af/8e/4fca051ed8ae4d23a15aaf0a82b18cb368e8cf84f1e3b474d5749ec46069/murmurhash-1.0.15-cp314-cp314-win_arm64.whl", hash = "sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4", size = 24341, upload-time = "2025-11-14T09:50:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/c72c2a4edd86aac829337ab9f83cf04cdb15e5d503e4c9a3a243f30a261c/murmurhash-1.0.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b", size = 30146, upload-time = "2025-11-14T09:50:54.705Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d7/72b47ebc86436cd0aa1fd4c6e8779521ec389397ac11389990278d0f7a47/murmurhash-1.0.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf", size = 30141, upload-time = "2025-11-14T09:50:55.829Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/6d2f09135079c34dc2d26e961c52742d558b320c61503f273eab6ba743d9/murmurhash-1.0.15-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0", size = 163898, upload-time = "2025-11-14T09:50:56.946Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e2/9c1b462e33f9cb2d632056f07c90b502fc20bd7da50a15d0557343bd2fed/murmurhash-1.0.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e", size = 168040, upload-time = "2025-11-14T09:50:58.234Z" }, + { url = "https://files.pythonhosted.org/packages/e8/73/8694db1408fcdfa73589f7df6c445437ea146986fa1e393ec60d26d6e30c/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749", size = 164239, upload-time = "2025-11-14T09:50:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f9/8e360bdfc3c44e267e7e046f0e0b9922766da92da26959a6963f597e6bb5/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc", size = 161811, upload-time = "2025-11-14T09:51:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/f9/31/97649680595b1096803d877ababb9a67c07f4378f177ec885eea28b9db6d/murmurhash-1.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc", size = 29817, upload-time = "2025-11-14T09:51:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/4fce8755f25d77324401886c00017c556be7ca3039575b94037aff905385/murmurhash-1.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d", size = 26219, upload-time = "2025-11-14T09:51:03.563Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "odfpy" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/73/8ade73f6749177003f7ce3304f524774adda96e6aaab30ea79fd8fda7934/odfpy-1.4.1.tar.gz", hash = "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec", size = 717045, upload-time = "2020-01-18T16:55:48.852Z" } + +[[package]] +name = "olefile" +version = "0.47" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, +] + +[[package]] +name = "openai" +version = "2.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f6/8d58b32ab32d9215973a1688aebd098252ee8af1766c0e4e36e7831f0295/orjson-3.11.8-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1cd0b77e77c95758f8e1100139844e99f3ccc87e71e6fc8e1c027e55807c549f", size = 229233, upload-time = "2026-03-31T16:15:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/2ffe35e71f6b92622e8ea4607bf33ecf7dfb51b3619dcfabfd36cbe2d0a5/orjson-3.11.8-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6a3d159d5ffa0e3961f353c4b036540996bf8b9697ccc38261c0eac1fd3347a6", size = 128772, upload-time = "2026-03-31T16:15:14.237Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/1f8682ae50d5c6897a563cb96bc106da8c9cb5b7b6e81a52e4cc086679b9/orjson-3.11.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76070a76e9c5ae661e2d9848f216980d8d533e0f8143e6ed462807b242e3c5e8", size = 131946, upload-time = "2026-03-31T16:15:15.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/5500f76f0eece84226e0689cb48dcde081104c2fa6e2483d17ca13685ffb/orjson-3.11.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:54153d21520a71a4c82a0dbb4523e468941d549d221dc173de0f019678cf3813", size = 130368, upload-time = "2026-03-31T16:15:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/da/4e/58b927e08fbe9840e6c920d9e299b051ea667463b1f39a56e668669f8508/orjson-3.11.8-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:469ac2125611b7c5741a0b3798cd9e5786cbad6345f9f400c77212be89563bec", size = 135540, upload-time = "2026-03-31T16:15:18.404Z" }, + { url = "https://files.pythonhosted.org/packages/56/7c/ba7cb871cba1bcd5cd02ee34f98d894c6cea96353ad87466e5aef2429c60/orjson-3.11.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14778ffd0f6896aa613951a7fbf4690229aa7a543cb2bfbe9f358e08aafa9546", size = 146877, upload-time = "2026-03-31T16:15:19.833Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/eb9c25fc1386696c6a342cd361c306452c75e0b55e86ad602dd4827a7fd7/orjson-3.11.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea56a955056a6d6c550cf18b3348656a9d9a4f02e2d0c02cabf3c73f1055d506", size = 132837, upload-time = "2026-03-31T16:15:21.282Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/5ddeb7fc1fbd9004aeccab08426f34c81a5b4c25c7061281862b015fce2b/orjson-3.11.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53a0f57e59a530d18a142f4d4ba6dfc708dc5fdedce45e98ff06b44930a2a48f", size = 133624, upload-time = "2026-03-31T16:15:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/90048793db94ee4b2fcec4ac8e5ddb077367637d6650be896b3494b79bb7/orjson-3.11.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b48e274f8824567d74e2158199e269597edf00823a1b12b63d48462bbf5123e", size = 141904, upload-time = "2026-03-31T16:15:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cf/eb284847487821a5d415e54149a6449ba9bfc5872ce63ab7be41b8ec401c/orjson-3.11.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3f262401086a3960586af06c054609365e98407151f5ea24a62893a40d80dbbb", size = 423742, upload-time = "2026-03-31T16:15:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/09/e12423d327071c851c13e76936f144a96adacfc037394dec35ac3fc8d1e8/orjson-3.11.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e8c6218b614badf8e229b697865df4301afa74b791b6c9ade01d19a9953a942", size = 147806, upload-time = "2026-03-31T16:15:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6d/37c2589ba864e582ffe7611643314785c6afb1f83c701654ef05daa8fcc7/orjson-3.11.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093d489fa039ddade2db541097dbb484999fcc65fc2b0ff9819141e2ab364f25", size = 136485, upload-time = "2026-03-31T16:15:29.749Z" }, + { url = "https://files.pythonhosted.org/packages/be/c9/135194a02ab76b04ed9a10f68624b7ebd238bbe55548878b11ff15a0f352/orjson-3.11.8-cp312-cp312-win32.whl", hash = "sha256:e0950ed1bcb9893f4293fd5c5a7ee10934fbf82c4101c70be360db23ce24b7d2", size = 131966, upload-time = "2026-03-31T16:15:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/9796f8fbe3cf30ce9cb696748dbb535e5c87be4bf4fe2e9ca498ef1fa8cf/orjson-3.11.8-cp312-cp312-win_amd64.whl", hash = "sha256:3cf17c141617b88ced4536b2135c552490f07799f6ad565948ea07bef0dcb9a6", size = 127441, upload-time = "2026-03-31T16:15:33.333Z" }, + { url = "https://files.pythonhosted.org/packages/cc/47/5aaf54524a7a4a0dd09dd778f3fa65dd2108290615b652e23d944152bc8e/orjson-3.11.8-cp312-cp312-win_arm64.whl", hash = "sha256:48854463b0572cc87dac7d981aa72ed8bf6deedc0511853dc76b8bbd5482d36d", size = 127364, upload-time = "2026-03-31T16:15:34.748Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +] + +[package.optional-dependencies] +excel = [ + { name = "odfpy" }, + { name = "openpyxl" }, + { name = "python-calamine" }, + { name = "pyxlsb" }, + { name = "xlrd" }, + { name = "xlsxwriter" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "preshed" +version = "3.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cymem" }, + { name = "murmurhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/75/fe6b7bbd0dea530a001b0e24c331b21a0be2786e402abf3c57f5dce43d4b/preshed-3.0.13.tar.gz", hash = "sha256:d75f718bbfd97e992f7827e0fa7faf6a91bdd9c922d5baa4b50d62731396cb89", size = 18338, upload-time = "2026-03-23T08:57:31.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/fb/ccff23c44c04088c248539005fcda78b9014512a34d170c5360f02ad908b/preshed-3.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5d14eea14bd01291388928991d7df7d60b9fd19ae970e55006eb4d29b0c1e8eb", size = 138497, upload-time = "2026-03-23T08:56:35.321Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/cad5a8145881a771e6c0d002f2e585fc19b962f120860b54d32af5baa342/preshed-3.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f05b08ce92399c0655b5e0eb5a1cc1f9e295703ed3aabdfaf6538dfa8ae23d57", size = 138010, upload-time = "2026-03-23T08:56:36.399Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a2/c5fed4fb3e946699259d11e4036a3cfdd8c89b3e542e3077d46781642425/preshed-3.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:62cf7f3113132891d6bba70ff547ad81c6fe50a31930bbbb8499f1d47cd122b7", size = 861498, upload-time = "2026-03-23T08:56:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/51/94/8c9bc48a6ea4903f53a1a0031ce8e35687526949f25821762ef21493c007/preshed-3.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b8de3f58043070a354477995acdd98626ce43e4193c708ebd0f694e467f5155", size = 868988, upload-time = "2026-03-23T08:56:39.324Z" }, + { url = "https://files.pythonhosted.org/packages/b6/df/ecd2f40055ff52527ca117ffbfafb888c1a3079b59fbabe03c5b8f9b7240/preshed-3.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:183b339956a9e1d7a4a00038a3b9587a734db9e8bd915939a49791bd1b372156", size = 1847382, upload-time = "2026-03-23T08:56:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/88/bdb244e40284ded3632a9f88c23bc80230bd7b2ae4a8b7f2cc91adead7a8/preshed-3.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e77bed56aded7cbe5d28d6bd2178bc5b13eda0e0e464dab205fb578fa915000", size = 1919236, upload-time = "2026-03-23T08:56:42.616Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/c91ea56342e6c364fc69b444a1ac5432327857199c44032c9cc9dc4c3a23/preshed-3.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:04d8f13f2986e5d11af5ac51f55ce3106c70c41b483d20ea392e6180bdd0f870", size = 122938, upload-time = "2026-03-23T08:56:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0b/6a99d99619fd83b14c696e2489caed7070647488d4d3ac0b723d35db2de0/preshed-3.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:19318dc1cd8cac6663c6c830bf7e0002d2de853769fb03e056774e97c21bedfd", size = 109194, upload-time = "2026-03-23T08:56:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/401158195d6dc7f6aef0b354d74d0e95c9da124499448c2b3dbb95b71204/preshed-3.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0d0c14187dc0078d8a63bf190ec045a4d13e7748b6caeb557a7d575e411410b", size = 137289, upload-time = "2026-03-23T08:56:46.516Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/e20e64573988528785447a6893b2e7ab287ecfd85b3888e978b28812fd20/preshed-3.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7770987c2e57497cd26124a9be5f652b5b3ccd0def89859ab0da8bca6144a3de", size = 136847, upload-time = "2026-03-23T08:56:47.572Z" }, + { url = "https://files.pythonhosted.org/packages/b9/72/18168f881359c4482d312f8dc196371bdd61c1583a52b34390da4c88bbea/preshed-3.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a7bc48220de579be6bdb0a8715482cf36e2a625a6fd5ad26c9f43485a4a23b5", size = 831478, upload-time = "2026-03-23T08:56:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3a/3543476091087102775568cea9885dde3453569e9aeee365809108de572f/preshed-3.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5c8462472f790c16708306aef3a102a762bd19dfe3d2f8ee08bd5e12f51b835", size = 839913, upload-time = "2026-03-23T08:56:49.937Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/b13f01329decc44ef53cfb6b4601ba85382dcb2a4ec78d9250f03a418066/preshed-3.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c046736239cc8d72670749b79b526e4111839a2fc461a58545d212797649129c", size = 1816452, upload-time = "2026-03-23T08:56:51.233Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/f1a996c6832234efd4d543041b582418d41ac480ee55c557ec9e65344637/preshed-3.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c333f18e9a81c8a6de0603fd8781e17115324b117c445ca91abdf7bfb1abe49", size = 1888978, upload-time = "2026-03-23T08:56:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b9/96fb71499049885ce19545903fdd38877bbc2be0da47e37c04d01f3e9f66/preshed-3.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:461327f8dd36520dcf1fd55a671e0c3c2c97a2d95e22fc85faa31173f4785dda", size = 122134, upload-time = "2026-03-23T08:56:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a7/32a4903019d936a2316fdd330bedddac287ac26326107d24fb76a1fbc60a/preshed-3.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:35d6c5acb3ee3b12b87a551913063f0cec784055c2af16e028c19fe875f079d0", size = 108497, upload-time = "2026-03-23T08:56:55.816Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b5/993886c98f5caaa6f07a648cac97a7c62a3093091cad65e1e43a1bd41cc4/preshed-3.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2f1efae396cadab5f3890a2fd43d2ee65373ef9096ccbb805e51e8d8bcc563b", size = 137882, upload-time = "2026-03-23T08:56:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/c6/86/b7fd137cbf140afd6c45e895946068a15f5b55642916de0075e6eb18581c/preshed-3.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d6acc1f5031a535a55a6f7148e2f274554a8343a16309c700cebea0fe7aee8c", size = 138233, upload-time = "2026-03-23T08:56:58.318Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/21a7e79625614134273dfed32bca5bb4c2ec1313e33fbd12d41657536f1f/preshed-3.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da9d931e7660dcdd757e5870269f0c159126d682ed73ed313971d199eb0f334", size = 834835, upload-time = "2026-03-23T08:56:59.48Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/2dbd299516461831ae90e0d5b0637137bf28520c4e6dd0b01d6f1886659a/preshed-3.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4ae5cfe075bb7a07982e382bca44f41ddf041f4d24cbd358e8cccfc049259b8", size = 834928, upload-time = "2026-03-23T08:57:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d3/af654eba4f6587c4ee02c5043e62c194b0a1c4431ffef0c67b9518f6b61c/preshed-3.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7557963d0125a3a7bcdb2eb6948f3e45da31b5a7f066b55320de3dea22d7557f", size = 1820368, upload-time = "2026-03-23T08:57:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/ebcb2b9e8cb881e40b55b0bf450f8a6b187e2ef3ae0c685cce81d2d85026/preshed-3.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c4bc60dc994864095d784b7e4d77dba3e64188d169ac88722b699d175561fddb", size = 1888251, upload-time = "2026-03-23T08:57:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/c6c012779edcaa6e2cd092c554e98dc53e77f41205b07208655ba77e2327/preshed-3.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:208dcebbe294bf1881ce33fb015d56ab2a7587aece85a09147727174207892e4", size = 125211, upload-time = "2026-03-23T08:57:05.83Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/390ef87d732ef64e673ef6bf9e5d898453986e979efa50fb3a400e2c0766/preshed-3.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:cf8e1a7a1823b2a7765121446c630140ac6e8650c07a6efbf375e168d1fef4f7", size = 111942, upload-time = "2026-03-23T08:57:06.996Z" }, + { url = "https://files.pythonhosted.org/packages/80/3a/a9dde3167bcecb27ae82ce4567b5ab1aa3989113ae6814c092ce223cc4ef/preshed-3.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ca43ecbc3783eda4d6ab3416ae2ecd9ef23dca5f53995843f69f7457bcd0677", size = 144997, upload-time = "2026-03-23T08:57:08.064Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/22d9355b50b6a13b407dcad0a81df83fb1d5602092d1f05834674dde8fda/preshed-3.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c8596e41a258ff213553a441e0bb3eb388fd8158e84a7bf3aae6d8ede2c166d3", size = 147294, upload-time = "2026-03-23T08:57:09.411Z" }, + { url = "https://files.pythonhosted.org/packages/70/42/a225ee83fdb306d2a503f21a627953b820f4e079c90c8a84338957cb8ff5/preshed-3.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f8856ca3d88e9b250630d70abb4f260d8933151ddfb413024784b25b009868e", size = 952110, upload-time = "2026-03-23T08:57:10.592Z" }, + { url = "https://files.pythonhosted.org/packages/40/ba/09a9dfe3d22d7e745483fd5d7f2a82cd4d39c161f7d2daa0faa4bd6402be/preshed-3.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e5b2865aecbd2e1e10e5d19bb8bfad765863c1307c6c3e51f2a08bd64122409", size = 932217, upload-time = "2026-03-23T08:57:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/e10e2e05133e7fcbd7c40536af1148c82dd24357b8f5726e2c7bc51cfd53/preshed-3.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09f96b477c987755b3c945df214ea1c1c80bfb350e9f34e78da89585535b77e8", size = 1896542, upload-time = "2026-03-23T08:57:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/37/aa/51e5b4109a4cdfae28c3613eeeb10764a3794ebef8de93ffbb109465bea3/preshed-3.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:670db59a52e1823b5f088c764df474e65b686592d4093adbeef14581c95ee2cb", size = 1959473, upload-time = "2026-03-23T08:57:15.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6a/1d966f367a14c703dde629d150d996c1b727d442f620300b21c9ec1a24d1/preshed-3.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:b03e21b0bf95eb56e23973f32cabb930e94f352228652f81c0955dbd6967d904", size = 146229, upload-time = "2026-03-23T08:57:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/22/80/368139067603e590a000122355f9c8576c8ebed4fb0b8849feaa2698489d/preshed-3.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc", size = 119339, upload-time = "2026-03-23T08:57:18.882Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-ai-slim" +version = "1.85.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "genai-prices" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pydantic-graph" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/6e/018aa88e340dd6e25b0a22f49737c44de56a9c69a4282377fac225197e63/pydantic_ai_slim-1.85.1.tar.gz", hash = "sha256:7394748844cbd28519add1e8aa24b665ffd7516da3579daaaf3de9e1787250a3", size = 562638, upload-time = "2026-04-22T00:08:23.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/cc/b91513022c89a0ba26d394fa5da5e1e9fbcbb6490a0e1161f73f7f5606e2/pydantic_ai_slim-1.85.1-py3-none-any.whl", hash = "sha256:4a22e1b532e9f8c8afa118ea2cbef2ea541e2f6d7247112fefc0a2bd6b929331", size = 718957, upload-time = "2026-04-22T00:08:15.457Z" }, +] + +[package.optional-dependencies] +anthropic = [ + { name = "anthropic" }, +] +google = [ + { name = "google-genai" }, +] +openai = [ + { name = "openai" }, + { name = "tiktoken" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pydantic-graph" +version = "1.85.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "logfire-api" }, + { name = "pydantic" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/dcdcafe71411a8a31fbce0e546186f2706a44ffd4c57afe021f00bda27f3/pydantic_graph-1.85.1.tar.gz", hash = "sha256:4cfd3feb2ce7d6f5f604034e432697567551458d3c29d755221d9288336cfdfd", size = 59244, upload-time = "2026-04-22T00:08:26.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/49/71b66c79df6ffbf3a340a33602ce44873548f589548d5fb5d8873b870f05/pydantic_graph-1.85.1-py3-none-any.whl", hash = "sha256:515bee899bbfbf00911e32db941c69f2a72bc8fff56ea03a99fa10cd0fa5c436", size = 73066, upload-time = "2026-04-22T00:08:19.025Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + +[[package]] +name = "pypandoc" +version = "1.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/d6/410615fc433e5d1eacc00db2044ae2a9c82302df0d35366fe2bd15de024d/pypandoc-1.17.tar.gz", hash = "sha256:51179abfd6e582a25ed03477541b48836b5bba5a4c3b282a547630793934d799", size = 69071, upload-time = "2026-03-14T22:39:07.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/86/e2ffa604eacfbec3f430b1d850e7e04c4101eca1a5828f9ae54bf51dfba4/pypandoc-1.17-py3-none-any.whl", hash = "sha256:01fdbffa61edb9f8e82e8faad6954efcb7b6f8f0634aead4d89e322a00225a67", size = 23554, upload-time = "2026-03-14T22:38:46.007Z" }, +] + +[[package]] +name = "pypandoc-binary" +version = "1.17" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/85/681a54111f0948821a5cf87ce30a88bb0a3f6848af5112c912abac4a2b77/pypandoc_binary-1.17-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:734726dc618ef276343e272e1a6b4567e59c2ef9ef41d5533042deac3b0531f1", size = 25553945, upload-time = "2026-03-14T22:38:47.91Z" }, + { url = "https://files.pythonhosted.org/packages/15/58/8fd107c68522957868c1e785fbea7595608df118e440e424d189668294df/pypandoc_binary-1.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fcfd28f347ed998dda28823fc6bc24f9310e7fdf3ddceaf925bf0563a100ab5b", size = 25553944, upload-time = "2026-03-14T22:38:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/ac1078239aae14b94c51975b7f46ad8e099e47d7ae26c175a5486b1c0099/pypandoc_binary-1.17-py3-none-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b620b21c9374e3e48aabd518492bf0776b148442ee28816f6aaf52da3d4387", size = 34460960, upload-time = "2026-03-14T22:38:53.391Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7f/1e5612b52900ebe590862dabeadf546f739b27527dcd8bfd632f8adac1be/pypandoc_binary-1.17-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ada156cb980cd54fd6534231788e668c00dbb591cbd24f0be0bd86812eb8788", size = 36867598, upload-time = "2026-03-14T22:38:56.351Z" }, + { url = "https://files.pythonhosted.org/packages/3b/31/a5a867159c4080e5d368f4a53540a727501a2f31affc297dc8e0fced96a7/pypandoc_binary-1.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2f439dcd211183bb3460253ca4511101df6e1acf4a01f45f5617e1fa2ad24279", size = 36867584, upload-time = "2026-03-14T22:38:59.087Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/6a51cd4e54bdf132c19416801077c34bd40ba182e85d843360d36ae03a2d/pypandoc_binary-1.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f6e6d3e4cfafbe23189a08db3d41f8def260bacd6e7e382bceadab7ba1f17da6", size = 34460949, upload-time = "2026-03-14T22:39:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b9/f47b77ba75ed5d47ec85fcc2ecfbf7f78e3a73347f3a09836634d930de98/pypandoc_binary-1.17-py3-none-win_amd64.whl", hash = "sha256:76fae066cd2d7e78fb97f0ec8e9e36f437b07187b689b0b415ca18216f8f898a", size = 40891661, upload-time = "2026-03-14T22:39:04.782Z" }, +] + +[[package]] +name = "pypdf" +version = "6.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" }, +] + +[[package]] +name = "pypdfium2" +version = "5.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/13/ee794b8a810b7226426c8b50d6c28637c059e7da0caf9936164f352ef858/pypdfium2-5.7.1.tar.gz", hash = "sha256:3b3b20a56048dbe3fd4bf397f9bec854c834668bc47ef6a7d9041b23bb04317b", size = 266791, upload-time = "2026-04-20T15:01:02.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/f7/e87ba0eec9cd4e9eedd4bbb867515da970525ca8c105dd5e254758216ee3/pypdfium2-5.7.1-py3-none-android_23_arm64_v8a.whl", hash = "sha256:8008f45e8adc4fc1ec2a51e018e01cd0692d4859bdbb28e88be221804f329468", size = 3367033, upload-time = "2026-04-20T15:00:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e1/a4b9be9a09fa9857958357ced51afb25518f6a48e4e68fdc9a091f0f2259/pypdfium2-5.7.1-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:892fcb5a618f5f551fffdb968ac2d64911953c3ba0f9aa628239705af68dbe15", size = 2824449, upload-time = "2026-04-20T15:00:24.913Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5d/c91abb2610316a1622f86ddf706fcd04d34c7e6923c3fa8fa145c8f7a372/pypdfium2-5.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7431847d45dedc3c7ffede15b58ac611e996a0cdcd61318a0190d46b9980ac2b", size = 3443730, upload-time = "2026-04-20T15:00:26.664Z" }, + { url = "https://files.pythonhosted.org/packages/50/8b/b9eefed83d6a0a59384ee64d25c1515e831c234c3ed6b8c6dfc8f99f4875/pypdfium2-5.7.1-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:548bd09c9f97565ae8ddba30bb65823cbf791b84e4cdb63ed582aec2c289dbe2", size = 3626483, upload-time = "2026-04-20T15:00:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/5b/98/6d62723e1f58d66e7e0073c4f12048f9d5dcd478369da0990db08e677dd5/pypdfium2-5.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18a15ad0918acc3ea98778394f0331b9ad2a1b7384ab3d8d8c63422ffd01ed13", size = 3610098, upload-time = "2026-04-20T15:00:30.344Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4a/f72b42578f30971c29915e33ee598ed451aa6f0c2808a71526c1b81afd8d/pypdfium2-5.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1df04564659d807fb38810d9bd1ac18419d8acbb5f87f2cb20675d7332635b18", size = 3340119, upload-time = "2026-04-20T15:00:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/de69c5feed470617f243e61cac841bfd1b5273d575c3d3b49b27f738e334/pypdfium2-5.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a146d036a6b085a406aa256548b827b63016714fd77f8e11b7f704c1175e8cc", size = 3738864, upload-time = "2026-04-20T15:00:33.798Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/69ff10766565c5ffcb66cebe780ce3bc4fe7cc16b218df8c240075881c66/pypdfium2-5.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3397b0d705b6858c87dec1dc9c44d4c7094601a9b231097f441b64d1a7d5ff0b", size = 4169839, upload-time = "2026-04-20T15:00:35.973Z" }, + { url = "https://files.pythonhosted.org/packages/03/4b/fff16a831a6f07aad02da0d02b620c455310b8bf4e2642909175dcb7ccae/pypdfium2-5.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc2cdf603ac766b91b7c1b455197ec1c3471089d75f999b046edb65ed6cedd80", size = 3657630, upload-time = "2026-04-20T15:00:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/9b/58/d3148917616164cfad347b0b509342737ed80e060afab07523ffeac2a05f/pypdfium2-5.7.1-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b1a6a5f3320b59138e7570a3f78840540383d058ac180a9a21f924ad3bd7f83", size = 3088898, upload-time = "2026-04-20T15:00:40.109Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/387ca4dfe9865a8d61114dae2debba4d86eed07cdc6a31c5527a049583be/pypdfium2-5.7.1-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:91b809c40a5fc248107d13fbcf1dd2c64dbc8e572693a9b93e350bf31efda92b", size = 2955404, upload-time = "2026-04-20T15:00:41.921Z" }, + { url = "https://files.pythonhosted.org/packages/ad/87/4afc2bfe35d71942f1bf9e774086f74af66a0a4e56338f39a7cbc5b8721c/pypdfium2-5.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85611ef61cbc0f5e04de8f99fec0f3db3920b09f46c62afa08c9caa21a74b353", size = 4126600, upload-time = "2026-04-20T15:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/872eef4cb8f0d8ebbf967ca713254ac71c75878a1d5798bc2b8d23104e52/pypdfium2-5.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b2764ab909f9b444d4e643be90b064c4053e6828c28bfd47639fc84526ba244d", size = 3742636, upload-time = "2026-04-20T15:00:46.009Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/3805a53623a72e20b68e6814b37582994298b231628656ff227fa1158a1f/pypdfium2-5.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:fcea3cc20b7cca7d84ceee68b9c6ef7fe773fb71c145542769dc2ceb27e9698a", size = 4332743, upload-time = "2026-04-20T15:00:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/92/61/3e3f8ae7ad04400bc3c6a75bbf59db500eaf9dff05477d1b25ff4a36363b/pypdfium2-5.7.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:f04546bc314973397148805d44f8e660e81aa80c2a87e12afb892c11493ded6c", size = 4377471, upload-time = "2026-04-20T15:00:49.443Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e0/1026f297b5be292cae7095aa4814d57faa3faba0b49552afcaa11a1c2e4e/pypdfium2-5.7.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:66275c8a854969bdf905abc7599e5623d62739c44604d69788ff5457082d275b", size = 3919215, upload-time = "2026-04-20T15:00:51.2Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5d/7d6d5b392fa42a997aadf127e3b2c25739199141054b33f759ba5d02e653/pypdfium2-5.7.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:bbed8f32040ce3b3236a512265976017c2465ea6643a1730f008b39e0339b8ce", size = 4263089, upload-time = "2026-04-20T15:00:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b8/d51bd4a1d426fa5b99d4516c77cc1892a8fbfd5a93a823e2679cf9b09ee0/pypdfium2-5.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c55d3df09bd0d72a1d192107dcbf80bcb2791662a3eca3b084001f947d3040d5", size = 4175967, upload-time = "2026-04-20T15:00:54.757Z" }, + { url = "https://files.pythonhosted.org/packages/30/52/06a6358856374ae4400ee1ad0ddaa01d5c31fcd6e8f4577e6a3ed1c40343/pypdfium2-5.7.1-py3-none-win32.whl", hash = "sha256:4f6bbe1211c5883c8fc9ce11008347e5b96ec6571456d959ae289cecdb2867f0", size = 3629154, upload-time = "2026-04-20T15:00:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/e0dbc9377d976d8b03ed0dd07fe9892e06d09fcf4f6a0e66df49366227d7/pypdfium2-5.7.1-py3-none-win_amd64.whl", hash = "sha256:fdf117af26bd310f4f176b3cf0e2e23f0f800e48dcf2bcf6c2cca0de3326f5cb", size = 3747295, upload-time = "2026-04-20T15:00:59.15Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/4759522f5bca0ac4cda9f42c7f3f818aa826568793bd8b4532d2d2ffa515/pypdfium2-5.7.1-py3-none-win_arm64.whl", hash = "sha256:622821698fcc30fc560bd4eead6df9e6b846de9876b82861bed0091c09a4c27b", size = 3540903, upload-time = "2026-04-20T15:01:00.994Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-examples" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "black" }, + { name = "pytest" }, + { name = "ruff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/71/4ae972fd95f474454aa450108ee1037830e7ba11840363e981b8d48fd16a/pytest_examples-0.0.18.tar.gz", hash = "sha256:9a464f007f805b113677a15e2f8942ebb92d7d3eb5312e9a405d018478ec9801", size = 21237, upload-time = "2025-05-06T07:46:10.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/52/7bbfb6e987d9a8a945f22941a8da63e3529465f1b106ef0e26f5df7c780d/pytest_examples-0.0.18-py3-none-any.whl", hash = "sha256:86c195b98c4e55049a0df3a0a990ca89123b7280473ab57608eecc6c47bcfe9c", size = 18169, upload-time = "2025-05-06T07:46:09.349Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-calamine" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/18/e1e53ade001b30a3c6642d876e5defe8431da8c31fb7798909e6c8ab8c34/python_calamine-0.6.2.tar.gz", hash = "sha256:2c90e5224c5e92db9fcd8f22b6085ce63b935cfe7a893ac9a1c3c56793bafd9d", size = 138000, upload-time = "2026-02-18T13:38:17.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ec/e111c1a3a4c138ebc41e416e33730ee6d7c54e714af21c2a4e59b41715a5/python_calamine-0.6.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:857e4cddadba9b55c76dc583c58c5dc101a6cd5320190c10f8b2ab98d66c9040", size = 879539, upload-time = "2026-02-18T13:36:21.674Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/fe4c2138ff21542e2f1130a4d83c330d7f9486b62775196e998b88a03de6/python_calamine-0.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd89d6a53e4b22328cd685fc054c31d359cb3ae67bd24bc57e1c1db62a4cfc97", size = 858642, upload-time = "2026-02-18T13:36:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/bfeaf45ac5e2f6553723dd2fbe127d1d17c6f26496db5781de42a933776a/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d6c9af39db39e0c70710ae79cd1b5d980f9c0aea55fc16d194460c1561a0c6a", size = 925242, upload-time = "2026-02-18T13:36:25.236Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6e/81106aa80609075015d400584030605b05f5e12931717160dcc58fdc4980/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a2382dbc410dd48c99d89ee460662cc70892fe1b2901ab982604b923e8eb8f6", size = 905295, upload-time = "2026-02-18T13:36:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ba/6311b24f9889246be63b664630c5601039ef771f7ed04c8f51aace39b7a9/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ebb93255709874ede5b5e62828cb5758e60097e5390b6c9a3eb7751b617b12e", size = 1063473, upload-time = "2026-02-18T13:36:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/23/e4/027a1b046d30768872307ebe808dc4cdc5357295cdcda98b30b3ea924904/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:837bca19bd945cb83aded433f4cf76e80d70a5400404d876400ca7e88e5ea311", size = 965355, upload-time = "2026-02-18T13:36:31.376Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4d/da8716a1b3a66938aaabe36873f6fa210fa063bab1b20c2ec236013de6b3/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723990a47668cb819f307ccc634741370d3cd3804a0ee8cda392a522ae6d5016", size = 935091, upload-time = "2026-02-18T13:36:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/9521e8da5496cbc4b18027626a40018301f546b3e9802ca2f3a6cb5b4739/python_calamine-0.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b067630d693e1d7de41e3d44a99c7dd3feebb52db8dda8636ac3f70d8b6a4ad6", size = 974070, upload-time = "2026-02-18T13:36:34.055Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b0/7a63963512c5ba7e9539b7452e2b1561625e63e4e29c044e487e2e93dcbe/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6ab09c9da53a2b33633e9f940aed11c08e083810a0fd6885826cdc52ba4f86a5", size = 1100321, upload-time = "2026-02-18T13:36:35.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/e2bc38a5cf9629f656adcdabe8e134028f60c236e4bb96375dda90db3fdd/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:ae08e1308a0d0c6b8b4cc0a039ed8a85fc9ee2f8a3ca9ea57b1af9f97ed68fe4", size = 1181039, upload-time = "2026-02-18T13:36:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/513117015fd5903ca6dde9c8fb8502af60af6965642f4e3311623943e673/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c441a20c7aff0e904ca01b5cdc1e5be2c6d4a41a24a0ea4d5ea6d211343bb95f", size = 1144843, upload-time = "2026-02-18T13:36:38.393Z" }, + { url = "https://files.pythonhosted.org/packages/a2/14/8846478dacf31535f5f15448ade3bc688b51f3183f1b52844451aa27b0e6/python_calamine-0.6.2-cp312-cp312-win32.whl", hash = "sha256:39cae8e66f8bce499f5f965f4575ddf61e30184cc97f02e1c7031a57abe0903b", size = 692411, upload-time = "2026-02-18T13:36:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/2d2dcf4ec7e5ec08e33bf966ab010a7be178a4b623bd5f7601d47f2c734c/python_calamine-0.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:1617efa24532f2420934a8cf77e6d33ff1740cae1d39355cab4f4cf141fdab49", size = 748960, upload-time = "2026-02-18T13:36:40.922Z" }, + { url = "https://files.pythonhosted.org/packages/f1/eb/2f50f3395c0435e6186cab56c36d04c06581ba827264bca1f1acae523aa3/python_calamine-0.6.2-cp312-cp312-win_arm64.whl", hash = "sha256:c2b378db494740e540e8157a7e5fe61dadae69ad2d988a7c80f9583f434acf07", size = 718992, upload-time = "2026-02-18T13:36:42.671Z" }, + { url = "https://files.pythonhosted.org/packages/15/db/f409c3ffa5d452b8184978c94440b48c933c79232c5e40fe9ce3608ff06d/python_calamine-0.6.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4c6e68c233841604fa3f63899d13bd2e47cddf0787c4b4b8188f74c3be452045", size = 878907, upload-time = "2026-02-18T13:36:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/8cf4309a00ad5628c45e69f13352d6a1e0e0a3148a2fc28d7a43a8cefec9/python_calamine-0.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0fd5bcbd904d05f8b9f127a93706fdbb0a5934efdc9677b402a82d91e6e3f920", size = 858314, upload-time = "2026-02-18T13:36:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cc/c5edfb89a99d19c66b029e2e6dc0db052709888753fc0a771bf28343c5e5/python_calamine-0.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cef6454aa1b3b2137d7a202c9f84b87dffdd187ff218f2cee459480c102c20a3", size = 924748, upload-time = "2026-02-18T13:36:46.462Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bd/d0504a0e85b1588ad4ddb97f2ba003d22d9ae7cd719b82a5be2e71d97519/python_calamine-0.6.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:19c55c35edaf89b4d18d5d3cfaac619362f2e8339e4c876f9f0c80640d990db3", size = 903646, upload-time = "2026-02-18T13:36:47.737Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/7cf92a77e83f62b8a106af36aa6b314f4b42abc7959787e5a746de4b0525/python_calamine-0.6.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d29984496a22286f511668ea6483293c0e58ac0f25916e1d88125e5e1d83313b", size = 1063499, upload-time = "2026-02-18T13:36:48.998Z" }, + { url = "https://files.pythonhosted.org/packages/0a/2b/1d90207328fa7f8e74ce13337ae2965669e762877846dab3db8a6f90dec3/python_calamine-0.6.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b0c4deabc2646c6c07abb3620088c5d6d2af26f8954726938ebcdbc6c56a8bd", size = 964671, upload-time = "2026-02-18T13:36:50.289Z" }, + { url = "https://files.pythonhosted.org/packages/30/2a/7a58828ef14801b4efb323ad9b1ae3d2d2e82e1c5ce35502189e7a201a14/python_calamine-0.6.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc536feb86c948b330c4db8a9f1d9f9094f8d70a981d04de87ece9d9b9300458", size = 935016, upload-time = "2026-02-18T13:36:51.628Z" }, + { url = "https://files.pythonhosted.org/packages/ba/63/08c63af2d5074d96b808ad7ae4cb04a3ab59d8d6260223b4d03d99b9cf49/python_calamine-0.6.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7c0f9e769c735cfb0564aefb4273c6dfeee9fbab1db69b9099cb19cfb8208ddf", size = 973549, upload-time = "2026-02-18T13:36:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/5f/91/ea25bd171222b9bab1f79e5cb923b891903afbcb19c5241528f9d87b80a9/python_calamine-0.6.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e3824c211eb9505461a9820ed893cf6e39a3af8024fd1892d2cc174ce8329955", size = 1100508, upload-time = "2026-02-18T13:36:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/79f4095e53b4935e36e9e2ed5c7d9683fb448dd9c1bba69144277df9b3a6/python_calamine-0.6.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8323edededa282cace538805cfa7cab30ad9dd19bca4a23215ea975c73ce9f26", size = 1177921, upload-time = "2026-02-18T13:36:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/67/02/d0328a96f2cac5cd7d13e50691207b6c06f33b22010d70d3dafde13e50fd/python_calamine-0.6.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f64b93e41dd878a317f958fbf9bfa64342ef9aea58956a93a52d4b9d646a6ef4", size = 1144850, upload-time = "2026-02-18T13:36:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/28/8c/97012240a29ad22a8a2fb69097d4de52e48a05b7e6cddda9916eec439c83/python_calamine-0.6.2-cp313-cp313-win32.whl", hash = "sha256:91fbfa837aaa6f7fc72e9277678aa0c95b0c3c7df76c7c7bac4ab4a128834a70", size = 691860, upload-time = "2026-02-18T13:36:59.156Z" }, + { url = "https://files.pythonhosted.org/packages/9e/35/f505780ba2228510412837a56bd9fb1721b021c2203afa10e25aebe67751/python_calamine-0.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:0b2464e036819ecce50181220e120d674b1caac806a31e48eed2e2183acf9a69", size = 748488, upload-time = "2026-02-18T13:37:01.432Z" }, + { url = "https://files.pythonhosted.org/packages/12/67/309ec85184f189709d238c9f2ec1b056354a8310a4eacefbfdd17b47061c/python_calamine-0.6.2-cp313-cp313-win_arm64.whl", hash = "sha256:64b1ce2bd452a9d2ae00a97e2629e3444b9669ce348e1f534f3a91f55694de15", size = 718567, upload-time = "2026-02-18T13:37:02.773Z" }, + { url = "https://files.pythonhosted.org/packages/66/a4/0be8520de23b10d3e9179fe620e22ea7ef5f864152cd7ce322df1c9f707a/python_calamine-0.6.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:050f0b830fcdf209826e98849432fb6ee1328895949bf7c63632fd34130cef8d", size = 877980, upload-time = "2026-02-18T13:37:03.932Z" }, + { url = "https://files.pythonhosted.org/packages/72/92/c6f3e47f84bd9b0298f63dca7a47136121c8a180b09660728ba381eb10a4/python_calamine-0.6.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f3636e14736cd2ab2377418aeb2ef8c17d1ce7e19bbbe52e445027cf43a2a745", size = 857296, upload-time = "2026-02-18T13:37:05.14Z" }, + { url = "https://files.pythonhosted.org/packages/4b/60/bb3cc5a7bfd5618307262c1234c38a137532ac17c4c385364a6594c59d91/python_calamine-0.6.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f86a51485f93264679eb449dc93dc498553f449322c81936eac47ace45365e89", size = 923713, upload-time = "2026-02-18T13:37:06.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/b9/156223f20a685071223bff0f9d220511ed9012e6ba96cede417dde13abcb/python_calamine-0.6.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b44fbdd11ac44dc5eecf49c1597e7234633cbc9f38c73521ce00278cf0bd8976", size = 902503, upload-time = "2026-02-18T13:37:07.696Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5b/64f62bbdaaaf7b8fec3c509038edc3cecf7f6dd8539828baf03ba45854ee/python_calamine-0.6.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0d5fd48c92ae04bf8ef1f326d7ec23295545d171f4b810dc8fa08f28932900f", size = 1061675, upload-time = "2026-02-18T13:37:09.006Z" }, + { url = "https://files.pythonhosted.org/packages/be/7e/0c440a6aac2b35328e6de7055ea20424456118e67f934ee778a79060f9f3/python_calamine-0.6.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19363504d08c5c2c7aca188a5c4ded89a47cbba1cbc9a083cd230839f977c5a8", size = 962969, upload-time = "2026-02-18T13:37:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/fcbe045e5595a6bc734e6e091909b64099a69725f8335596a6493c21aa05/python_calamine-0.6.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72078b550a871249c07b71fe5b94fbd30857604ff99380304d273d84a8bcd7c8", size = 934205, upload-time = "2026-02-18T13:37:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/e4/44/361972390dea31d700b8a8974510cf7d5cac0a0bc563fa1726879b801e2f/python_calamine-0.6.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e18e524ef1532f8269739b63ca9c6ab7dbd75e9dff20ca7e2e2d8d13c59964b2", size = 971168, upload-time = "2026-02-18T13:37:12.95Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/635566a955138d14fd1ecbc49be48f9add3e2107861507ce1fefd92192a9/python_calamine-0.6.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b665c55d5d03b5cc205e4b68c711712cff8aac273f2aa930ab8ab5960b9dc90f", size = 1099592, upload-time = "2026-02-18T13:37:14.312Z" }, + { url = "https://files.pythonhosted.org/packages/57/12/6b02a3adba57ed2ef7f2ed5ffa557f6a29a06a77f1fd40770ab3d530d2c9/python_calamine-0.6.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:dc21843a6fca8ae5a722e66bde14324da4f43be98b772b0689ac75dd89d888fd", size = 1176734, upload-time = "2026-02-18T13:37:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fb/b6ffd03dc468b0e3bb5747b747bcb4cdd6a98fea7b0f444d8600f2ebaa4e/python_calamine-0.6.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:e16192fbbb3a3009c89aa62530d807bca272e68a67b362da5c9d156a8950cd51", size = 1143744, upload-time = "2026-02-18T13:37:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/ca5d3f09c98d0420fb03643aea3897c2d68e77df7d3108ad660e9024c277/python_calamine-0.6.2-cp313-cp313t-win_amd64.whl", hash = "sha256:a94a560c0b7ec791f6edfb3fede6ade35b048a61be80e584de8411bf930a8902", size = 749291, upload-time = "2026-02-18T13:37:19.842Z" }, + { url = "https://files.pythonhosted.org/packages/73/b3/a9ee154d185e64edfeb5bb0c5621a650bc946c071a7be5a2ccfe81da413e/python_calamine-0.6.2-cp313-cp313t-win_arm64.whl", hash = "sha256:2574072b9e26aeae26ebd051a1661bb72fd202ce2904f920f9c605de9555c057", size = 716122, upload-time = "2026-02-18T13:37:21.172Z" }, + { url = "https://files.pythonhosted.org/packages/86/c3/30e8ebbc5813d332edc6733c63f861bb87b61ad8a71fc97f39d687fb0195/python_calamine-0.6.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:630d32f10b16bafbca86fb9373e7a4eccbd0268bc9e80dac923b731a8e472704", size = 878817, upload-time = "2026-02-18T13:37:22.358Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b7/2c0c82c1d3938bee3972fe97103da158ef9cf2b3bd2ba88ef1fa7e766564/python_calamine-0.6.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39de4d8c1f9db34d02a2d9b7eaad55cdd013b5881cf0a5ab281e2167d090b22e", size = 857826, upload-time = "2026-02-18T13:37:23.856Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b0/260135d30b0c5e1b723bb5d450426614a20409b27b9e5cdd17076abe1516/python_calamine-0.6.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36148e9c5022494fd6a2c111fb51d24e6e39cbf3027a3ddedad44545598609ca", size = 925839, upload-time = "2026-02-18T13:37:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/6cc11c5287020a04326da01e46a7a4169d4496d462f94c69ac993e4b6c1d/python_calamine-0.6.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2526bddc75829b4376a515cad83afeab4019bbe5b770a892852de66b0017527b", size = 902844, upload-time = "2026-02-18T13:37:27.015Z" }, + { url = "https://files.pythonhosted.org/packages/9c/56/c2197448e66cf8369ecc3ed6450fc26085404b8ddf3f3409958d82a44908/python_calamine-0.6.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:711a664b8cf1e4f6c55fbd5e15e70fc5792e382e3866416044c23b0d3ffdd055", size = 1063688, upload-time = "2026-02-18T13:37:28.824Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/c26b811cf88b39afc107af73fab1a42af56d7ea19e33b80eddc3e869a6e7/python_calamine-0.6.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d449b6130f1469810ebe9f423f9efecaabc60e110db7a5a56d0f098ea78b22f", size = 963750, upload-time = "2026-02-18T13:37:30.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/4f4bee70187f148661f6112b6cff572c199518b943b4821d9303c3d5084d/python_calamine-0.6.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f3841986cf512893e8871555ef586387e5e36484cebd0d9398046c3bde1e13c", size = 933485, upload-time = "2026-02-18T13:37:32.082Z" }, + { url = "https://files.pythonhosted.org/packages/08/67/5f9826d9ee2cb167fa86a496f3dd6551aa727c8dcef8041eb7362c0eeb80/python_calamine-0.6.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7a8f437273c8dee9d9ae89cc766b6c313a1a99155b74a1a6560a01b82db89b51", size = 973008, upload-time = "2026-02-18T13:37:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/8384433127d4bef3b663e71285d9c5f21d2e312e6b9ae37170290ec28566/python_calamine-0.6.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6a2dfcfbf1907f37e6a13e2dffff409f79cff911e44e1ce7deb65510b8bbb0de", size = 1101447, upload-time = "2026-02-18T13:37:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/57/ab/f86f30f3f72a930e6787c7a28b1042458045572c785b6362a77e42920fb1/python_calamine-0.6.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:0c4a6131835f28897cdf36942067220e2c8c6c23f4b7747a094dca6748190c12", size = 1176774, upload-time = "2026-02-18T13:37:37.256Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/3a4d0332b5a30604c6e2645f3a3a54d443ee78ba45d4ad2be015e32bab4a/python_calamine-0.6.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e8f50885c5042fb3bdd9ad820e4b871e6a1758e15957964acf0515b5d0fb3984", size = 1143674, upload-time = "2026-02-18T13:37:39.007Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e1/28254dc423f63a62d1c0da649e673ca492ac84250adcc63f90547b83bfbf/python_calamine-0.6.2-cp314-cp314-win32.whl", hash = "sha256:d4b6fe3564596b1a85fdb7dea60ae7dda2bd56898e88128e0306ebfca29d3659", size = 691485, upload-time = "2026-02-18T13:37:40.64Z" }, + { url = "https://files.pythonhosted.org/packages/17/24/3954b1279ea1b4e25368bccd139098d1abeb3188f4100f2604555be67bae/python_calamine-0.6.2-cp314-cp314-win_amd64.whl", hash = "sha256:39a6703c80e71c9df2eefa4b9aedd994c27d6ae1fda07a48ee3306414d76d39b", size = 749519, upload-time = "2026-02-18T13:37:41.871Z" }, + { url = "https://files.pythonhosted.org/packages/ae/be/e103f840b48677a85085eaca4667fb2f7c0828c7c49b3ea9e1300d5074bc/python_calamine-0.6.2-cp314-cp314-win_arm64.whl", hash = "sha256:cedae91678a016690775a815c7dc66288b3f0968451bf2161689846b5b330b84", size = 718799, upload-time = "2026-02-18T13:37:43.692Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/08e98171718cec3a22e0c4082714894d9ae71c8aaba2ca47dabc5dbf4cf0/python_calamine-0.6.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5c3ca40133330cccdafb7326c39f7dd60247ad1995d9b92fdcd5052853fc31e5", size = 876825, upload-time = "2026-02-18T13:37:44.933Z" }, + { url = "https://files.pythonhosted.org/packages/bf/06/327a49b20cd2565457a1eb361b8e078aeb2eb8c2473358924563fc737701/python_calamine-0.6.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5599d12fa06ad42694255fecb1de48f6eb2d074fa55b2f532a93158ae1cc3958", size = 857368, upload-time = "2026-02-18T13:37:46.798Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/b0d380ea649833acc79ecc829470cb632565b865713865c6ba995e505e55/python_calamine-0.6.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb50c1f6303650d5712a707c8c13842eeebcd433bd660dcdaebc8aedd9085d37", size = 922634, upload-time = "2026-02-18T13:37:50.205Z" }, + { url = "https://files.pythonhosted.org/packages/50/b7/89128cca52c80c8b9649176bac374356f1923997af0b262a7b5547479fb1/python_calamine-0.6.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42f1b3172fc2c916990a9749c30f5c2aad5351a807c6597febf7b5b9444eaf4d", size = 901578, upload-time = "2026-02-18T13:37:52.108Z" }, + { url = "https://files.pythonhosted.org/packages/78/40/bf06d465c761d59beab8d42cb4f5b648862a8ef0a1d900790b7efce1fa5f/python_calamine-0.6.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3bb6370d855c9035e8727e6d8685775d411e5f5a3b114e0048bacd2efc2dc5", size = 1062802, upload-time = "2026-02-18T13:37:53.383Z" }, + { url = "https://files.pythonhosted.org/packages/b9/fd/87792c5f5c5822036ee4bdb01853bd7cb854f982f88cb7fdf6405a36072d/python_calamine-0.6.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d790fa2065c3c5d07de27ead53486b6afa64b935036444e5593c670baaf7394", size = 964571, upload-time = "2026-02-18T13:37:54.754Z" }, + { url = "https://files.pythonhosted.org/packages/31/6c/9981f4ca131d104e7e2d275c97a22026984c766009ec98269fb3b23a8a9f/python_calamine-0.6.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba4ac4bc59fb16e76d57bbbb2b5567e9d78f99e0b7d6cf27b1fc968dddad9e52", size = 934577, upload-time = "2026-02-18T13:37:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/19/02/7c1fe7038f9921d520b4bf52299c260db4e21cbba7d3df29ed960ebb31c6/python_calamine-0.6.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16750f933fd68d6796c24390d5379abe02cc592b8cb5c2c715d09885a4e4db78", size = 971699, upload-time = "2026-02-18T13:37:57.303Z" }, + { url = "https://files.pythonhosted.org/packages/06/10/9da5009d84154e6d86dd73c7f35fe6402803eb054c198a22605d74ab07a0/python_calamine-0.6.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0c3a65ee5e1bbed8d32225882b6fae147c187a5019b895bd1a9631fb1e8ebd1b", size = 1098043, upload-time = "2026-02-18T13:37:59.168Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d0/53238c2185ad59659245d8bc7a86e4902860bd3c73303744b039a35ae517/python_calamine-0.6.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:80f54662715b25078e90794d792df6ef45154f1affea472c9e802c5d3dda5a9e", size = 1175384, upload-time = "2026-02-18T13:38:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/3e/40/c421fe66af1e94267a66735940dfc01f7e423eb8c0217a9bc97b03927de6/python_calamine-0.6.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:7568800d967b7b7b56d1a139d8d6c343b70d88695c8f3c3906aaa1b8bff76900", size = 1144251, upload-time = "2026-02-18T13:38:01.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/ed/def5e5fa257658894ca2ca3f9c532064056cd1b686f3bc2861f6313ccac7/python_calamine-0.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:aab8ef96f19feb5df3704dc04805b1e0d6e82827546bea92d660344c674ed9e1", size = 748446, upload-time = "2026-02-18T13:38:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/f6/24/3587fb169ddd82e78fcd4cd7b2e3eb3ecaa9b28dbee1da18dd0db13b27e6/python_calamine-0.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:514b3b0ccba57cf807bd4869a76020eb53e2d797f35c95fceb274a5208da1651", size = 714386, upload-time = "2026-02-18T13:38:04.643Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-iso639" +version = "2026.4.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/c8/22c80367213029ea3abc4e7ab6e1ed8545542f98e5db6e1ab4f2973890ad/python_iso639-2026.4.20.tar.gz", hash = "sha256:00570376d24788f889578991bb2ad93c030a014c1d373f64f2ceffe84732a537", size = 173955, upload-time = "2026-04-20T14:15:47.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/71/520fbac49c0650aba66093396282e1e4a1315a7242461c21480132a1b0df/python_iso639-2026.4.20-py3-none-any.whl", hash = "sha256:60a380571fafdbcc6190c5c1ee78e217194332cbe3caec76345327712e5a65cb", size = 167842, upload-time = "2026-04-20T14:15:46.308Z" }, +] + +[[package]] +name = "python-magic" +version = "0.4.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, +] + +[[package]] +name = "python-oxmsg" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "olefile" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/4e/869f34faedbc968796d2c7e9837dede079c9cb9750917356b1f1eda926e9/python_oxmsg-0.0.2.tar.gz", hash = "sha256:a6aff4deb1b5975d44d49dab1d9384089ffeec819e19c6940bc7ffbc84775fad", size = 34713, upload-time = "2025-02-03T17:13:47.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/67/f56c69a98c7eb244025845506387d0f961681657c9fcd8b2d2edd148f9d2/python_oxmsg-0.0.2-py3-none-any.whl", hash = "sha256:22be29b14c46016bcd05e34abddfd8e05ee82082f53b82753d115da3fc7d0355", size = 31455, upload-time = "2025-02-03T17:13:46.061Z" }, +] + +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyxlsb" +version = "1.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/13/eebaeb7a40b062d1c6f7f91d09e73d30a69e33e4baa7cbe4b7658548b1cd/pyxlsb-1.0.10.tar.gz", hash = "sha256:8062d1ea8626d3f1980e8b1cfe91a4483747449242ecb61013bc2df85435f685", size = 22424, upload-time = "2022-10-14T19:17:47.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/92/345823838ae367c59b63e03aef9c331f485370f9df6d049256a61a28f06d/pyxlsb-1.0.10-py2.py3-none-any.whl", hash = "sha256:87c122a9a622e35ca5e741d2e541201d28af00fb46bec492cfa9586890b120b4", size = 23849, upload-time = "2022-10-14T19:17:46.079Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, +] + +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, + { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "selectolax" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/1a/ce7768c1bff5cf07e3e12f904d54a63c3f85f78e3b299c0b561839d0238e/selectolax-0.4.8.tar.gz", hash = "sha256:cd703165b9a346be255e2ca5b4219e01009911977ac8a474d8ccb7e32e9a4fae", size = 4875521, upload-time = "2026-05-04T15:10:44.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/3c/9cf255f11d04cf203b61f5a85fb273bc85778c3ab5d2ad98ce892f7df3b4/selectolax-0.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbcd4cb837dec4b16a3ab6a8b2ec4388ea20c050092b68536dc9a60ce8f19b56", size = 2236812, upload-time = "2026-05-04T15:09:20.193Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/4c343f767fa61131fc03b3de278dfcff024485996d7d6c917b55b0f9ce16/selectolax-0.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1cdcf946dd46e11640ca0a0361945c3ed65627ba4bd219ccf84a53fab28c072", size = 2287194, upload-time = "2026-05-04T15:09:22.15Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7d/5a47a0d102709297b6b09473a58c7e9727955a0d6e0c8eba9b27574e3680/selectolax-0.4.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:074254670a8a00b36202ab0cd99ce8fb2a25b3b2f10072891a35a6a85c53f679", size = 2369192, upload-time = "2026-05-04T15:09:23.768Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/48b5cfa5b2803379855050310c876a6f68b7e53598f9252e5bb3fb911ff0/selectolax-0.4.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bbd33d348a555b37d5f922c421338db690500e5b579353ffa24e3bd23a04704", size = 2415401, upload-time = "2026-05-04T15:09:25.349Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/cf0ffe4668af87f2071253ad43cccf73ac6bc6324c13fefe38fdb198d54b/selectolax-0.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c0cf863f652ed6de58c9f3599173be70064827807f46ddd38ad82b185fead322", size = 2373885, upload-time = "2026-05-04T15:09:26.889Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/4de417c1bbc44c31453ac0582f578ddfdf4f4e29d55d451e4435cef7f4eb/selectolax-0.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ca2e1a9a06d1b1a7549d2828240f1888a172b60720baf8a2cb90936482d8b6b4", size = 2437684, upload-time = "2026-05-04T15:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e4/a465e3d7b641b7693c36a20401c1cd924bd9038dfbee195c11087de08aae/selectolax-0.4.8-cp312-cp312-win32.whl", hash = "sha256:b812691bbdf7c958b29956138917bd46cd78b96f2d4f51f56063f584f0d6c476", size = 1759388, upload-time = "2026-05-04T15:09:30.679Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/b7e681cf829f58be0927b10d606ec96126e3d863b11c4ba6cabb701a1b32/selectolax-0.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:877df05c9fb306ed6b4d094cbd655ad8e89ea4744d3597eb1e5217c7f71d4ea4", size = 1855778, upload-time = "2026-05-04T15:09:32.284Z" }, + { url = "https://files.pythonhosted.org/packages/2d/85/9b64306821eb38fb84510e43bd5e8685fbf179ce5717496522fdf208573c/selectolax-0.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:52db6be936a0f0648d9d8acbfdf16d4c5a16600cfc31db1b557b0effd8b164ce", size = 1805553, upload-time = "2026-05-04T15:09:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fb/678ea1250811a4b42686c506df43d3c752ce2158dc66fd4f60c39d4ae1d6/selectolax-0.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb2c4bed19e0c3e34b877b6df607150725d84a38dda32bccb030d8744078eda6", size = 2236220, upload-time = "2026-05-04T15:09:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fe/1624eb5024e897bf4074bfc31f9e5e823160aed1ac14e7720e849a3d1109/selectolax-0.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a2d83e04bf0a9f0a8c9a6a7989df1844acc2242025f26293c1e12ce82b4a3f9", size = 2286820, upload-time = "2026-05-04T15:09:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/f0d4e4061de679f5f3d1858f50d31b09935fa97c05f8a2b2e46c344c74a8/selectolax-0.4.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8513c179ec9758d9bbc43a9e7e58fecfcfc1ec88e9100ed592cce2703c316b63", size = 2368949, upload-time = "2026-05-04T15:09:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2d/2ba76651c93907b2c3c0005d4745df94493e29bd15cfb9edd7c0e6a7a4d9/selectolax-0.4.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b6225fd6b5bec6be59bc07aeee17f50448c9b4769531afaa079c74650ebd2c5", size = 2415032, upload-time = "2026-05-04T15:09:40.93Z" }, + { url = "https://files.pythonhosted.org/packages/b8/08/a20f9ef0213b8f33c5164781e8cc18eeb059779ed8b4051df39cbfde3d50/selectolax-0.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:899b62117147fd804b43822e7af0c422730c6743b27b20b233c4896363b1e655", size = 2373656, upload-time = "2026-05-04T15:09:42.862Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8d/ba692cc70aa45feb55d8241e05f8b8bde97ed4c7d5b4d83b148d1f6ebc43/selectolax-0.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:573b67610d2876b67707eaaad71d28da5a0d4ce72ab4880a68abeacdbdd825b1", size = 2437386, upload-time = "2026-05-04T15:09:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cb/771e3e7ad7ef51ddafecf7b80e92421113cc4026fb951740009633d34d1d/selectolax-0.4.8-cp313-cp313-win32.whl", hash = "sha256:69cf8b1edbc2f6e401a1be0a2fc6eb1ea05679446053724a3ac399abea1e98d3", size = 1759310, upload-time = "2026-05-04T15:09:47.103Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4d/6635f0ba2d0f456f5f5ded29691fab431da94ae8403b88379da730337971/selectolax-0.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:e57a2c314b339ffd6da899bf7d37a3d850aa03b8bcbcbb25d183fdae4525ad6d", size = 1856663, upload-time = "2026-05-04T15:09:49.247Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2d/a9adae174d3b6e83eda10854e3921f298a2764f604bb6b8bea68461cb003/selectolax-0.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:9a593eaf1a6686b559e3f65f20d21c592933cf6c18a0a042a9f46d96a88f54d3", size = 1805441, upload-time = "2026-05-04T15:09:51.3Z" }, + { url = "https://files.pythonhosted.org/packages/25/60/d1a14de44725277d91111931d785edc8387de1e95fe0987766b1e73a899e/selectolax-0.4.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:412d8c3203d294a36bed5b01942d8f81724a6731458e257b0153f2f4425e3da9", size = 2252331, upload-time = "2026-05-04T15:09:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/4e/79/2455aab4dc66a7f6c49af5dda759eca3bdbd20f2fa4193eb1daf32abc93e/selectolax-0.4.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:45e02ed68d9888828edae568c17ebd055f265221c2784329ed63078283c4e0ea", size = 2304020, upload-time = "2026-05-04T15:09:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/04/81/ae320006cb5f327926d7888fabdec3181c1a4dbb6218ebe05f10f204fdd0/selectolax-0.4.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f7aa77dec4a04b8b63aeaa505c168ac2597df2be426783ede8d74d56e925b48", size = 2368262, upload-time = "2026-05-04T15:09:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b4/cf8c860906aeeb074a0babe070d399416b9ec80c1029e1ed187aed443936/selectolax-0.4.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fab78f7cab55c06a1a994f522adb5b4c5302aaacac60bdca7de1f8eaacaa9f9", size = 2414392, upload-time = "2026-05-04T15:09:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/ab5f27398bfa65d49e92b5846e0ed4bb92c45c5cfe759d3c14ba47845085/selectolax-0.4.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7923f90a5d51325cde8b47e41aa71a0f81429afb85d26966369389c5eaf99c6e", size = 2390375, upload-time = "2026-05-04T15:10:01.06Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3d/0045463e7b8e9229a422507aac28fa71fa5cf548c1410f47f1337fe54bdc/selectolax-0.4.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7af017e28bcdcea1d7aaeab329f78515fab3becf30c4c9408e0c9b8303be1393", size = 2454560, upload-time = "2026-05-04T15:10:03.135Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9c/628723b10aece08f963b8d38473433df66986b419c9789d130cae9ff61dd/selectolax-0.4.8-cp314-cp314-win32.whl", hash = "sha256:40f6de3a820983c1f3feacb60351677b69d0c829b63aa11c7bd4811bcbcb8f42", size = 1870694, upload-time = "2026-05-04T15:10:04.912Z" }, + { url = "https://files.pythonhosted.org/packages/34/e4/2d111f68cbd2ed1270d37b2fe23f9dbd2059cfa80f492ee19b3ad3b641b4/selectolax-0.4.8-cp314-cp314-win_amd64.whl", hash = "sha256:ce6668c260ada8880106f6d37cd073b53ac3342f04b8a771dacb6ea68d953285", size = 1965854, upload-time = "2026-05-04T15:10:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/f0042a20e4ca914e66d2f513491bfc92d907ead59d3a63c6e56e6130b006/selectolax-0.4.8-cp314-cp314-win_arm64.whl", hash = "sha256:5125e171b16bbbdb76ea36140c165f183b1e99fe4170eb9b79a7141090bba51c", size = 1914520, upload-time = "2026-05-04T15:10:08.223Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3b/5d0087a277802a0054073d67919e560de3fada54497762f065ea5e54b0cb/selectolax-0.4.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:bc041e2554c4cce221401bbde42177a99670fdba8a60448f0ae65731fc08a0b5", size = 2266985, upload-time = "2026-05-04T15:10:10.405Z" }, + { url = "https://files.pythonhosted.org/packages/b0/07/1c398f0040f21897945d6a750dbc49bf1e0190f57490ba631bda4a421e86/selectolax-0.4.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:957fd757b0491e29f96235cb5ce0a46b8cdc8cce41f5032805387311d2504677", size = 2314767, upload-time = "2026-05-04T15:10:12.239Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/4ef2186699d1097f7275eade50cc27d41a1ecb8311b8d46c185e5266dd9d/selectolax-0.4.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d001ceff303937e0d05ecba4c1a8d39ea04840a653ccaecf59446667c16f65bc", size = 2374250, upload-time = "2026-05-04T15:10:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/9f/01/bb5fe5a69997282764174d4fdd0246d6ceb269c03806f07ad454d3fdc07c/selectolax-0.4.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f293e28183b329bed738918d18cd35966add6600bad2f3431470ffa18c0639e", size = 2425296, upload-time = "2026-05-04T15:10:16.493Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/5114524c7480c6f55791ef7ab82a4a923b0367553bc9f39541ed8b392117/selectolax-0.4.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3972b884caa78abf3710a0e7723dc705974dbdaef46d98a05e47c68493d86922", size = 2401425, upload-time = "2026-05-04T15:10:18.261Z" }, + { url = "https://files.pythonhosted.org/packages/59/8d/e51946644e5abbe53d08634ac2251f73132d290116a9d349ec5d381c8ba0/selectolax-0.4.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a6aacac9d106a2c992b817b5c1d4a1aa2e825403a5c1354386657968fa4b9b15", size = 2462447, upload-time = "2026-05-04T15:10:20.026Z" }, + { url = "https://files.pythonhosted.org/packages/9b/54/2250ce95c2b4f47f43b5fb9a156db823faffe1623c95e42137c738234573/selectolax-0.4.8-cp314-cp314t-win32.whl", hash = "sha256:ccfb2d172db96401a8d4ebc2dc12f4f4f20208597f5e6510d3f6bdc5a49c46b5", size = 1919388, upload-time = "2026-05-04T15:10:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/4e576a67fdd3a44d76418a196b7850890af8ef254efbca3eabef1b70fad5/selectolax-0.4.8-cp314-cp314t-win_amd64.whl", hash = "sha256:5c34fd9a29a430c1f8800d94e7a2c4fd0b1aef10485b0871fbae914cde232c41", size = 2036030, upload-time = "2026-05-04T15:10:23.775Z" }, + { url = "https://files.pythonhosted.org/packages/31/58/1e080cf00d7c4712e6411cd9cd3c927ec6ad4adf25d280a73ef38aeeb68a/selectolax-0.4.8-cp314-cp314t-win_arm64.whl", hash = "sha256:136dfad919728023fcd13fc773a1c488204fb0efd9c1f9baf7bd815fbb35eec3", size = 1939289, upload-time = "2026-05-04T15:10:25.481Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smart-open" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/33/7a00ac9b4a63afb4279b99a766f6cbe56c443526dcbf5db97b219e21fde9/smart_open-7.6.0.tar.gz", hash = "sha256:44717f46b5ff276fac03b88e5d13d1c416f064f3b7b081381b0fa8889004bd7e", size = 54548, upload-time = "2026-04-13T09:48:04.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/bc/2761410d0541e975f384bc89f062d716bf119499dd097eb1af33dcd3b1c0/smart_open-7.6.0-py3-none-any.whl", hash = "sha256:2a78f454610a826aa688065b54b4a0a9b12a5599fa61d5190e9bac2df5e5f53f", size = 64591, upload-time = "2026-04-13T09:48:02.687Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "spacy" +version = "3.8.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "jinja2" }, + { name = "murmurhash" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "spacy-legacy" }, + { name = "spacy-loggers" }, + { name = "srsly" }, + { name = "thinc" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "wasabi" }, + { name = "weasel" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/78/e4f2ae19a791cae756cd0e801204953eaec4e9ab75a60ad39f671dbb8d5a/spacy-3.8.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:726f02c60a2c6b0029167370d22d51731172a053d29c7e2ea6190db6de3ab483", size = 6218335, upload-time = "2026-03-29T10:40:46.298Z" }, + { url = "https://files.pythonhosted.org/packages/06/df/178bbab47fa209c8baf2f1e609cbddc6b18a985200be1ceee22bd5b89beb/spacy-3.8.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e3ebe50b93f2d40e8ec3451255528bb622ccb12be39fd140bb87668ce8d1075b", size = 6033860, upload-time = "2026-03-29T10:40:47.861Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e8/048d83b73b28686307bd9a60878a58de7b7b21b562ca4de8b5bd558031e9/spacy-3.8.14-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:daeb64b048f12c059997281aed53eb8776d26416dd313cf17ad6f63124b2b564", size = 32725099, upload-time = "2026-03-29T10:40:50.194Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/1799af5f4ccc8eb7500e4a20ca301488134429dba08cda5be68ce6ab2992/spacy-3.8.14-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d45715a24446f23b98ec3f09409a1d4111983d1d64613250ee38c3270e21853", size = 33205838, upload-time = "2026-03-29T10:40:53.029Z" }, + { url = "https://files.pythonhosted.org/packages/78/07/81ab9acd0ec64bfdd7339acfc4cf35f5fb74bbbb0b2be7e64d717c416bac/spacy-3.8.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1069a8be34940809f8462eb69f09a3f0ce59bf8b9cb82475f2a8e3580f50ece0", size = 32090380, upload-time = "2026-03-29T10:40:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/74/a5/b081b5bd3cedb2634c23eb470b5e24c65c894c57646567f47627291c2b3f/spacy-3.8.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2dfa77aec7fdebac0455d8afd4ce1d92d6f868b03d507ed1976179a63db7b374", size = 32991946, upload-time = "2026-03-29T10:40:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/4371413a6dfc1fa837282a365498165f828c2f3fe018dfb35336acc869e0/spacy-3.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:9def18c76a4472b326cb91a195623c9ca38a2b86999ad2df9e00b49ba8c63734", size = 14226946, upload-time = "2026-03-29T10:41:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/f3/5e/12ac876017da6c1e6b72afcc3c8b309996227fd3aa15382cd3311aee21b8/spacy-3.8.14-cp312-cp312-win_arm64.whl", hash = "sha256:d6257133357e4801c9c5d011925af5439b0a015aacf3c16528aa0009982431c7", size = 13628765, upload-time = "2026-03-29T10:41:03.806Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e5/822bbdfa459fee863ef2e9879a34b0ae5db7cd1e3eb76d32c766f19222e9/spacy-3.8.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b4f60fa8b9641a5e93e7a96db0cdd106d05d61756bf1d0ddcd1705ad347909a", size = 6202114, upload-time = "2026-03-29T10:41:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/7e/de/0e512154113e1f341567f2b9341835775e4180c180221e60faedaebb2f65/spacy-3.8.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0860c57220c633ccb20468bcd64bfb0d28908990c371a8857951d093a148dc8e", size = 6015458, upload-time = "2026-03-29T10:41:07.79Z" }, + { url = "https://files.pythonhosted.org/packages/0c/4f/29c7e56afc7db07348a9e0efe0243b5eef465d5dc3d56433f164378c3fa6/spacy-3.8.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c24620b7dba879c69cebc51ef3b1107d4d4e44a1e0d4baa439372887d00c3fd9", size = 32510659, upload-time = "2026-03-29T10:41:09.88Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ce/cae678f664d5467016819253f5d6e52f8e68a12d8e799b651d73ec2a9a4b/spacy-3.8.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9699c1248d115d5825987c287a6f6acd66386ef3ebee7994ee67ba093e932c59", size = 32841057, upload-time = "2026-03-29T10:41:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/04/d4/419868afd449bdd367df005932537eea66c71e97c899ba278f3124933f3c/spacy-3.8.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:042d799e342fdb6bb5b02a4213a95acc9116c40ed3c849bb0a8296fbe648ec22", size = 31763252, upload-time = "2026-03-29T10:41:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/df5c1fee45f200b749ba72eeb536fbb2c545fc56230324954263b2f3be00/spacy-3.8.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b2264294097336e86832e8663f1ab3a7215621184863c96c082ab17ee11937", size = 32717872, upload-time = "2026-03-29T10:41:18.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/f1882ec2f5cc9c4e73cf2132997a03c397d7ceeb5ee7f7bb878b51a16365/spacy-3.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:4b6d4f20e291a7c70e37de2f246622b44a0ce82efaa710c9801c6bd599e75177", size = 14220335, upload-time = "2026-03-29T10:41:20.89Z" }, +] + +[[package]] +name = "spacy-legacy" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/79/91f9d7cc8db5642acad830dcc4b49ba65a7790152832c4eceb305e46d681/spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774", size = 23806, upload-time = "2023-01-23T09:04:15.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/12e842c70ff8828e34e543a2c7176dac4da006ca6901c9e8b43efab8bc6b/spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f", size = 29971, upload-time = "2023-01-23T09:04:13.45Z" }, +] + +[[package]] +name = "spacy-loggers" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/3d/926db774c9c98acf66cb4ed7faf6c377746f3e00b84b700d0868b95d0712/spacy-loggers-1.0.5.tar.gz", hash = "sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24", size = 20811, upload-time = "2023-09-11T12:26:52.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" }, +] + +[[package]] +name = "srsly" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/db/f794f219a6c788b881252d2536a8c4a97d2bdaadc690391e1cb53d123d71/srsly-2.5.3.tar.gz", hash = "sha256:08f98dbecbff3a31466c4ae7c833131f59d3655a0ad8ac749e6e2c149e2b0680", size = 490881, upload-time = "2026-03-23T11:56:59.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/e9f7fcec4cc92ad8bad6316c4241638b8cf7380382d4489d94ec6c436452/srsly-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:71e51c046ccbeefb86524c6b1e17574f579c6ac4dc8ea4a09437d3e8f88342d3", size = 658379, upload-time = "2026-03-23T11:55:59.85Z" }, + { url = "https://files.pythonhosted.org/packages/21/e4/fea4512e9785f58509b2cf67d993323848e583161b5fcfdc7dd9d7c1f3df/srsly-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f73c0db911552e94fe2016e1759d261d2f47926f68826664cada3723c87006a", size = 658513, upload-time = "2026-03-23T11:56:01.239Z" }, + { url = "https://files.pythonhosted.org/packages/20/b1/53591681b6ff2699a4f97b2d5552ba196eaa6a979b0873605f4c04b5f7ee/srsly-2.5.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c1ac27ae5f4bb9163c7d2c45fc8ec173aac3d92e32086d9472b326c5c6e570e", size = 1172265, upload-time = "2026-03-23T11:56:02.589Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c9/741e29f534919a944a16da4184924b1d3404c4bf60716ab2b91be771d1e3/srsly-2.5.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99026bcd9cbd3211cc36517400b04ca0fc5d3e412b14daf84ee6e65f67d9a2d8", size = 1180873, upload-time = "2026-03-23T11:56:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/89/57/5554f786eccf78b2750d6ac63be126e1b67badec2cb409dd611cf6f8c52b/srsly-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:07d682679e639eb46ff7e6da4a92714f4d5ffe351d088ee66f221e9b1f8865bb", size = 1120437, upload-time = "2026-03-23T11:56:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/9b4f73b1be3692f86d72ccc131c8e50f26f824d5c8830a59390bcc5b60ef/srsly-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e0542d85d6b55cf2934050d6ffcb1cd76c768dcf9572e7467002cf087bb366d", size = 1137376, upload-time = "2026-03-23T11:56:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/5a/de/89ca640ca1953c4612279ce515d0af35658df3c06cdb324329bc91b4a7e1/srsly-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:598f1e494c18cacb978299d77125415a586417081959f8ec3f068b32d97f8933", size = 652459, upload-time = "2026-03-23T11:56:07.994Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/7ab6d49e36d9cc72ee15746cabd116eb6f338be8a06c1882968ee9d6c7d7/srsly-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:4b1b721cd3ad1a9b2343519aadc786a4d09d5c0666962d49852eb12d6ec3fe26", size = 638411, upload-time = "2026-03-23T11:56:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5c/12901e3794f4158abc6da750725aad6c2afddb1e4227b300fe7c71f66957/srsly-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e67b6bbacbfadea5e100266d2797f2d4cec9883ea4dc84a5537673850036a8d8", size = 656750, upload-time = "2026-03-23T11:56:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/181c26370995f96f56f1b64b801e3ca1e0d703fc36506ae28606d62369fb/srsly-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:348c231b4477d8fe86603131d0f166d2feac9c372704dfc4398be71cc5b6fb07", size = 656746, upload-time = "2026-03-23T11:56:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/35876c78889f8ffe11ed3521644e666c3aef20ea31527b70f47456cf35c2/srsly-2.5.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b0938c2978c91ae1ef9c1f2ba35abb86330e198fb23469e356eba311e02233ee", size = 1155762, upload-time = "2026-03-23T11:56:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/3e/da/40b71ca9906c8eb8f8feb6ac11d33dad458c85a56e1de764b96d402168a0/srsly-2.5.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f6a837954429ecbe6dcdd27390d2fb4c7d01a3f99c9ffcf9ce66b2a6dd1b738", size = 1161092, upload-time = "2026-03-23T11:56:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/c0dd30cc8b93ce8137ff4766f743c882440ce49195fffc5d50eaeef311a6/srsly-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3576c125c486ce2958c2047e8858fe3cfc9ea877adfa05203b0986f9badee355", size = 1109984, upload-time = "2026-03-23T11:56:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/f3/34354f183d8faafc631585571224b54d1b4b67e796972c36519c074ca355/srsly-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fb59c42922e095d1ea36085c55bc16e2adb06a7bfe57b24d381e0194ae699f2", size = 1128409, upload-time = "2026-03-23T11:56:18.761Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d9/5531f8a19492060b4e76e4ab06aca6f096fb5128fe18cc813d1772daf653/srsly-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:111805927f05f5db440aeeacb85ce43da0b19ce7b2a09567a9ef8d30f3cc4d83", size = 650820, upload-time = "2026-03-23T11:56:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/62fb7a971eca29e12f03fb9ddacb058548c14d33e5b5675ff0f85839cc7b/srsly-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:0f106b0a700ab56e4a7c431b0f1444009ab6cb332edc7bbf6811c2a43f4722cb", size = 637278, upload-time = "2026-03-23T11:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5b/e4ef43c2a381711230af98d4c94a5323df48d6a7899ee652e05bf889290e/srsly-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:39c13d552a9f9674a12cdcdc66b0c2f02f3430d0cd04c5f9cf598824c2bd3d65", size = 661294, upload-time = "2026-03-23T11:56:23.29Z" }, + { url = "https://files.pythonhosted.org/packages/92/2d/ebce7f3717e52cd0a01f4ec570f388f3b7098526794fcf1ad734e0b8f852/srsly-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14c930767cc169611a2dc14e23bc7638cfb616d6f79029700ade033607343540", size = 660952, upload-time = "2026-03-23T11:56:24.908Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/a8f3e9b214be2624c8e8a78d38ca7b1d4e26b92d57018412e4bfc4abe89a/srsly-2.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f2d464f0d0237e32fb53f0ec6f05418652c550e772b50e9918e83a1577cba4d", size = 1154554, upload-time = "2026-03-23T11:56:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/2a89dc3180a51e633a87a079ca064225f4aaf46c7b2a5fc720e28f261d98/srsly-2.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d18933248a5bb0ad56a1bae6003a9a7f37daac2ecb0c5bcbfaaf081b317e1c84", size = 1155746, upload-time = "2026-03-23T11:56:28.102Z" }, + { url = "https://files.pythonhosted.org/packages/b8/36/72e5ce3153927ca404b6f5bf5280e6ff3399c11557df472b153945468e0a/srsly-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7ea5412ea229e571ac9738cbe14f845cc06c8e4e956afb5f42061ccd087ef31f", size = 1112374, upload-time = "2026-03-23T11:56:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/0895de109c28eca0d41a811ab7c076d4e4a505e8466f06bae22f5180a1dd/srsly-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8d3988970b4cf7d03bdd5b5169302ff84562dd2e1e0f84aeb34df3e5b5dc19bf", size = 1127732, upload-time = "2026-03-23T11:56:31.458Z" }, + { url = "https://files.pythonhosted.org/packages/c7/79/a37fa7759797fbdfe0a2e029ab13e78b1e81e191220d2bb8ff57d869aefb/srsly-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:6a02d7dcc16126c8fae1c1c09b2072798a1dc482ab5f9c52b12c7114dac47325", size = 656467, upload-time = "2026-03-23T11:56:33.14Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/0dae019b3b90ad9037f91de4c390555cdaac9460a93ad62b02b03babdff5/srsly-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:1c9129c4abe31903ff7996904a51afdd5428060de6c3d12af49a4da5e8df2821", size = 643040, upload-time = "2026-03-23T11:56:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/3a/44/72dd5285b2e05435d98b0797f101d91d9b345d491ddc1fdb9bd09e27ccb8/srsly-2.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:29d5d01ba4c2e9c01f936e5e6d5babc4a47b38c9cbd6e1ec23f6d5a49df32605", size = 666200, upload-time = "2026-03-23T11:56:35.753Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ad/002c71b87fc3f648c9bf0ec47de0c3822bf2c95c8896a589dd03e7fd3977/srsly-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c8df4039426d99f0148b5743542842ab96b82daded0b342555e15a639927757", size = 667409, upload-time = "2026-03-23T11:56:37.172Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/2cea3d5e80aeecfc4ece9e7e1783e7792cc3bad7ab85ab585882e1db4e38/srsly-2.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:06a43d63bde2e8cccadb953d7fff70b18196ca286b65dd2ad16006d65f3f8166", size = 1265941, upload-time = "2026-03-23T11:56:38.825Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/8a4d7e86dd0370a2e5af251b646000197bb5b7e0f9aa360c71bbfb253d0d/srsly-2.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:808cfafc047f0dec507a34c8fa8e4cda5722737fd33577df73452f52f7aca644", size = 1250693, upload-time = "2026-03-23T11:56:40.449Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/340129de5ea7b237271b12f8a6962cfa7eb0c5a3056794626d348c5ae7c7/srsly-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71d4cbe2b2a1335c76ed0acae2dc862163787d8b01a705e1949796907ed94ccd", size = 1242408, upload-time = "2026-03-23T11:56:41.8Z" }, + { url = "https://files.pythonhosted.org/packages/01/cb/d7fee7ab27c6aa2e3f865fb7b50ba18c81a4c763bba12bdf53df246441bc/srsly-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f69083d33cb329cfc74317da937fb3270c0f40fabc1b4488702d8074b4a3e", size = 1242749, upload-time = "2026-03-23T11:56:43.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d1/9bad3a0f2fa7b72f4e0cf1d267b00513092d20ef538c47f72823ae4f7656/srsly-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8ac016ffaeac35bc010992b71bf8afdd39d458f201c8138d84cf78778a936e6c", size = 673783, upload-time = "2026-03-23T11:56:44.875Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "thinc" +version = "8.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blis" }, + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "murmurhash" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "setuptools" }, + { name = "srsly" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/46/76df95f2c327f9a9cef30c1523bf285627897097163584dcf5f77b2ebce2/thinc-8.3.13.tar.gz", hash = "sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9", size = 194640, upload-time = "2026-03-23T07:22:36.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/af/f7c1ebfe92eb5d27d7f2f3da67a11e2eb57bc30ab1553279af6dc65b65a8/thinc-8.3.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d", size = 821097, upload-time = "2026-03-23T07:21:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/69d7338575d98df85d0b54c0f5fc277dba72587fe9ab846ecdd12a998bcb/thinc-8.3.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411", size = 791932, upload-time = "2026-03-23T07:21:58.38Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a5/21d010c81e81e1589e5ccb4950e521804d13726e541e87f644c51815673b/thinc-8.3.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49", size = 3854219, upload-time = "2026-03-23T07:21:59.819Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ff/6914bf370bd1d604d89e6dfb46b97d10cd9b00d42ff8c036283e92314a8c/thinc-8.3.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f", size = 3903307, upload-time = "2026-03-23T07:22:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/5572b47fa155fb3388c071515b74024fa17a6efd1df9406da378f0aa84ef/thinc-8.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258", size = 4836882, upload-time = "2026-03-23T07:22:03.565Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a8d77c7bac089697c6df302cc3c936a1ab36a4720deae889e6f1dbcbd0eb/thinc-8.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59", size = 5033398, upload-time = "2026-03-23T07:22:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/5651bb1f904d04220fc7670035ada921bf0638e2cff6444d67c12887a968/thinc-8.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb", size = 1721248, upload-time = "2026-03-23T07:22:06.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/8d/683703de021ffbe46833d722b70f49ffbbca8e5bd6876256977555d92d7d/thinc-8.3.13-cp312-cp312-win_arm64.whl", hash = "sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525", size = 1645777, upload-time = "2026-03-23T07:22:08.104Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/7b46942176df459d1804a9e77b0976f7c56f3abf3ec7485d0e5f836a0382/thinc-8.3.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8", size = 817337, upload-time = "2026-03-23T07:22:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/a7/79/53085a72cd8f4fc4e6e313d05ea5aa98e870684f4a0fb318a9875fc0a964/thinc-8.3.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec", size = 788120, upload-time = "2026-03-23T07:22:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3e/d61b462b16da95ac6885f95bb395e672040ee594833e571a6edcffd234f5/thinc-8.3.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22", size = 3844666, upload-time = "2026-03-23T07:22:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/898cc654bb123734c71ec5a425c02ca34439517d01ce1c95a6563295580e/thinc-8.3.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f", size = 3890658, upload-time = "2026-03-23T07:22:14.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/56/1abdbf0a4ad628e8a05d6516fe0745969649d805367a3dccad8ee872981b/thinc-8.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3", size = 4819933, upload-time = "2026-03-23T07:22:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/f1/22/b84dbdc6be5055bbdb2a7352e2c393f67e8593c137f1b83c82bf1e062b6e/thinc-8.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499", size = 5018099, upload-time = "2026-03-23T07:22:18.356Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/763cd7ba949334c9d2cddc92dadb68b344cb9546dc01b8d4a733dcaa16c1/thinc-8.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f", size = 1720309, upload-time = "2026-03-23T07:22:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/f5/15/a11f7bb3cbc97dfecf32a90552f5a8f8a5c99316a99c6c17bdabf5baf256/thinc-8.3.13-cp313-cp313-win_arm64.whl", hash = "sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521", size = 1644606, upload-time = "2026-03-23T07:22:21.339Z" }, + { url = "https://files.pythonhosted.org/packages/80/40/f4937d113912c6d669ffe982356ab29dcb6c7fe3be926a15981dbbb6a91c/thinc-8.3.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865", size = 817024, upload-time = "2026-03-23T07:22:23.005Z" }, + { url = "https://files.pythonhosted.org/packages/d2/00/4d4ed1a11ba2920b85a03a0683b16d97dc5beb2e78078dbf0e13e43bcea7/thinc-8.3.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e", size = 792096, upload-time = "2026-03-23T07:22:24.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/dc33d6932be8721af2ef76b4a3a6e8020648630eabae61fb916d2a861d1d/thinc-8.3.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b", size = 3842215, upload-time = "2026-03-23T07:22:25.836Z" }, + { url = "https://files.pythonhosted.org/packages/af/bc/a6d37d8dadc2c5b524f51192413481160c42c9dd6105e8d5551531623225/thinc-8.3.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb", size = 3849253, upload-time = "2026-03-23T07:22:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/ce9c7067f1dfe5985875927de9cf7a79f9dae3e69487fd650dfba558029d/thinc-8.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe", size = 4831163, upload-time = "2026-03-23T07:22:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a8/f57819347fc4d8bef2204d15fcbb9d7dff2d6cdd5f83d5ed91456ddacc55/thinc-8.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990", size = 4986051, upload-time = "2026-03-23T07:22:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/a82214bb7c7c1e2d92b69e1a7654be90cfab180082c6108e45a98af2422c/thinc-8.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc", size = 1740382, upload-time = "2026-03-23T07:22:32.869Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ef/1648fda54e9689058335ff54f650a7a314db2a42e21af1b83949b2dc748e/thinc-8.3.13-cp314-cp314-win_arm64.whl", hash = "sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f", size = 1667687, upload-time = "2026-03-23T07:22:34.967Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "transformers" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/e9/c6c80a07690142a7d05444271f47b9f3c8aac7dea01d52e1137ee480ad78/transformers-5.6.2.tar.gz", hash = "sha256:e657134c3e5a6bc00a3c35f4e2674bb51adfcd89898495b788a18552bac2b91a", size = 8311867, upload-time = "2026-04-23T18:33:29.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" }, +] + +[[package]] +name = "typer" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + +[[package]] +name = "unstructured" +version = "0.21.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "charset-normalizer" }, + { name = "emoji" }, + { name = "filelock" }, + { name = "filetype" }, + { name = "html5lib" }, + { name = "installer" }, + { name = "langdetect" }, + { name = "lxml" }, + { name = "numba" }, + { name = "numpy" }, + { name = "psutil" }, + { name = "python-iso639" }, + { name = "python-magic" }, + { name = "python-oxmsg" }, + { name = "rapidfuzz" }, + { name = "regex" }, + { name = "requests" }, + { name = "spacy" }, + { name = "tqdm" }, + { name = "typing-extensions" }, + { name = "unstructured-client" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/e6/fbef61517d130af1def3b81681e253a5679f19de2f04e439afbbf1f021e0/unstructured-0.21.5.tar.gz", hash = "sha256:3e220d0c2b9c8ec12c99767162b95ab0acfca75e979b82c66c15ca15caa60139", size = 1501811, upload-time = "2026-02-24T15:29:27.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/b6/7e6dd60bde81d5a4d4ddf426f566a5d1b4c30490053caed69e47f55c676f/unstructured-0.21.5-py3-none-any.whl", hash = "sha256:d88a277c368462b69a8843b9cb22476f3cc4d0a58455536520359387224b3366", size = 1554925, upload-time = "2026-02-24T15:29:26.009Z" }, +] + +[package.optional-dependencies] +docx = [ + { name = "python-docx" }, +] +epub = [ + { name = "pypandoc-binary", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, +] +md = [ + { name = "markdown" }, +] +ppt = [ + { name = "python-pptx" }, +] +pptx = [ + { name = "python-pptx" }, +] + +[[package]] +name = "unstructured-client" +version = "0.43.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pypdf" }, + { name = "pypdfium2" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/a8/82f91b5e891996737473f911ce0103cab85275043ee6314cdc7249d17d74/unstructured_client-0.43.2.tar.gz", hash = "sha256:dce158622e789833d29fe321c3cdf6048588e4f51b88ac4fd94a0ff06edbfc9a", size = 105852, upload-time = "2026-04-04T14:30:57.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/de/680fd60d0b2cf96a72c4e3086d6ee697f4d40cbf34f49e22a379c4f0bd31/unstructured_client-0.43.2-py3-none-any.whl", hash = "sha256:65bda439c250fa5041d0d8374af407c1fc051cd78c0ebd62600d2562c16bd00f", size = 224522, upload-time = "2026-04-04T14:30:56.244Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "wasabi" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/f9/054e6e2f1071e963b5e746b48d1e3727470b2a490834d18ad92364929db3/wasabi-1.1.3.tar.gz", hash = "sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878", size = 30391, upload-time = "2024-05-31T16:56:18.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/7c/34330a89da55610daa5f245ddce5aab81244321101614751e7537f125133/wasabi-1.1.3-py3-none-any.whl", hash = "sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c", size = 27880, upload-time = "2024-05-31T16:56:16.699Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "weasel" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpathlib" }, + { name = "confection" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "smart-open" }, + { name = "srsly" }, + { name = "typer" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/e5/e272bb9a045105a1fdf4b798d8086f5932a178f4d738f17a74f5c9e0ae9a/weasel-1.0.0.tar.gz", hash = "sha256:7b129b44c90cc543b760532974ca1e4eb30dad2aa2026f57bdce66354ae610fc", size = 38682, upload-time = "2026-03-20T08:10:25.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/07/57ebf7a6798b016c064bd0ca81b4c6a99daa4dc377b898bc7b41eb6b5af0/weasel-1.0.0-py3-none-any.whl", hash = "sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc", size = 50713, upload-time = "2026-03-20T08:10:23.637Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "webvtt-py" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/f6/7c9c964681fb148e0293e6860108d378e09ccab2218f9063fd3eb87f840a/webvtt-py-0.5.1.tar.gz", hash = "sha256:2040dd325277ddadc1e0c6cc66cbc4a1d9b6b49b24c57a0c3364374c3e8a3dc1", size = 55128, upload-time = "2024-05-30T13:40:17.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ed/aad7e0f5a462d679f7b4d2e0d8502c3096740c883b5bbed5103146480937/webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7", size = 19802, upload-time = "2024-05-30T13:40:14.661Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] diff --git a/docker/.env.example b/docker/.env.example index 29741474fa..c708a40c15 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,1249 +1,163 @@ -# ------------------------------ -# Environment Variables for API service & worker -# ------------------------------ +# ------------------------------------------------------------------ +# Essential defaults for Docker Compose deployments. +# Only include variables required for services to start. +# +# For a default deployment, copy this file to .env and run: +# docker compose up -d +# +# Optional and provider-specific variables live under docker/envs/. +# Copy an optional *.env.example file beside itself without the +# .example suffix when you need those advanced settings. +# Values in docker/.env take precedence over docker/envs/*.env files. +# ------------------------------------------------------------------ -# ------------------------------ -# Common Variables -# ------------------------------ - -# The backend URL of the console API, -# used to concatenate the authorization callback. -# If empty, it is the same domain. -# Example: https://api.console.dify.ai +# Core service URLs CONSOLE_API_URL= - -# The front-end URL of the console web, -# used to concatenate some front-end addresses and for CORS configuration use. -# If empty, it is the same domain. -# Example: https://console.dify.ai CONSOLE_WEB_URL= - -# Service API Url, -# used to display Service API Base Url to the front-end. -# If empty, it is the same domain. -# Example: https://api.dify.ai SERVICE_API_URL= - -# Trigger external URL -# used to display trigger endpoint API Base URL to the front-end. -# Example: https://api.dify.ai TRIGGER_URL=http://localhost - -# WebApp API backend Url, -# used to declare the back-end URL for the front-end API. -# If empty, it is the same domain. -# Example: https://api.app.dify.ai APP_API_URL= - -# WebApp Url, -# used to display WebAPP API Base Url to the front-end. -# If empty, it is the same domain. -# Example: https://app.dify.ai APP_WEB_URL= - -# File preview or download Url prefix. -# used to display File preview or download Url to the front-end or as Multi-model inputs; -# Url is signed and has expiration time. -# Setting FILES_URL is required for file processing plugins. -# - For https://example.com, use FILES_URL=https://example.com -# - For http://example.com, use FILES_URL=http://example.com -# Recommendation: use a dedicated domain (e.g., https://upload.example.com). -# Alternatively, use http://:5001 or http://api:5001, -# ensuring port 5001 is externally accessible (see docker-compose.yaml). FILES_URL= - -# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network. -# Set this to the internal Docker service URL for proper plugin file access. -# Example: INTERNAL_FILES_URL=http://api:5001 INTERNAL_FILES_URL= +ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} +NEXT_PUBLIC_SOCKET_URL=ws://localhost -# Ensure UTF-8 encoding +# Runtime and security LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONIOENCODING=utf-8 - -# Set UV cache directory to avoid permission issues with non-existent home directory UV_CACHE_DIR=/tmp/.uv-cache - -# ------------------------------ -# Server Configuration -# ------------------------------ - -# The log level for the application. -# Supported values are `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` -LOG_LEVEL=INFO -# Log output format: text or json -LOG_OUTPUT_FORMAT=text -# Log file path -LOG_FILE=/app/logs/server.log -# Log file max size, the unit is MB -LOG_FILE_MAX_SIZE=20 -# Log file max backup count -LOG_FILE_BACKUP_COUNT=5 -# Log dateformat -LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S -# Log Timezone -LOG_TZ=UTC - -# Debug mode, default is false. -# It is recommended to turn on this configuration for local development -# to prevent some problems caused by monkey patch. -DEBUG=false - -# Flask debug mode, it can output trace information at the interface when turned on, -# which is convenient for debugging. -FLASK_DEBUG=false - -# Enable request logging, which will log the request and response information. -# And the log level is DEBUG -ENABLE_REQUEST_LOGGING=False - -# A secret key that is used for securely signing the session cookie -# and encrypting sensitive information on the database. -# You can generate a strong key using `openssl rand -base64 42`. -SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U - -# Password for admin user initialization. -# If left unset, admin user will not be prompted for a password -# when creating the initial admin account. -# The length of the password cannot exceed 30 characters. +# Leave empty to auto-generate a persistent key in the storage directory. +SECRET_KEY= INIT_PASSWORD= - -# Deployment environment. -# Supported values are `PRODUCTION`, `TESTING`. Default is `PRODUCTION`. -# Testing environment. There will be a distinct color label on the front-end page, -# indicating that this environment is a testing environment. DEPLOY_ENV=PRODUCTION - -# Whether to enable the version check policy. -# If set to empty, https://updates.dify.ai will be called for version check. CHECK_UPDATE_URL=https://updates.dify.ai - -# Used to change the OpenAI base address, default is https://api.openai.com/v1. -# When OpenAI cannot be accessed in China, replace it with a domestic mirror address, -# or when a local model provides OpenAI compatible API, it can be replaced. OPENAI_API_BASE=https://api.openai.com/v1 - -# When enabled, migrations will be executed prior to application startup -# and the application will start after the migrations have completed. MIGRATION_ENABLED=true - -# File Access Time specifies a time interval in seconds for the file to be accessed. -# The default value is 300 seconds. FILES_ACCESS_TIMEOUT=300 +# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. +ENABLE_COLLABORATION_MODE=true -# Collaboration mode toggle -# To open collaboration features, you also need to set SERVER_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker -ENABLE_COLLABORATION_MODE=false - -# Access token expiration time in minutes -ACCESS_TOKEN_EXPIRE_MINUTES=60 - -# Refresh token expiration time in days -REFRESH_TOKEN_EXPIRE_DAYS=30 - -# The default number of active requests for the application, where 0 means unlimited, should be a non-negative integer. -APP_DEFAULT_ACTIVE_REQUESTS=0 -# The maximum number of active requests for the application, where 0 means unlimited, should be a non-negative integer. -APP_MAX_ACTIVE_REQUESTS=0 -APP_MAX_EXECUTION_TIME=1200 - -# ------------------------------ -# Container Startup Related Configuration -# Only effective when starting with docker image or docker-compose. -# ------------------------------ - -# API service binding address, default: 0.0.0.0, i.e., all addresses can be accessed. +# Logging and server workers +LOG_LEVEL=INFO +LOG_OUTPUT_FORMAT=text +LOG_FILE=/app/logs/server.log +LOG_FILE_MAX_SIZE=20 +LOG_FILE_BACKUP_COUNT=5 +LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S +LOG_TZ=UTC +DEBUG=false +FLASK_DEBUG=false +ENABLE_REQUEST_LOGGING=False DIFY_BIND_ADDRESS=0.0.0.0 - -# API service binding port number, default 5001. DIFY_PORT=5001 - -# The number of API server workers, i.e., the number of workers. -# Formula: number of cpu cores x 2 + 1 for sync, 1 for Gevent -# Reference: https://docs.gunicorn.org/en/stable/design.html#how-many-workers SERVER_WORKER_AMOUNT=1 - -# Defaults to gevent. If using windows, it can be switched to sync or solo. -# -# Warning: Changing this parameter requires disabling patching for -# psycopg2 and gRPC (see `gunicorn.conf.py` and `celery_entrypoint.py`). -# Modifying it may also decrease throughput. -# -# It is strongly discouraged to change this parameter. -# If enable collaboration mode, it must be set to geventwebsocket.gunicorn.workers.GeventWebSocketWorker SERVER_WORKER_CLASS=gevent - -# Default number of worker connections, the default is 10. SERVER_WORKER_CONNECTIONS=10 - -# Similar to SERVER_WORKER_CLASS. -# If using windows, it can be switched to sync or solo. -# -# Warning: Changing this parameter requires disabling patching for -# psycopg2 and gRPC (see `gunicorn_conf.py` and `celery_entrypoint.py`). -# Modifying it may also decrease throughput. -# -# It is strongly discouraged to change this parameter. -CELERY_WORKER_CLASS= - -# Request handling timeout. The default is 200, -# it is recommended to set it to 360 to support a longer sse connection time. +API_WEBSOCKET_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +API_WEBSOCKET_WORKER_CONNECTIONS=1000 +API_WEBSOCKET_GUNICORN_TIMEOUT=360 GUNICORN_TIMEOUT=360 - -# The number of Celery workers. The default is 4 for development environments -# to allow parallel processing of workflows, document indexing, and other async tasks. -# Adjust based on your system resources and workload requirements. +CELERY_WORKER_CLASS= CELERY_WORKER_AMOUNT=4 - -# Flag indicating whether to enable autoscaling of Celery workers. -# -# Autoscaling is useful when tasks are CPU intensive and can be dynamically -# allocated and deallocated based on the workload. -# -# When autoscaling is enabled, the maximum and minimum number of workers can -# be specified. The autoscaling algorithm will dynamically adjust the number -# of workers within the specified range. -# -# Default is false (i.e., autoscaling is disabled). -# -# Example: -# CELERY_AUTO_SCALE=true CELERY_AUTO_SCALE=false - -# The maximum number of Celery workers that can be autoscaled. -# This is optional and only used when autoscaling is enabled. -# Default is not set. CELERY_MAX_WORKERS= - -# The minimum number of Celery workers that can be autoscaled. -# This is optional and only used when autoscaling is enabled. -# Default is not set. CELERY_MIN_WORKERS= +COMPOSE_WORKER_HEALTHCHECK_DISABLED=true +COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s +COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s -# API Tool configuration -API_TOOL_DEFAULT_CONNECT_TIMEOUT=10 -API_TOOL_DEFAULT_READ_TIMEOUT=60 - -# ------------------------------- -# Datasource Configuration -# -------------------------------- -ENABLE_WEBSITE_JINAREADER=true -ENABLE_WEBSITE_FIRECRAWL=true -ENABLE_WEBSITE_WATERCRAWL=true - -# Enable inline LaTeX rendering with single dollar signs ($...$) in the web frontend -# Default is false for security reasons to prevent conflicts with regular text -NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false - -# ------------------------------ -# Database Configuration -# The database uses PostgreSQL or MySQL. OceanBase and seekdb are also supported. Please use the public schema. -# It is consistent with the configuration in the database service below. -# You can adjust the database configuration according to your needs. -# ------------------------------ - -# Database type, supported values are `postgresql`, `mysql`, `oceanbase`, `seekdb` +# Database DB_TYPE=postgresql -# For MySQL, only `root` user is supported for now DB_USERNAME=postgres DB_PASSWORD=difyai123456 DB_HOST=db_postgres DB_PORT=5432 DB_DATABASE=dify - -# The size of the database connection pool. -# The default is 30 connections, which can be appropriately increased. SQLALCHEMY_POOL_SIZE=30 -# The default is 10 connections, which allows temporary overflow beyond the pool size. SQLALCHEMY_MAX_OVERFLOW=10 -# Database connection pool recycling time, the default is 3600 seconds. SQLALCHEMY_POOL_RECYCLE=3600 -# Whether to print SQL, default is false. SQLALCHEMY_ECHO=false -# If True, will test connections for liveness upon each checkout SQLALCHEMY_POOL_PRE_PING=false -# Whether to enable the Last in first out option or use default FIFO queue if is false SQLALCHEMY_POOL_USE_LIFO=false -# Number of seconds to wait for a connection from the pool before raising a timeout error. -# Default is 30 SQLALCHEMY_POOL_TIMEOUT=30 - -# Maximum number of connections to the database -# Default is 100 -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS +SQLALCHEMY_POOL_RESET_ON_RETURN=rollback +PGDATA=/var/lib/postgresql/data/pgdata POSTGRES_MAX_CONNECTIONS=200 - -# Sets the amount of shared memory used for postgres's shared buffers. -# Default is 128MB -# Recommended value: 25% of available memory -# Reference: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-SHARED-BUFFERS POSTGRES_SHARED_BUFFERS=128MB - -# Sets the amount of memory used by each database worker for working space. -# Default is 4MB -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM POSTGRES_WORK_MEM=4MB - -# Sets the amount of memory reserved for maintenance activities. -# Default is 64MB -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-MAINTENANCE-WORK-MEM POSTGRES_MAINTENANCE_WORK_MEM=64MB - -# Sets the planner's assumption about the effective cache size. -# Default is 4096MB -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB - -# Sets the maximum allowed duration of any statement before termination. -# Default is 0 (no timeout). -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT -# A value of 0 prevents the server from timing out statements. POSTGRES_STATEMENT_TIMEOUT=0 - -# Sets the maximum allowed duration of any idle in-transaction session before termination. -# Default is 0 (no timeout). -# -# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT -# A value of 0 prevents the server from terminating idle sessions. POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 -# MySQL Performance Configuration -# Maximum number of connections to MySQL -# -# Default is 1000 -MYSQL_MAX_CONNECTIONS=1000 - -# InnoDB buffer pool size -# Default is 512M -# Recommended value: 70-80% of available memory for dedicated MySQL server -# Reference: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_buffer_pool_size -MYSQL_INNODB_BUFFER_POOL_SIZE=512M - -# InnoDB log file size -# Default is 128M -# Reference: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_log_file_size -MYSQL_INNODB_LOG_FILE_SIZE=128M - -# InnoDB flush log at transaction commit -# Default is 2 (flush to OS cache, sync every second) -# Options: 0 (no flush), 1 (flush and sync), 2 (flush to OS cache) -# Reference: https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit -MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2 - -# ------------------------------ -# Redis Configuration -# This Redis configuration is used for caching and for pub/sub during conversation. -# ------------------------------ - +# Redis and Celery REDIS_HOST=redis REDIS_PORT=6379 REDIS_USERNAME= REDIS_PASSWORD=difyai123456 REDIS_USE_SSL=false -# SSL configuration for Redis (when REDIS_USE_SSL=true) REDIS_SSL_CERT_REQS=CERT_NONE -# Options: CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED REDIS_SSL_CA_CERTS= -# Path to CA certificate file for SSL verification REDIS_SSL_CERTFILE= -# Path to client certificate file for SSL authentication REDIS_SSL_KEYFILE= -# Path to client private key file for SSL authentication REDIS_DB=0 -# Optional global prefix for Redis keys, topics, streams, and Celery Redis transport artifacts. -# Leave empty to preserve current unprefixed behavior. REDIS_KEY_PREFIX= -# Optional: limit total Redis connections used by API/Worker (unset for default) -# Align with API's REDIS_MAX_CONNECTIONS in configs REDIS_MAX_CONNECTIONS= - -# Whether to use Redis Sentinel mode. -# If set to true, the application will automatically discover and connect to the master node through Sentinel. -REDIS_USE_SENTINEL=false - -# List of Redis Sentinel nodes. If Sentinel mode is enabled, provide at least one Sentinel IP and port. -# Format: `:,:,:` -REDIS_SENTINELS= -REDIS_SENTINEL_SERVICE_NAME= -REDIS_SENTINEL_USERNAME= -REDIS_SENTINEL_PASSWORD= -REDIS_SENTINEL_SOCKET_TIMEOUT=0.1 - -# List of Redis Cluster nodes. If Cluster mode is enabled, provide at least one Cluster IP and port. -# Format: `:,:,:` -REDIS_USE_CLUSTERS=false -REDIS_CLUSTERS= -REDIS_CLUSTERS_PASSWORD= - -# Redis connection and retry configuration -# max redis retry REDIS_RETRY_RETRIES=3 -# Base delay (in seconds) for exponential backoff on retries REDIS_RETRY_BACKOFF_BASE=1.0 -# Cap (in seconds) for exponential backoff on retries REDIS_RETRY_BACKOFF_CAP=10.0 -# Timeout (in seconds) for Redis socket operations REDIS_SOCKET_TIMEOUT=5.0 -# Timeout (in seconds) for establishing a Redis connection REDIS_SOCKET_CONNECT_TIMEOUT=5.0 -# Interval (in seconds) for Redis health checks REDIS_HEALTH_CHECK_INTERVAL=30 - -# ------------------------------ -# Celery Configuration -# ------------------------------ - -# Use standalone redis as the broker, and redis db 1 for celery broker. (redis_username is usually set by default as empty) -# Format as follows: `redis://:@:/`. -# Example: redis://:difyai123456@redis:6379/1 -# If use Redis Sentinel, format as follows: `sentinel://:@:/` -# For high availability, you can configure multiple Sentinel nodes (if provided) separated by semicolons like below example: -# Example: sentinel://:difyai123456@localhost:26379/1;sentinel://:difyai12345@localhost:26379/1;sentinel://:difyai12345@localhost:26379/1 CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_BACKEND=redis BROKER_USE_SSL=false - -# If you are using Redis Sentinel for high availability, configure the following settings. -CELERY_USE_SENTINEL=false -CELERY_SENTINEL_MASTER_NAME= -CELERY_SENTINEL_PASSWORD= -CELERY_SENTINEL_SOCKET_TIMEOUT=0.1 -# e.g. {"tasks.add": {"rate_limit": "10/s"}} CELERY_TASK_ANNOTATIONS=null +EVENT_BUS_REDIS_URL= +EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub +EVENT_BUS_REDIS_USE_CLUSTERS=false -# ------------------------------ -# CORS Configuration -# Used to set the front-end cross-domain access policy. -# ------------------------------ - -# Specifies the allowed origins for cross-origin requests to the Web API, -# e.g. https://dify.app or * for all origins. +# Web and app limits WEB_API_CORS_ALLOW_ORIGINS=* - -# Specifies the allowed origins for cross-origin requests to the console API, -# e.g. https://cloud.dify.ai or * for all origins. CONSOLE_CORS_ALLOW_ORIGINS=* -# When the frontend and backend run on different subdomains, set COOKIE_DOMAIN to the site's top-level domain (e.g., `example.com`). Leading dots are optional. COOKIE_DOMAIN= -# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. NEXT_PUBLIC_COOKIE_DOMAIN= -# WebSocket server URL. -NEXT_PUBLIC_SOCKET_URL=ws://localhost NEXT_PUBLIC_BATCH_CONCURRENCY=5 +API_SENTRY_DSN= +API_SENTRY_TRACES_SAMPLE_RATE=1.0 +API_SENTRY_PROFILES_SAMPLE_RATE=1.0 +WEB_SENTRY_DSN= +AMPLITUDE_API_KEY= +TEXT_GENERATION_TIMEOUT_MS=60000 +CSP_WHITELIST= +ALLOW_EMBED=false +ALLOW_INLINE_STYLES=false +ALLOW_UNSAFE_DATA_SCHEME=false +TOP_K_MAX_VALUE=10 +INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 +LOOP_NODE_MAX_COUNT=100 +MAX_TOOLS_NUM=10 +MAX_PARALLEL_LIMIT=10 +MAX_ITERATIONS_NUM=99 +MAX_TREE_DEPTH=50 +ENABLE_WEBSITE_JINAREADER=true +ENABLE_WEBSITE_FIRECRAWL=true +ENABLE_WEBSITE_WATERCRAWL=true +NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false +EXPERIMENTAL_ENABLE_VINEXT=false -# ------------------------------ -# File Storage Configuration -# ------------------------------ - -# The type of storage to use for storing user files. +# Storage and default vector store STORAGE_TYPE=opendal - -# Apache OpenDAL Configuration -# The configuration for OpenDAL consists of the following format: OPENDAL__. -# You can find all the service configurations (CONFIG_NAME) in the repository at: https://github.com/apache/opendal/tree/main/core/src/services. -# Dify will scan configurations starting with OPENDAL_ and automatically apply them. -# The scheme name for the OpenDAL storage. OPENDAL_SCHEME=fs -# Configurations for OpenDAL Local File System. OPENDAL_FS_ROOT=storage - -# ClickZetta Volume Configuration (for storage backend) -# To use ClickZetta Volume as storage backend, set STORAGE_TYPE=clickzetta-volume -# Note: ClickZetta Volume will reuse the existing CLICKZETTA_* connection parameters - -# Volume type selection (three types available): -# - user: Personal/small team use, simple config, user-level permissions -# - table: Enterprise multi-tenant, smart routing, table-level + user-level permissions -# - external: Data lake integration, external storage connection, volume-level + storage-level permissions -CLICKZETTA_VOLUME_TYPE=user - -# External Volume name (required only when TYPE=external) -CLICKZETTA_VOLUME_NAME= - -# Table Volume table prefix (used only when TYPE=table) -CLICKZETTA_VOLUME_TABLE_PREFIX=dataset_ - -# Dify file directory prefix (isolates from other apps, recommended to keep default) -CLICKZETTA_VOLUME_DIFY_PREFIX=dify_km - -# S3 Configuration -# -S3_ENDPOINT= -S3_REGION=us-east-1 -S3_BUCKET_NAME=difyai -S3_ACCESS_KEY= -S3_SECRET_KEY= -S3_ADDRESS_STYLE=auto -# Whether to use AWS managed IAM roles for authenticating with the S3 service. -# If set to false, the access key and secret key must be provided. -S3_USE_AWS_MANAGED_IAM=false - -# Workflow run and Conversation archive storage (S3-compatible) -ARCHIVE_STORAGE_ENABLED=false -ARCHIVE_STORAGE_ENDPOINT= -ARCHIVE_STORAGE_ARCHIVE_BUCKET= -ARCHIVE_STORAGE_EXPORT_BUCKET= -ARCHIVE_STORAGE_ACCESS_KEY= -ARCHIVE_STORAGE_SECRET_KEY= -ARCHIVE_STORAGE_REGION=auto - -# Azure Blob Configuration -# -AZURE_BLOB_ACCOUNT_NAME=difyai -AZURE_BLOB_ACCOUNT_KEY=difyai -AZURE_BLOB_CONTAINER_NAME=difyai-container -AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net - -# Google Storage Configuration -# -GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name -GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64= - -# The Alibaba Cloud OSS configurations, -# -ALIYUN_OSS_BUCKET_NAME=your-bucket-name -ALIYUN_OSS_ACCESS_KEY=your-access-key -ALIYUN_OSS_SECRET_KEY=your-secret-key -ALIYUN_OSS_ENDPOINT=https://oss-ap-southeast-1-internal.aliyuncs.com -ALIYUN_OSS_REGION=ap-southeast-1 -ALIYUN_OSS_AUTH_VERSION=v4 -# Don't start with '/'. OSS doesn't support leading slash in object names. -ALIYUN_OSS_PATH=your-path -# Optional CloudBox ID for Aliyun OSS, DO NOT enable it if you are not using CloudBox. -#ALIYUN_CLOUDBOX_ID=your-cloudbox-id - -# Tencent COS Configuration -# -TENCENT_COS_BUCKET_NAME=your-bucket-name -TENCENT_COS_SECRET_KEY=your-secret-key -TENCENT_COS_SECRET_ID=your-secret-id -TENCENT_COS_REGION=your-region -TENCENT_COS_SCHEME=your-scheme -TENCENT_COS_CUSTOM_DOMAIN=your-custom-domain - -# Oracle Storage Configuration -# -OCI_ENDPOINT=https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com -OCI_BUCKET_NAME=your-bucket-name -OCI_ACCESS_KEY=your-access-key -OCI_SECRET_KEY=your-secret-key -OCI_REGION=us-ashburn-1 - -# Huawei OBS Configuration -# -HUAWEI_OBS_BUCKET_NAME=your-bucket-name -HUAWEI_OBS_SECRET_KEY=your-secret-key -HUAWEI_OBS_ACCESS_KEY=your-access-key -HUAWEI_OBS_SERVER=your-server-url -HUAWEI_OBS_PATH_STYLE=false - -# Volcengine TOS Configuration -# -VOLCENGINE_TOS_BUCKET_NAME=your-bucket-name -VOLCENGINE_TOS_SECRET_KEY=your-secret-key -VOLCENGINE_TOS_ACCESS_KEY=your-access-key -VOLCENGINE_TOS_ENDPOINT=your-server-url -VOLCENGINE_TOS_REGION=your-region - -# Baidu OBS Storage Configuration -# -BAIDU_OBS_BUCKET_NAME=your-bucket-name -BAIDU_OBS_SECRET_KEY=your-secret-key -BAIDU_OBS_ACCESS_KEY=your-access-key -BAIDU_OBS_ENDPOINT=your-server-url - -# Supabase Storage Configuration -# -SUPABASE_BUCKET_NAME=your-bucket-name -SUPABASE_API_KEY=your-access-key -SUPABASE_URL=your-server-url - -# ------------------------------ -# Vector Database Configuration -# ------------------------------ - -# The type of vector store to use. -# Supported values are `weaviate`, `oceanbase`, `seekdb`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `opengauss`, `tablestore`, `vastbase`, `tidb`, `tidb_on_qdrant`, `baidu`, `lindorm`, `huawei_cloud`, `upstash`, `matrixone`, `clickzetta`, `alibabacloud_mysql`, `iris`, `hologres`. VECTOR_STORE=weaviate -# Prefix used to create collection name in vector database VECTOR_INDEX_NAME_PREFIX=Vector_index - -# The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`. WEAVIATE_ENDPOINT=http://weaviate:8080 WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051 WEAVIATE_TOKENIZATION=word - -# For OceanBase metadata database configuration, available when `DB_TYPE` is `oceanbase`. -# For OceanBase vector database configuration, available when `VECTOR_STORE` is `oceanbase` -# If you want to use OceanBase as both vector database and metadata database, you need to set both `DB_TYPE` and `VECTOR_STORE` to `oceanbase`, and set Database Configuration is the same as the vector database. -# seekdb is the lite version of OceanBase and shares the connection configuration with OceanBase. -OCEANBASE_VECTOR_HOST=oceanbase -OCEANBASE_VECTOR_PORT=2881 -OCEANBASE_VECTOR_USER=root@test -OCEANBASE_VECTOR_PASSWORD=difyai123456 -OCEANBASE_VECTOR_DATABASE=test -OCEANBASE_CLUSTER_NAME=difyai -OCEANBASE_MEMORY_LIMIT=6G -OCEANBASE_ENABLE_HYBRID_SEARCH=false -# For OceanBase vector database, built-in fulltext parsers are `ngram`, `beng`, `space`, `ngram2`, `ik` -# For OceanBase vector database, external fulltext parsers (require plugin installation) are `japanese_ftparser`, `thai_ftparser` -OCEANBASE_FULLTEXT_PARSER=ik -SEEKDB_MEMORY_LIMIT=2G - -# The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`. -QDRANT_URL=http://qdrant:6333 -QDRANT_API_KEY=difyai123456 -QDRANT_CLIENT_TIMEOUT=20 -QDRANT_GRPC_ENABLED=false -QDRANT_GRPC_PORT=6334 -QDRANT_REPLICATION_FACTOR=1 - -# Milvus configuration. Only available when VECTOR_STORE is `milvus`. -# The milvus uri. -MILVUS_URI=http://host.docker.internal:19530 -MILVUS_DATABASE= -MILVUS_TOKEN= -MILVUS_USER= -MILVUS_PASSWORD= -MILVUS_ENABLE_HYBRID_SEARCH=False -MILVUS_ANALYZER_PARAMS= - -# MyScale configuration, only available when VECTOR_STORE is `myscale` -# For multi-language support, please set MYSCALE_FTS_PARAMS with referring to: -# https://myscale.com/docs/en/text-search/#understanding-fts-index-parameters -MYSCALE_HOST=myscale -MYSCALE_PORT=8123 -MYSCALE_USER=default -MYSCALE_PASSWORD= -MYSCALE_DATABASE=dify -MYSCALE_FTS_PARAMS= - -# Couchbase configurations, only available when VECTOR_STORE is `couchbase` -# The connection string must include hostname defined in the docker-compose file (couchbase-server in this case) -COUCHBASE_CONNECTION_STRING=couchbase://couchbase-server -COUCHBASE_USER=Administrator -COUCHBASE_PASSWORD=password -COUCHBASE_BUCKET_NAME=Embeddings -COUCHBASE_SCOPE_NAME=_default - -# Hologres configurations, only available when VECTOR_STORE is `hologres` -# access_key_id is used as the PG username, access_key_secret is used as the PG password -HOLOGRES_HOST= -HOLOGRES_PORT=80 -HOLOGRES_DATABASE= -HOLOGRES_ACCESS_KEY_ID= -HOLOGRES_ACCESS_KEY_SECRET= -HOLOGRES_SCHEMA=public -HOLOGRES_TOKENIZER=jieba -HOLOGRES_DISTANCE_METHOD=Cosine -HOLOGRES_BASE_QUANTIZATION_TYPE=rabitq -HOLOGRES_MAX_DEGREE=64 -HOLOGRES_EF_CONSTRUCTION=400 - -# pgvector configurations, only available when VECTOR_STORE is `pgvector` -PGVECTOR_HOST=pgvector -PGVECTOR_PORT=5432 -PGVECTOR_USER=postgres -PGVECTOR_PASSWORD=difyai123456 -PGVECTOR_DATABASE=dify -PGVECTOR_MIN_CONNECTION=1 -PGVECTOR_MAX_CONNECTION=5 -PGVECTOR_PG_BIGM=false -PGVECTOR_PG_BIGM_VERSION=1.2-20240606 - -# vastbase configurations, only available when VECTOR_STORE is `vastbase` -VASTBASE_HOST=vastbase -VASTBASE_PORT=5432 -VASTBASE_USER=dify -VASTBASE_PASSWORD=Difyai123456 -VASTBASE_DATABASE=dify -VASTBASE_MIN_CONNECTION=1 -VASTBASE_MAX_CONNECTION=5 - -# pgvecto-rs configurations, only available when VECTOR_STORE is `pgvecto-rs` -PGVECTO_RS_HOST=pgvecto-rs -PGVECTO_RS_PORT=5432 -PGVECTO_RS_USER=postgres -PGVECTO_RS_PASSWORD=difyai123456 -PGVECTO_RS_DATABASE=dify - -# analyticdb configurations, only available when VECTOR_STORE is `analyticdb` -ANALYTICDB_KEY_ID=your-ak -ANALYTICDB_KEY_SECRET=your-sk -ANALYTICDB_REGION_ID=cn-hangzhou -ANALYTICDB_INSTANCE_ID=gp-ab123456 -ANALYTICDB_ACCOUNT=testaccount -ANALYTICDB_PASSWORD=testpassword -ANALYTICDB_NAMESPACE=dify -ANALYTICDB_NAMESPACE_PASSWORD=difypassword -ANALYTICDB_HOST=gp-test.aliyuncs.com -ANALYTICDB_PORT=5432 -ANALYTICDB_MIN_CONNECTION=1 -ANALYTICDB_MAX_CONNECTION=5 - -# TiDB vector configurations, only available when VECTOR_STORE is `tidb_vector` -TIDB_VECTOR_HOST=tidb -TIDB_VECTOR_PORT=4000 -TIDB_VECTOR_USER= -TIDB_VECTOR_PASSWORD= -TIDB_VECTOR_DATABASE=dify - -# Matrixone vector configurations. -MATRIXONE_HOST=matrixone -MATRIXONE_PORT=6001 -MATRIXONE_USER=dump -MATRIXONE_PASSWORD=111 -MATRIXONE_DATABASE=dify - -# Tidb on qdrant configuration, only available when VECTOR_STORE is `tidb_on_qdrant` -TIDB_ON_QDRANT_URL=http://127.0.0.1 -TIDB_ON_QDRANT_API_KEY=dify -TIDB_ON_QDRANT_CLIENT_TIMEOUT=20 -TIDB_ON_QDRANT_GRPC_ENABLED=false -TIDB_ON_QDRANT_GRPC_PORT=6334 -TIDB_PUBLIC_KEY=dify -TIDB_PRIVATE_KEY=dify -TIDB_API_URL=http://127.0.0.1 -TIDB_IAM_API_URL=http://127.0.0.1 -TIDB_REGION=regions/aws-us-east-1 -TIDB_PROJECT_ID=dify -TIDB_SPEND_LIMIT=100 - -# Chroma configuration, only available when VECTOR_STORE is `chroma` -CHROMA_HOST=127.0.0.1 -CHROMA_PORT=8000 -CHROMA_TENANT=default_tenant -CHROMA_DATABASE=default_database -CHROMA_AUTH_PROVIDER=chromadb.auth.token_authn.TokenAuthClientProvider -CHROMA_AUTH_CREDENTIALS= - -# Oracle configuration, only available when VECTOR_STORE is `oracle` -ORACLE_USER=dify -ORACLE_PASSWORD=dify -ORACLE_DSN=oracle:1521/FREEPDB1 -ORACLE_CONFIG_DIR=/app/api/storage/wallet -ORACLE_WALLET_LOCATION=/app/api/storage/wallet -ORACLE_WALLET_PASSWORD=dify -ORACLE_IS_AUTONOMOUS=false - -# AlibabaCloud MySQL configuration, only available when VECTOR_STORE is `alibabcloud_mysql` -ALIBABACLOUD_MYSQL_HOST=127.0.0.1 -ALIBABACLOUD_MYSQL_PORT=3306 -ALIBABACLOUD_MYSQL_USER=root -ALIBABACLOUD_MYSQL_PASSWORD=difyai123456 -ALIBABACLOUD_MYSQL_DATABASE=dify -ALIBABACLOUD_MYSQL_MAX_CONNECTION=5 -ALIBABACLOUD_MYSQL_HNSW_M=6 - -# relyt configurations, only available when VECTOR_STORE is `relyt` -RELYT_HOST=db -RELYT_PORT=5432 -RELYT_USER=postgres -RELYT_PASSWORD=difyai123456 -RELYT_DATABASE=postgres - -# open search configuration, only available when VECTOR_STORE is `opensearch` -OPENSEARCH_HOST=opensearch -OPENSEARCH_PORT=9200 -OPENSEARCH_SECURE=true -OPENSEARCH_VERIFY_CERTS=true -OPENSEARCH_AUTH_METHOD=basic -OPENSEARCH_USER=admin -OPENSEARCH_PASSWORD=admin -# If using AWS managed IAM, e.g. Managed Cluster or OpenSearch Serverless -OPENSEARCH_AWS_REGION=ap-southeast-1 -OPENSEARCH_AWS_SERVICE=aoss - -# tencent vector configurations, only available when VECTOR_STORE is `tencent` -TENCENT_VECTOR_DB_URL=http://127.0.0.1 -TENCENT_VECTOR_DB_API_KEY=dify -TENCENT_VECTOR_DB_TIMEOUT=30 -TENCENT_VECTOR_DB_USERNAME=dify -TENCENT_VECTOR_DB_DATABASE=dify -TENCENT_VECTOR_DB_SHARD=1 -TENCENT_VECTOR_DB_REPLICAS=2 -TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH=false - -# ElasticSearch configuration, only available when VECTOR_STORE is `elasticsearch` -ELASTICSEARCH_HOST=0.0.0.0 -ELASTICSEARCH_PORT=9200 -ELASTICSEARCH_USERNAME=elastic -ELASTICSEARCH_PASSWORD=elastic -KIBANA_PORT=5601 - -# Using ElasticSearch Cloud Serverless, or not. -ELASTICSEARCH_USE_CLOUD=false -ELASTICSEARCH_CLOUD_URL=YOUR-ELASTICSEARCH_CLOUD_URL -ELASTICSEARCH_API_KEY=YOUR-ELASTICSEARCH_API_KEY - -ELASTICSEARCH_VERIFY_CERTS=False -ELASTICSEARCH_CA_CERTS= -ELASTICSEARCH_REQUEST_TIMEOUT=100000 -ELASTICSEARCH_RETRY_ON_TIMEOUT=True -ELASTICSEARCH_MAX_RETRIES=10 - -# baidu vector configurations, only available when VECTOR_STORE is `baidu` -BAIDU_VECTOR_DB_ENDPOINT=http://127.0.0.1:5287 -BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS=30000 -BAIDU_VECTOR_DB_ACCOUNT=root -BAIDU_VECTOR_DB_API_KEY=dify -BAIDU_VECTOR_DB_DATABASE=dify -BAIDU_VECTOR_DB_SHARD=1 -BAIDU_VECTOR_DB_REPLICAS=3 -BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER -BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE -BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500 -BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05 -BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300 - -# VikingDB configurations, only available when VECTOR_STORE is `vikingdb` -VIKINGDB_ACCESS_KEY=your-ak -VIKINGDB_SECRET_KEY=your-sk -VIKINGDB_REGION=cn-shanghai -VIKINGDB_HOST=api-vikingdb.xxx.volces.com -VIKINGDB_SCHEMA=http -VIKINGDB_CONNECTION_TIMEOUT=30 -VIKINGDB_SOCKET_TIMEOUT=30 - -# Lindorm configuration, only available when VECTOR_STORE is `lindorm` -LINDORM_URL=http://localhost:30070 -LINDORM_USERNAME=admin -LINDORM_PASSWORD=admin -LINDORM_USING_UGC=True -LINDORM_QUERY_TIMEOUT=1 - -# opengauss configurations, only available when VECTOR_STORE is `opengauss` -OPENGAUSS_HOST=opengauss -OPENGAUSS_PORT=6600 -OPENGAUSS_USER=postgres -OPENGAUSS_PASSWORD=Dify@123 -OPENGAUSS_DATABASE=dify -OPENGAUSS_MIN_CONNECTION=1 -OPENGAUSS_MAX_CONNECTION=5 -OPENGAUSS_ENABLE_PQ=false - -# huawei cloud search service vector configurations, only available when VECTOR_STORE is `huawei_cloud` -HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200 -HUAWEI_CLOUD_USER=admin -HUAWEI_CLOUD_PASSWORD=admin - -# Upstash Vector configuration, only available when VECTOR_STORE is `upstash` -UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io -UPSTASH_VECTOR_TOKEN=dify - -# TableStore Vector configuration -# (only used when VECTOR_STORE is tablestore) -TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com -TABLESTORE_INSTANCE_NAME=instance-name -TABLESTORE_ACCESS_KEY_ID=xxx -TABLESTORE_ACCESS_KEY_SECRET=xxx -TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE=false - -# Clickzetta configuration, only available when VECTOR_STORE is `clickzetta` -CLICKZETTA_USERNAME= -CLICKZETTA_PASSWORD= -CLICKZETTA_INSTANCE= -CLICKZETTA_SERVICE=api.clickzetta.com -CLICKZETTA_WORKSPACE=quick_start -CLICKZETTA_VCLUSTER=default_ap -CLICKZETTA_SCHEMA=dify -CLICKZETTA_BATCH_SIZE=100 -CLICKZETTA_ENABLE_INVERTED_INDEX=true -CLICKZETTA_ANALYZER_TYPE=chinese -CLICKZETTA_ANALYZER_MODE=smart -CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance - -# InterSystems IRIS configuration, only available when VECTOR_STORE is `iris` -IRIS_HOST=iris -IRIS_SUPER_SERVER_PORT=1972 -IRIS_WEB_SERVER_PORT=52773 -IRIS_USER=_SYSTEM -IRIS_PASSWORD=Dify@1234 -IRIS_DATABASE=USER -IRIS_SCHEMA=dify -IRIS_CONNECTION_URL= -IRIS_MIN_CONNECTION=1 -IRIS_MAX_CONNECTION=3 -IRIS_TEXT_INDEX=true -IRIS_TEXT_INDEX_LANGUAGE=en -IRIS_TIMEZONE=UTC - -# ------------------------------ -# Knowledge Configuration -# ------------------------------ - -# Upload file size limit, default 15M. -UPLOAD_FILE_SIZE_LIMIT=15 - -# The maximum number of files that can be uploaded at a time, default 5. -UPLOAD_FILE_BATCH_LIMIT=5 - -# Comma-separated list of file extensions blocked from upload for security reasons. -# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll). -# Empty by default to allow all file types. -# Recommended: exe,bat,cmd,com,scr,vbs,ps1,msi,dll -UPLOAD_FILE_EXTENSION_BLACKLIST= - -# Maximum number of files allowed in a single chunk attachment, default 10. -SINGLE_CHUNK_ATTACHMENT_LIMIT=10 - -# Maximum number of files allowed in a image batch upload operation -IMAGE_FILE_BATCH_LIMIT=10 - -# Maximum allowed image file size for attachments in megabytes, default 2. -ATTACHMENT_IMAGE_FILE_SIZE_LIMIT=2 - -# Timeout for downloading image attachments in seconds, default 60. -ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT=60 - - -# ETL type, support: `dify`, `Unstructured` -# `dify` Dify's proprietary file extraction scheme -# `Unstructured` Unstructured.io file extraction scheme -ETL_TYPE=dify - -# Unstructured API path and API key, needs to be configured when ETL_TYPE is Unstructured -# Or using Unstructured for document extractor node for pptx. -# For example: http://unstructured:8000/general/v0/general -UNSTRUCTURED_API_URL= -UNSTRUCTURED_API_KEY= -SCARF_NO_ANALYTICS=true - -# ------------------------------ -# Model Configuration -# ------------------------------ - -# The maximum number of tokens allowed for prompt generation. -# This setting controls the upper limit of tokens that can be used by the LLM -# when generating a prompt in the prompt generation tool. -# Default: 512 tokens. -PROMPT_GENERATION_MAX_TOKENS=512 - -# The maximum number of tokens allowed for code generation. -# This setting controls the upper limit of tokens that can be used by the LLM -# when generating code in the code generation tool. -# Default: 1024 tokens. -CODE_GENERATION_MAX_TOKENS=1024 - -# Enable or disable plugin based token counting. If disabled, token counting will return 0. -# This can improve performance by skipping token counting operations. -# Default: false (disabled). -PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false - -# ------------------------------ -# Multi-modal Configuration -# ------------------------------ - -# The format of the image/video/audio/document sent when the multi-modal model is input, -# the default is base64, optional url. -# The delay of the call in url mode will be lower than that in base64 mode. -# It is generally recommended to use the more compatible base64 mode. -# If configured as url, you need to configure FILES_URL as an externally accessible address so that the multi-modal model can access the image/video/audio/document. -MULTIMODAL_SEND_FORMAT=base64 -# Upload image file size limit, default 10M. -UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 -# Upload video file size limit, default 100M. -UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 -# Upload audio file size limit, default 50M. -UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 - -# ------------------------------ -# Sentry Configuration -# Used for application monitoring and error log tracking. -# ------------------------------ -SENTRY_DSN= - -# API Service Sentry DSN address, default is empty, when empty, -# all monitoring information is not reported to Sentry. -# If not set, Sentry error reporting will be disabled. -API_SENTRY_DSN= -# API Service The reporting ratio of Sentry events, if it is 0.01, it is 1%. -API_SENTRY_TRACES_SAMPLE_RATE=1.0 -# API Service The reporting ratio of Sentry profiles, if it is 0.01, it is 1%. -API_SENTRY_PROFILES_SAMPLE_RATE=1.0 - -# Web Service Sentry DSN address, default is empty, when empty, -# all monitoring information is not reported to Sentry. -# If not set, Sentry error reporting will be disabled. -WEB_SENTRY_DSN= - -# Plugin_daemon Service Sentry DSN address, default is empty, when empty, -# all monitoring information is not reported to Sentry. -# If not set, Sentry error reporting will be disabled. -PLUGIN_SENTRY_ENABLED=false -PLUGIN_SENTRY_DSN= - -# ------------------------------ -# Notion Integration Configuration -# Variables can be obtained by applying for Notion integration: https://www.notion.so/my-integrations -# ------------------------------ - -# Configure as "public" or "internal". -# Since Notion's OAuth redirect URL only supports HTTPS, -# if deploying locally, please use Notion's internal integration. -NOTION_INTEGRATION_TYPE=public -# Notion OAuth client secret (used for public integration type) -NOTION_CLIENT_SECRET= -# Notion OAuth client id (used for public integration type) -NOTION_CLIENT_ID= -# Notion internal integration secret. -# If the value of NOTION_INTEGRATION_TYPE is "internal", -# you need to configure this variable. -NOTION_INTERNAL_SECRET= - -# ------------------------------ -# Mail related configuration -# ------------------------------ - -# Mail type, support: resend, smtp, sendgrid -MAIL_TYPE=resend - -# Default send from email address, if not specified -# If using SendGrid, use the 'from' field for authentication if necessary. -MAIL_DEFAULT_SEND_FROM= - -# API-Key for the Resend email provider, used when MAIL_TYPE is `resend`. -RESEND_API_URL=https://api.resend.com -RESEND_API_KEY=your-resend-api-key - - -# SMTP server configuration, used when MAIL_TYPE is `smtp` -SMTP_SERVER= -SMTP_PORT=465 -SMTP_USERNAME= -SMTP_PASSWORD= -SMTP_USE_TLS=true -SMTP_OPPORTUNISTIC_TLS=false -# Optional: override the local hostname used for SMTP HELO/EHLO -SMTP_LOCAL_HOSTNAME= - -# Sendgid configuration -SENDGRID_API_KEY= - -# ------------------------------ -# Others Configuration -# ------------------------------ - -# Maximum length of segmentation tokens for indexing -INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 - -# Member invitation link valid time (hours), -# Default: 72. -INVITE_EXPIRY_HOURS=72 - -# Reset password token valid time (minutes), -RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 -EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 -CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 -OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 - -# The sandbox service endpoint. -CODE_EXECUTION_ENDPOINT=http://sandbox:8194 -CODE_EXECUTION_API_KEY=dify-sandbox -CODE_EXECUTION_SSL_VERIFY=True -CODE_EXECUTION_POOL_MAX_CONNECTIONS=100 -CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20 -CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0 -CODE_MAX_NUMBER=9223372036854775807 -CODE_MIN_NUMBER=-9223372036854775808 -CODE_MAX_DEPTH=5 -CODE_MAX_PRECISION=20 -CODE_MAX_STRING_LENGTH=400000 -CODE_MAX_STRING_ARRAY_LENGTH=30 -CODE_MAX_OBJECT_ARRAY_LENGTH=30 -CODE_MAX_NUMBER_ARRAY_LENGTH=1000 -CODE_EXECUTION_CONNECT_TIMEOUT=10 -CODE_EXECUTION_READ_TIMEOUT=60 -CODE_EXECUTION_WRITE_TIMEOUT=10 -TEMPLATE_TRANSFORM_MAX_LENGTH=400000 - -# Workflow runtime configuration -WORKFLOW_MAX_EXECUTION_STEPS=500 -WORKFLOW_MAX_EXECUTION_TIME=1200 -WORKFLOW_CALL_MAX_DEPTH=5 -MAX_VARIABLE_SIZE=204800 -WORKFLOW_FILE_UPLOAD_LIMIT=10 - -# GraphEngine Worker Pool Configuration -# Minimum number of workers per GraphEngine instance (default: 1) -GRAPH_ENGINE_MIN_WORKERS=1 -# Maximum number of workers per GraphEngine instance (default: 10) -GRAPH_ENGINE_MAX_WORKERS=10 -# Queue depth threshold that triggers worker scale up (default: 3) -GRAPH_ENGINE_SCALE_UP_THRESHOLD=3 -# Seconds of idle time before scaling down workers (default: 5.0) -GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME=5.0 - -# Workflow storage configuration -# Options: rdbms, hybrid -# rdbms: Use only the relational database (default) -# hybrid: Save new data to object storage, read from both object storage and RDBMS -WORKFLOW_NODE_EXECUTION_STORAGE=rdbms - -# Repository configuration -# Core workflow execution repository implementation -# Options: -# - core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository (default) -# - core.repositories.celery_workflow_execution_repository.CeleryWorkflowExecutionRepository -# - extensions.logstore.repositories.logstore_workflow_execution_repository.LogstoreWorkflowExecutionRepository -CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository - -# Core workflow node execution repository implementation -# Options: -# - core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository (default) -# - core.repositories.celery_workflow_node_execution_repository.CeleryWorkflowNodeExecutionRepository -# - extensions.logstore.repositories.logstore_workflow_node_execution_repository.LogstoreWorkflowNodeExecutionRepository -CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository - -# API workflow run repository implementation -# Options: -# - repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository (default) -# - extensions.logstore.repositories.logstore_api_workflow_run_repository.LogstoreAPIWorkflowRunRepository -API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository - -# API workflow node execution repository implementation -# Options: -# - repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository (default) -# - extensions.logstore.repositories.logstore_api_workflow_node_execution_repository.LogstoreAPIWorkflowNodeExecutionRepository -API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository - -# Workflow log cleanup configuration -# Enable automatic cleanup of workflow run logs to manage database size -WORKFLOW_LOG_CLEANUP_ENABLED=false -# Number of days to retain workflow run logs (default: 30 days) -WORKFLOW_LOG_RETENTION_DAYS=30 -# Batch size for workflow log cleanup operations (default: 100) -WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 -# Comma-separated list of workflow IDs to clean logs for -WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= - -# Aliyun SLS Logstore Configuration -# Aliyun Access Key ID -ALIYUN_SLS_ACCESS_KEY_ID= -# Aliyun Access Key Secret -ALIYUN_SLS_ACCESS_KEY_SECRET= -# Aliyun SLS Endpoint (e.g., cn-hangzhou.log.aliyuncs.com) -ALIYUN_SLS_ENDPOINT= -# Aliyun SLS Region (e.g., cn-hangzhou) -ALIYUN_SLS_REGION= -# Aliyun SLS Project Name -ALIYUN_SLS_PROJECT_NAME= -# Number of days to retain workflow run logs (default: 365 days, 3650 for permanent storage) -ALIYUN_SLS_LOGSTORE_TTL=365 -# Enable dual-write to both SLS LogStore and SQL database (default: false) -LOGSTORE_DUAL_WRITE_ENABLED=false -# Enable dual-read fallback to SQL database when LogStore returns no results (default: true) -# Useful for migration scenarios where historical data exists only in SQL database -LOGSTORE_DUAL_READ_ENABLED=true -# Control flag for whether to write the `graph` field to LogStore. -# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field; -# otherwise write an empty {} instead. Defaults to writing the `graph` field. -LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true - -# HTTP request node in workflow configuration -HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 -HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 -HTTP_REQUEST_NODE_SSL_VERIFY=True - -# HTTP request node timeout configuration -# Maximum timeout values (in seconds) that users can set in HTTP request nodes -# - Connect timeout: Time to wait for establishing connection (default: 10s) -# - Read timeout: Time to wait for receiving response data (default: 600s, 10 minutes) -# - Write timeout: Time to wait for sending request data (default: 600s, 10 minutes) -HTTP_REQUEST_MAX_CONNECT_TIMEOUT=10 -HTTP_REQUEST_MAX_READ_TIMEOUT=600 -HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 - -# Base64 encoded CA certificate data for custom certificate verification (PEM format, optional) -# HTTP_REQUEST_NODE_SSL_CERT_DATA=LS0tLS1CRUdJTi... -# Base64 encoded client certificate data for mutual TLS authentication (PEM format, optional) -# HTTP_REQUEST_NODE_SSL_CLIENT_CERT_DATA=LS0tLS1CRUdJTi... -# Base64 encoded client private key data for mutual TLS authentication (PEM format, optional) -# HTTP_REQUEST_NODE_SSL_CLIENT_KEY_DATA=LS0tLS1CRUdJTi... - -# Webhook request configuration -WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 - -# Respect X-* headers to redirect clients -RESPECT_XFORWARD_HEADERS_ENABLED=false - -# SSRF Proxy server HTTP URL -SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 -# SSRF Proxy server HTTPS URL -SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 - -# Maximum loop count in the workflow -LOOP_NODE_MAX_COUNT=100 - -# The maximum number of tools that can be used in the agent. -MAX_TOOLS_NUM=10 - -# Maximum number of Parallelism branches in the workflow -MAX_PARALLEL_LIMIT=10 - -# The maximum number of iterations for agent setting -MAX_ITERATIONS_NUM=99 - -# ------------------------------ -# Environment Variables for web Service -# ------------------------------ - -# The timeout for the text generation in millisecond -TEXT_GENERATION_TIMEOUT_MS=60000 - -# Enable the experimental vinext runtime shipped in the image. -EXPERIMENTAL_ENABLE_VINEXT=false - -# Allow inline style attributes in Markdown rendering. -# Enable this if your workflows use Jinja2 templates with styled HTML. -# Only recommended for self-hosted deployments with trusted content. -ALLOW_INLINE_STYLES=false - -# Allow rendering unsafe URLs which have "data:" scheme. -ALLOW_UNSAFE_DATA_SCHEME=false - -# Maximum number of tree depth in the workflow -MAX_TREE_DEPTH=50 - -# ------------------------------ -# Environment Variables for database Service -# ------------------------------ -# Postgres data directory -PGDATA=/var/lib/postgresql/data/pgdata - -# MySQL Default Configuration -MYSQL_HOST_VOLUME=./volumes/mysql/data - -# ------------------------------ -# Environment Variables for sandbox Service -# ------------------------------ - -# The API key for the sandbox service -SANDBOX_API_KEY=dify-sandbox -# The mode in which the Gin framework runs -SANDBOX_GIN_MODE=release -# The timeout for the worker in seconds -SANDBOX_WORKER_TIMEOUT=15 -# Enable network for the sandbox service -SANDBOX_ENABLE_NETWORK=true -# HTTP proxy URL for SSRF protection -SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 -# HTTPS proxy URL for SSRF protection -SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 -# The port on which the sandbox service runs -SANDBOX_PORT=8194 - -# ------------------------------ -# Environment Variables for weaviate Service -# (only used when VECTOR_STORE is weaviate) -# ------------------------------ WEAVIATE_PERSISTENCE_DATA_PATH=/var/lib/weaviate WEAVIATE_QUERY_DEFAULTS_LIMIT=25 WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true @@ -1259,118 +173,26 @@ WEAVIATE_ENABLE_TOKENIZER_GSE=false WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false -# ------------------------------ -# Environment Variables for Chroma -# (only used when VECTOR_STORE is chroma) -# ------------------------------ - -# Authentication credentials for Chroma server -CHROMA_SERVER_AUTHN_CREDENTIALS=difyai123456 -# Authentication provider for Chroma server -CHROMA_SERVER_AUTHN_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider -# Persistence setting for Chroma server -CHROMA_IS_PERSISTENT=TRUE - -# ------------------------------ -# Environment Variables for Oracle Service -# (only used when VECTOR_STORE is oracle) -# ------------------------------ -ORACLE_PWD=Dify123456 -ORACLE_CHARACTERSET=AL32UTF8 - -# ------------------------------ -# Environment Variables for milvus Service -# (only used when VECTOR_STORE is milvus) -# ------------------------------ -# ETCD configuration for auto compaction mode -ETCD_AUTO_COMPACTION_MODE=revision -# ETCD configuration for auto compaction retention in terms of number of revisions -ETCD_AUTO_COMPACTION_RETENTION=1000 -# ETCD configuration for backend quota in bytes -ETCD_QUOTA_BACKEND_BYTES=4294967296 -# ETCD configuration for the number of changes before triggering a snapshot -ETCD_SNAPSHOT_COUNT=50000 -# MinIO access key for authentication -MINIO_ACCESS_KEY=minioadmin -# MinIO secret key for authentication -MINIO_SECRET_KEY=minioadmin -# ETCD service endpoints -ETCD_ENDPOINTS=etcd:2379 -# MinIO service address -MINIO_ADDRESS=minio:9000 -# Enable or disable security authorization -MILVUS_AUTHORIZATION_ENABLED=true - -# ------------------------------ -# Environment Variables for pgvector / pgvector-rs Service -# (only used when VECTOR_STORE is pgvector / pgvector-rs) -# ------------------------------ -PGVECTOR_PGUSER=postgres -# The password for the default postgres user. -PGVECTOR_POSTGRES_PASSWORD=difyai123456 -# The name of the default postgres database. -PGVECTOR_POSTGRES_DB=dify -# postgres data directory -PGVECTOR_PGDATA=/var/lib/postgresql/data/pgdata - -# ------------------------------ -# Environment Variables for opensearch -# (only used when VECTOR_STORE is opensearch) -# ------------------------------ -OPENSEARCH_DISCOVERY_TYPE=single-node -OPENSEARCH_BOOTSTRAP_MEMORY_LOCK=true -OPENSEARCH_JAVA_OPTS_MIN=512m -OPENSEARCH_JAVA_OPTS_MAX=1024m -OPENSEARCH_INITIAL_ADMIN_PASSWORD=Qazwsxedc!@#123 -OPENSEARCH_MEMLOCK_SOFT=-1 -OPENSEARCH_MEMLOCK_HARD=-1 -OPENSEARCH_NOFILE_SOFT=65536 -OPENSEARCH_NOFILE_HARD=65536 - -# ------------------------------ -# Environment Variables for Nginx reverse proxy -# ------------------------------ -NGINX_SERVER_NAME=_ -NGINX_HTTPS_ENABLED=false -# HTTP port -NGINX_PORT=80 -# SSL settings are only applied when HTTPS_ENABLED is true -NGINX_SSL_PORT=443 -# if HTTPS_ENABLED is true, you're required to add your own SSL certificates/keys to the `./nginx/ssl` directory -# and modify the env vars below accordingly. -NGINX_SSL_CERT_FILENAME=dify.crt -NGINX_SSL_CERT_KEY_FILENAME=dify.key -NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3 - -# Nginx performance tuning -NGINX_WORKER_PROCESSES=auto -NGINX_CLIENT_MAX_BODY_SIZE=100M -NGINX_KEEPALIVE_TIMEOUT=65 - -# Proxy settings -NGINX_PROXY_READ_TIMEOUT=3600s -NGINX_PROXY_SEND_TIMEOUT=3600s - -# Set true to accept requests for /.well-known/acme-challenge/ -NGINX_ENABLE_CERTBOT_CHALLENGE=false - -# ------------------------------ -# Certbot Configuration -# ------------------------------ - -# Email address (required to get certificates from Let's Encrypt) -CERTBOT_EMAIL=your_email@example.com - -# Domain name -CERTBOT_DOMAIN=your_domain.com - -# certbot command options -# i.e: --force-renewal --dry-run --test-cert --debug -CERTBOT_OPTIONS= - -# ------------------------------ -# Environment Variables for SSRF Proxy -# ------------------------------ +# Sandbox and SSRF proxy +CODE_EXECUTION_ENDPOINT=http://sandbox:8194 +CODE_EXECUTION_API_KEY=dify-sandbox +CODE_EXECUTION_SSL_VERIFY=True +CODE_EXECUTION_POOL_MAX_CONNECTIONS=100 +CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0 +CODE_EXECUTION_CONNECT_TIMEOUT=10 +CODE_EXECUTION_READ_TIMEOUT=60 +CODE_EXECUTION_WRITE_TIMEOUT=10 +SANDBOX_API_KEY=dify-sandbox +SANDBOX_GIN_MODE=release +SANDBOX_WORKER_TIMEOUT=15 +SANDBOX_ENABLE_NETWORK=true +SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 +SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 +SANDBOX_PORT=8194 +PIP_MIRROR_URL= +SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 +SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 SSRF_HTTP_PORT=3128 SSRF_COREDUMP_DIR=/var/spool/squid SSRF_REVERSE_PROXY_PORT=8194 @@ -1383,67 +205,7 @@ SSRF_POOL_MAX_CONNECTIONS=100 SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 SSRF_POOL_KEEPALIVE_EXPIRY=5.0 -# ------------------------------ -# docker env var for specifying vector db and metadata db type at startup -# (based on the vector db and metadata db type, the corresponding docker -# compose profile will be used) -# if you want to use unstructured, add ',unstructured' to the end -# ------------------------------ -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} - -# ------------------------------ -# Worker health check configuration for worker and worker_beat services. -# Set to false to enable the health check. -# Note: enabling the health check may cause periodic CPU spikes and increased load, -# as it establishes a broker connection and sends a Celery ping on every check interval. -# ------------------------------ -COMPOSE_WORKER_HEALTHCHECK_DISABLED=true -# Interval between health checks (e.g. 30s, 1m) -COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s -# Timeout for each health check (e.g. 30s, 1m) -COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s - -# ------------------------------ -# Docker Compose Service Expose Host Port Configurations -# ------------------------------ -EXPOSE_NGINX_PORT=80 -EXPOSE_NGINX_SSL_PORT=443 - -# ---------------------------------------------------------------------------- -# ModelProvider & Tool Position Configuration -# Used to specify the model providers and tools that can be used in the app. -# ---------------------------------------------------------------------------- - -# Pin, include, and exclude tools -# Use comma-separated values with no spaces between items. -# Example: POSITION_TOOL_PINS=bing,google -POSITION_TOOL_PINS= -POSITION_TOOL_INCLUDES= -POSITION_TOOL_EXCLUDES= - -# Pin, include, and exclude model providers -# Use comma-separated values with no spaces between items. -# Example: POSITION_PROVIDER_PINS=openai,openllm -POSITION_PROVIDER_PINS= -POSITION_PROVIDER_INCLUDES= -POSITION_PROVIDER_EXCLUDES= - -# CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -CSP_WHITELIST= - -# Enable or disable create tidb service job -CREATE_TIDB_SERVICE_JOB_ENABLED=false - -# Maximum number of submitted thread count in a ThreadPool for parallel node execution -MAX_SUBMIT_COUNT=100 - -# The maximum number of top-k value for RAG. -TOP_K_MAX_VALUE=10 - -# ------------------------------ -# Plugin Daemon Configuration -# ------------------------------ - +# Plugin daemon DB_PLUGIN_DATABASE=dify_plugin EXPOSE_PLUGIN_DAEMON_PORT=5002 PLUGIN_DAEMON_PORT=5002 @@ -1452,180 +214,45 @@ PLUGIN_DAEMON_URL=http://plugin_daemon:5002 PLUGIN_MAX_PACKAGE_SIZE=52428800 PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 PLUGIN_PPROF_ENABLED=false - PLUGIN_DEBUGGING_HOST=0.0.0.0 PLUGIN_DEBUGGING_PORT=5003 EXPOSE_PLUGIN_DEBUGGING_HOST=localhost EXPOSE_PLUGIN_DEBUGGING_PORT=5003 - -# If this key is changed, DIFY_INNER_API_KEY in plugin_daemon service must also be updated or agent node will fail. PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 PLUGIN_DIFY_INNER_API_URL=http://api:5001 - -ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} - -MARKETPLACE_ENABLED=true -MARKETPLACE_API_URL=https://marketplace.dify.ai - -# Creators Platform configuration -CREATORS_PLATFORM_FEATURES_ENABLED=true -CREATORS_PLATFORM_API_URL=https://creators.dify.ai -CREATORS_PLATFORM_OAUTH_CLIENT_ID= - FORCE_VERIFYING_SIGNATURE=true -ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES=true - PLUGIN_STDIO_BUFFER_SIZE=1024 PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880 - PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120 -# Plugin Daemon side timeout (configure to match the API side below) PLUGIN_MAX_EXECUTION_TIMEOUT=600 -# API side timeout (configure to match the Plugin Daemon side above) -PLUGIN_DAEMON_TIMEOUT=600.0 -# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple -PIP_MIRROR_URL= - -# https://github.com/langgenius/dify-plugin-daemon/blob/main/.env.example -# Plugin storage type, local aws_s3 tencent_cos azure_blob aliyun_oss volcengine_tos PLUGIN_STORAGE_TYPE=local PLUGIN_STORAGE_LOCAL_ROOT=/app/storage PLUGIN_WORKING_PATH=/app/storage/cwd PLUGIN_INSTALLED_PATH=plugin PLUGIN_PACKAGE_CACHE_PATH=plugin_packages PLUGIN_MEDIA_CACHE_PATH=assets -# Plugin oss bucket PLUGIN_STORAGE_OSS_BUCKET= -# Plugin oss s3 credentials -PLUGIN_S3_USE_AWS=false -PLUGIN_S3_USE_AWS_MANAGED_IAM=false -PLUGIN_S3_ENDPOINT= -PLUGIN_S3_USE_PATH_STYLE=false -PLUGIN_AWS_ACCESS_KEY= -PLUGIN_AWS_SECRET_KEY= -PLUGIN_AWS_REGION= -# Plugin oss azure blob -PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME= -PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING= -# Plugin oss tencent cos -PLUGIN_TENCENT_COS_SECRET_KEY= -PLUGIN_TENCENT_COS_SECRET_ID= -PLUGIN_TENCENT_COS_REGION= -# Plugin oss aliyun oss -PLUGIN_ALIYUN_OSS_REGION= -PLUGIN_ALIYUN_OSS_ENDPOINT= -PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID= -PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET= -PLUGIN_ALIYUN_OSS_AUTH_VERSION=v4 -PLUGIN_ALIYUN_OSS_PATH= -# Plugin oss volcengine tos -PLUGIN_VOLCENGINE_TOS_ENDPOINT= -PLUGIN_VOLCENGINE_TOS_ACCESS_KEY= -PLUGIN_VOLCENGINE_TOS_SECRET_KEY= -PLUGIN_VOLCENGINE_TOS_REGION= +PLUGIN_SENTRY_ENABLED=false +PLUGIN_SENTRY_DSN= +MARKETPLACE_ENABLED=true +MARKETPLACE_API_URL=https://marketplace.dify.ai +MARKETPLACE_URL= -# ------------------------------ -# OTLP Collector Configuration -# ------------------------------ -ENABLE_OTEL=false -OTLP_TRACE_ENDPOINT= -OTLP_METRIC_ENDPOINT= -OTLP_BASE_ENDPOINT=http://localhost:4318 -OTLP_API_KEY= -OTEL_EXPORTER_OTLP_PROTOCOL= -OTEL_EXPORTER_TYPE=otlp -OTEL_SAMPLING_RATE=0.1 -OTEL_BATCH_EXPORT_SCHEDULE_DELAY=5000 -OTEL_MAX_QUEUE_SIZE=2048 -OTEL_MAX_EXPORT_BATCH_SIZE=512 -OTEL_METRIC_EXPORT_INTERVAL=60000 -OTEL_BATCH_EXPORT_TIMEOUT=10000 -OTEL_METRIC_EXPORT_TIMEOUT=30000 - -# Prevent Clickjacking -ALLOW_EMBED=false - -# Dataset queue monitor configuration -QUEUE_MONITOR_THRESHOLD=200 -# You can configure multiple ones, separated by commas. eg: test1@dify.ai,test2@dify.ai -QUEUE_MONITOR_ALERT_EMAILS= -# Monitor interval in minutes, default is 30 minutes -QUEUE_MONITOR_INTERVAL=30 - -# Swagger UI configuration -SWAGGER_UI_ENABLED=false -SWAGGER_UI_PATH=/swagger-ui.html - -# Whether to encrypt dataset IDs when exporting DSL files (default: true) -# Set to false to export dataset IDs as plain text for easier cross-environment import -DSL_EXPORT_ENCRYPT_DATASET_ID=true - -# Maximum number of segments for dataset segments API (0 for unlimited) -DATASET_MAX_SEGMENTS_PER_REQUEST=0 - -# Celery schedule tasks configuration -ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false -ENABLE_CLEAN_UNUSED_DATASETS_TASK=false -ENABLE_CREATE_TIDB_SERVERLESS_TASK=false -ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false -ENABLE_CLEAN_MESSAGES=false -ENABLE_WORKFLOW_RUN_CLEANUP_TASK=false -ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false -ENABLE_DATASETS_QUEUE_MONITOR=false -ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true -ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true -WORKFLOW_SCHEDULE_POLLER_INTERVAL=1 -WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100 -WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0 - -# Tenant isolated task queue configuration -TENANT_ISOLATED_TASK_CONCURRENCY=1 - -# Maximum allowed CSV file size for annotation import in megabytes -ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2 -#Maximum number of annotation records allowed in a single import -ANNOTATION_IMPORT_MAX_RECORDS=10000 -# Minimum number of annotation records required in a single import -ANNOTATION_IMPORT_MIN_RECORDS=1 -ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5 -ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20 -# Maximum number of concurrent annotation import tasks per tenant -ANNOTATION_IMPORT_MAX_CONCURRENT=5 - -# The API key of amplitude -AMPLITUDE_API_KEY= - -# Sandbox expired records clean configuration -SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 -SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 -SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200 -SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 - - -# Redis URL used for event bus between API and -# celery worker -# defaults to url constructed from `REDIS_*` -# configurations -EVENT_BUS_REDIS_URL= -# Event transport type. Options are: -# -# - pubsub: normal Pub/Sub (at-most-once) -# - sharded: sharded Pub/Sub (at-most-once) -# - streams: Redis Streams (at-least-once, recommended to avoid subscriber races) -# -# Note: Before enabling 'streams' in production, estimate your expected event volume and retention needs. -# Configure Redis memory limits and stream trimming appropriately (e.g., MAXLEN and key expiry) to reduce -# the risk of data loss from Redis auto-eviction under memory pressure. -# Also accepts ENV: EVENT_BUS_REDIS_CHANNEL_TYPE. -EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub -# Whether to use Redis cluster mode while use redis as event bus. -# It's highly recommended to enable this for large deployments. -EVENT_BUS_REDIS_USE_CLUSTERS=false - -# Whether to Enable human input timeout check task -ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true -# Human input timeout check interval in minutes -HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 - - -SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000 +# Nginx and Docker Compose +NGINX_SERVER_NAME=_ +NGINX_HTTPS_ENABLED=false +NGINX_PORT=80 +NGINX_SSL_PORT=443 +NGINX_SSL_CERT_FILENAME=dify.crt +NGINX_SSL_CERT_KEY_FILENAME=dify.key +NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3 +NGINX_WORKER_PROCESSES=auto +NGINX_CLIENT_MAX_BODY_SIZE=100M +NGINX_KEEPALIVE_TIMEOUT=65 +NGINX_PROXY_READ_TIMEOUT=3600s +NGINX_PROXY_SEND_TIMEOUT=3600s +NGINX_ENABLE_CERTBOT_CHALLENGE=false +NGINX_SOCKET_IO_UPSTREAM=api_websocket:5001 +EXPOSE_NGINX_PORT=80 +EXPOSE_NGINX_SSL_PORT=443 +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000000..c3a47ad592 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,3 @@ +# Ignore actual .env files (keep only .env.example files in git) +*.env +!*.env.example diff --git a/docker/README.md b/docker/README.md index 3130fa9886..2e21a2ce8f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -5,44 +5,46 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T ### What's Updated - **Certbot Container**: `docker-compose.yaml` now contains `certbot` for managing SSL certificates. This container automatically renews certificates and ensures secure HTTPS connections.\ - For more information, refer `docker/certbot/README.md`. + For more information, refer to `docker/certbot/README.md`. -- **Persistent Environment Variables**: Environment variables are now managed through a `.env` file, ensuring that your configurations persist across deployments. +- **Persistent Environment Variables**: Essential startup defaults are provided in `.env.example`, while local values are stored in `.env`, ensuring that your configurations persist across deployments. > What is `.env`?

- > The `.env` file is a crucial component in Docker and Docker Compose environments, serving as a centralized configuration file where you can define environment variables that are accessible to the containers at runtime. This file simplifies the management of environment settings across different stages of development, testing, and production, providing consistency and ease of configuration to deployments. + > The `.env` file is the local startup file. Copy it from `.env.example` for a default deployment. Optional advanced settings live in `envs/*.env.example` files. - **Unified Vector Database Services**: All vector database services are now managed from a single Docker Compose file `docker-compose.yaml`. You can switch between different vector databases by setting the `VECTOR_STORE` environment variable in your `.env` file. -- **Mandatory .env File**: A `.env` file is now required to run `docker compose up`. This file is crucial for configuring your deployment and for any custom settings to persist through upgrades. - ### How to Deploy Dify with `docker-compose.yaml` 1. **Prerequisites**: Ensure Docker and Docker Compose are installed on your system. -1. **Environment Setup**: +2. **Environment Setup**: - Navigate to the `docker` directory. - - Copy the `.env.example` file to a new file named `.env` by running `cp .env.example .env`. - - Customize the `.env` file as needed. Refer to the `.env.example` file for detailed configuration options. - - **Optional (Recommended for upgrades)**: - You may use the environment synchronization tool to help keep your `.env` file aligned with the latest `.env.example` updates, while preserving your custom settings. - This is especially useful when upgrading Dify or managing a large, customized `.env` file. + - Copy `.env.example` to `.env`. + - Customize `.env` when you need to change essential startup defaults. Copy optional files from `envs/` without the `.example` suffix when you need advanced settings. + - **Optional (for advanced deployments)**: + If you maintain a full `.env` file copied from `.env.example`, you may use the environment synchronization tool to keep it aligned with the latest `.env.example` updates while preserving your custom settings. See the [Environment Variables Synchronization](#environment-variables-synchronization) section below. -1. **Running the Services**: - - Execute `docker compose up` from the `docker` directory to start the services. - - To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. -1. **SSL Certificate Setup**: - - Refer `docker/certbot/README.md` to set up SSL certificates using Certbot. -1. **OpenTelemetry Collector Setup**: - - Change `ENABLE_OTEL` to `true` in `.env`. - - Configure `OTLP_BASE_ENDPOINT` properly. +3. **Running the Services**: + - Execute `docker compose up -d` from the `docker` directory to start the services. + - To specify a vector database, set the `VECTOR_STORE` variable in your `.env` file to your desired vector database service, such as `milvus`, `weaviate`, or `opensearch`. See `envs/vectorstores/` for the full list of supported options. + ```bash + cp .env.example .env + docker compose up -d + ``` + +4. **SSL Certificate Setup**: + - Refer to `docker/certbot/README.md` to set up SSL certificates using Certbot. +5. **OpenTelemetry Collector Setup**: + - Copy `envs/core-services/shared.env.example` to `envs/core-services/shared.env`. + - Set `ENABLE_OTEL=true` and configure `OTLP_BASE_ENDPOINT`. Tune the other `OTEL_*` knobs in the same file if needed. ### How to Deploy Middleware for Developing Dify 1. **Middleware Setup**: - Use the `docker-compose.middleware.yaml` for setting up essential middleware services like databases and caches. - Navigate to the `docker` directory. - - Ensure the `middleware.env` file is created by running `cp middleware.env.example middleware.env` (refer to the `middleware.env.example` file). -1. **Running Middleware Services**: + - Ensure the `middleware.env` file is created by running `cp envs/middleware.env.example middleware.env` (refer to the `envs/middleware.env.example` file). +2. **Running Middleware Services**: - Navigate to the `docker` directory. - Execute `docker compose --env-file middleware.env -f docker-compose.middleware.yaml -p dify up -d` to start PostgreSQL/MySQL (per `DB_TYPE`) plus the bundled Weaviate instance. @@ -53,12 +55,18 @@ Welcome to the new `docker` directory for deploying Dify using Docker Compose. T For users migrating from the `docker-legacy` setup: 1. **Review Changes**: Familiarize yourself with the new `.env` configuration and Docker Compose setup. -1. **Transfer Customizations**: +2. **Transfer Customizations**: - If you have customized configurations such as `docker-compose.yaml`, `ssrf_proxy/squid.conf`, or `nginx/conf.d/default.conf`, you will need to reflect these changes in the `.env` file you create. -1. **Data Migration**: +3. **Data Migration**: - Ensure that data from services like databases and caches is backed up and migrated appropriately to the new structure if necessary. -### Overview of `.env` +### Overview of `.env`, `.env.example`, and `envs/` + +- `.env.example` contains the essential default configuration for Docker Compose deployments. +- `.env` contains local startup values copied from `.env.example` and any local changes. +- `envs/*.env.example` files contain optional advanced configuration grouped by theme. + +Docker Compose reads `envs/*.env` files when present, then reads `.env` last so values in `.env` take precedence. #### Key Modules and Customization @@ -68,59 +76,63 @@ For users migrating from the `docker-legacy` setup: #### Other notable variables -The `.env.example` file provided in the Docker setup is extensive and covers a wide range of configuration options. It is structured into several sections, each pertaining to different aspects of the application and its services. Here are some of the key sections and variables: +The root `.env.example` file contains the essential startup settings. Optional and provider-specific settings are grouped in `envs/*.env.example` files. Here are some of the key sections and variables: 1. **Common Variables**: - - `CONSOLE_API_URL`, `SERVICE_API_URL`: URLs for different API services. - - `APP_WEB_URL`: Frontend application URL. - - `FILES_URL`: Base URL for file downloads and previews. + - `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `SERVICE_API_URL`, `APP_API_URL`, `APP_WEB_URL`: URLs for the API and frontend services. + - `FILES_URL`, `INTERNAL_FILES_URL`: Public and internal base URLs for file downloads and previews. + - `ENDPOINT_URL_TEMPLATE`, `NEXT_PUBLIC_SOCKET_URL`, `TRIGGER_URL`: Additional service URLs. + + See `.env.example` for the full list. -1. **Server Configuration**: +2. **Server Configuration**: - `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings. - - `SECRET_KEY`: A key for encrypting session cookies and other sensitive data. + - `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself. -1. **Database Configuration**: +3. **Database Configuration**: - `DB_USERNAME`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_DATABASE`: PostgreSQL database credentials and connection details. -1. **Redis Configuration**: +4. **Redis Configuration**: - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis server connection settings. - `REDIS_KEY_PREFIX`: Optional global namespace prefix for Redis keys, topics, streams, and Celery Redis transport artifacts. -1. **Celery Configuration**: +5. **Celery Configuration**: - `CELERY_BROKER_URL`: Configuration for Celery message broker. -1. **Storage Configuration**: +6. **Storage Configuration**: - - `STORAGE_TYPE`, `S3_BUCKET_NAME`, `AZURE_BLOB_ACCOUNT_NAME`: Settings for file storage options like local, S3, Azure Blob, etc. + - `STORAGE_TYPE`, `OPENDAL_SCHEME`, `OPENDAL_FS_ROOT`: Default local file storage settings. Optional storage backends are configured from the files under `envs/`. -1. **Vector Database Configuration**: +7. **Vector Database Configuration**: - - `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`). + - `VECTOR_STORE`: Type of vector database (e.g., `weaviate`, `milvus`). See `envs/vectorstores/` for the full list of supported options. - Specific settings for each vector store like `WEAVIATE_ENDPOINT`, `MILVUS_URI`. -1. **CORS Configuration**: +8. **CORS Configuration**: - `WEB_API_CORS_ALLOW_ORIGINS`, `CONSOLE_CORS_ALLOW_ORIGINS`: Settings for cross-origin resource sharing. -1. **OpenTelemetry Configuration**: +9. **OpenTelemetry Configuration**: - `ENABLE_OTEL`: Enable OpenTelemetry collector in api. - `OTLP_BASE_ENDPOINT`: Endpoint for your OTLP exporter. -1. **Other Service-Specific Environment Variables**: +10. **Other Service-Specific Environment Variables**: - - Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`. + - Each service like `nginx`, `redis`, `db`, and vector databases have specific environment variables that are directly referenced in the `docker-compose.yaml`. ### Environment Variables Synchronization -When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example`. +When upgrading Dify or pulling the latest changes, new environment variables may be introduced in `.env.example` or the optional files under `envs/`. -To help keep your existing `.env` file up to date **without losing your custom values**, an optional environment variables synchronization tool is provided. +If you use the default workflow, review `.env.example` and keep your `.env` aligned with essential startup values. + +If you maintain a customized `.env` file copied from `.env.example`, an optional environment variables synchronization tool is provided. > This tool performs a **one-way synchronization** from `.env.example` to `.env`. > Existing values in `.env` are never overwritten automatically. @@ -143,9 +155,9 @@ Before synchronization, the current `.env` file is saved to the `env-backup/` di **When to use** -- After upgrading Dify to a newer version +- After upgrading Dify to a newer version with a full `.env` file - When `.env.example` has been updated with new environment variables -- When managing a large or heavily customized `.env` file +- When managing a large or heavily customized `.env` file copied from `.env.example` **Usage** diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 87fa01f671..d3cff12a4b 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -1,4 +1,202 @@ -x-shared-env: &shared-api-worker-env +# Shared configuration using YAML anchors and env_file +x-shared-api-worker-config: &shared-api-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/api.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-config: &shared-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-beat-config: &shared-worker-beat-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker-beat.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + services: # Init container to fix permissions init_permissions: @@ -21,12 +219,9 @@ services: # API service api: - image: langgenius/dify-api:1.14.0 - restart: always + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.2 environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'api' starts the API server. MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -66,15 +261,37 @@ services: - ssrf_proxy_network - default + # WebSocket service for workflow collaboration. + api_websocket: + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.2 + profiles: + - collaboration + environment: + MODE: api + SERVER_WORKER_AMOUNT: 1 + SERVER_WORKER_CLASS: ${API_WEBSOCKET_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} + SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000} + GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360} + depends_on: + db_postgres: + condition: service_healthy + required: false + db_mysql: + condition: service_healthy + required: false + redis: + condition: service_started + networks: + - ssrf_proxy_network + - default + # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.14.0 - restart: always + <<: *shared-worker-config + image: langgenius/dify-api:1.14.2 environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker' starts the Celery worker for processing all queues. MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -115,12 +332,9 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.14.0 - restart: always + <<: *shared-worker-beat-config + image: langgenius/dify-api:1.14.2 environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks. MODE: beat depends_on: init_permissions: @@ -152,8 +366,14 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.14.0 + image: langgenius/dify-web:1.14.2 restart: always + env_file: + - path: ./envs/core-services/web.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} APP_API_URL: ${APP_API_URL:-} @@ -170,8 +390,8 @@ services: ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} - TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} - INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-} + TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10} + INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} @@ -228,7 +448,7 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456} MYSQL_DATABASE: ${DB_DATABASE:-dify} command: > - --max_connections=1000 + --max_connections=${MYSQL_MAX_CONNECTIONS:-1000} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M} --innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} @@ -270,6 +490,12 @@ services: sandbox: image: langgenius/dify-sandbox:0.2.15 restart: always + env_file: + - path: ./envs/core-services/sandbox.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: # The DifySandbox configurations # Make sure you are changing this key for your deployment with a strong key. @@ -292,11 +518,26 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.6.0-local + image: langgenius/dify-plugin-daemon:0.6.1-local restart: always + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/plugin-daemon.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default environment: - # Use the shared environment variables. - <<: *shared-api-worker-env DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} DB_SSL_MODE: ${DB_SSL_MODE:-disable} SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002} @@ -402,8 +643,8 @@ services: - ./certbot/update-cert.template.txt:/update-cert.template.txt - ./certbot/docker-entrypoint.sh:/docker-entrypoint.sh environment: - - CERTBOT_EMAIL=${CERTBOT_EMAIL} - - CERTBOT_DOMAIN=${CERTBOT_DOMAIN} + - CERTBOT_EMAIL=${CERTBOT_EMAIL:-} + - CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-} - CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-} entrypoint: ["/docker-entrypoint.sh"] command: ["tail", "-f", "/dev/null"] @@ -445,6 +686,7 @@ services: NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} + NGINX_SOCKET_IO_UPSTREAM: ${NGINX_SOCKET_IO_UPSTREAM:-api_websocket:5001} CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} depends_on: - api diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 23c26c6695..170e171856 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -51,7 +51,7 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456} MYSQL_DATABASE: ${DB_DATABASE:-dify} command: > - --max_connections=1000 + --max_connections=${MYSQL_MAX_CONNECTIONS:-1000} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M} --innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} @@ -129,7 +129,7 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.6.0-local + image: langgenius/dify-plugin-daemon:0.6.1-local restart: always env_file: - ./middleware.env diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index a72136049d..da94f7f16c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -4,724 +4,204 @@ # or docker-compose-template.yaml and regenerate this file. # ================================================================== -x-shared-env: &shared-api-worker-env - CONSOLE_API_URL: ${CONSOLE_API_URL:-} - CONSOLE_WEB_URL: ${CONSOLE_WEB_URL:-} - SERVICE_API_URL: ${SERVICE_API_URL:-} - TRIGGER_URL: ${TRIGGER_URL:-http://localhost} - APP_API_URL: ${APP_API_URL:-} - APP_WEB_URL: ${APP_WEB_URL:-} - FILES_URL: ${FILES_URL:-} - INTERNAL_FILES_URL: ${INTERNAL_FILES_URL:-} - LANG: ${LANG:-C.UTF-8} - LC_ALL: ${LC_ALL:-C.UTF-8} - PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8} - UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text} - LOG_FILE: ${LOG_FILE:-/app/logs/server.log} - LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20} - LOG_FILE_BACKUP_COUNT: ${LOG_FILE_BACKUP_COUNT:-5} - LOG_DATEFORMAT: ${LOG_DATEFORMAT:-%Y-%m-%d %H:%M:%S} - LOG_TZ: ${LOG_TZ:-UTC} - DEBUG: ${DEBUG:-false} - FLASK_DEBUG: ${FLASK_DEBUG:-false} - ENABLE_REQUEST_LOGGING: ${ENABLE_REQUEST_LOGGING:-False} - SECRET_KEY: ${SECRET_KEY:-sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U} - INIT_PASSWORD: ${INIT_PASSWORD:-} - DEPLOY_ENV: ${DEPLOY_ENV:-PRODUCTION} - CHECK_UPDATE_URL: ${CHECK_UPDATE_URL:-https://updates.dify.ai} - OPENAI_API_BASE: ${OPENAI_API_BASE:-https://api.openai.com/v1} - MIGRATION_ENABLED: ${MIGRATION_ENABLED:-true} - FILES_ACCESS_TIMEOUT: ${FILES_ACCESS_TIMEOUT:-300} - ENABLE_COLLABORATION_MODE: ${ENABLE_COLLABORATION_MODE:-false} - ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} - REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} - APP_DEFAULT_ACTIVE_REQUESTS: ${APP_DEFAULT_ACTIVE_REQUESTS:-0} - APP_MAX_ACTIVE_REQUESTS: ${APP_MAX_ACTIVE_REQUESTS:-0} - APP_MAX_EXECUTION_TIME: ${APP_MAX_EXECUTION_TIME:-1200} - DIFY_BIND_ADDRESS: ${DIFY_BIND_ADDRESS:-0.0.0.0} - DIFY_PORT: ${DIFY_PORT:-5001} - SERVER_WORKER_AMOUNT: ${SERVER_WORKER_AMOUNT:-1} - SERVER_WORKER_CLASS: ${SERVER_WORKER_CLASS:-gevent} - SERVER_WORKER_CONNECTIONS: ${SERVER_WORKER_CONNECTIONS:-10} - CELERY_WORKER_CLASS: ${CELERY_WORKER_CLASS:-} - GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-360} - CELERY_WORKER_AMOUNT: ${CELERY_WORKER_AMOUNT:-4} - CELERY_AUTO_SCALE: ${CELERY_AUTO_SCALE:-false} - CELERY_MAX_WORKERS: ${CELERY_MAX_WORKERS:-} - CELERY_MIN_WORKERS: ${CELERY_MIN_WORKERS:-} - API_TOOL_DEFAULT_CONNECT_TIMEOUT: ${API_TOOL_DEFAULT_CONNECT_TIMEOUT:-10} - API_TOOL_DEFAULT_READ_TIMEOUT: ${API_TOOL_DEFAULT_READ_TIMEOUT:-60} - ENABLE_WEBSITE_JINAREADER: ${ENABLE_WEBSITE_JINAREADER:-true} - ENABLE_WEBSITE_FIRECRAWL: ${ENABLE_WEBSITE_FIRECRAWL:-true} - ENABLE_WEBSITE_WATERCRAWL: ${ENABLE_WEBSITE_WATERCRAWL:-true} - NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: ${NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX:-false} - DB_TYPE: ${DB_TYPE:-postgresql} - DB_USERNAME: ${DB_USERNAME:-postgres} - DB_PASSWORD: ${DB_PASSWORD:-difyai123456} - DB_HOST: ${DB_HOST:-db_postgres} - DB_PORT: ${DB_PORT:-5432} - DB_DATABASE: ${DB_DATABASE:-dify} - SQLALCHEMY_POOL_SIZE: ${SQLALCHEMY_POOL_SIZE:-30} - SQLALCHEMY_MAX_OVERFLOW: ${SQLALCHEMY_MAX_OVERFLOW:-10} - SQLALCHEMY_POOL_RECYCLE: ${SQLALCHEMY_POOL_RECYCLE:-3600} - SQLALCHEMY_ECHO: ${SQLALCHEMY_ECHO:-false} - SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false} - SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false} - SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30} - POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200} - POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB} - POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB} - POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB} - POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB} - POSTGRES_STATEMENT_TIMEOUT: ${POSTGRES_STATEMENT_TIMEOUT:-0} - POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: ${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0} - MYSQL_MAX_CONNECTIONS: ${MYSQL_MAX_CONNECTIONS:-1000} - MYSQL_INNODB_BUFFER_POOL_SIZE: ${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} - MYSQL_INNODB_LOG_FILE_SIZE: ${MYSQL_INNODB_LOG_FILE_SIZE:-128M} - MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT: ${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} - REDIS_HOST: ${REDIS_HOST:-redis} - REDIS_PORT: ${REDIS_PORT:-6379} - REDIS_USERNAME: ${REDIS_USERNAME:-} - REDIS_PASSWORD: ${REDIS_PASSWORD:-difyai123456} - REDIS_USE_SSL: ${REDIS_USE_SSL:-false} - REDIS_SSL_CERT_REQS: ${REDIS_SSL_CERT_REQS:-CERT_NONE} - REDIS_SSL_CA_CERTS: ${REDIS_SSL_CA_CERTS:-} - REDIS_SSL_CERTFILE: ${REDIS_SSL_CERTFILE:-} - REDIS_SSL_KEYFILE: ${REDIS_SSL_KEYFILE:-} - REDIS_DB: ${REDIS_DB:-0} - REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-} - REDIS_MAX_CONNECTIONS: ${REDIS_MAX_CONNECTIONS:-} - REDIS_USE_SENTINEL: ${REDIS_USE_SENTINEL:-false} - REDIS_SENTINELS: ${REDIS_SENTINELS:-} - REDIS_SENTINEL_SERVICE_NAME: ${REDIS_SENTINEL_SERVICE_NAME:-} - REDIS_SENTINEL_USERNAME: ${REDIS_SENTINEL_USERNAME:-} - REDIS_SENTINEL_PASSWORD: ${REDIS_SENTINEL_PASSWORD:-} - REDIS_SENTINEL_SOCKET_TIMEOUT: ${REDIS_SENTINEL_SOCKET_TIMEOUT:-0.1} - REDIS_USE_CLUSTERS: ${REDIS_USE_CLUSTERS:-false} - REDIS_CLUSTERS: ${REDIS_CLUSTERS:-} - REDIS_CLUSTERS_PASSWORD: ${REDIS_CLUSTERS_PASSWORD:-} - REDIS_RETRY_RETRIES: ${REDIS_RETRY_RETRIES:-3} - REDIS_RETRY_BACKOFF_BASE: ${REDIS_RETRY_BACKOFF_BASE:-1.0} - REDIS_RETRY_BACKOFF_CAP: ${REDIS_RETRY_BACKOFF_CAP:-10.0} - REDIS_SOCKET_TIMEOUT: ${REDIS_SOCKET_TIMEOUT:-5.0} - REDIS_SOCKET_CONNECT_TIMEOUT: ${REDIS_SOCKET_CONNECT_TIMEOUT:-5.0} - REDIS_HEALTH_CHECK_INTERVAL: ${REDIS_HEALTH_CHECK_INTERVAL:-30} - CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://:difyai123456@redis:6379/1} - CELERY_BACKEND: ${CELERY_BACKEND:-redis} - BROKER_USE_SSL: ${BROKER_USE_SSL:-false} - CELERY_USE_SENTINEL: ${CELERY_USE_SENTINEL:-false} - CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-} - CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-} - CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1} - CELERY_TASK_ANNOTATIONS: ${CELERY_TASK_ANNOTATIONS:-null} - WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*} - CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*} - COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} - NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} - NEXT_PUBLIC_SOCKET_URL: ${NEXT_PUBLIC_SOCKET_URL:-ws://localhost} - NEXT_PUBLIC_BATCH_CONCURRENCY: ${NEXT_PUBLIC_BATCH_CONCURRENCY:-5} - STORAGE_TYPE: ${STORAGE_TYPE:-opendal} - OPENDAL_SCHEME: ${OPENDAL_SCHEME:-fs} - OPENDAL_FS_ROOT: ${OPENDAL_FS_ROOT:-storage} - CLICKZETTA_VOLUME_TYPE: ${CLICKZETTA_VOLUME_TYPE:-user} - CLICKZETTA_VOLUME_NAME: ${CLICKZETTA_VOLUME_NAME:-} - CLICKZETTA_VOLUME_TABLE_PREFIX: ${CLICKZETTA_VOLUME_TABLE_PREFIX:-dataset_} - CLICKZETTA_VOLUME_DIFY_PREFIX: ${CLICKZETTA_VOLUME_DIFY_PREFIX:-dify_km} - S3_ENDPOINT: ${S3_ENDPOINT:-} - S3_REGION: ${S3_REGION:-us-east-1} - S3_BUCKET_NAME: ${S3_BUCKET_NAME:-difyai} - S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} - S3_SECRET_KEY: ${S3_SECRET_KEY:-} - S3_ADDRESS_STYLE: ${S3_ADDRESS_STYLE:-auto} - S3_USE_AWS_MANAGED_IAM: ${S3_USE_AWS_MANAGED_IAM:-false} - ARCHIVE_STORAGE_ENABLED: ${ARCHIVE_STORAGE_ENABLED:-false} - ARCHIVE_STORAGE_ENDPOINT: ${ARCHIVE_STORAGE_ENDPOINT:-} - ARCHIVE_STORAGE_ARCHIVE_BUCKET: ${ARCHIVE_STORAGE_ARCHIVE_BUCKET:-} - ARCHIVE_STORAGE_EXPORT_BUCKET: ${ARCHIVE_STORAGE_EXPORT_BUCKET:-} - ARCHIVE_STORAGE_ACCESS_KEY: ${ARCHIVE_STORAGE_ACCESS_KEY:-} - ARCHIVE_STORAGE_SECRET_KEY: ${ARCHIVE_STORAGE_SECRET_KEY:-} - ARCHIVE_STORAGE_REGION: ${ARCHIVE_STORAGE_REGION:-auto} - AZURE_BLOB_ACCOUNT_NAME: ${AZURE_BLOB_ACCOUNT_NAME:-difyai} - AZURE_BLOB_ACCOUNT_KEY: ${AZURE_BLOB_ACCOUNT_KEY:-difyai} - AZURE_BLOB_CONTAINER_NAME: ${AZURE_BLOB_CONTAINER_NAME:-difyai-container} - AZURE_BLOB_ACCOUNT_URL: ${AZURE_BLOB_ACCOUNT_URL:-https://.blob.core.windows.net} - GOOGLE_STORAGE_BUCKET_NAME: ${GOOGLE_STORAGE_BUCKET_NAME:-your-bucket-name} - GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64: ${GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64:-} - ALIYUN_OSS_BUCKET_NAME: ${ALIYUN_OSS_BUCKET_NAME:-your-bucket-name} - ALIYUN_OSS_ACCESS_KEY: ${ALIYUN_OSS_ACCESS_KEY:-your-access-key} - ALIYUN_OSS_SECRET_KEY: ${ALIYUN_OSS_SECRET_KEY:-your-secret-key} - ALIYUN_OSS_ENDPOINT: ${ALIYUN_OSS_ENDPOINT:-https://oss-ap-southeast-1-internal.aliyuncs.com} - ALIYUN_OSS_REGION: ${ALIYUN_OSS_REGION:-ap-southeast-1} - ALIYUN_OSS_AUTH_VERSION: ${ALIYUN_OSS_AUTH_VERSION:-v4} - ALIYUN_OSS_PATH: ${ALIYUN_OSS_PATH:-your-path} - TENCENT_COS_BUCKET_NAME: ${TENCENT_COS_BUCKET_NAME:-your-bucket-name} - TENCENT_COS_SECRET_KEY: ${TENCENT_COS_SECRET_KEY:-your-secret-key} - TENCENT_COS_SECRET_ID: ${TENCENT_COS_SECRET_ID:-your-secret-id} - TENCENT_COS_REGION: ${TENCENT_COS_REGION:-your-region} - TENCENT_COS_SCHEME: ${TENCENT_COS_SCHEME:-your-scheme} - TENCENT_COS_CUSTOM_DOMAIN: ${TENCENT_COS_CUSTOM_DOMAIN:-your-custom-domain} - OCI_ENDPOINT: ${OCI_ENDPOINT:-https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com} - OCI_BUCKET_NAME: ${OCI_BUCKET_NAME:-your-bucket-name} - OCI_ACCESS_KEY: ${OCI_ACCESS_KEY:-your-access-key} - OCI_SECRET_KEY: ${OCI_SECRET_KEY:-your-secret-key} - OCI_REGION: ${OCI_REGION:-us-ashburn-1} - HUAWEI_OBS_BUCKET_NAME: ${HUAWEI_OBS_BUCKET_NAME:-your-bucket-name} - HUAWEI_OBS_SECRET_KEY: ${HUAWEI_OBS_SECRET_KEY:-your-secret-key} - HUAWEI_OBS_ACCESS_KEY: ${HUAWEI_OBS_ACCESS_KEY:-your-access-key} - HUAWEI_OBS_SERVER: ${HUAWEI_OBS_SERVER:-your-server-url} - HUAWEI_OBS_PATH_STYLE: ${HUAWEI_OBS_PATH_STYLE:-false} - VOLCENGINE_TOS_BUCKET_NAME: ${VOLCENGINE_TOS_BUCKET_NAME:-your-bucket-name} - VOLCENGINE_TOS_SECRET_KEY: ${VOLCENGINE_TOS_SECRET_KEY:-your-secret-key} - VOLCENGINE_TOS_ACCESS_KEY: ${VOLCENGINE_TOS_ACCESS_KEY:-your-access-key} - VOLCENGINE_TOS_ENDPOINT: ${VOLCENGINE_TOS_ENDPOINT:-your-server-url} - VOLCENGINE_TOS_REGION: ${VOLCENGINE_TOS_REGION:-your-region} - BAIDU_OBS_BUCKET_NAME: ${BAIDU_OBS_BUCKET_NAME:-your-bucket-name} - BAIDU_OBS_SECRET_KEY: ${BAIDU_OBS_SECRET_KEY:-your-secret-key} - BAIDU_OBS_ACCESS_KEY: ${BAIDU_OBS_ACCESS_KEY:-your-access-key} - BAIDU_OBS_ENDPOINT: ${BAIDU_OBS_ENDPOINT:-your-server-url} - SUPABASE_BUCKET_NAME: ${SUPABASE_BUCKET_NAME:-your-bucket-name} - SUPABASE_API_KEY: ${SUPABASE_API_KEY:-your-access-key} - SUPABASE_URL: ${SUPABASE_URL:-your-server-url} - VECTOR_STORE: ${VECTOR_STORE:-weaviate} - VECTOR_INDEX_NAME_PREFIX: ${VECTOR_INDEX_NAME_PREFIX:-Vector_index} - WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080} - WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} - WEAVIATE_GRPC_ENDPOINT: ${WEAVIATE_GRPC_ENDPOINT:-grpc://weaviate:50051} - WEAVIATE_TOKENIZATION: ${WEAVIATE_TOKENIZATION:-word} - OCEANBASE_VECTOR_HOST: ${OCEANBASE_VECTOR_HOST:-oceanbase} - OCEANBASE_VECTOR_PORT: ${OCEANBASE_VECTOR_PORT:-2881} - OCEANBASE_VECTOR_USER: ${OCEANBASE_VECTOR_USER:-root@test} - OCEANBASE_VECTOR_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456} - OCEANBASE_VECTOR_DATABASE: ${OCEANBASE_VECTOR_DATABASE:-test} - OCEANBASE_CLUSTER_NAME: ${OCEANBASE_CLUSTER_NAME:-difyai} - OCEANBASE_MEMORY_LIMIT: ${OCEANBASE_MEMORY_LIMIT:-6G} - OCEANBASE_ENABLE_HYBRID_SEARCH: ${OCEANBASE_ENABLE_HYBRID_SEARCH:-false} - OCEANBASE_FULLTEXT_PARSER: ${OCEANBASE_FULLTEXT_PARSER:-ik} - SEEKDB_MEMORY_LIMIT: ${SEEKDB_MEMORY_LIMIT:-2G} - QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} - QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456} - QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20} - QDRANT_GRPC_ENABLED: ${QDRANT_GRPC_ENABLED:-false} - QDRANT_GRPC_PORT: ${QDRANT_GRPC_PORT:-6334} - QDRANT_REPLICATION_FACTOR: ${QDRANT_REPLICATION_FACTOR:-1} - MILVUS_URI: ${MILVUS_URI:-http://host.docker.internal:19530} - MILVUS_DATABASE: ${MILVUS_DATABASE:-} - MILVUS_TOKEN: ${MILVUS_TOKEN:-} - MILVUS_USER: ${MILVUS_USER:-} - MILVUS_PASSWORD: ${MILVUS_PASSWORD:-} - MILVUS_ENABLE_HYBRID_SEARCH: ${MILVUS_ENABLE_HYBRID_SEARCH:-False} - MILVUS_ANALYZER_PARAMS: ${MILVUS_ANALYZER_PARAMS:-} - MYSCALE_HOST: ${MYSCALE_HOST:-myscale} - MYSCALE_PORT: ${MYSCALE_PORT:-8123} - MYSCALE_USER: ${MYSCALE_USER:-default} - MYSCALE_PASSWORD: ${MYSCALE_PASSWORD:-} - MYSCALE_DATABASE: ${MYSCALE_DATABASE:-dify} - MYSCALE_FTS_PARAMS: ${MYSCALE_FTS_PARAMS:-} - COUCHBASE_CONNECTION_STRING: ${COUCHBASE_CONNECTION_STRING:-couchbase://couchbase-server} - COUCHBASE_USER: ${COUCHBASE_USER:-Administrator} - COUCHBASE_PASSWORD: ${COUCHBASE_PASSWORD:-password} - COUCHBASE_BUCKET_NAME: ${COUCHBASE_BUCKET_NAME:-Embeddings} - COUCHBASE_SCOPE_NAME: ${COUCHBASE_SCOPE_NAME:-_default} - HOLOGRES_HOST: ${HOLOGRES_HOST:-} - HOLOGRES_PORT: ${HOLOGRES_PORT:-80} - HOLOGRES_DATABASE: ${HOLOGRES_DATABASE:-} - HOLOGRES_ACCESS_KEY_ID: ${HOLOGRES_ACCESS_KEY_ID:-} - HOLOGRES_ACCESS_KEY_SECRET: ${HOLOGRES_ACCESS_KEY_SECRET:-} - HOLOGRES_SCHEMA: ${HOLOGRES_SCHEMA:-public} - HOLOGRES_TOKENIZER: ${HOLOGRES_TOKENIZER:-jieba} - HOLOGRES_DISTANCE_METHOD: ${HOLOGRES_DISTANCE_METHOD:-Cosine} - HOLOGRES_BASE_QUANTIZATION_TYPE: ${HOLOGRES_BASE_QUANTIZATION_TYPE:-rabitq} - HOLOGRES_MAX_DEGREE: ${HOLOGRES_MAX_DEGREE:-64} - HOLOGRES_EF_CONSTRUCTION: ${HOLOGRES_EF_CONSTRUCTION:-400} - PGVECTOR_HOST: ${PGVECTOR_HOST:-pgvector} - PGVECTOR_PORT: ${PGVECTOR_PORT:-5432} - PGVECTOR_USER: ${PGVECTOR_USER:-postgres} - PGVECTOR_PASSWORD: ${PGVECTOR_PASSWORD:-difyai123456} - PGVECTOR_DATABASE: ${PGVECTOR_DATABASE:-dify} - PGVECTOR_MIN_CONNECTION: ${PGVECTOR_MIN_CONNECTION:-1} - PGVECTOR_MAX_CONNECTION: ${PGVECTOR_MAX_CONNECTION:-5} - PGVECTOR_PG_BIGM: ${PGVECTOR_PG_BIGM:-false} - PGVECTOR_PG_BIGM_VERSION: ${PGVECTOR_PG_BIGM_VERSION:-1.2-20240606} - VASTBASE_HOST: ${VASTBASE_HOST:-vastbase} - VASTBASE_PORT: ${VASTBASE_PORT:-5432} - VASTBASE_USER: ${VASTBASE_USER:-dify} - VASTBASE_PASSWORD: ${VASTBASE_PASSWORD:-Difyai123456} - VASTBASE_DATABASE: ${VASTBASE_DATABASE:-dify} - VASTBASE_MIN_CONNECTION: ${VASTBASE_MIN_CONNECTION:-1} - VASTBASE_MAX_CONNECTION: ${VASTBASE_MAX_CONNECTION:-5} - PGVECTO_RS_HOST: ${PGVECTO_RS_HOST:-pgvecto-rs} - PGVECTO_RS_PORT: ${PGVECTO_RS_PORT:-5432} - PGVECTO_RS_USER: ${PGVECTO_RS_USER:-postgres} - PGVECTO_RS_PASSWORD: ${PGVECTO_RS_PASSWORD:-difyai123456} - PGVECTO_RS_DATABASE: ${PGVECTO_RS_DATABASE:-dify} - ANALYTICDB_KEY_ID: ${ANALYTICDB_KEY_ID:-your-ak} - ANALYTICDB_KEY_SECRET: ${ANALYTICDB_KEY_SECRET:-your-sk} - ANALYTICDB_REGION_ID: ${ANALYTICDB_REGION_ID:-cn-hangzhou} - ANALYTICDB_INSTANCE_ID: ${ANALYTICDB_INSTANCE_ID:-gp-ab123456} - ANALYTICDB_ACCOUNT: ${ANALYTICDB_ACCOUNT:-testaccount} - ANALYTICDB_PASSWORD: ${ANALYTICDB_PASSWORD:-testpassword} - ANALYTICDB_NAMESPACE: ${ANALYTICDB_NAMESPACE:-dify} - ANALYTICDB_NAMESPACE_PASSWORD: ${ANALYTICDB_NAMESPACE_PASSWORD:-difypassword} - ANALYTICDB_HOST: ${ANALYTICDB_HOST:-gp-test.aliyuncs.com} - ANALYTICDB_PORT: ${ANALYTICDB_PORT:-5432} - ANALYTICDB_MIN_CONNECTION: ${ANALYTICDB_MIN_CONNECTION:-1} - ANALYTICDB_MAX_CONNECTION: ${ANALYTICDB_MAX_CONNECTION:-5} - TIDB_VECTOR_HOST: ${TIDB_VECTOR_HOST:-tidb} - TIDB_VECTOR_PORT: ${TIDB_VECTOR_PORT:-4000} - TIDB_VECTOR_USER: ${TIDB_VECTOR_USER:-} - TIDB_VECTOR_PASSWORD: ${TIDB_VECTOR_PASSWORD:-} - TIDB_VECTOR_DATABASE: ${TIDB_VECTOR_DATABASE:-dify} - MATRIXONE_HOST: ${MATRIXONE_HOST:-matrixone} - MATRIXONE_PORT: ${MATRIXONE_PORT:-6001} - MATRIXONE_USER: ${MATRIXONE_USER:-dump} - MATRIXONE_PASSWORD: ${MATRIXONE_PASSWORD:-111} - MATRIXONE_DATABASE: ${MATRIXONE_DATABASE:-dify} - TIDB_ON_QDRANT_URL: ${TIDB_ON_QDRANT_URL:-http://127.0.0.1} - TIDB_ON_QDRANT_API_KEY: ${TIDB_ON_QDRANT_API_KEY:-dify} - TIDB_ON_QDRANT_CLIENT_TIMEOUT: ${TIDB_ON_QDRANT_CLIENT_TIMEOUT:-20} - TIDB_ON_QDRANT_GRPC_ENABLED: ${TIDB_ON_QDRANT_GRPC_ENABLED:-false} - TIDB_ON_QDRANT_GRPC_PORT: ${TIDB_ON_QDRANT_GRPC_PORT:-6334} - TIDB_PUBLIC_KEY: ${TIDB_PUBLIC_KEY:-dify} - TIDB_PRIVATE_KEY: ${TIDB_PRIVATE_KEY:-dify} - TIDB_API_URL: ${TIDB_API_URL:-http://127.0.0.1} - TIDB_IAM_API_URL: ${TIDB_IAM_API_URL:-http://127.0.0.1} - TIDB_REGION: ${TIDB_REGION:-regions/aws-us-east-1} - TIDB_PROJECT_ID: ${TIDB_PROJECT_ID:-dify} - TIDB_SPEND_LIMIT: ${TIDB_SPEND_LIMIT:-100} - CHROMA_HOST: ${CHROMA_HOST:-127.0.0.1} - CHROMA_PORT: ${CHROMA_PORT:-8000} - CHROMA_TENANT: ${CHROMA_TENANT:-default_tenant} - CHROMA_DATABASE: ${CHROMA_DATABASE:-default_database} - CHROMA_AUTH_PROVIDER: ${CHROMA_AUTH_PROVIDER:-chromadb.auth.token_authn.TokenAuthClientProvider} - CHROMA_AUTH_CREDENTIALS: ${CHROMA_AUTH_CREDENTIALS:-} - ORACLE_USER: ${ORACLE_USER:-dify} - ORACLE_PASSWORD: ${ORACLE_PASSWORD:-dify} - ORACLE_DSN: ${ORACLE_DSN:-oracle:1521/FREEPDB1} - ORACLE_CONFIG_DIR: ${ORACLE_CONFIG_DIR:-/app/api/storage/wallet} - ORACLE_WALLET_LOCATION: ${ORACLE_WALLET_LOCATION:-/app/api/storage/wallet} - ORACLE_WALLET_PASSWORD: ${ORACLE_WALLET_PASSWORD:-dify} - ORACLE_IS_AUTONOMOUS: ${ORACLE_IS_AUTONOMOUS:-false} - ALIBABACLOUD_MYSQL_HOST: ${ALIBABACLOUD_MYSQL_HOST:-127.0.0.1} - ALIBABACLOUD_MYSQL_PORT: ${ALIBABACLOUD_MYSQL_PORT:-3306} - ALIBABACLOUD_MYSQL_USER: ${ALIBABACLOUD_MYSQL_USER:-root} - ALIBABACLOUD_MYSQL_PASSWORD: ${ALIBABACLOUD_MYSQL_PASSWORD:-difyai123456} - ALIBABACLOUD_MYSQL_DATABASE: ${ALIBABACLOUD_MYSQL_DATABASE:-dify} - ALIBABACLOUD_MYSQL_MAX_CONNECTION: ${ALIBABACLOUD_MYSQL_MAX_CONNECTION:-5} - ALIBABACLOUD_MYSQL_HNSW_M: ${ALIBABACLOUD_MYSQL_HNSW_M:-6} - RELYT_HOST: ${RELYT_HOST:-db} - RELYT_PORT: ${RELYT_PORT:-5432} - RELYT_USER: ${RELYT_USER:-postgres} - RELYT_PASSWORD: ${RELYT_PASSWORD:-difyai123456} - RELYT_DATABASE: ${RELYT_DATABASE:-postgres} - OPENSEARCH_HOST: ${OPENSEARCH_HOST:-opensearch} - OPENSEARCH_PORT: ${OPENSEARCH_PORT:-9200} - OPENSEARCH_SECURE: ${OPENSEARCH_SECURE:-true} - OPENSEARCH_VERIFY_CERTS: ${OPENSEARCH_VERIFY_CERTS:-true} - OPENSEARCH_AUTH_METHOD: ${OPENSEARCH_AUTH_METHOD:-basic} - OPENSEARCH_USER: ${OPENSEARCH_USER:-admin} - OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD:-admin} - OPENSEARCH_AWS_REGION: ${OPENSEARCH_AWS_REGION:-ap-southeast-1} - OPENSEARCH_AWS_SERVICE: ${OPENSEARCH_AWS_SERVICE:-aoss} - TENCENT_VECTOR_DB_URL: ${TENCENT_VECTOR_DB_URL:-http://127.0.0.1} - TENCENT_VECTOR_DB_API_KEY: ${TENCENT_VECTOR_DB_API_KEY:-dify} - TENCENT_VECTOR_DB_TIMEOUT: ${TENCENT_VECTOR_DB_TIMEOUT:-30} - TENCENT_VECTOR_DB_USERNAME: ${TENCENT_VECTOR_DB_USERNAME:-dify} - TENCENT_VECTOR_DB_DATABASE: ${TENCENT_VECTOR_DB_DATABASE:-dify} - TENCENT_VECTOR_DB_SHARD: ${TENCENT_VECTOR_DB_SHARD:-1} - TENCENT_VECTOR_DB_REPLICAS: ${TENCENT_VECTOR_DB_REPLICAS:-2} - TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH: ${TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH:-false} - ELASTICSEARCH_HOST: ${ELASTICSEARCH_HOST:-0.0.0.0} - ELASTICSEARCH_PORT: ${ELASTICSEARCH_PORT:-9200} - ELASTICSEARCH_USERNAME: ${ELASTICSEARCH_USERNAME:-elastic} - ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-elastic} - KIBANA_PORT: ${KIBANA_PORT:-5601} - ELASTICSEARCH_USE_CLOUD: ${ELASTICSEARCH_USE_CLOUD:-false} - ELASTICSEARCH_CLOUD_URL: ${ELASTICSEARCH_CLOUD_URL:-YOUR-ELASTICSEARCH_CLOUD_URL} - ELASTICSEARCH_API_KEY: ${ELASTICSEARCH_API_KEY:-YOUR-ELASTICSEARCH_API_KEY} - ELASTICSEARCH_VERIFY_CERTS: ${ELASTICSEARCH_VERIFY_CERTS:-False} - ELASTICSEARCH_CA_CERTS: ${ELASTICSEARCH_CA_CERTS:-} - ELASTICSEARCH_REQUEST_TIMEOUT: ${ELASTICSEARCH_REQUEST_TIMEOUT:-100000} - ELASTICSEARCH_RETRY_ON_TIMEOUT: ${ELASTICSEARCH_RETRY_ON_TIMEOUT:-True} - ELASTICSEARCH_MAX_RETRIES: ${ELASTICSEARCH_MAX_RETRIES:-10} - BAIDU_VECTOR_DB_ENDPOINT: ${BAIDU_VECTOR_DB_ENDPOINT:-http://127.0.0.1:5287} - BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS: ${BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS:-30000} - BAIDU_VECTOR_DB_ACCOUNT: ${BAIDU_VECTOR_DB_ACCOUNT:-root} - BAIDU_VECTOR_DB_API_KEY: ${BAIDU_VECTOR_DB_API_KEY:-dify} - BAIDU_VECTOR_DB_DATABASE: ${BAIDU_VECTOR_DB_DATABASE:-dify} - BAIDU_VECTOR_DB_SHARD: ${BAIDU_VECTOR_DB_SHARD:-1} - BAIDU_VECTOR_DB_REPLICAS: ${BAIDU_VECTOR_DB_REPLICAS:-3} - BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER: ${BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER:-DEFAULT_ANALYZER} - BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE: ${BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE:-COARSE_MODE} - BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT: ${BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT:-500} - BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO: ${BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO:-0.05} - BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS: ${BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS:-300} - VIKINGDB_ACCESS_KEY: ${VIKINGDB_ACCESS_KEY:-your-ak} - VIKINGDB_SECRET_KEY: ${VIKINGDB_SECRET_KEY:-your-sk} - VIKINGDB_REGION: ${VIKINGDB_REGION:-cn-shanghai} - VIKINGDB_HOST: ${VIKINGDB_HOST:-api-vikingdb.xxx.volces.com} - VIKINGDB_SCHEMA: ${VIKINGDB_SCHEMA:-http} - VIKINGDB_CONNECTION_TIMEOUT: ${VIKINGDB_CONNECTION_TIMEOUT:-30} - VIKINGDB_SOCKET_TIMEOUT: ${VIKINGDB_SOCKET_TIMEOUT:-30} - LINDORM_URL: ${LINDORM_URL:-http://localhost:30070} - LINDORM_USERNAME: ${LINDORM_USERNAME:-admin} - LINDORM_PASSWORD: ${LINDORM_PASSWORD:-admin} - LINDORM_USING_UGC: ${LINDORM_USING_UGC:-True} - LINDORM_QUERY_TIMEOUT: ${LINDORM_QUERY_TIMEOUT:-1} - OPENGAUSS_HOST: ${OPENGAUSS_HOST:-opengauss} - OPENGAUSS_PORT: ${OPENGAUSS_PORT:-6600} - OPENGAUSS_USER: ${OPENGAUSS_USER:-postgres} - OPENGAUSS_PASSWORD: ${OPENGAUSS_PASSWORD:-Dify@123} - OPENGAUSS_DATABASE: ${OPENGAUSS_DATABASE:-dify} - OPENGAUSS_MIN_CONNECTION: ${OPENGAUSS_MIN_CONNECTION:-1} - OPENGAUSS_MAX_CONNECTION: ${OPENGAUSS_MAX_CONNECTION:-5} - OPENGAUSS_ENABLE_PQ: ${OPENGAUSS_ENABLE_PQ:-false} - HUAWEI_CLOUD_HOSTS: ${HUAWEI_CLOUD_HOSTS:-https://127.0.0.1:9200} - HUAWEI_CLOUD_USER: ${HUAWEI_CLOUD_USER:-admin} - HUAWEI_CLOUD_PASSWORD: ${HUAWEI_CLOUD_PASSWORD:-admin} - UPSTASH_VECTOR_URL: ${UPSTASH_VECTOR_URL:-https://xxx-vector.upstash.io} - UPSTASH_VECTOR_TOKEN: ${UPSTASH_VECTOR_TOKEN:-dify} - TABLESTORE_ENDPOINT: ${TABLESTORE_ENDPOINT:-https://instance-name.cn-hangzhou.ots.aliyuncs.com} - TABLESTORE_INSTANCE_NAME: ${TABLESTORE_INSTANCE_NAME:-instance-name} - TABLESTORE_ACCESS_KEY_ID: ${TABLESTORE_ACCESS_KEY_ID:-xxx} - TABLESTORE_ACCESS_KEY_SECRET: ${TABLESTORE_ACCESS_KEY_SECRET:-xxx} - TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE: ${TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE:-false} - CLICKZETTA_USERNAME: ${CLICKZETTA_USERNAME:-} - CLICKZETTA_PASSWORD: ${CLICKZETTA_PASSWORD:-} - CLICKZETTA_INSTANCE: ${CLICKZETTA_INSTANCE:-} - CLICKZETTA_SERVICE: ${CLICKZETTA_SERVICE:-api.clickzetta.com} - CLICKZETTA_WORKSPACE: ${CLICKZETTA_WORKSPACE:-quick_start} - CLICKZETTA_VCLUSTER: ${CLICKZETTA_VCLUSTER:-default_ap} - CLICKZETTA_SCHEMA: ${CLICKZETTA_SCHEMA:-dify} - CLICKZETTA_BATCH_SIZE: ${CLICKZETTA_BATCH_SIZE:-100} - CLICKZETTA_ENABLE_INVERTED_INDEX: ${CLICKZETTA_ENABLE_INVERTED_INDEX:-true} - CLICKZETTA_ANALYZER_TYPE: ${CLICKZETTA_ANALYZER_TYPE:-chinese} - CLICKZETTA_ANALYZER_MODE: ${CLICKZETTA_ANALYZER_MODE:-smart} - CLICKZETTA_VECTOR_DISTANCE_FUNCTION: ${CLICKZETTA_VECTOR_DISTANCE_FUNCTION:-cosine_distance} - IRIS_HOST: ${IRIS_HOST:-iris} - IRIS_SUPER_SERVER_PORT: ${IRIS_SUPER_SERVER_PORT:-1972} - IRIS_WEB_SERVER_PORT: ${IRIS_WEB_SERVER_PORT:-52773} - IRIS_USER: ${IRIS_USER:-_SYSTEM} - IRIS_PASSWORD: ${IRIS_PASSWORD:-Dify@1234} - IRIS_DATABASE: ${IRIS_DATABASE:-USER} - IRIS_SCHEMA: ${IRIS_SCHEMA:-dify} - IRIS_CONNECTION_URL: ${IRIS_CONNECTION_URL:-} - IRIS_MIN_CONNECTION: ${IRIS_MIN_CONNECTION:-1} - IRIS_MAX_CONNECTION: ${IRIS_MAX_CONNECTION:-3} - IRIS_TEXT_INDEX: ${IRIS_TEXT_INDEX:-true} - IRIS_TEXT_INDEX_LANGUAGE: ${IRIS_TEXT_INDEX_LANGUAGE:-en} - IRIS_TIMEZONE: ${IRIS_TIMEZONE:-UTC} - UPLOAD_FILE_SIZE_LIMIT: ${UPLOAD_FILE_SIZE_LIMIT:-15} - UPLOAD_FILE_BATCH_LIMIT: ${UPLOAD_FILE_BATCH_LIMIT:-5} - UPLOAD_FILE_EXTENSION_BLACKLIST: ${UPLOAD_FILE_EXTENSION_BLACKLIST:-} - SINGLE_CHUNK_ATTACHMENT_LIMIT: ${SINGLE_CHUNK_ATTACHMENT_LIMIT:-10} - IMAGE_FILE_BATCH_LIMIT: ${IMAGE_FILE_BATCH_LIMIT:-10} - ATTACHMENT_IMAGE_FILE_SIZE_LIMIT: ${ATTACHMENT_IMAGE_FILE_SIZE_LIMIT:-2} - ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT: ${ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT:-60} - ETL_TYPE: ${ETL_TYPE:-dify} - UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-} - UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-} - SCARF_NO_ANALYTICS: ${SCARF_NO_ANALYTICS:-true} - PROMPT_GENERATION_MAX_TOKENS: ${PROMPT_GENERATION_MAX_TOKENS:-512} - CODE_GENERATION_MAX_TOKENS: ${CODE_GENERATION_MAX_TOKENS:-1024} - PLUGIN_BASED_TOKEN_COUNTING_ENABLED: ${PLUGIN_BASED_TOKEN_COUNTING_ENABLED:-false} - MULTIMODAL_SEND_FORMAT: ${MULTIMODAL_SEND_FORMAT:-base64} - UPLOAD_IMAGE_FILE_SIZE_LIMIT: ${UPLOAD_IMAGE_FILE_SIZE_LIMIT:-10} - UPLOAD_VIDEO_FILE_SIZE_LIMIT: ${UPLOAD_VIDEO_FILE_SIZE_LIMIT:-100} - UPLOAD_AUDIO_FILE_SIZE_LIMIT: ${UPLOAD_AUDIO_FILE_SIZE_LIMIT:-50} - SENTRY_DSN: ${SENTRY_DSN:-} - API_SENTRY_DSN: ${API_SENTRY_DSN:-} - API_SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} - API_SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0} - WEB_SENTRY_DSN: ${WEB_SENTRY_DSN:-} - PLUGIN_SENTRY_ENABLED: ${PLUGIN_SENTRY_ENABLED:-false} - PLUGIN_SENTRY_DSN: ${PLUGIN_SENTRY_DSN:-} - NOTION_INTEGRATION_TYPE: ${NOTION_INTEGRATION_TYPE:-public} - NOTION_CLIENT_SECRET: ${NOTION_CLIENT_SECRET:-} - NOTION_CLIENT_ID: ${NOTION_CLIENT_ID:-} - NOTION_INTERNAL_SECRET: ${NOTION_INTERNAL_SECRET:-} - MAIL_TYPE: ${MAIL_TYPE:-resend} - MAIL_DEFAULT_SEND_FROM: ${MAIL_DEFAULT_SEND_FROM:-} - RESEND_API_URL: ${RESEND_API_URL:-https://api.resend.com} - RESEND_API_KEY: ${RESEND_API_KEY:-your-resend-api-key} - SMTP_SERVER: ${SMTP_SERVER:-} - SMTP_PORT: ${SMTP_PORT:-465} - SMTP_USERNAME: ${SMTP_USERNAME:-} - SMTP_PASSWORD: ${SMTP_PASSWORD:-} - SMTP_USE_TLS: ${SMTP_USE_TLS:-true} - SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false} - SMTP_LOCAL_HOSTNAME: ${SMTP_LOCAL_HOSTNAME:-} - SENDGRID_API_KEY: ${SENDGRID_API_KEY:-} - INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} - INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72} - RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5} - EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES: ${EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES:-5} - CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES: ${CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES:-5} - OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES: ${OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES:-5} - CODE_EXECUTION_ENDPOINT: ${CODE_EXECUTION_ENDPOINT:-http://sandbox:8194} - CODE_EXECUTION_API_KEY: ${CODE_EXECUTION_API_KEY:-dify-sandbox} - CODE_EXECUTION_SSL_VERIFY: ${CODE_EXECUTION_SSL_VERIFY:-True} - CODE_EXECUTION_POOL_MAX_CONNECTIONS: ${CODE_EXECUTION_POOL_MAX_CONNECTIONS:-100} - CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS: ${CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS:-20} - CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY: ${CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY:-5.0} - CODE_MAX_NUMBER: ${CODE_MAX_NUMBER:-9223372036854775807} - CODE_MIN_NUMBER: ${CODE_MIN_NUMBER:--9223372036854775808} - CODE_MAX_DEPTH: ${CODE_MAX_DEPTH:-5} - CODE_MAX_PRECISION: ${CODE_MAX_PRECISION:-20} - CODE_MAX_STRING_LENGTH: ${CODE_MAX_STRING_LENGTH:-400000} - CODE_MAX_STRING_ARRAY_LENGTH: ${CODE_MAX_STRING_ARRAY_LENGTH:-30} - CODE_MAX_OBJECT_ARRAY_LENGTH: ${CODE_MAX_OBJECT_ARRAY_LENGTH:-30} - CODE_MAX_NUMBER_ARRAY_LENGTH: ${CODE_MAX_NUMBER_ARRAY_LENGTH:-1000} - CODE_EXECUTION_CONNECT_TIMEOUT: ${CODE_EXECUTION_CONNECT_TIMEOUT:-10} - CODE_EXECUTION_READ_TIMEOUT: ${CODE_EXECUTION_READ_TIMEOUT:-60} - CODE_EXECUTION_WRITE_TIMEOUT: ${CODE_EXECUTION_WRITE_TIMEOUT:-10} - TEMPLATE_TRANSFORM_MAX_LENGTH: ${TEMPLATE_TRANSFORM_MAX_LENGTH:-400000} - WORKFLOW_MAX_EXECUTION_STEPS: ${WORKFLOW_MAX_EXECUTION_STEPS:-500} - WORKFLOW_MAX_EXECUTION_TIME: ${WORKFLOW_MAX_EXECUTION_TIME:-1200} - WORKFLOW_CALL_MAX_DEPTH: ${WORKFLOW_CALL_MAX_DEPTH:-5} - MAX_VARIABLE_SIZE: ${MAX_VARIABLE_SIZE:-204800} - WORKFLOW_FILE_UPLOAD_LIMIT: ${WORKFLOW_FILE_UPLOAD_LIMIT:-10} - GRAPH_ENGINE_MIN_WORKERS: ${GRAPH_ENGINE_MIN_WORKERS:-1} - GRAPH_ENGINE_MAX_WORKERS: ${GRAPH_ENGINE_MAX_WORKERS:-10} - GRAPH_ENGINE_SCALE_UP_THRESHOLD: ${GRAPH_ENGINE_SCALE_UP_THRESHOLD:-3} - GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME: ${GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME:-5.0} - WORKFLOW_NODE_EXECUTION_STORAGE: ${WORKFLOW_NODE_EXECUTION_STORAGE:-rdbms} - CORE_WORKFLOW_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository} - CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository} - API_WORKFLOW_RUN_REPOSITORY: ${API_WORKFLOW_RUN_REPOSITORY:-repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository} - API_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${API_WORKFLOW_NODE_EXECUTION_REPOSITORY:-repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository} - WORKFLOW_LOG_CLEANUP_ENABLED: ${WORKFLOW_LOG_CLEANUP_ENABLED:-false} - WORKFLOW_LOG_RETENTION_DAYS: ${WORKFLOW_LOG_RETENTION_DAYS:-30} - WORKFLOW_LOG_CLEANUP_BATCH_SIZE: ${WORKFLOW_LOG_CLEANUP_BATCH_SIZE:-100} - WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS: ${WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS:-} - ALIYUN_SLS_ACCESS_KEY_ID: ${ALIYUN_SLS_ACCESS_KEY_ID:-} - ALIYUN_SLS_ACCESS_KEY_SECRET: ${ALIYUN_SLS_ACCESS_KEY_SECRET:-} - ALIYUN_SLS_ENDPOINT: ${ALIYUN_SLS_ENDPOINT:-} - ALIYUN_SLS_REGION: ${ALIYUN_SLS_REGION:-} - ALIYUN_SLS_PROJECT_NAME: ${ALIYUN_SLS_PROJECT_NAME:-} - ALIYUN_SLS_LOGSTORE_TTL: ${ALIYUN_SLS_LOGSTORE_TTL:-365} - LOGSTORE_DUAL_WRITE_ENABLED: ${LOGSTORE_DUAL_WRITE_ENABLED:-false} - LOGSTORE_DUAL_READ_ENABLED: ${LOGSTORE_DUAL_READ_ENABLED:-true} - LOGSTORE_ENABLE_PUT_GRAPH_FIELD: ${LOGSTORE_ENABLE_PUT_GRAPH_FIELD:-true} - HTTP_REQUEST_NODE_MAX_BINARY_SIZE: ${HTTP_REQUEST_NODE_MAX_BINARY_SIZE:-10485760} - HTTP_REQUEST_NODE_MAX_TEXT_SIZE: ${HTTP_REQUEST_NODE_MAX_TEXT_SIZE:-1048576} - HTTP_REQUEST_NODE_SSL_VERIFY: ${HTTP_REQUEST_NODE_SSL_VERIFY:-True} - HTTP_REQUEST_MAX_CONNECT_TIMEOUT: ${HTTP_REQUEST_MAX_CONNECT_TIMEOUT:-10} - HTTP_REQUEST_MAX_READ_TIMEOUT: ${HTTP_REQUEST_MAX_READ_TIMEOUT:-600} - HTTP_REQUEST_MAX_WRITE_TIMEOUT: ${HTTP_REQUEST_MAX_WRITE_TIMEOUT:-600} - WEBHOOK_REQUEST_BODY_MAX_SIZE: ${WEBHOOK_REQUEST_BODY_MAX_SIZE:-10485760} - RESPECT_XFORWARD_HEADERS_ENABLED: ${RESPECT_XFORWARD_HEADERS_ENABLED:-false} - SSRF_PROXY_HTTP_URL: ${SSRF_PROXY_HTTP_URL:-http://ssrf_proxy:3128} - SSRF_PROXY_HTTPS_URL: ${SSRF_PROXY_HTTPS_URL:-http://ssrf_proxy:3128} - LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} - MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} - MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} - MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} - TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} - EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} - ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} - ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} - MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50} - PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} - MYSQL_HOST_VOLUME: ${MYSQL_HOST_VOLUME:-./volumes/mysql/data} - SANDBOX_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox} - SANDBOX_GIN_MODE: ${SANDBOX_GIN_MODE:-release} - SANDBOX_WORKER_TIMEOUT: ${SANDBOX_WORKER_TIMEOUT:-15} - SANDBOX_ENABLE_NETWORK: ${SANDBOX_ENABLE_NETWORK:-true} - SANDBOX_HTTP_PROXY: ${SANDBOX_HTTP_PROXY:-http://ssrf_proxy:3128} - SANDBOX_HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128} - SANDBOX_PORT: ${SANDBOX_PORT:-8194} - WEAVIATE_PERSISTENCE_DATA_PATH: ${WEAVIATE_PERSISTENCE_DATA_PATH:-/var/lib/weaviate} - WEAVIATE_QUERY_DEFAULTS_LIMIT: ${WEAVIATE_QUERY_DEFAULTS_LIMIT:-25} - WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: ${WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED:-true} - WEAVIATE_DEFAULT_VECTORIZER_MODULE: ${WEAVIATE_DEFAULT_VECTORIZER_MODULE:-none} - WEAVIATE_CLUSTER_HOSTNAME: ${WEAVIATE_CLUSTER_HOSTNAME:-node1} - WEAVIATE_AUTHENTICATION_APIKEY_ENABLED: ${WEAVIATE_AUTHENTICATION_APIKEY_ENABLED:-true} - WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS: ${WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} - WEAVIATE_AUTHENTICATION_APIKEY_USERS: ${WEAVIATE_AUTHENTICATION_APIKEY_USERS:-hello@dify.ai} - WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED: ${WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED:-true} - WEAVIATE_AUTHORIZATION_ADMINLIST_USERS: ${WEAVIATE_AUTHORIZATION_ADMINLIST_USERS:-hello@dify.ai} - WEAVIATE_DISABLE_TELEMETRY: ${WEAVIATE_DISABLE_TELEMETRY:-false} - WEAVIATE_ENABLE_TOKENIZER_GSE: ${WEAVIATE_ENABLE_TOKENIZER_GSE:-false} - WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA:-false} - WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR: ${WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR:-false} - CHROMA_SERVER_AUTHN_CREDENTIALS: ${CHROMA_SERVER_AUTHN_CREDENTIALS:-difyai123456} - CHROMA_SERVER_AUTHN_PROVIDER: ${CHROMA_SERVER_AUTHN_PROVIDER:-chromadb.auth.token_authn.TokenAuthenticationServerProvider} - CHROMA_IS_PERSISTENT: ${CHROMA_IS_PERSISTENT:-TRUE} - ORACLE_PWD: ${ORACLE_PWD:-Dify123456} - ORACLE_CHARACTERSET: ${ORACLE_CHARACTERSET:-AL32UTF8} - ETCD_AUTO_COMPACTION_MODE: ${ETCD_AUTO_COMPACTION_MODE:-revision} - ETCD_AUTO_COMPACTION_RETENTION: ${ETCD_AUTO_COMPACTION_RETENTION:-1000} - ETCD_QUOTA_BACKEND_BYTES: ${ETCD_QUOTA_BACKEND_BYTES:-4294967296} - ETCD_SNAPSHOT_COUNT: ${ETCD_SNAPSHOT_COUNT:-50000} - MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} - MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} - ETCD_ENDPOINTS: ${ETCD_ENDPOINTS:-etcd:2379} - MINIO_ADDRESS: ${MINIO_ADDRESS:-minio:9000} - MILVUS_AUTHORIZATION_ENABLED: ${MILVUS_AUTHORIZATION_ENABLED:-true} - PGVECTOR_PGUSER: ${PGVECTOR_PGUSER:-postgres} - PGVECTOR_POSTGRES_PASSWORD: ${PGVECTOR_POSTGRES_PASSWORD:-difyai123456} - PGVECTOR_POSTGRES_DB: ${PGVECTOR_POSTGRES_DB:-dify} - PGVECTOR_PGDATA: ${PGVECTOR_PGDATA:-/var/lib/postgresql/data/pgdata} - OPENSEARCH_DISCOVERY_TYPE: ${OPENSEARCH_DISCOVERY_TYPE:-single-node} - OPENSEARCH_BOOTSTRAP_MEMORY_LOCK: ${OPENSEARCH_BOOTSTRAP_MEMORY_LOCK:-true} - OPENSEARCH_JAVA_OPTS_MIN: ${OPENSEARCH_JAVA_OPTS_MIN:-512m} - OPENSEARCH_JAVA_OPTS_MAX: ${OPENSEARCH_JAVA_OPTS_MAX:-1024m} - OPENSEARCH_INITIAL_ADMIN_PASSWORD: ${OPENSEARCH_INITIAL_ADMIN_PASSWORD:-Qazwsxedc!@#123} - OPENSEARCH_MEMLOCK_SOFT: ${OPENSEARCH_MEMLOCK_SOFT:--1} - OPENSEARCH_MEMLOCK_HARD: ${OPENSEARCH_MEMLOCK_HARD:--1} - OPENSEARCH_NOFILE_SOFT: ${OPENSEARCH_NOFILE_SOFT:-65536} - OPENSEARCH_NOFILE_HARD: ${OPENSEARCH_NOFILE_HARD:-65536} - NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-_} - NGINX_HTTPS_ENABLED: ${NGINX_HTTPS_ENABLED:-false} - NGINX_PORT: ${NGINX_PORT:-80} - NGINX_SSL_PORT: ${NGINX_SSL_PORT:-443} - NGINX_SSL_CERT_FILENAME: ${NGINX_SSL_CERT_FILENAME:-dify.crt} - NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key} - NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.2 TLSv1.3} - NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto} - NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M} - NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65} - NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} - NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} - NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} - CERTBOT_EMAIL: ${CERTBOT_EMAIL:-your_email@example.com} - CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-your_domain.com} - CERTBOT_OPTIONS: ${CERTBOT_OPTIONS:-} - SSRF_HTTP_PORT: ${SSRF_HTTP_PORT:-3128} - SSRF_COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} - SSRF_REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194} - SSRF_SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox} - SSRF_DEFAULT_TIME_OUT: ${SSRF_DEFAULT_TIME_OUT:-5} - SSRF_DEFAULT_CONNECT_TIME_OUT: ${SSRF_DEFAULT_CONNECT_TIME_OUT:-5} - SSRF_DEFAULT_READ_TIME_OUT: ${SSRF_DEFAULT_READ_TIME_OUT:-5} - SSRF_DEFAULT_WRITE_TIME_OUT: ${SSRF_DEFAULT_WRITE_TIME_OUT:-5} - SSRF_POOL_MAX_CONNECTIONS: ${SSRF_POOL_MAX_CONNECTIONS:-100} - SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS: ${SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS:-20} - SSRF_POOL_KEEPALIVE_EXPIRY: ${SSRF_POOL_KEEPALIVE_EXPIRY:-5.0} - EXPOSE_NGINX_PORT: ${EXPOSE_NGINX_PORT:-80} - EXPOSE_NGINX_SSL_PORT: ${EXPOSE_NGINX_SSL_PORT:-443} - POSITION_TOOL_PINS: ${POSITION_TOOL_PINS:-} - POSITION_TOOL_INCLUDES: ${POSITION_TOOL_INCLUDES:-} - POSITION_TOOL_EXCLUDES: ${POSITION_TOOL_EXCLUDES:-} - POSITION_PROVIDER_PINS: ${POSITION_PROVIDER_PINS:-} - POSITION_PROVIDER_INCLUDES: ${POSITION_PROVIDER_INCLUDES:-} - POSITION_PROVIDER_EXCLUDES: ${POSITION_PROVIDER_EXCLUDES:-} - CSP_WHITELIST: ${CSP_WHITELIST:-} - CREATE_TIDB_SERVICE_JOB_ENABLED: ${CREATE_TIDB_SERVICE_JOB_ENABLED:-false} - MAX_SUBMIT_COUNT: ${MAX_SUBMIT_COUNT:-100} - TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10} - DB_PLUGIN_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} - EXPOSE_PLUGIN_DAEMON_PORT: ${EXPOSE_PLUGIN_DAEMON_PORT:-5002} - PLUGIN_DAEMON_PORT: ${PLUGIN_DAEMON_PORT:-5002} - PLUGIN_DAEMON_KEY: ${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi} - PLUGIN_DAEMON_URL: ${PLUGIN_DAEMON_URL:-http://plugin_daemon:5002} - PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800} - PLUGIN_MODEL_SCHEMA_CACHE_TTL: ${PLUGIN_MODEL_SCHEMA_CACHE_TTL:-3600} - PLUGIN_PPROF_ENABLED: ${PLUGIN_PPROF_ENABLED:-false} - PLUGIN_DEBUGGING_HOST: ${PLUGIN_DEBUGGING_HOST:-0.0.0.0} - PLUGIN_DEBUGGING_PORT: ${PLUGIN_DEBUGGING_PORT:-5003} - EXPOSE_PLUGIN_DEBUGGING_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost} - EXPOSE_PLUGIN_DEBUGGING_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003} - PLUGIN_DIFY_INNER_API_KEY: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} - PLUGIN_DIFY_INNER_API_URL: ${PLUGIN_DIFY_INNER_API_URL:-http://api:5001} - ENDPOINT_URL_TEMPLATE: ${ENDPOINT_URL_TEMPLATE:-http://localhost/e/{hook_id}} - MARKETPLACE_ENABLED: ${MARKETPLACE_ENABLED:-true} - MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} - CREATORS_PLATFORM_FEATURES_ENABLED: ${CREATORS_PLATFORM_FEATURES_ENABLED:-true} - CREATORS_PLATFORM_API_URL: ${CREATORS_PLATFORM_API_URL:-https://creators.dify.ai} - CREATORS_PLATFORM_OAUTH_CLIENT_ID: ${CREATORS_PLATFORM_OAUTH_CLIENT_ID:-} - FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true} - ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES: ${ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES:-true} - PLUGIN_STDIO_BUFFER_SIZE: ${PLUGIN_STDIO_BUFFER_SIZE:-1024} - PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880} - PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120} - PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600} - PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0} - PIP_MIRROR_URL: ${PIP_MIRROR_URL:-} - PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local} - PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage} - PLUGIN_WORKING_PATH: ${PLUGIN_WORKING_PATH:-/app/storage/cwd} - PLUGIN_INSTALLED_PATH: ${PLUGIN_INSTALLED_PATH:-plugin} - PLUGIN_PACKAGE_CACHE_PATH: ${PLUGIN_PACKAGE_CACHE_PATH:-plugin_packages} - PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets} - PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-} - PLUGIN_S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false} - PLUGIN_S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false} - PLUGIN_S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-} - PLUGIN_S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false} - PLUGIN_AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-} - PLUGIN_AWS_SECRET_KEY: ${PLUGIN_AWS_SECRET_KEY:-} - PLUGIN_AWS_REGION: ${PLUGIN_AWS_REGION:-} - PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME: ${PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME:-} - PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING: ${PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING:-} - PLUGIN_TENCENT_COS_SECRET_KEY: ${PLUGIN_TENCENT_COS_SECRET_KEY:-} - PLUGIN_TENCENT_COS_SECRET_ID: ${PLUGIN_TENCENT_COS_SECRET_ID:-} - PLUGIN_TENCENT_COS_REGION: ${PLUGIN_TENCENT_COS_REGION:-} - PLUGIN_ALIYUN_OSS_REGION: ${PLUGIN_ALIYUN_OSS_REGION:-} - PLUGIN_ALIYUN_OSS_ENDPOINT: ${PLUGIN_ALIYUN_OSS_ENDPOINT:-} - PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID:-} - PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET: ${PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET:-} - PLUGIN_ALIYUN_OSS_AUTH_VERSION: ${PLUGIN_ALIYUN_OSS_AUTH_VERSION:-v4} - PLUGIN_ALIYUN_OSS_PATH: ${PLUGIN_ALIYUN_OSS_PATH:-} - PLUGIN_VOLCENGINE_TOS_ENDPOINT: ${PLUGIN_VOLCENGINE_TOS_ENDPOINT:-} - PLUGIN_VOLCENGINE_TOS_ACCESS_KEY: ${PLUGIN_VOLCENGINE_TOS_ACCESS_KEY:-} - PLUGIN_VOLCENGINE_TOS_SECRET_KEY: ${PLUGIN_VOLCENGINE_TOS_SECRET_KEY:-} - PLUGIN_VOLCENGINE_TOS_REGION: ${PLUGIN_VOLCENGINE_TOS_REGION:-} - ENABLE_OTEL: ${ENABLE_OTEL:-false} - OTLP_TRACE_ENDPOINT: ${OTLP_TRACE_ENDPOINT:-} - OTLP_METRIC_ENDPOINT: ${OTLP_METRIC_ENDPOINT:-} - OTLP_BASE_ENDPOINT: ${OTLP_BASE_ENDPOINT:-http://localhost:4318} - OTLP_API_KEY: ${OTLP_API_KEY:-} - OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-} - OTEL_EXPORTER_TYPE: ${OTEL_EXPORTER_TYPE:-otlp} - OTEL_SAMPLING_RATE: ${OTEL_SAMPLING_RATE:-0.1} - OTEL_BATCH_EXPORT_SCHEDULE_DELAY: ${OTEL_BATCH_EXPORT_SCHEDULE_DELAY:-5000} - OTEL_MAX_QUEUE_SIZE: ${OTEL_MAX_QUEUE_SIZE:-2048} - OTEL_MAX_EXPORT_BATCH_SIZE: ${OTEL_MAX_EXPORT_BATCH_SIZE:-512} - OTEL_METRIC_EXPORT_INTERVAL: ${OTEL_METRIC_EXPORT_INTERVAL:-60000} - OTEL_BATCH_EXPORT_TIMEOUT: ${OTEL_BATCH_EXPORT_TIMEOUT:-10000} - OTEL_METRIC_EXPORT_TIMEOUT: ${OTEL_METRIC_EXPORT_TIMEOUT:-30000} - ALLOW_EMBED: ${ALLOW_EMBED:-false} - QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200} - QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-} - QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30} - SWAGGER_UI_ENABLED: ${SWAGGER_UI_ENABLED:-false} - SWAGGER_UI_PATH: ${SWAGGER_UI_PATH:-/swagger-ui.html} - DSL_EXPORT_ENCRYPT_DATASET_ID: ${DSL_EXPORT_ENCRYPT_DATASET_ID:-true} - DATASET_MAX_SEGMENTS_PER_REQUEST: ${DATASET_MAX_SEGMENTS_PER_REQUEST:-0} - ENABLE_CLEAN_EMBEDDING_CACHE_TASK: ${ENABLE_CLEAN_EMBEDDING_CACHE_TASK:-false} - ENABLE_CLEAN_UNUSED_DATASETS_TASK: ${ENABLE_CLEAN_UNUSED_DATASETS_TASK:-false} - ENABLE_CREATE_TIDB_SERVERLESS_TASK: ${ENABLE_CREATE_TIDB_SERVERLESS_TASK:-false} - ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK: ${ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK:-false} - ENABLE_CLEAN_MESSAGES: ${ENABLE_CLEAN_MESSAGES:-false} - ENABLE_WORKFLOW_RUN_CLEANUP_TASK: ${ENABLE_WORKFLOW_RUN_CLEANUP_TASK:-false} - ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: ${ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK:-false} - ENABLE_DATASETS_QUEUE_MONITOR: ${ENABLE_DATASETS_QUEUE_MONITOR:-false} - ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK: ${ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK:-true} - ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK: ${ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK:-true} - WORKFLOW_SCHEDULE_POLLER_INTERVAL: ${WORKFLOW_SCHEDULE_POLLER_INTERVAL:-1} - WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE: ${WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE:-100} - WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK: ${WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK:-0} - TENANT_ISOLATED_TASK_CONCURRENCY: ${TENANT_ISOLATED_TASK_CONCURRENCY:-1} - ANNOTATION_IMPORT_FILE_SIZE_LIMIT: ${ANNOTATION_IMPORT_FILE_SIZE_LIMIT:-2} - ANNOTATION_IMPORT_MAX_RECORDS: ${ANNOTATION_IMPORT_MAX_RECORDS:-10000} - ANNOTATION_IMPORT_MIN_RECORDS: ${ANNOTATION_IMPORT_MIN_RECORDS:-1} - ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE:-5} - ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:-20} - ANNOTATION_IMPORT_MAX_CONCURRENT: ${ANNOTATION_IMPORT_MAX_CONCURRENT:-5} - AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-} - SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21} - SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000} - SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL:-200} - SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30} - EVENT_BUS_REDIS_URL: ${EVENT_BUS_REDIS_URL:-} - EVENT_BUS_REDIS_CHANNEL_TYPE: ${EVENT_BUS_REDIS_CHANNEL_TYPE:-pubsub} - EVENT_BUS_REDIS_USE_CLUSTERS: ${EVENT_BUS_REDIS_USE_CLUSTERS:-false} - ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true} - HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1} - SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000} +# Shared configuration using YAML anchors and env_file +x-shared-api-worker-config: &shared-api-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/api.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-config: &shared-worker-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always + +x-shared-worker-beat-config: &shared-worker-beat-config + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/worker-beat.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - path: ./envs/vectorstores/weaviate.env + required: false + - path: ./envs/vectorstores/qdrant.env + required: false + - path: ./envs/vectorstores/oceanbase.env + required: false + - path: ./envs/vectorstores/seekdb.env + required: false + - path: ./envs/vectorstores/couchbase.env + required: false + - path: ./envs/vectorstores/pgvector.env + required: false + - path: ./envs/vectorstores/vastbase.env + required: false + - path: ./envs/vectorstores/pgvecto-rs.env + required: false + - path: ./envs/vectorstores/chroma.env + required: false + - path: ./envs/vectorstores/iris.env + required: false + - path: ./envs/vectorstores/oracle.env + required: false + - path: ./envs/vectorstores/opengauss.env + required: false + - path: ./envs/vectorstores/myscale.env + required: false + - path: ./envs/vectorstores/matrixone.env + required: false + - path: ./envs/vectorstores/elasticsearch.env + required: false + - path: ./envs/vectorstores/opensearch.env + required: false + - path: ./envs/vectorstores/milvus.env + required: false + - path: ./envs/infrastructure/nginx.env + required: false + - path: ./envs/infrastructure/certbot.env + required: false + - path: ./envs/infrastructure/ssrf-proxy.env + required: false + - path: ./envs/infrastructure/etcd.env + required: false + - path: ./envs/infrastructure/minio.env + required: false + - path: ./envs/infrastructure/milvus-standalone.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default + restart: always services: # Init container to fix permissions @@ -745,12 +225,9 @@ services: # API service api: - image: langgenius/dify-api:1.14.0 - restart: always + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.2 environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'api' starts the API server. MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -790,15 +267,37 @@ services: - ssrf_proxy_network - default + # WebSocket service for workflow collaboration. + api_websocket: + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.2 + profiles: + - collaboration + environment: + MODE: api + SERVER_WORKER_AMOUNT: 1 + SERVER_WORKER_CLASS: ${API_WEBSOCKET_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} + SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000} + GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360} + depends_on: + db_postgres: + condition: service_healthy + required: false + db_mysql: + condition: service_healthy + required: false + redis: + condition: service_started + networks: + - ssrf_proxy_network + - default + # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.14.0 - restart: always + <<: *shared-worker-config + image: langgenius/dify-api:1.14.2 environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker' starts the Celery worker for processing all queues. MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0} @@ -839,12 +338,9 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.14.0 - restart: always + <<: *shared-worker-beat-config + image: langgenius/dify-api:1.14.2 environment: - # Use the shared environment variables. - <<: *shared-api-worker-env - # Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks. MODE: beat depends_on: init_permissions: @@ -876,8 +372,14 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.14.0 + image: langgenius/dify-web:1.14.2 restart: always + env_file: + - path: ./envs/core-services/web.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} APP_API_URL: ${APP_API_URL:-} @@ -894,8 +396,8 @@ services: ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} - TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-} - INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-} + TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-10} + INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100} MAX_TOOLS_NUM: ${MAX_TOOLS_NUM:-10} MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} @@ -952,7 +454,7 @@ services: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456} MYSQL_DATABASE: ${DB_DATABASE:-dify} command: > - --max_connections=1000 + --max_connections=${MYSQL_MAX_CONNECTIONS:-1000} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_log_file_size=${MYSQL_INNODB_LOG_FILE_SIZE:-128M} --innodb_flush_log_at_trx_commit=${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2} @@ -994,6 +496,12 @@ services: sandbox: image: langgenius/dify-sandbox:0.2.15 restart: always + env_file: + - path: ./envs/core-services/sandbox.env + required: false + - path: ./envs/security.env + required: false + - ./.env environment: # The DifySandbox configurations # Make sure you are changing this key for your deployment with a strong key. @@ -1016,11 +524,26 @@ services: # plugin daemon plugin_daemon: - image: langgenius/dify-plugin-daemon:0.6.0-local + image: langgenius/dify-plugin-daemon:0.6.1-local restart: always + env_file: + - path: ./envs/core-services/shared.env + required: false + - path: ./envs/core-services/plugin-daemon.env + required: false + - path: ./envs/security.env + required: false + - path: ./envs/databases/db-postgres.env + required: false + - path: ./envs/databases/db-mysql.env + required: false + - path: ./envs/databases/redis.env + required: false + - ./.env + networks: + - ssrf_proxy_network + - default environment: - # Use the shared environment variables. - <<: *shared-api-worker-env DB_DATABASE: ${DB_PLUGIN_DATABASE:-dify_plugin} DB_SSL_MODE: ${DB_SSL_MODE:-disable} SERVER_PORT: ${PLUGIN_DAEMON_PORT:-5002} @@ -1126,8 +649,8 @@ services: - ./certbot/update-cert.template.txt:/update-cert.template.txt - ./certbot/docker-entrypoint.sh:/docker-entrypoint.sh environment: - - CERTBOT_EMAIL=${CERTBOT_EMAIL} - - CERTBOT_DOMAIN=${CERTBOT_DOMAIN} + - CERTBOT_EMAIL=${CERTBOT_EMAIL:-} + - CERTBOT_DOMAIN=${CERTBOT_DOMAIN:-} - CERTBOT_OPTIONS=${CERTBOT_OPTIONS:-} entrypoint: ["/docker-entrypoint.sh"] command: ["tail", "-f", "/dev/null"] @@ -1169,6 +692,7 @@ services: NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} + NGINX_SOCKET_IO_UPSTREAM: ${NGINX_SOCKET_IO_UPSTREAM:-api_websocket:5001} CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} depends_on: - api diff --git a/docker/envs/core-services/api.env.example b/docker/envs/core-services/api.env.example new file mode 100644 index 0000000000..1a3fc7a4ab --- /dev/null +++ b/docker/envs/core-services/api.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Api Configuration +# ------------------------------ + +MODE=api +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=1.0 +SENTRY_PROFILES_SAMPLE_RATE=1.0 +PLUGIN_REMOTE_INSTALL_HOST=localhost +PLUGIN_REMOTE_INSTALL_PORT=5003 +PLUGIN_MAX_PACKAGE_SIZE=52428800 +PLUGIN_DAEMON_TIMEOUT=600.0 +INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 diff --git a/docker/envs/core-services/plugin-daemon.env.example b/docker/envs/core-services/plugin-daemon.env.example new file mode 100644 index 0000000000..c3b1bef974 --- /dev/null +++ b/docker/envs/core-services/plugin-daemon.env.example @@ -0,0 +1,23 @@ +# ------------------------------ +# Plugin Daemon Configuration +# ------------------------------ + +DB_PLUGIN_DATABASE=dify_plugin +PLUGIN_DAEMON_URL=http://plugin_daemon:5002 +PLUGIN_PPROF_ENABLED=false +PLUGIN_DIFY_INNER_API_URL=http://api:5001 +FORCE_VERIFYING_SIGNATURE=true +PLUGIN_STDIO_BUFFER_SIZE=1024 +PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880 +PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120 +PLUGIN_MAX_EXECUTION_TIMEOUT=600 +PLUGIN_DEBUGGING_HOST=0.0.0.0 +PLUGIN_DEBUGGING_PORT=5003 +PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi +PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 +PLUGIN_DAEMON_PORT=5002 +CELERY_WORKER_CLASS= +PLUGIN_STORAGE_TYPE=local +PLUGIN_STORAGE_LOCAL_ROOT=/app/storage +PLUGIN_WORKING_PATH=/app/storage/cwd +PLUGIN_STORAGE_OSS_BUCKET= diff --git a/docker/envs/core-services/sandbox.env.example b/docker/envs/core-services/sandbox.env.example new file mode 100644 index 0000000000..5d4ee6614b --- /dev/null +++ b/docker/envs/core-services/sandbox.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Sandbox Configuration +# ------------------------------ + +SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128 +SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128 +SANDBOX_PORT=8194 +PIP_MIRROR_URL= +SANDBOX_API_KEY=dify-sandbox +SANDBOX_GIN_MODE=release +SANDBOX_WORKER_TIMEOUT=15 +SANDBOX_ENABLE_NETWORK=true +SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 +SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 +SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_MAX_INTERVAL=200 +SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 +SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000 diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example new file mode 100644 index 0000000000..fca0b57d0c --- /dev/null +++ b/docker/envs/core-services/shared.env.example @@ -0,0 +1,475 @@ +# ------------------------------ +# Shared API/Worker Configuration +# ------------------------------ + +CONSOLE_WEB_URL= +SERVICE_API_URL= +TRIGGER_URL=http://localhost +APP_WEB_URL= +FILES_URL= +INTERNAL_FILES_URL= +LANG=C.UTF-8 +LC_ALL=C.UTF-8 +PYTHONIOENCODING=utf-8 +UV_CACHE_DIR=/tmp/.uv-cache +CHECK_UPDATE_URL=https://updates.dify.ai +OPENAI_API_BASE=https://api.openai.com/v1 +MIGRATION_ENABLED=true +FILES_ACCESS_TIMEOUT=300 +# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. +ENABLE_COLLABORATION_MODE=true +CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 +CELERY_TASK_ANNOTATIONS=null +AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net +SUPABASE_URL=your-server-url +TIDB_ON_QDRANT_URL=http://127.0.0.1 +TIDB_ON_QDRANT_API_KEY=dify +TIDB_API_URL=http://127.0.0.1 +TIDB_IAM_API_URL=http://127.0.0.1 +TIDB_REGION=regions/aws-us-east-1 +TIDB_PROJECT_ID=dify +TIDB_SPEND_LIMIT=100 +TENCENT_VECTOR_DB_URL=http://127.0.0.1 +TENCENT_VECTOR_DB_API_KEY=dify +LINDORM_URL=http://localhost:30070 +LINDORM_USERNAME=admin +UPSTASH_VECTOR_URL=https://xxx-vector.upstash.io +UPLOAD_FILE_SIZE_LIMIT=15 +UPLOAD_FILE_BATCH_LIMIT=5 +UPLOAD_FILE_EXTENSION_BLACKLIST= +SINGLE_CHUNK_ATTACHMENT_LIMIT=10 +IMAGE_FILE_BATCH_LIMIT=10 +ATTACHMENT_IMAGE_FILE_SIZE_LIMIT=2 +ATTACHMENT_IMAGE_DOWNLOAD_TIMEOUT=60 +ETL_TYPE=dify +UNSTRUCTURED_API_URL= +MULTIMODAL_SEND_FORMAT=base64 +UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 +UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 +UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 +API_SENTRY_DSN= +API_SENTRY_TRACES_SAMPLE_RATE=1.0 +API_SENTRY_PROFILES_SAMPLE_RATE=1.0 +WEB_SENTRY_DSN= +PLUGIN_SENTRY_ENABLED=false +PLUGIN_SENTRY_DSN= +NOTION_INTEGRATION_TYPE=public +RESEND_API_URL=https://api.resend.com +SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 +SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 +PGDATA=/var/lib/postgresql/data/pgdata +PLUGIN_MAX_PACKAGE_SIZE=52428800 +PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 +ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} +LOG_LEVEL=INFO +LOG_OUTPUT_FORMAT=text +LOG_FILE=/app/logs/server.log +LOG_FILE_MAX_SIZE=20 +LOG_FILE_BACKUP_COUNT=5 +LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S +LOG_TZ=UTC +DEBUG=false +FLASK_DEBUG=false +ENABLE_REQUEST_LOGGING=False +OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60 +OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5 +WORKFLOW_LOG_CLEANUP_ENABLED=false +WORKFLOW_LOG_RETENTION_DAYS=30 +WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 +WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= +EXPOSE_PLUGIN_DEBUGGING_HOST=localhost +EXPOSE_PLUGIN_DEBUGGING_PORT=5003 +DEPLOY_ENV=PRODUCTION +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REFRESH_TOKEN_EXPIRE_DAYS=30 +APP_DEFAULT_ACTIVE_REQUESTS=0 +APP_MAX_ACTIVE_REQUESTS=0 +APP_MAX_EXECUTION_TIME=1200 +DIFY_BIND_ADDRESS=0.0.0.0 +DIFY_PORT=5001 +SERVER_WORKER_AMOUNT=1 +SERVER_WORKER_CLASS=gevent +SERVER_WORKER_CONNECTIONS=10 +API_WEBSOCKET_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +API_WEBSOCKET_WORKER_CONNECTIONS=1000 +API_WEBSOCKET_GUNICORN_TIMEOUT=360 +CELERY_SENTINEL_PASSWORD= +S3_ACCESS_KEY= +S3_SECRET_KEY= +ARCHIVE_STORAGE_ACCESS_KEY= +ARCHIVE_STORAGE_SECRET_KEY= +AZURE_BLOB_ACCOUNT_KEY=difyai +ALIYUN_OSS_ACCESS_KEY=your-access-key +ALIYUN_OSS_SECRET_KEY=your-secret-key +TENCENT_COS_SECRET_KEY=your-secret-key +TENCENT_COS_SECRET_ID=your-secret-id +OCI_ACCESS_KEY=your-access-key +OCI_SECRET_KEY=your-secret-key +HUAWEI_OBS_SECRET_KEY=your-secret-key +HUAWEI_OBS_ACCESS_KEY=your-access-key +VOLCENGINE_TOS_SECRET_KEY=your-secret-key +VOLCENGINE_TOS_ACCESS_KEY=your-access-key +BAIDU_OBS_SECRET_KEY=your-secret-key +BAIDU_OBS_ACCESS_KEY=your-access-key +SUPABASE_API_KEY=your-access-key +ALIBABACLOUD_MYSQL_PASSWORD=difyai123456 +RELYT_PASSWORD=difyai123456 +LINDORM_PASSWORD=admin +LINDORM_USING_UGC=True +LINDORM_QUERY_TIMEOUT=1 +HUAWEI_CLOUD_PASSWORD=admin +UPSTASH_VECTOR_TOKEN=dify +TABLESTORE_ACCESS_KEY_ID=xxx +TABLESTORE_ACCESS_KEY_SECRET=xxx +TABLESTORE_NORMALIZE_FULLTEXT_BM25_SCORE=false +CLICKZETTA_PASSWORD= +CLICKZETTA_INSTANCE= +CLICKZETTA_SERVICE=api.clickzetta.com +CLICKZETTA_WORKSPACE=quick_start +CLICKZETTA_VCLUSTER=default_ap +CLICKZETTA_SCHEMA=dify +CLICKZETTA_BATCH_SIZE=100 +CLICKZETTA_ENABLE_INVERTED_INDEX=true +CLICKZETTA_ANALYZER_TYPE=chinese +CLICKZETTA_ANALYZER_MODE=smart +UNSTRUCTURED_API_KEY= +SCARF_NO_ANALYTICS=true +PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false +NOTION_CLIENT_SECRET= +NOTION_CLIENT_ID= +NOTION_INTERNAL_SECRET= +MAIL_TYPE=resend +MAIL_DEFAULT_SEND_FROM= +RESEND_API_KEY=your-resend-api-key +SMTP_SERVER= +SMTP_PORT=465 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_USE_TLS=true +SMTP_OPPORTUNISTIC_TLS=false +SMTP_LOCAL_HOSTNAME= +SENDGRID_API_KEY= +INVITE_EXPIRY_HOURS=72 +RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 +CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 +OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 +CODE_EXECUTION_ENDPOINT=http://sandbox:8194 +CODE_EXECUTION_API_KEY=dify-sandbox +CODE_EXECUTION_SSL_VERIFY=True +CODE_EXECUTION_POOL_MAX_CONNECTIONS=100 +CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0 +CODE_MAX_NUMBER=9223372036854775807 +CODE_MIN_NUMBER=-9223372036854775808 +CODE_MAX_DEPTH=5 +CODE_MAX_PRECISION=20 +CODE_MAX_STRING_LENGTH=400000 +CODE_MAX_STRING_ARRAY_LENGTH=30 +CODE_MAX_OBJECT_ARRAY_LENGTH=30 +CODE_MAX_NUMBER_ARRAY_LENGTH=1000 +CODE_EXECUTION_CONNECT_TIMEOUT=10 +CODE_EXECUTION_READ_TIMEOUT=60 +CODE_EXECUTION_WRITE_TIMEOUT=10 +TEMPLATE_TRANSFORM_MAX_LENGTH=400000 +WORKFLOW_MAX_EXECUTION_STEPS=500 +WORKFLOW_MAX_EXECUTION_TIME=1200 +WORKFLOW_CALL_MAX_DEPTH=5 +MAX_VARIABLE_SIZE=204800 +WORKFLOW_FILE_UPLOAD_LIMIT=10 +GRAPH_ENGINE_MIN_WORKERS=3 +GRAPH_ENGINE_MAX_WORKERS=10 +GRAPH_ENGINE_SCALE_UP_THRESHOLD=3 +GRAPH_ENGINE_SCALE_DOWN_IDLE_TIME=5.0 +ALIYUN_SLS_ACCESS_KEY_ID= +ALIYUN_SLS_ACCESS_KEY_SECRET= +WEBHOOK_REQUEST_BODY_MAX_SIZE=10485760 +RESPECT_XFORWARD_HEADERS_ENABLED=false +SSRF_HTTP_PORT=3128 +SSRF_COREDUMP_DIR=/var/spool/squid +SSRF_REVERSE_PROXY_PORT=8194 +SSRF_SANDBOX_HOST=sandbox +SSRF_DEFAULT_TIME_OUT=5 +SSRF_DEFAULT_CONNECT_TIME_OUT=5 +SSRF_DEFAULT_READ_TIME_OUT=5 +SSRF_DEFAULT_WRITE_TIME_OUT=5 +SSRF_POOL_MAX_CONNECTIONS=100 +SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +SSRF_POOL_KEEPALIVE_EXPIRY=5.0 +PLUGIN_AWS_ACCESS_KEY= +PLUGIN_AWS_SECRET_KEY= +PLUGIN_AWS_REGION= +PLUGIN_TENCENT_COS_SECRET_KEY= +PLUGIN_TENCENT_COS_SECRET_ID= +PLUGIN_ALIYUN_OSS_ACCESS_KEY_ID= +PLUGIN_ALIYUN_OSS_ACCESS_KEY_SECRET= +PLUGIN_VOLCENGINE_TOS_ACCESS_KEY= +PLUGIN_VOLCENGINE_TOS_SECRET_KEY= +OTLP_API_KEY= +OTEL_EXPORTER_OTLP_PROTOCOL= +OTEL_EXPORTER_TYPE=otlp +OTEL_SAMPLING_RATE=0.1 +OTEL_BATCH_EXPORT_SCHEDULE_DELAY=5000 +OTEL_MAX_QUEUE_SIZE=2048 +OTEL_MAX_EXPORT_BATCH_SIZE=512 +OTEL_METRIC_EXPORT_INTERVAL=60000 +OTEL_BATCH_EXPORT_TIMEOUT=10000 +OTEL_METRIC_EXPORT_TIMEOUT=30000 +QUEUE_MONITOR_THRESHOLD=200 +QUEUE_MONITOR_ALERT_EMAILS= +QUEUE_MONITOR_INTERVAL=30 +SWAGGER_UI_ENABLED=false +SWAGGER_UI_PATH=/swagger-ui.html +DSL_EXPORT_ENCRYPT_DATASET_ID=true +DATASET_MAX_SEGMENTS_PER_REQUEST=0 +ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false +ENABLE_CLEAN_UNUSED_DATASETS_TASK=false +ENABLE_CREATE_TIDB_SERVERLESS_TASK=false +ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false +ENABLE_CLEAN_MESSAGES=false +ENABLE_WORKFLOW_RUN_CLEANUP_TASK=false +ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false +ENABLE_DATASETS_QUEUE_MONITOR=false +ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true +ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=true +WORKFLOW_SCHEDULE_POLLER_INTERVAL=1 +WORKFLOW_SCHEDULE_POLLER_BATCH_SIZE=100 +WORKFLOW_SCHEDULE_MAX_DISPATCH_PER_TICK=0 +TENANT_ISOLATED_TASK_CONCURRENCY=1 +ANNOTATION_IMPORT_FILE_SIZE_LIMIT=2 +ANNOTATION_IMPORT_MAX_RECORDS=10000 +ANNOTATION_IMPORT_MIN_RECORDS=1 +ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5 +ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20 +ANNOTATION_IMPORT_MAX_CONCURRENT=5 +CREATORS_PLATFORM_FEATURES_ENABLED=true +CREATORS_PLATFORM_API_URL=https://creators.dify.ai +CREATORS_PLATFORM_OAUTH_CLIENT_ID= +TIDB_VECTOR_DATABASE=dify +ALIBABACLOUD_MYSQL_HOST=127.0.0.1 +ALIBABACLOUD_MYSQL_PORT=3306 +ALIBABACLOUD_MYSQL_USER=root +ALIBABACLOUD_MYSQL_DATABASE=dify +ALIBABACLOUD_MYSQL_MAX_CONNECTION=5 +ALIBABACLOUD_MYSQL_HNSW_M=6 +RELYT_DATABASE=postgres +TENCENT_VECTOR_DB_DATABASE=dify +BAIDU_VECTOR_DB_DATABASE=dify +EXPOSE_PLUGIN_DAEMON_PORT=5002 +GUNICORN_TIMEOUT=360 +CELERY_WORKER_AMOUNT= +CELERY_AUTO_SCALE=false +CELERY_MAX_WORKERS= +CELERY_MIN_WORKERS= +API_TOOL_DEFAULT_CONNECT_TIMEOUT=10 +API_TOOL_DEFAULT_READ_TIMEOUT=60 +CELERY_BACKEND=redis +CELERY_USE_SENTINEL=false +CELERY_SENTINEL_MASTER_NAME= +CELERY_SENTINEL_SOCKET_TIMEOUT=0.1 +WEB_API_CORS_ALLOW_ORIGINS=* +CONSOLE_CORS_ALLOW_ORIGINS=* +COOKIE_DOMAIN= +OPENDAL_SCHEME=fs +OPENDAL_FS_ROOT=storage +CLICKZETTA_VOLUME_TYPE=user +CLICKZETTA_VOLUME_NAME= +CLICKZETTA_VOLUME_TABLE_PREFIX=dataset_ +CLICKZETTA_VOLUME_DIFY_PREFIX=dify_km +S3_ENDPOINT= +S3_REGION=us-east-1 +S3_BUCKET_NAME=difyai +S3_ADDRESS_STYLE=auto +S3_USE_AWS_MANAGED_IAM=false +ARCHIVE_STORAGE_ENABLED=false +ARCHIVE_STORAGE_ENDPOINT= +ARCHIVE_STORAGE_ARCHIVE_BUCKET= +ARCHIVE_STORAGE_EXPORT_BUCKET= +ARCHIVE_STORAGE_REGION=auto +AZURE_BLOB_ACCOUNT_NAME=difyai +AZURE_BLOB_CONTAINER_NAME=difyai-container +GOOGLE_STORAGE_BUCKET_NAME=your-bucket-name +GOOGLE_STORAGE_SERVICE_ACCOUNT_JSON_BASE64= +ALIYUN_OSS_BUCKET_NAME=your-bucket-name +ALIYUN_OSS_ENDPOINT=https://oss-ap-southeast-1-internal.aliyuncs.com +ALIYUN_OSS_REGION=ap-southeast-1 +ALIYUN_OSS_AUTH_VERSION=v4 +ALIYUN_OSS_PATH=your-path +ALIYUN_CLOUDBOX_ID=your-cloudbox-id +TENCENT_COS_BUCKET_NAME=your-bucket-name +TENCENT_COS_REGION=your-region +TENCENT_COS_SCHEME=your-scheme +TENCENT_COS_CUSTOM_DOMAIN=your-custom-domain +OCI_ENDPOINT=https://your-object-storage-namespace.compat.objectstorage.us-ashburn-1.oraclecloud.com +OCI_BUCKET_NAME=your-bucket-name +OCI_REGION=us-ashburn-1 +HUAWEI_OBS_BUCKET_NAME=your-bucket-name +HUAWEI_OBS_SERVER=your-server-url +HUAWEI_OBS_PATH_STYLE=false +VOLCENGINE_TOS_BUCKET_NAME=your-bucket-name +VOLCENGINE_TOS_ENDPOINT=your-server-url +VOLCENGINE_TOS_REGION=your-region +BAIDU_OBS_BUCKET_NAME=your-bucket-name +BAIDU_OBS_ENDPOINT=your-server-url +SUPABASE_BUCKET_NAME=your-bucket-name +TENCENT_VECTOR_DB_TIMEOUT=30 +TENCENT_VECTOR_DB_USERNAME=dify +TENCENT_VECTOR_DB_SHARD=1 +TENCENT_VECTOR_DB_REPLICAS=2 +TENCENT_VECTOR_DB_ENABLE_HYBRID_SEARCH=false +BAIDU_VECTOR_DB_ENDPOINT=http://127.0.0.1:5287 +BAIDU_VECTOR_DB_CONNECTION_TIMEOUT_MS=30000 +BAIDU_VECTOR_DB_ACCOUNT=root +BAIDU_VECTOR_DB_API_KEY=dify +BAIDU_VECTOR_DB_SHARD=1 +BAIDU_VECTOR_DB_REPLICAS=3 +BAIDU_VECTOR_DB_INVERTED_INDEX_ANALYZER=DEFAULT_ANALYZER +BAIDU_VECTOR_DB_INVERTED_INDEX_PARSER_MODE=COARSE_MODE +BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT=500 +BAIDU_VECTOR_DB_AUTO_BUILD_ROW_COUNT_INCREMENT_RATIO=0.05 +BAIDU_VECTOR_DB_REBUILD_INDEX_TIMEOUT_IN_SECONDS=300 +HUAWEI_CLOUD_HOSTS=https://127.0.0.1:9200 +HUAWEI_CLOUD_USER=admin +WORKFLOW_NODE_EXECUTION_STORAGE=rdbms +CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository +CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository +API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository +API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository +ALIYUN_SLS_ENDPOINT= +ALIYUN_SLS_REGION= +ALIYUN_SLS_PROJECT_NAME= +ALIYUN_SLS_LOGSTORE_TTL=365 +LOGSTORE_DUAL_WRITE_ENABLED=false +LOGSTORE_DUAL_READ_ENABLED=true +LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true +HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760 +HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576 +HTTP_REQUEST_NODE_SSL_VERIFY=True +HTTP_REQUEST_MAX_CONNECT_TIMEOUT=10 +HTTP_REQUEST_MAX_READ_TIMEOUT=600 +HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 +PLUGIN_INSTALLED_PATH=plugin +PLUGIN_PACKAGE_CACHE_PATH=plugin_packages +PLUGIN_MEDIA_CACHE_PATH=assets +PLUGIN_S3_USE_AWS=false +PLUGIN_S3_USE_AWS_MANAGED_IAM=false +PLUGIN_S3_ENDPOINT= +PLUGIN_S3_USE_PATH_STYLE=false +PLUGIN_AZURE_BLOB_STORAGE_CONTAINER_NAME= +PLUGIN_AZURE_BLOB_STORAGE_CONNECTION_STRING= +PLUGIN_TENCENT_COS_REGION= +PLUGIN_ALIYUN_OSS_REGION= +PLUGIN_ALIYUN_OSS_ENDPOINT= +PLUGIN_ALIYUN_OSS_AUTH_VERSION=v4 +PLUGIN_ALIYUN_OSS_PATH= +PLUGIN_VOLCENGINE_TOS_ENDPOINT= +PLUGIN_VOLCENGINE_TOS_REGION= +ENABLE_OTEL=false +OTLP_TRACE_ENDPOINT= +OTLP_METRIC_ENDPOINT= +# Prefix used to create collection name in vector database +OTLP_BASE_ENDPOINT=http://localhost:4318 +WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051 +ANALYTICDB_KEY_ID=your-ak +ANALYTICDB_KEY_SECRET=your-sk +ANALYTICDB_REGION_ID=cn-hangzhou +ANALYTICDB_INSTANCE_ID=gp-ab123456 +ANALYTICDB_ACCOUNT=testaccount +ANALYTICDB_PASSWORD=testpassword +ANALYTICDB_NAMESPACE=dify +ANALYTICDB_NAMESPACE_PASSWORD=difypassword +ANALYTICDB_HOST=gp-test.aliyuncs.com +ANALYTICDB_PORT=5432 +ANALYTICDB_MIN_CONNECTION=1 +ANALYTICDB_MAX_CONNECTION=5 +TIDB_VECTOR_HOST=tidb +TIDB_VECTOR_PORT=4000 +TIDB_VECTOR_USER= +TIDB_VECTOR_PASSWORD= +TIDB_ON_QDRANT_CLIENT_TIMEOUT=20 +TIDB_ON_QDRANT_GRPC_ENABLED=false +TIDB_ON_QDRANT_GRPC_PORT=6334 +TIDB_PUBLIC_KEY=dify +TIDB_PRIVATE_KEY=dify +RELYT_HOST=db +RELYT_PORT=5432 +RELYT_USER=postgres +VIKINGDB_ACCESS_KEY=your-ak +VIKINGDB_SECRET_KEY=your-sk +VIKINGDB_REGION=cn-shanghai +VIKINGDB_HOST=api-vikingdb.xxx.volces.com +VIKINGDB_SCHEME=http +VIKINGDB_CONNECTION_TIMEOUT=30 +VIKINGDB_SOCKET_TIMEOUT=30 +TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com +TABLESTORE_INSTANCE_NAME=instance-name +CLICKZETTA_USERNAME= +CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration +EXPOSE_NGINX_PORT=80 +EXPOSE_NGINX_SSL_PORT=443 +POSITION_TOOL_PINS= +POSITION_TOOL_INCLUDES= +POSITION_TOOL_EXCLUDES= +POSITION_PROVIDER_PINS= +POSITION_PROVIDER_INCLUDES= +POSITION_PROVIDER_EXCLUDES= +CREATE_TIDB_SERVICE_JOB_ENABLED=false +MAX_SUBMIT_COUNT=100 + +# Vector Store Configuration +STORAGE_TYPE=opendal +VECTOR_STORE=weaviate +VECTOR_INDEX_NAME_PREFIX=Vector_index +WEAVIATE_ENDPOINT=http://weaviate:8080 +WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih +WEAVIATE_TOKENIZATION=word +OCEANBASE_VECTOR_HOST=oceanbase +OCEANBASE_VECTOR_PORT=2881 +OCEANBASE_VECTOR_USER=root@test +OCEANBASE_VECTOR_PASSWORD=difyai123456 +OCEANBASE_VECTOR_DATABASE=test +OCEANBASE_ENABLE_HYBRID_SEARCH=false +OCEANBASE_FULLTEXT_PARSER=ik +SEEKDB_MEMORY_LIMIT=2G +QDRANT_URL=http://qdrant:6333 +QDRANT_API_KEY=difyai123456 +QDRANT_CLIENT_TIMEOUT=20 +QDRANT_GRPC_ENABLED=false +QDRANT_GRPC_PORT=6334 +QDRANT_REPLICATION_FACTOR=1 +MILVUS_URI=http://host.docker.internal:19530 +MILVUS_TOKEN= +MILVUS_USER= +MILVUS_PASSWORD= +MILVUS_ANALYZER_PARAMS= +PGVECTOR_HOST=pgvector +PGVECTOR_PORT=5432 +PGVECTOR_USER=postgres +PGVECTOR_PASSWORD=difyai123456 +PGVECTOR_DATABASE=dify +PGVECTOR_MIN_CONNECTION=1 +PGVECTOR_MAX_CONNECTION=5 +PGVECTOR_PG_BIGM=false +PGVECTOR_PG_BIGM_VERSION=1.2-20240606 + +# Hologres Configuration +HOLOGRES_HOST= +HOLOGRES_PORT=80 +HOLOGRES_DATABASE= +HOLOGRES_ACCESS_KEY_ID= +HOLOGRES_ACCESS_KEY_SECRET= +HOLOGRES_SCHEMA=public +HOLOGRES_TOKENIZER=jieba +HOLOGRES_DISTANCE_METHOD=Cosine +HOLOGRES_BASE_QUANTIZATION_TYPE=rabitq +HOLOGRES_MAX_DEGREE=64 +HOLOGRES_EF_CONSTRUCTION=400 + +# Milvus API Configuration +MILVUS_DATABASE= +MILVUS_ENABLE_HYBRID_SEARCH=False + +# Human Input Task Configuration +ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true +HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1 diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example new file mode 100644 index 0000000000..d366cd87ba --- /dev/null +++ b/docker/envs/core-services/web.env.example @@ -0,0 +1,30 @@ +# ------------------------------ +# Web Configuration +# ------------------------------ + +CONSOLE_API_URL= +APP_API_URL= +SENTRY_DSN= +NEXT_PUBLIC_SOCKET_URL=ws://localhost +EXPERIMENTAL_ENABLE_VINEXT=false +LOOP_NODE_MAX_COUNT=100 +MAX_TOOLS_NUM=10 +MAX_PARALLEL_LIMIT=10 +MAX_ITERATIONS_NUM=99 +TEXT_GENERATION_TIMEOUT_MS=60000 +ALLOW_INLINE_STYLES=false +ALLOW_UNSAFE_DATA_SCHEME=false +MAX_TREE_DEPTH=50 +MARKETPLACE_ENABLED=true +MARKETPLACE_API_URL=https://marketplace.dify.ai +INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000 +ALLOW_EMBED=false +AMPLITUDE_API_KEY= +ENABLE_WEBSITE_JINAREADER=true +ENABLE_WEBSITE_FIRECRAWL=true +ENABLE_WEBSITE_WATERCRAWL=true +NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false +NEXT_PUBLIC_COOKIE_DOMAIN= +NEXT_PUBLIC_BATCH_CONCURRENCY=5 +CSP_WHITELIST= +TOP_K_MAX_VALUE=10 diff --git a/docker/envs/core-services/worker-beat.env.example b/docker/envs/core-services/worker-beat.env.example new file mode 100644 index 0000000000..380fe02b68 --- /dev/null +++ b/docker/envs/core-services/worker-beat.env.example @@ -0,0 +1,8 @@ +# ------------------------------ +# Worker Beat Configuration +# ------------------------------ + +MODE=beat +COMPOSE_WORKER_HEALTHCHECK_DISABLED=true +COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s +COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s diff --git a/docker/envs/core-services/worker.env.example b/docker/envs/core-services/worker.env.example new file mode 100644 index 0000000000..58cf4ea901 --- /dev/null +++ b/docker/envs/core-services/worker.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Worker Configuration +# ------------------------------ + +MODE=worker +SENTRY_DSN= +SENTRY_TRACES_SAMPLE_RATE=1.0 +SENTRY_PROFILES_SAMPLE_RATE=1.0 +PLUGIN_MAX_PACKAGE_SIZE=52428800 +INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1 +COMPOSE_WORKER_HEALTHCHECK_DISABLED=true +COMPOSE_WORKER_HEALTHCHECK_INTERVAL=30s +COMPOSE_WORKER_HEALTHCHECK_TIMEOUT=30s diff --git a/docker/envs/databases/db-mysql.env.example b/docker/envs/databases/db-mysql.env.example new file mode 100644 index 0000000000..b3ea6801fe --- /dev/null +++ b/docker/envs/databases/db-mysql.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Db Mysql Configuration +# ------------------------------ + +MYSQL_INNODB_LOG_FILE_SIZE=128M +MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2 +MYSQL_MAX_CONNECTIONS=1000 +MYSQL_INNODB_BUFFER_POOL_SIZE=512M +MYSQL_HOST_VOLUME=./volumes/mysql/data diff --git a/docker/envs/databases/db-postgres.env.example b/docker/envs/databases/db-postgres.env.example new file mode 100644 index 0000000000..14cefb6bee --- /dev/null +++ b/docker/envs/databases/db-postgres.env.example @@ -0,0 +1,26 @@ +# ------------------------------ +# Db Postgres Configuration +# ------------------------------ + +PGDATA=/var/lib/postgresql/data/pgdata +DB_TYPE=postgresql +DB_USERNAME=postgres +DB_PASSWORD=difyai123456 +DB_HOST=db_postgres +DB_PORT=5432 +DB_DATABASE=dify +SQLALCHEMY_POOL_SIZE=30 +SQLALCHEMY_MAX_OVERFLOW=10 +SQLALCHEMY_POOL_RECYCLE=3600 +SQLALCHEMY_ECHO=false +SQLALCHEMY_POOL_PRE_PING=false +SQLALCHEMY_POOL_USE_LIFO=false +SQLALCHEMY_POOL_TIMEOUT=30 +SQLALCHEMY_POOL_RESET_ON_RETURN=rollback +POSTGRES_MAX_CONNECTIONS=100 +POSTGRES_SHARED_BUFFERS=128MB +POSTGRES_WORK_MEM=4MB +POSTGRES_MAINTENANCE_WORK_MEM=64MB +POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB +POSTGRES_STATEMENT_TIMEOUT=0 +POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 diff --git a/docker/envs/databases/redis.env.example b/docker/envs/databases/redis.env.example new file mode 100644 index 0000000000..74bcb6525e --- /dev/null +++ b/docker/envs/databases/redis.env.example @@ -0,0 +1,35 @@ +# ------------------------------ +# Redis Configuration +# ------------------------------ + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_USERNAME= +REDIS_PASSWORD=difyai123456 +REDIS_USE_SSL=false +REDIS_SSL_CERT_REQS=CERT_NONE +REDIS_SSL_CA_CERTS= +REDIS_SSL_CERTFILE= +REDIS_SSL_KEYFILE= +REDIS_DB=0 +REDIS_KEY_PREFIX= +REDIS_MAX_CONNECTIONS= +REDIS_USE_SENTINEL=false +REDIS_SENTINELS= +REDIS_SENTINEL_SERVICE_NAME= +REDIS_SENTINEL_USERNAME= +REDIS_SENTINEL_PASSWORD= +REDIS_SENTINEL_SOCKET_TIMEOUT=0.1 +REDIS_USE_CLUSTERS=false +REDIS_CLUSTERS= +REDIS_CLUSTERS_PASSWORD= +REDIS_RETRY_RETRIES=3 +REDIS_RETRY_BACKOFF_BASE=1.0 +REDIS_RETRY_BACKOFF_CAP=10.0 +REDIS_SOCKET_TIMEOUT=5.0 +REDIS_SOCKET_CONNECT_TIMEOUT=5.0 +REDIS_HEALTH_CHECK_INTERVAL=30 +EVENT_BUS_REDIS_URL= +EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub +EVENT_BUS_REDIS_USE_CLUSTERS=false +BROKER_USE_SSL=false diff --git a/docker/envs/infrastructure/certbot.env.example b/docker/envs/infrastructure/certbot.env.example new file mode 100644 index 0000000000..c654fbe02f --- /dev/null +++ b/docker/envs/infrastructure/certbot.env.example @@ -0,0 +1,7 @@ +# ------------------------------ +# Certbot Configuration +# ------------------------------ + +CERTBOT_EMAIL=your_email@example.com +CERTBOT_DOMAIN=your_domain.com +CERTBOT_OPTIONS= diff --git a/docker/envs/infrastructure/etcd.env.example b/docker/envs/infrastructure/etcd.env.example new file mode 100644 index 0000000000..4dca26671a --- /dev/null +++ b/docker/envs/infrastructure/etcd.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Etcd Configuration +# ------------------------------ + diff --git a/docker/envs/infrastructure/milvus-standalone.env.example b/docker/envs/infrastructure/milvus-standalone.env.example new file mode 100644 index 0000000000..7e87ed2648 --- /dev/null +++ b/docker/envs/infrastructure/milvus-standalone.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Milvus Standalone Configuration +# ------------------------------ + diff --git a/docker/envs/infrastructure/minio.env.example b/docker/envs/infrastructure/minio.env.example new file mode 100644 index 0000000000..7c8e1fa35a --- /dev/null +++ b/docker/envs/infrastructure/minio.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Minio Configuration +# ------------------------------ + diff --git a/docker/envs/infrastructure/nginx.env.example b/docker/envs/infrastructure/nginx.env.example new file mode 100644 index 0000000000..fcb369a47d --- /dev/null +++ b/docker/envs/infrastructure/nginx.env.example @@ -0,0 +1,18 @@ +# ------------------------------ +# Nginx Configuration +# ------------------------------ + +NGINX_SERVER_NAME=_ +NGINX_HTTPS_ENABLED=false +NGINX_PORT=80 +NGINX_SSL_PORT=443 +NGINX_SSL_CERT_FILENAME=dify.crt +NGINX_SSL_CERT_KEY_FILENAME=dify.key +NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3 +NGINX_WORKER_PROCESSES=auto +NGINX_CLIENT_MAX_BODY_SIZE=100M +NGINX_KEEPALIVE_TIMEOUT=65 +NGINX_PROXY_READ_TIMEOUT=3600s +NGINX_PROXY_SEND_TIMEOUT=3600s +NGINX_ENABLE_CERTBOT_CHALLENGE=false +NGINX_SOCKET_IO_UPSTREAM=api_websocket:5001 diff --git a/docker/envs/infrastructure/ssrf-proxy.env.example b/docker/envs/infrastructure/ssrf-proxy.env.example new file mode 100644 index 0000000000..210a782494 --- /dev/null +++ b/docker/envs/infrastructure/ssrf-proxy.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Ssrf Proxy Configuration +# ------------------------------ + +SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128 +SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 +SSRF_HTTP_PORT=3128 +SSRF_COREDUMP_DIR=/var/spool/squid +SSRF_REVERSE_PROXY_PORT=8194 +SSRF_SANDBOX_HOST=sandbox +SSRF_DEFAULT_TIME_OUT=5 +SSRF_DEFAULT_CONNECT_TIME_OUT=5 +SSRF_DEFAULT_READ_TIME_OUT=5 +SSRF_DEFAULT_WRITE_TIME_OUT=5 +SSRF_POOL_MAX_CONNECTIONS=100 +SSRF_POOL_MAX_KEEPALIVE_CONNECTIONS=20 +SSRF_POOL_KEEPALIVE_EXPIRY=5.0 diff --git a/docker/middleware.env.example b/docker/envs/middleware.env.example similarity index 100% rename from docker/middleware.env.example rename to docker/envs/middleware.env.example diff --git a/docker/envs/security.env.example b/docker/envs/security.env.example new file mode 100644 index 0000000000..d7556d91e5 --- /dev/null +++ b/docker/envs/security.env.example @@ -0,0 +1,41 @@ +# ------------------------------ +# Security Configuration +# ------------------------------ + +TIDB_ON_QDRANT_API_KEY=dify +TENCENT_VECTOR_DB_API_KEY=dify +ALIBABACLOUD_MYSQL_PASSWORD=difyai123456 +RELYT_PASSWORD=difyai123456 +LINDORM_PASSWORD=admin +HUAWEI_CLOUD_PASSWORD=admin +UPSTASH_VECTOR_TOKEN=dify +TABLESTORE_ACCESS_KEY_ID=xxx +TABLESTORE_ACCESS_KEY_SECRET=xxx +UNSTRUCTURED_API_KEY= +PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false +NOTION_CLIENT_SECRET= +NOTION_INTERNAL_SECRET= +RESEND_API_KEY=your-resend-api-key +SMTP_PASSWORD= +SENDGRID_API_KEY= +RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5 +EMAIL_REGISTER_TOKEN_EXPIRY_MINUTES=5 +CHANGE_EMAIL_TOKEN_EXPIRY_MINUTES=5 +OWNER_TRANSFER_TOKEN_EXPIRY_MINUTES=5 +CODE_EXECUTION_API_KEY=dify-sandbox +ALIYUN_SLS_ACCESS_KEY_ID= +ALIYUN_SLS_ACCESS_KEY_SECRET= +OTLP_API_KEY= +BAIDU_VECTOR_DB_API_KEY=dify +ANALYTICDB_KEY_ID=your-ak +ANALYTICDB_KEY_SECRET=your-sk +ANALYTICDB_PASSWORD=testpassword +ANALYTICDB_NAMESPACE_PASSWORD=difypassword +TIDB_VECTOR_PASSWORD= +TIDB_PUBLIC_KEY=dify +TIDB_PRIVATE_KEY=dify +VIKINGDB_ACCESS_KEY=your-ak +VIKINGDB_SECRET_KEY=your-sk +# Leave empty to auto-generate a persistent key in the storage directory. +SECRET_KEY= +INIT_PASSWORD= diff --git a/docker/envs/vectorstores/chroma.env.example b/docker/envs/vectorstores/chroma.env.example new file mode 100644 index 0000000000..2a15375a3d --- /dev/null +++ b/docker/envs/vectorstores/chroma.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Chroma Configuration +# ------------------------------ + +CHROMA_DATABASE=default_database +CHROMA_AUTH_PROVIDER=chromadb.auth.token_authn.TokenAuthClientProvider +CHROMA_AUTH_CREDENTIALS= +CHROMA_HOST=127.0.0.1 +CHROMA_PORT=8000 +CHROMA_TENANT=default_tenant +CHROMA_SERVER_AUTHN_CREDENTIALS=difyai123456 +CHROMA_SERVER_AUTHN_PROVIDER=chromadb.auth.token_authn.TokenAuthenticationServerProvider +CHROMA_IS_PERSISTENT=TRUE diff --git a/docker/envs/vectorstores/couchbase.env.example b/docker/envs/vectorstores/couchbase.env.example new file mode 100644 index 0000000000..4329d9c723 --- /dev/null +++ b/docker/envs/vectorstores/couchbase.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Couchbase Configuration +# ------------------------------ + +COUCHBASE_PASSWORD=password +COUCHBASE_BUCKET_NAME=Embeddings +COUCHBASE_SCOPE_NAME=_default +COUCHBASE_CONNECTION_STRING=couchbase://couchbase-server +COUCHBASE_USER=Administrator diff --git a/docker/envs/vectorstores/elasticsearch.env.example b/docker/envs/vectorstores/elasticsearch.env.example new file mode 100644 index 0000000000..2aaa965cd7 --- /dev/null +++ b/docker/envs/vectorstores/elasticsearch.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Elasticsearch Configuration +# ------------------------------ + +ELASTICSEARCH_CLOUD_URL=YOUR-ELASTICSEARCH_CLOUD_URL +ELASTICSEARCH_PASSWORD=elastic +KIBANA_PORT=5601 +ELASTICSEARCH_USE_CLOUD=false +ELASTICSEARCH_API_KEY=YOUR-ELASTICSEARCH_API_KEY +ELASTICSEARCH_VERIFY_CERTS=False +ELASTICSEARCH_CA_CERTS= +ELASTICSEARCH_REQUEST_TIMEOUT=100000 +ELASTICSEARCH_RETRY_ON_TIMEOUT=True +ELASTICSEARCH_MAX_RETRIES=10 +ELASTICSEARCH_HOST=0.0.0.0 +ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_USERNAME=elastic diff --git a/docker/envs/vectorstores/iris.env.example b/docker/envs/vectorstores/iris.env.example new file mode 100644 index 0000000000..b1eb39bff8 --- /dev/null +++ b/docker/envs/vectorstores/iris.env.example @@ -0,0 +1,17 @@ +# ------------------------------ +# Iris Configuration +# ------------------------------ + +IRIS_CONNECTION_URL= +IRIS_MIN_CONNECTION=1 +IRIS_MAX_CONNECTION=3 +IRIS_TEXT_INDEX=true +IRIS_TEXT_INDEX_LANGUAGE=en +IRIS_TIMEZONE=UTC +IRIS_PASSWORD=Dify@1234 +IRIS_DATABASE=USER +IRIS_SCHEMA=dify +IRIS_HOST=iris +IRIS_SUPER_SERVER_PORT=1972 +IRIS_WEB_SERVER_PORT=52773 +IRIS_USER=_SYSTEM diff --git a/docker/envs/vectorstores/matrixone.env.example b/docker/envs/vectorstores/matrixone.env.example new file mode 100644 index 0000000000..931375f8b4 --- /dev/null +++ b/docker/envs/vectorstores/matrixone.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Matrixone Configuration +# ------------------------------ + +MATRIXONE_PASSWORD=111 +MATRIXONE_HOST=matrixone +MATRIXONE_PORT=6001 +MATRIXONE_USER=dump +MATRIXONE_DATABASE=dify diff --git a/docker/envs/vectorstores/milvus.env.example b/docker/envs/vectorstores/milvus.env.example new file mode 100644 index 0000000000..d16879ca7b --- /dev/null +++ b/docker/envs/vectorstores/milvus.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Milvus Configuration +# ------------------------------ + +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +ETCD_ENDPOINTS=etcd:2379 +MINIO_ADDRESS=minio:9000 +ETCD_AUTO_COMPACTION_MODE=revision +ETCD_AUTO_COMPACTION_RETENTION=1000 +ETCD_QUOTA_BACKEND_BYTES=4294967296 +ETCD_SNAPSHOT_COUNT=50000 +MILVUS_AUTHORIZATION_ENABLED=true diff --git a/docker/envs/vectorstores/myscale.env.example b/docker/envs/vectorstores/myscale.env.example new file mode 100644 index 0000000000..eaa9e88cc0 --- /dev/null +++ b/docker/envs/vectorstores/myscale.env.example @@ -0,0 +1,10 @@ +# ------------------------------ +# Myscale Configuration +# ------------------------------ + +MYSCALE_PASSWORD= +MYSCALE_DATABASE=dify +MYSCALE_FTS_PARAMS= +MYSCALE_HOST=myscale +MYSCALE_PORT=8123 +MYSCALE_USER=default diff --git a/docker/envs/vectorstores/oceanbase.env.example b/docker/envs/vectorstores/oceanbase.env.example new file mode 100644 index 0000000000..42bed8df6a --- /dev/null +++ b/docker/envs/vectorstores/oceanbase.env.example @@ -0,0 +1,6 @@ +# ------------------------------ +# Oceanbase Configuration +# ------------------------------ + +OCEANBASE_CLUSTER_NAME=difyai +OCEANBASE_MEMORY_LIMIT=6G diff --git a/docker/envs/vectorstores/opengauss.env.example b/docker/envs/vectorstores/opengauss.env.example new file mode 100644 index 0000000000..9f58499b64 --- /dev/null +++ b/docker/envs/vectorstores/opengauss.env.example @@ -0,0 +1,12 @@ +# ------------------------------ +# Opengauss Configuration +# ------------------------------ + +OPENGAUSS_PASSWORD=Dify@123 +OPENGAUSS_DATABASE=dify +OPENGAUSS_MIN_CONNECTION=1 +OPENGAUSS_MAX_CONNECTION=5 +OPENGAUSS_ENABLE_PQ=false +OPENGAUSS_HOST=opengauss +OPENGAUSS_PORT=6600 +OPENGAUSS_USER=postgres diff --git a/docker/envs/vectorstores/opensearch.env.example b/docker/envs/vectorstores/opensearch.env.example new file mode 100644 index 0000000000..a6a9283378 --- /dev/null +++ b/docker/envs/vectorstores/opensearch.env.example @@ -0,0 +1,22 @@ +# ------------------------------ +# Opensearch Configuration +# ------------------------------ + +OPENSEARCH_PASSWORD=admin +OPENSEARCH_AWS_REGION=ap-southeast-1 +OPENSEARCH_AWS_SERVICE=aoss +OPENSEARCH_INITIAL_ADMIN_PASSWORD=Qazwsxedc!@#123 +OPENSEARCH_MEMLOCK_SOFT=-1 +OPENSEARCH_MEMLOCK_HARD=-1 +OPENSEARCH_NOFILE_SOFT=65536 +OPENSEARCH_NOFILE_HARD=65536 +OPENSEARCH_HOST=opensearch +OPENSEARCH_PORT=9200 +OPENSEARCH_SECURE=true +OPENSEARCH_VERIFY_CERTS=true +OPENSEARCH_AUTH_METHOD=basic +OPENSEARCH_USER=admin +OPENSEARCH_DISCOVERY_TYPE=single-node +OPENSEARCH_BOOTSTRAP_MEMORY_LOCK=true +OPENSEARCH_JAVA_OPTS_MIN=512m +OPENSEARCH_JAVA_OPTS_MAX=1024m diff --git a/docker/envs/vectorstores/oracle.env.example b/docker/envs/vectorstores/oracle.env.example new file mode 100644 index 0000000000..c8f24db41a --- /dev/null +++ b/docker/envs/vectorstores/oracle.env.example @@ -0,0 +1,13 @@ +# ------------------------------ +# Oracle Configuration +# ------------------------------ + +ORACLE_PASSWORD=dify +ORACLE_DSN=oracle:1521/FREEPDB1 +ORACLE_CONFIG_DIR=/app/api/storage/wallet +ORACLE_WALLET_LOCATION=/app/api/storage/wallet +ORACLE_WALLET_PASSWORD=dify +ORACLE_IS_AUTONOMOUS=false +ORACLE_USER=dify +ORACLE_PWD=Dify123456 +ORACLE_CHARACTERSET=AL32UTF8 diff --git a/docker/envs/vectorstores/pgvecto-rs.env.example b/docker/envs/vectorstores/pgvecto-rs.env.example new file mode 100644 index 0000000000..6428e5dd67 --- /dev/null +++ b/docker/envs/vectorstores/pgvecto-rs.env.example @@ -0,0 +1,9 @@ +# ------------------------------ +# Pgvecto Rs Configuration +# ------------------------------ + +PGVECTO_RS_HOST=pgvecto-rs +PGVECTO_RS_PORT=5432 +PGVECTO_RS_USER=postgres +PGVECTO_RS_PASSWORD=difyai123456 +PGVECTO_RS_DATABASE=dify diff --git a/docker/envs/vectorstores/pgvector.env.example b/docker/envs/vectorstores/pgvector.env.example new file mode 100644 index 0000000000..9fd1dbf962 --- /dev/null +++ b/docker/envs/vectorstores/pgvector.env.example @@ -0,0 +1,8 @@ +# ------------------------------ +# Pgvector Configuration +# ------------------------------ + +PGVECTOR_PGUSER=postgres +PGVECTOR_POSTGRES_PASSWORD=difyai123456 +PGVECTOR_POSTGRES_DB=dify +PGVECTOR_PGDATA=/var/lib/postgresql/data/pgdata diff --git a/docker/envs/vectorstores/qdrant.env.example b/docker/envs/vectorstores/qdrant.env.example new file mode 100644 index 0000000000..a3555fe547 --- /dev/null +++ b/docker/envs/vectorstores/qdrant.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Qdrant Configuration +# ------------------------------ + diff --git a/docker/envs/vectorstores/seekdb.env.example b/docker/envs/vectorstores/seekdb.env.example new file mode 100644 index 0000000000..4307fbede2 --- /dev/null +++ b/docker/envs/vectorstores/seekdb.env.example @@ -0,0 +1,4 @@ +# ------------------------------ +# Seekdb Configuration +# ------------------------------ + diff --git a/docker/envs/vectorstores/vastbase.env.example b/docker/envs/vectorstores/vastbase.env.example new file mode 100644 index 0000000000..2c9db50fbe --- /dev/null +++ b/docker/envs/vectorstores/vastbase.env.example @@ -0,0 +1,11 @@ +# ------------------------------ +# Vastbase Configuration +# ------------------------------ + +VASTBASE_PASSWORD=Difyai123456 +VASTBASE_DATABASE=dify +VASTBASE_MIN_CONNECTION=1 +VASTBASE_MAX_CONNECTION=5 +VASTBASE_HOST=vastbase +VASTBASE_PORT=5432 +VASTBASE_USER=dify diff --git a/docker/envs/vectorstores/weaviate.env.example b/docker/envs/vectorstores/weaviate.env.example new file mode 100644 index 0000000000..82a3ccb172 --- /dev/null +++ b/docker/envs/vectorstores/weaviate.env.example @@ -0,0 +1,18 @@ +# ------------------------------ +# Weaviate Configuration +# ------------------------------ + +WEAVIATE_PERSISTENCE_DATA_PATH=/var/lib/weaviate +WEAVIATE_QUERY_DEFAULTS_LIMIT=25 +WEAVIATE_AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true +WEAVIATE_DEFAULT_VECTORIZER_MODULE=none +WEAVIATE_CLUSTER_HOSTNAME=node1 +WEAVIATE_AUTHENTICATION_APIKEY_ENABLED=true +WEAVIATE_AUTHENTICATION_APIKEY_ALLOWED_KEYS=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih +WEAVIATE_AUTHENTICATION_APIKEY_USERS=hello@dify.ai +WEAVIATE_AUTHORIZATION_ADMINLIST_ENABLED=true +WEAVIATE_AUTHORIZATION_ADMINLIST_USERS=hello@dify.ai +WEAVIATE_DISABLE_TELEMETRY=false +WEAVIATE_ENABLE_TOKENIZER_GSE=false +WEAVIATE_ENABLE_TOKENIZER_KAGOME_JA=false +WEAVIATE_ENABLE_TOKENIZER_KAGOME_KR=false diff --git a/docker/generate_docker_compose b/docker/generate_docker_compose index 46d948f3c1..580091e006 100755 --- a/docker/generate_docker_compose +++ b/docker/generate_docker_compose @@ -64,25 +64,61 @@ def generate_shared_env_block(env_vars, anchor_name="shared-api-worker-env"): return "\n".join(lines) -def insert_shared_env(template_path, output_path, shared_env_block, header_comments): +def create_env_files_from_example(env_example_path): """ - Inserts the shared environment variables block and header comments into the template file, - removing any existing x-shared-env anchors, and generates the final docker-compose.yaml file. - Always writes with LF line endings. + Creates actual env files from .env.example by copying the categorized .env.example files. + This allows docker-compose to use env_file references. + Supports per-module structure with subdirectories. + """ + base_dir = os.path.dirname(os.path.abspath(env_example_path)) + root_env_file = os.path.join(base_dir, ".env") + if not os.path.exists(root_env_file): + with open(env_example_path, "r", encoding="utf-8") as src, open( + root_env_file, "w", encoding="utf-8", newline="\n" + ) as dst: + dst.write(src.read()) + print(f"Created {root_env_file}") + else: + print(f"{root_env_file} already exists, skipping") + + envs_dir = os.path.join(base_dir, "envs") + if not os.path.isdir(envs_dir): + print(f"No envs directory found at {envs_dir}, skipping split env files") + return [] + + created_files = [] + # Walk through all .env.example files in subdirectories + for root, dirs, files in os.walk(envs_dir): + for file in files: + if file.endswith('.env.example'): + example_file = os.path.join(root, file) + env_file = example_file.replace('.env.example', '.env') + + if os.path.exists(env_file): + print(f"{env_file} already exists, skipping") + continue + + # Copy .example to actual file + with open(example_file, "r", encoding="utf-8") as src, open( + env_file, "w", encoding="utf-8", newline="\n" + ) as dst: + dst.write(src.read()) + created_files.append(env_file) + print(f"Created {env_file}") + + return created_files + + +def insert_shared_env(template_path, output_path, header_comments): + """ + Copies the template file to output path with header comments. + The template now uses env_file references instead of a huge YAML anchor. """ with open(template_path, "r", encoding="utf-8") as f: template_content = f.read() - # Remove existing x-shared-env: &shared-api-worker-env lines - template_content = re.sub( - r"^x-shared-env: &shared-api-worker-env\s*\n?", - "", - template_content, - flags=re.MULTILINE, - ) - - # Prepare the final content with header comments and shared env block - final_content = f"{header_comments}\n{shared_env_block}\n\n{template_content}" + # Prepare the final content with header comments + final_content = f"{header_comments}\n{template_content}" with open(output_path, "w", encoding="utf-8", newline="\n") as f: f.write(final_content) @@ -90,10 +126,10 @@ def insert_shared_env(template_path, output_path, shared_env_block, header_comme def main(): - env_example_path = ".env.example" - template_path = "docker-compose-template.yaml" - output_path = "docker-compose.yaml" - anchor_name = "shared-api-worker-env" # Can be modified as needed + base_dir = os.path.dirname(os.path.abspath(__file__)) + env_example_path = os.path.join(base_dir, ".env.example") + template_path = os.path.join(base_dir, "docker-compose-template.yaml") + output_path = os.path.join(base_dir, "docker-compose.yaml") # Define header comments to be added at the top of docker-compose.yaml header_comments = ( @@ -110,17 +146,14 @@ def main(): print(f"Error: File {path} does not exist.") sys.exit(1) - # Parse .env.example file - env_vars = parse_env_example(env_example_path) + # Create env files from categorized .env.example files + # These files are used by docker-compose's env_file directive + # This ensures .env files exist even in CI/CD environments + create_env_files_from_example(env_example_path) - if not env_vars: - print("Warning: No environment variables found in .env.example.") - - # Generate shared environment variables block - shared_env_block = generate_shared_env_block(env_vars, anchor_name) - - # Insert shared environment variables block and header comments into the template - insert_shared_env(template_path, output_path, shared_env_block, header_comments) + # Copy template to output with header comments + # The template now uses env_file references instead of a huge YAML anchor + insert_shared_env(template_path, output_path, header_comments) if __name__ == "__main__": diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index 94a748290f..64c720ca2b 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -15,7 +15,9 @@ server { } location /socket.io/ { - proxy_pass http://api:5001; + resolver 127.0.0.11 valid=30s ipv6=off; + set $socket_io_upstream ${NGINX_SOCKET_IO_UPSTREAM}; + proxy_pass http://$socket_io_upstream; include proxy.conf; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index e56aab20a7..c05b5105be 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -31,7 +31,7 @@ pnpm -C e2e check `pnpm install` is resolved through the repository workspace and uses the shared root lockfile plus `pnpm-workspace.yaml`. -Use `pnpm check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package. +Use `pnpm -C e2e check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package. Common commands: @@ -68,8 +68,8 @@ flowchart TD C --> D["Cucumber loads config, steps, and support modules"] D --> E["BeforeAll bootstraps shared auth state via /install"] E --> F{"Which command is running?"} - F -->|`pnpm e2e`| G["Run config default tags: not @fresh and not @skip"] - F -->|`pnpm e2e:full*`| H["Override tags to not @skip"] + F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip"] + F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip"] G --> I["Per-scenario BrowserContext from shared browser"] H --> I I --> J["Failure artifacts written to cucumber-report/artifacts"] @@ -99,7 +99,7 @@ Behavior depends on instance state: - uninitialized instance: completes install and stores authenticated state - initialized instance: signs in and reuses authenticated state -Because of that, the `@fresh` install scenario only runs in the `pnpm e2e:full*` flows. The default `pnpm e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance. +Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance. Reset all persisted E2E state: @@ -126,7 +126,7 @@ pnpm -C e2e e2e:middleware:up Stop the full middleware stack: ```bash -pnpm e2e:middleware:down +pnpm -C e2e e2e:middleware:down ``` The middleware stack includes: @@ -141,15 +141,15 @@ The middleware stack includes: Fresh install verification: ```bash -pnpm e2e:full +pnpm -C e2e e2e:full ``` Run the Cucumber suite against an already running middleware stack: ```bash -pnpm e2e:middleware:up -pnpm e2e -pnpm e2e:middleware:down +pnpm -C e2e e2e:middleware:up +pnpm -C e2e e2e +pnpm -C e2e e2e:middleware:down ``` Artifacts and diagnostics: diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index d5742bdaa8..3ec038b065 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -40,7 +40,7 @@ Then('the shared app page should be accessible', async function (this: DifyWorld When('I run the shared workflow app', async function (this: DifyWorld) { const page = this.getPage() - const runButton = page.getByTestId('run-button') + const runButton = page.getByRole('button', { name: 'Execute' }) await expect(runButton).toBeEnabled({ timeout: 15_000 }) await runButton.click() diff --git a/e2e/scripts/common.ts b/e2e/scripts/common.ts index ea6c897b2d..2964892dd0 100644 --- a/e2e/scripts/common.ts +++ b/e2e/scripts/common.ts @@ -36,7 +36,7 @@ export const webDir = path.join(rootDir, 'web') export const middlewareComposeFile = path.join(dockerDir, 'docker-compose.middleware.yaml') export const middlewareEnvFile = path.join(dockerDir, 'middleware.env') -export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.example') +export const middlewareEnvExampleFile = path.join(dockerDir, 'envs', 'middleware.env.example') export const webEnvLocalFile = path.join(webDir, '.env.local') export const webEnvExampleFile = path.join(webDir, '.env.example') export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example') diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bbb5cd5af9..07504d2754 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -120,6 +120,9 @@ } }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": { + "react/static-components": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -155,33 +158,15 @@ } }, "web/app/account/(commonLayout)/account-page/email-change-modal.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, "ts/no-explicit-any": { "count": 5 } }, - "web/app/account/(commonLayout)/account-page/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/account/(commonLayout)/delete-account/components/feed-back.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": { "react/set-state-in-effect": { "count": 1 } }, - "web/app/account/(commonLayout)/delete-account/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/account/oauth/authorize/layout.tsx": { "ts/no-explicit-any": { "count": 1 @@ -210,13 +195,15 @@ "count": 1 } }, + "web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/app/annotation/batch-add-annotation-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 }, @@ -267,26 +254,11 @@ "count": 1 } }, - "web/app/components/app/app-access-control/specific-groups-or-members.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/app-publisher/features-wrapper.tsx": { "ts/no-explicit-any": { "count": 4 } }, - "web/app/components/app/app-publisher/index.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, - "web/app/components/app/app-publisher/version-info-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/base/var-highlight/index.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -298,9 +270,6 @@ } }, "web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -316,27 +285,11 @@ } }, "web/app/components/app/configuration/config-var/config-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 4 } }, - "web/app/components/app/configuration/config-var/config-modal/type-select.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/app/configuration/config-var/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config-var/select-var-type.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -349,11 +302,6 @@ "count": 1 } }, - "web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config/agent/agent-tools/index.tsx": { "ts/no-explicit-any": { "count": 9 @@ -371,17 +319,11 @@ } }, "web/app/components/app/configuration/config/assistant-type-picker/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } }, "web/app/components/app/configuration/config/automatic/get-automatic-res.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -409,15 +351,7 @@ "count": 1 } }, - "web/app/components/app/configuration/config/automatic/version-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/config/code-generator/get-code-generator-res.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -436,18 +370,10 @@ } }, "web/app/components/app/configuration/dataset-config/params-config/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } }, - "web/app/components/app/configuration/dataset-config/select-dataset/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/settings-modal/index.tsx": { "react/set-state-in-effect": { "count": 2 @@ -493,7 +419,7 @@ }, "web/app/components/app/configuration/debug/index.tsx": { "react/set-state-in-effect": { - "count": 2 + "count": 1 }, "ts/no-explicit-any": { "count": 11 @@ -517,26 +443,10 @@ "count": 1 } }, - "web/app/components/app/create-app-modal/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/app/create-from-dsl-modal/dsl-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/create-from-dsl-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 }, @@ -544,11 +454,6 @@ "count": 2 } }, - "web/app/components/app/duplicate-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/log/filter.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -575,27 +480,8 @@ "count": 2 } }, - "web/app/components/app/overview/customize/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/app/overview/embedded/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, - "web/app/components/app/overview/settings/index.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "react/set-state-in-effect": { - "count": 3 - }, - "regexp/no-unused-capturing-group": { + "web/app/components/app/overview/app-chart.tsx": { + "react/component-hook-factories": { "count": 1 } }, @@ -605,9 +491,6 @@ } }, "web/app/components/app/switch-app-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } @@ -622,11 +505,6 @@ "count": 2 } }, - "web/app/components/app/workflow-log/detail.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/workflow-log/filter.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -642,25 +520,6 @@ "count": 1 } }, - "web/app/components/apps/app-card.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/apps/list.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - }, - "react/unsupported-syntax": { - "count": 2 - } - }, "web/app/components/apps/new-app-card.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -766,14 +625,14 @@ "react-refresh/only-export-components": { "count": 1 }, - "react/component-hook-factories": { - "count": 1 - }, "react/no-nested-component-definitions": { "count": 1 }, "react/set-state-in-effect": { "count": 1 + }, + "react/static-components": { + "count": 2 } }, "web/app/components/base/carousel/index.tsx": { @@ -830,11 +689,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat-with-history/sidebar/rename-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/chat/chat/answer/agent-content.tsx": { "style/multiline-ternary": { "count": 2 @@ -849,18 +703,10 @@ } }, "web/app/components/base/chat/chat/answer/index.tsx": { - "react/set-state-in-effect": { - "count": 3 - }, "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/base/chat/chat/answer/operation.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { "react/set-state-in-effect": { "count": 1 @@ -943,24 +789,11 @@ "count": 10 } }, - "web/app/components/base/checkbox/index.stories.tsx": { - "no-console": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/chip/index.tsx": { "ts/no-explicit-any": { "count": 3 } }, - "web/app/components/base/content-dialog/index.stories.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 @@ -976,26 +809,11 @@ "count": 1 } }, - "web/app/components/base/dialog/index.stories.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, - "web/app/components/base/drawer-plus/index.stories.tsx": { - "react/component-hook-factories": { - "count": 1 - } - }, - "web/app/components/base/emoji-picker/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/error-boundary/index.tsx": { "react-refresh/only-export-components": { "count": 3 }, - "react/component-hook-factories": { + "react/jsx-no-key-after-spread": { "count": 1 }, "ts/no-explicit-any": { @@ -1012,16 +830,6 @@ "count": 1 } }, - "web/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/base/features/new-feature-panel/annotation-reply/config-param.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx": { "ts/no-explicit-any": { "count": 3 @@ -1048,18 +856,10 @@ } }, "web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } }, - "web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/features/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -1111,14 +911,6 @@ "count": 3 } }, - "web/app/components/base/form/components/base/base-field.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/base/form/components/base/base-form.tsx": { "ts/no-explicit-any": { "count": 6 @@ -1134,27 +926,26 @@ "count": 1 } }, - "web/app/components/base/form/components/field/variable-or-constant-input.tsx": { - "no-console": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/form/components/field/variable-selector.tsx": { "no-console": { "count": 1 } }, + "web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/base/form/form-scenarios/base/field.tsx": { - "react/component-hook-factories": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } }, + "web/app/components/base/form/form-scenarios/base/index.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/base/form/form-scenarios/base/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -1168,10 +959,12 @@ "count": 2 } }, + "web/app/components/base/form/form-scenarios/input-field/__tests__/field.spec.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/base/form/form-scenarios/input-field/field.tsx": { - "react/component-hook-factories": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -1184,10 +977,12 @@ "count": 2 } }, + "web/app/components/base/form/form-scenarios/node-panel/__tests__/field.spec.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/base/form/form-scenarios/node-panel/field.tsx": { - "react/component-hook-factories": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -1323,7 +1118,7 @@ }, "web/app/components/base/icons/src/vender/line/development/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 2 + "count": 1 } }, "web/app/components/base/icons/src/vender/line/editor/index.ts": { @@ -1348,7 +1143,7 @@ }, "web/app/components/base/icons/src/vender/line/general/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 12 + "count": 11 } }, "web/app/components/base/icons/src/vender/line/images/index.ts": { @@ -1540,18 +1335,10 @@ } }, "web/app/components/base/markdown-blocks/code-block.tsx": { - "react/set-state-in-effect": { - "count": 7 - }, "ts/no-explicit-any": { "count": 9 } }, - "web/app/components/base/markdown-blocks/form.tsx": { - "erasable-syntax-only/enums": { - "count": 3 - } - }, "web/app/components/base/markdown-blocks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 10 @@ -1602,7 +1389,7 @@ }, "web/app/components/base/mermaid/index.tsx": { "react/set-state-in-effect": { - "count": 7 + "count": 4 }, "regexp/no-super-linear-backtracking": { "count": 3 @@ -1627,32 +1414,6 @@ "count": 1 } }, - "web/app/components/base/message-log-modal/index.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/base/modal-like-wrap/index.stories.tsx": { - "no-console": { - "count": 3 - } - }, - "web/app/components/base/modal/index.stories.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, - "web/app/components/base/modal/modal.stories.tsx": { - "no-console": { - "count": 4 - }, - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/new-audio-button/index.tsx": { "ts/no-explicit-any": { "count": 1 @@ -1696,7 +1457,12 @@ }, "web/app/components/base/prompt-editor/index.tsx": { "ts/no-explicit-any": { - "count": 4 + "count": 3 + } + }, + "web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": { + "no-restricted-imports": { + "count": 1 } }, "web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": { @@ -1784,8 +1550,8 @@ } }, "web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx": { - "ts/no-explicit-any": { - "count": 2 + "no-restricted-imports": { + "count": 1 } }, "web/app/components/base/prompt-editor/plugins/update-block.tsx": { @@ -1877,26 +1643,6 @@ "count": 1 } }, - "web/app/components/base/tag-management/__tests__/panel.spec.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/base/tag-management/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/base/tag-management/tag-item-editor.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/base/tag-management/tag-remove-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/text-generation/hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -1915,11 +1661,6 @@ "count": 1 } }, - "web/app/components/base/video-gallery/VideoPlayer.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/voice-input/__tests__/index.spec.tsx": { "ts/no-explicit-any": { "count": 3 @@ -1941,15 +1682,9 @@ "web/app/components/base/with-input-validation/index.stories.tsx": { "no-console": { "count": 1 - }, - "react/component-hook-factories": { - "count": 1 } }, "web/app/components/base/with-input-validation/index.tsx": { - "react/component-hook-factories": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -1959,11 +1694,6 @@ "count": 4 } }, - "web/app/components/billing/annotation-full/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/billing/billing-page/__tests__/index.spec.tsx": { "ts/no-explicit-any": { "count": 4 @@ -1974,11 +1704,6 @@ "count": 4 } }, - "web/app/components/billing/plan/index.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/billing/pricing/assets/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 12 @@ -1992,6 +1717,11 @@ "count": 1 } }, + "web/app/components/billing/pricing/plans/self-hosted-plan-item/button.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/billing/pricing/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2002,9 +1732,9 @@ "count": 4 } }, - "web/app/components/billing/upgrade-btn/index.tsx": { - "ts/no-explicit-any": { - "count": 3 + "web/app/components/datasets/common/document-status-with-action/status-with-action.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/datasets/common/image-previewer/index.tsx": { @@ -2032,11 +1762,6 @@ "count": 1 } }, - "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/dsl-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -2045,9 +1770,6 @@ "web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/datasets/create-from-pipeline/list/template-card/details/types.ts": { @@ -2055,16 +1777,6 @@ "count": 1 } }, - "web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/file-preview/index.tsx": { "react/set-state-in-effect": { "count": 1 @@ -2119,11 +1831,6 @@ "count": 1 } }, - "web/app/components/datasets/create/stop-embedding-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/create/website/firecrawl/index.tsx": { "no-console": { "count": 1 @@ -2182,36 +1889,16 @@ "count": 4 } }, - "web/app/components/datasets/documents/components/operations.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/documents/components/rename-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx": { "erasable-syntax-only/enums": { "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": { "react/set-state-in-effect": { "count": 5 @@ -2233,6 +1920,9 @@ } }, "web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": { + "react/static-components": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -2248,6 +1938,9 @@ } }, "web/app/components/datasets/documents/create-from-pipeline/process-documents/form.tsx": { + "react/static-components": { + "count": 2 + }, "ts/no-explicit-any": { "count": 3 } @@ -2257,11 +1950,6 @@ "count": 2 } }, - "web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/steps/index.ts": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -2272,12 +1960,9 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/batch-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 1 + "web/app/components/datasets/documents/detail/batch-modal/csv-downloader.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/datasets/documents/detail/completed/common/chunk-content.tsx": { @@ -2285,26 +1970,11 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/common/regeneration-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/documents/detail/completed/components/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, "web/app/components/datasets/documents/detail/completed/components/segment-list-content.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/display-toggle.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 5 @@ -2321,9 +1991,9 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/segment-card/index.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/datasets/documents/detail/completed/segment-list.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/datasets/documents/detail/context.ts": { @@ -2359,14 +2029,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/segment-add/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": { "ts/no-explicit-any": { "count": 6 @@ -2387,21 +2049,21 @@ "count": 1 } }, + "web/app/components/datasets/formatted-text/flavours/edit-slice.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "web/app/components/datasets/formatted-text/flavours/preview-slice.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/datasets/formatted-text/flavours/type.ts": { "ts/no-empty-object-type": { "count": 1 } }, - "web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/hit-testing/components/result-item-external.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/hit-testing/components/score.tsx": { "unicorn/prefer-number-properties": { "count": 1 @@ -2412,21 +2074,11 @@ "count": 1 } }, - "web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx": { "ts/no-explicit-any": { "count": 2 } }, - "web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.ts": { "react/set-state-in-effect": { "count": 1 @@ -2440,52 +2092,24 @@ "count": 2 } }, - "web/app/components/datasets/metadata/metadata-dataset/create-content.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/datasets/metadata/types.ts": { "erasable-syntax-only/enums": { "count": 2 } }, - "web/app/components/datasets/rename-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/settings/chunk-structure/types.ts": { "erasable-syntax-only/enums": { "count": 1 } }, - "web/app/components/datasets/settings/index-method/index.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/develop/code.tsx": { + "ts/no-explicit-any": { + "count": 7 } }, - "web/app/components/develop/code.tsx": { - "ts/no-empty-object-type": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 9 + "web/app/components/develop/doc.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/develop/md.tsx": { @@ -2496,22 +2120,12 @@ "count": 2 } }, - "web/app/components/develop/secret-key/secret-key-generate.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/develop/secret-key/secret-key-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/explore/banner/banner-item.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 }, "react/set-state-in-effect": { - "count": 3 + "count": 1 } }, "web/app/components/explore/banner/indicator-button.tsx": { @@ -2522,27 +2136,11 @@ "count": 2 } }, - "web/app/components/explore/create-app-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - }, - "unicorn/prefer-number-properties": { - "count": 1 - } - }, "web/app/components/explore/item-operation/index.tsx": { "react/set-state-in-effect": { "count": 1 } }, - "web/app/components/explore/try-app/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/explore/try-app/tab.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -2622,16 +2220,6 @@ "count": 1 } }, - "web/app/components/header/account-about/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/header/account-setting/api-based-extension-page/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/card.tsx": { "ts/no-explicit-any": { "count": 2 @@ -2667,20 +2255,12 @@ "count": 1 } }, - "web/app/components/header/account-setting/members-page/invite-modal/index.tsx": { - "react/set-state-in-effect": { - "count": 3 - } - }, "web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": { "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { - "count": 3 + "count": 2 } }, "web/app/components/header/account-setting/model-provider-page/declarations.ts": { @@ -2704,12 +2284,7 @@ "count": 4 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/header/account-setting/model-provider-page/model-auth/credential-selector.tsx": { + "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": { "no-restricted-imports": { "count": 1 } @@ -2740,21 +2315,10 @@ } }, "web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 3 } }, - "web/app/components/header/account-setting/model-provider-page/model-modal/Form.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 6 - } - }, "web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx": { "unicorn/prefer-number-properties": { "count": 2 @@ -2776,16 +2340,13 @@ } }, "web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx": { "react/set-state-in-effect": { - "count": 2 + "count": 1 } }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-auth-dropdown/__tests__/use-activate-credential.spec.tsx": { @@ -2793,20 +2354,12 @@ "count": 2 } }, - "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": { "ts/no-explicit-any": { "count": 5 } }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 }, @@ -2824,14 +2377,6 @@ "count": 4 } }, - "web/app/components/header/app-nav/index.tsx": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/header/header-wrapper.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2856,9 +2401,6 @@ "erasable-syntax-only/enums": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-refresh/only-export-components": { "count": 1 } @@ -2874,30 +2416,12 @@ } }, "web/app/components/plugins/install-plugin/install-from-github/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, - "web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/plugins/install-plugin/install-from-local-package/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx": { "ts/no-explicit-any": { "count": 2 } }, - "web/app/components/plugins/install-plugin/install-from-marketplace/index.tsx": { - "no-restricted-imports": { + "web/app/components/plugins/install-plugin/install-from-github/steps/selectPackage.tsx": { + "ts/no-explicit-any": { "count": 1 } }, @@ -2911,6 +2435,11 @@ "count": 1 } }, + "web/app/components/plugins/plugin-auth/authorized/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-auth/authorized/item.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3005,11 +2534,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "ts/no-explicit-any": { "count": 2 @@ -3025,44 +2549,11 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-oauth-client-state.ts": { - "erasable-syntax-only/enums": { - "count": 2 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": { - "no-barrel-files/no-barrel-files": { - "count": 3 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/create/oauth-client.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/create/types.ts": { "erasable-syntax-only/enums": { "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/edit/manual-edit-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/edit/oauth-edit-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -3076,11 +2567,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/subscription-list/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -3091,12 +2577,7 @@ "count": 7 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/schema-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-item.tsx": { + "web/app/components/plugins/plugin-detail-panel/tool-selector/components/tool-base-form.tsx": { "no-restricted-imports": { "count": 1 } @@ -3106,29 +2587,21 @@ "count": 2 } }, + "web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "ts/no-explicit-any": { "count": 5 } }, - "web/app/components/plugins/plugin-item/action.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-item/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/plugins/plugin-mutation-model/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-page/context.ts": { "ts/no-explicit-any": { "count": 1 @@ -3144,31 +2617,11 @@ "count": 2 } }, - "web/app/components/plugins/plugin-page/plugin-info.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/readme-panel/index.tsx": { - "react/unsupported-syntax": { - "count": 1 - } - }, - "web/app/components/plugins/readme-panel/store.ts": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": { "erasable-syntax-only/enums": { "count": 2 } }, - "web/app/components/plugins/reference-setting-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/types.ts": { "erasable-syntax-only/enums": { "count": 7 @@ -3182,9 +2635,19 @@ "count": 1 } }, + "web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx": { + "react/static-components": { + "count": 4 + } + }, + "web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx": { + "react/static-components": { + "count": 4 + } + }, "web/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields.tsx": { - "react/component-hook-factories": { - "count": 1 + "react/static-components": { + "count": 2 }, "ts/no-explicit-any": { "count": 1 @@ -3195,18 +2658,20 @@ "count": 2 } }, + "web/app/components/rag-pipeline/components/panel/input-field/editor/form/index.tsx": { + "react/static-components": { + "count": 6 + } + }, "web/app/components/rag-pipeline/components/panel/input-field/editor/form/initial-fields.tsx": { - "react/component-hook-factories": { - "count": 1 + "react/static-components": { + "count": 2 }, "ts/no-explicit-any": { "count": 2 } }, "web/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings.tsx": { - "react/component-hook-factories": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -3221,9 +2686,9 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/panel/input-field/index.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": { @@ -3232,6 +2697,9 @@ } }, "web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/options.tsx": { + "react/static-components": { + "count": 2 + }, "ts/no-explicit-any": { "count": 2 } @@ -3256,11 +2724,6 @@ "count": 4 } }, - "web/app/components/rag-pipeline/components/publish-as-knowledge-pipeline-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/components/rag-pipeline-children.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3276,16 +2739,6 @@ "count": 2 } }, - "web/app/components/rag-pipeline/components/update-dsl-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/rag-pipeline/components/version-mismatch-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 9 @@ -3341,11 +2794,6 @@ "count": 1 } }, - "web/app/components/share/text-generation/info-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/share/text-generation/menu-dropdown.tsx": { "react/set-state-in-effect": { "count": 1 @@ -3361,11 +2809,24 @@ "count": 1 } }, + "web/app/components/share/text-generation/run-batch/csv-download/index.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/share/text-generation/run-batch/csv-reader/index.tsx": { + "react/static-components": { + "count": 2 + }, "ts/no-explicit-any": { "count": 2 } }, + "web/app/components/share/text-generation/run-batch/res-download/index.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/share/text-generation/run-once/index.tsx": { "react/set-state-in-effect": { "count": 1 @@ -3384,16 +2845,6 @@ "count": 2 } }, - "web/app/components/tools/edit-custom-collection-modal/config-credentials.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/tools/edit-custom-collection-modal/index.tsx": { "react/set-state-in-effect": { "count": 4 @@ -3412,24 +2863,11 @@ "count": 1 } }, - "web/app/components/tools/mcp/mcp-server-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/tools/mcp/modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/tools/mcp/provider-card.tsx": { "ts/no-explicit-any": { "count": 3 @@ -3458,6 +2896,11 @@ "count": 4 } }, + "web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/workflow-app/components/workflow-children.tsx": { "ts/no-explicit-any": { "count": 3 @@ -3488,11 +2931,6 @@ "count": 2 } }, - "web/app/components/workflow-app/hooks/use-workflow-run.ts": { - "ts/no-explicit-any": { - "count": 5 - } - }, "web/app/components/workflow-app/hooks/use-workflow-template.ts": { "ts/no-explicit-any": { "count": 2 @@ -3513,22 +2951,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/featured-tools.tsx": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, - "web/app/components/workflow/block-selector/featured-triggers.tsx": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/workflow/block-selector/hooks.ts": { "react/set-state-in-effect": { "count": 1 @@ -3539,6 +2961,11 @@ "count": 1 } }, + "web/app/components/workflow/block-selector/main.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { "react/set-state-in-effect": { "count": 1 @@ -3554,34 +2981,11 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/tabs.tsx": { + "web/app/components/workflow/block-selector/tool-picker.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/block-selector/tool/tool-list-flat-view/list.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/tool/tool.tsx": { - "react/set-state-in-effect": { - "count": 2 - } - }, - "web/app/components/workflow/block-selector/trigger-plugin/action-item.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/workflow/block-selector/trigger-plugin/item.tsx": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/types.ts": { "erasable-syntax-only/enums": { "count": 4 @@ -3620,9 +3024,9 @@ "count": 1 } }, - "web/app/components/workflow/dsl-export-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/workflow/header/__tests__/index.spec.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/workflow/header/run-mode.tsx": { @@ -3663,7 +3067,7 @@ }, "web/app/components/workflow/hooks/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 26 + "count": 25 } }, "web/app/components/workflow/hooks/use-checklist.ts": { @@ -3771,9 +3175,6 @@ } }, "web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, "ts/no-explicit-any": { "count": 6 } @@ -3799,11 +3200,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/error-handle/types.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -3818,6 +3214,9 @@ } }, "web/app/components/workflow/nodes/_base/components/input-var-type-icon.tsx": { + "react/static-components": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -3835,11 +3234,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/memory-config.tsx": { "unicorn/prefer-number-properties": { "count": 1 @@ -3875,11 +3269,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/variable/match-schema-type.ts": { "ts/no-explicit-any": { "count": 8 @@ -3900,14 +3289,17 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/variable/var-type-picker.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx": { + "react/static-components": { + "count": 2 } }, "web/app/components/workflow/nodes/_base/components/variable/variable-label/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 + }, + "react/use-memo": { + "count": 2 } }, "web/app/components/workflow/nodes/_base/components/variable/variable-label/index.tsx": { @@ -3917,7 +3309,7 @@ }, "web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx": { "react/set-state-in-effect": { - "count": 3 + "count": 2 }, "ts/no-explicit-any": { "count": 6 @@ -3995,6 +3387,9 @@ } }, "web/app/components/workflow/nodes/agent/node.tsx": { + "react/jsx-no-key-after-spread": { + "count": 2 + }, "ts/no-explicit-any": { "count": 2 } @@ -4147,16 +3542,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/http/components/authorization/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/http/components/curl-panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/http/components/key-value/key-value-edit/index.tsx": { "ts/no-explicit-any": { "count": 2 @@ -4196,9 +3581,6 @@ } }, "web/app/components/workflow/nodes/human-input/components/form-content.tsx": { - "react/component-hook-factories": { - "count": 1 - }, "react/no-nested-component-definitions": { "count": 1 }, @@ -4224,16 +3606,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/if-else/components/condition-list/condition-operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/if-else/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4249,9 +3621,9 @@ "count": 5 } }, - "web/app/components/workflow/nodes/iteration-start/index.tsx": { - "no-restricted-imports": { - "count": 1 + "web/app/components/workflow/nodes/index.tsx": { + "react/static-components": { + "count": 4 } }, "web/app/components/workflow/nodes/iteration/default.ts": { @@ -4269,21 +3641,11 @@ "count": 6 } }, - "web/app/components/workflow/nodes/knowledge-base/components/chunk-structure/selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx": { "ts/no-explicit-any": { "count": 4 } }, - "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/search-method-option.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": { "ts/no-explicit-any": { "count": 2 @@ -4312,26 +3674,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-value-method.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter/metadata-filter-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -4378,11 +3720,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx": { "ts/no-explicit-any": { "count": 3 @@ -4408,16 +3745,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/type-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -4459,26 +3786,11 @@ "count": 7 } }, - "web/app/components/workflow/nodes/loop-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/condition-list/condition-input.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/workflow/nodes/loop/components/condition-list/condition-operator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/loop/components/condition-number-input.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/loop/components/loop-variables/form-item.tsx": { "ts/no-explicit-any": { "count": 3 @@ -4523,9 +3835,6 @@ } }, "web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4535,11 +3844,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/parameter-extractor/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/parameter-extractor/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -4558,42 +3862,6 @@ "count": 9 } }, - "web/app/components/workflow/nodes/question-classifier/components/advanced-setting.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/question-classifier/components/class-item.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/question-classifier/components/class-list.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, - "react/unsupported-syntax": { - "count": 2 - } - }, - "web/app/components/workflow/nodes/question-classifier/default.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/question-classifier/node.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/question-classifier/use-config.ts": { - "react/set-state-in-effect": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/workflow/nodes/question-classifier/use-single-run-form-params.ts": { "ts/no-explicit-any": { "count": 8 @@ -4650,9 +3918,6 @@ } }, "web/app/components/workflow/nodes/tool/components/tool-form/item.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4696,9 +3961,6 @@ } }, "web/app/components/workflow/nodes/trigger-plugin/components/trigger-form/item.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -4754,11 +4016,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/trigger-webhook/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/utils.ts": { "ts/no-explicit-any": { "count": 1 @@ -4785,6 +4042,9 @@ } }, "web/app/components/workflow/note-node/note-editor/plugins/link-editor-plugin/component.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react/set-state-in-effect": { "count": 1 } @@ -4795,6 +4055,9 @@ } }, "web/app/components/workflow/operator/add-block.tsx": { + "no-restricted-imports": { + "count": 1 + }, "ts/no-explicit-any": { "count": 1 } @@ -4809,6 +4072,11 @@ "count": 1 } }, + "web/app/components/workflow/panel/__tests__/index.spec.tsx": { + "react/static-components": { + "count": 2 + } + }, "web/app/components/workflow/panel/chat-record/index.tsx": { "ts/no-explicit-any": { "count": 8 @@ -4856,9 +4124,6 @@ } }, "web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -4869,9 +4134,6 @@ } }, "web/app/components/workflow/panel/env-panel/variable-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react/set-state-in-effect": { "count": 4 }, @@ -4889,16 +4151,6 @@ "count": 4 } }, - "web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/panel/workflow-preview.tsx": { "ts/no-explicit-any": { "count": 2 @@ -4916,7 +4168,7 @@ }, "web/app/components/workflow/run/index.tsx": { "react/set-state-in-effect": { - "count": 2 + "count": 1 } }, "web/app/components/workflow/run/iteration-log/index.tsx": { @@ -5034,9 +4286,6 @@ } }, "web/app/components/workflow/update-dsl-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -5102,9 +4351,6 @@ } }, "web/app/components/workflow/variable-inspect/listening.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -5143,46 +4389,21 @@ "count": 5 } }, - "web/app/components/workflow/workflow-preview/components/nodes/base.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/workflow-preview/components/nodes/constants.ts": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/workflow/workflow-preview/components/nodes/iteration-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/workflow-preview/components/nodes/loop-start/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/workflow-preview/components/zoom-in-out.tsx": { "erasable-syntax-only/enums": { "count": 1 } }, - "web/app/education-apply/expire-notice-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/education-apply/hooks.ts": { "react/set-state-in-effect": { "count": 5 } }, - "web/app/education-apply/verify-state-modal.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/forgot-password/ForgotPasswordForm.spec.tsx": { "ts/no-explicit-any": { "count": 5 @@ -5218,11 +4439,6 @@ "count": 1 } }, - "web/app/signin/one-more-step.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/signup/layout.tsx": { "ts/no-explicit-any": { "count": 1 @@ -5272,9 +4488,6 @@ } }, "web/hooks/use-mitt.ts": { - "react/component-hook-factories": { - "count": 1 - }, "ts/no-explicit-any": { "count": 2 } @@ -5404,11 +4617,6 @@ "count": 1 } }, - "web/plugins/dev-proxy/server.spec.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/scripts/component-analyzer.js": { "regexp/no-unused-capturing-group": { "count": 6 @@ -5462,11 +4670,6 @@ "count": 2 } }, - "web/service/knowledge/use-dataset.ts": { - "@tanstack/query/exhaustive-deps": { - "count": 1 - } - }, "web/service/share.ts": { "erasable-syntax-only/enums": { "count": 1 @@ -5480,11 +4683,6 @@ "count": 2 } }, - "web/service/use-apps.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/service/use-common.ts": { "ts/no-empty-object-type": { "count": 1 @@ -5522,9 +4720,6 @@ }, "ts/no-explicit-any": { "count": 3 - }, - "ts/no-non-null-asserted-optional-chain": { - "count": 1 } }, "web/service/use-tools.ts": { @@ -5603,11 +4798,6 @@ "count": 1 } }, - "web/utils/context.ts": { - "react/component-hook-factories": { - "count": 1 - } - }, "web/utils/error-parser.ts": { "no-console": { "count": 1 diff --git a/eslint.config.mjs b/eslint.config.mjs index ae9fdaff01..1380ed67d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,17 @@ import antfu, { GLOB_MARKDOWN } from '@antfu/eslint-config' import md from 'eslint-markdown' import markdownPreferences from 'eslint-plugin-markdown-preferences' +const GENERATED_IGNORES = [ + '**/storybook-static/', + '**/.next/', + 'web/next/', + 'web/next-env.d.ts', + '**/dist/', + '**/coverage/', + 'e2e/.auth/', + 'e2e/cucumber-report/', +] + export default antfu( { ignores: original => [ @@ -15,6 +26,7 @@ export default antfu( '!package.json', '!pnpm-workspace.yaml', '!vite.config.ts', + ...GENERATED_IGNORES, ...original, ], typescript: { diff --git a/package.json b/package.json index a563b574f7..e3fcd40ab1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "dify", "type": "module", "private": true, - "packageManager": "pnpm@11.0.0", + "packageManager": "pnpm@11.1.1", "engines": { "node": "^22.22.1" }, diff --git a/packages/contracts/README.md b/packages/contracts/README.md new file mode 100644 index 0000000000..e9320c921d --- /dev/null +++ b/packages/contracts/README.md @@ -0,0 +1,40 @@ +# Contracts + +## API OpenAPI Readiness + + + + + +Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`. + +Are we OpenAPI ready? **No.** Current generated API contracts are **16.7% ready**. + +| Surface | Ready | Not ready | Total | Ready % | +| --------- | ------: | --------: | ------: | --------: | +| console | 96 | 474 | 570 | 16.8% | +| service | 16 | 72 | 88 | 18.2% | +| web | 5 | 36 | 41 | 12.2% | +| **total** | **117** | **582** | **699** | **16.7%** | + +Readiness here means the generated contract operation is not marked with: + +> Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + +Operations marked with that warning should not be migrated to blindly. Prefer fixing backend OpenAPI annotations first so the generated contract has accurate request and response types, then migrate callers endpoint by endpoint. + +The current heuristic marks an operation as not ready when a request body or success response that should have a body contains a loose object type, or when an operation has no documented 2xx response. 204, 205, and 304 responses are treated as bodyless when the request type is otherwise accurate. + + + +## How to Improve Readiness + +Improve the ready percentage by fixing the backend annotations that produce loose generated types, then regenerating the contracts. + +- Add accurate request body schemas for endpoints that currently generate loose object types. +- Add accurate 2xx response schemas for endpoints that return JSON payloads. +- Use 204 responses for endpoints that intentionally return no body. +- Avoid untyped dictionaries, raw objects, or `additionalProperties: true` responses unless the API really returns an arbitrary object. +- Regenerate with `pnpm -C packages/contracts gen-api-contract` and use this README to verify the updated percentage. + +Do not remove the generated warning just to increase the number. The warning should disappear because the backend OpenAPI output became accurate enough for callers to migrate safely. diff --git a/packages/contracts/generated/api/console/account/orpc.gen.ts b/packages/contracts/generated/api/console/account/orpc.gen.ts index a926103667..7308733a12 100644 --- a/packages/contracts/generated/api/console/account/orpc.gen.ts +++ b/packages/contracts/generated/api/console/account/orpc.gen.ts @@ -45,10 +45,16 @@ import { /** * Get account avatar url + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get account avatar url', + deprecated: true, + description: + 'Get account avatar url\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAccountAvatar', @@ -74,8 +80,16 @@ export const avatar = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountChangeEmailCheckEmailUnique', @@ -104,8 +118,16 @@ export const reset = { post: post3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountChangeEmailValidity', @@ -119,8 +141,16 @@ export const validity = { post: post4, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountChangeEmail', @@ -137,8 +167,16 @@ export const changeEmail = { validity, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountDeleteFeedback', @@ -152,8 +190,16 @@ export const feedback = { post: post6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAccountDeleteVerify', @@ -166,8 +212,16 @@ export const verify = { get: get2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountDelete', @@ -222,8 +276,16 @@ export const get5 = oc }) .output(zGetAccountEducationResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountEducation', @@ -240,8 +302,16 @@ export const education = { verify: verify2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAccountInit', diff --git a/packages/contracts/generated/api/console/account/types.gen.ts b/packages/contracts/generated/api/console/account/types.gen.ts index 9021d4c8fb..6ccac5f94a 100644 --- a/packages/contracts/generated/api/console/account/types.gen.ts +++ b/packages/contracts/generated/api/console/account/types.gen.ts @@ -92,7 +92,7 @@ export type AccountInterfaceLanguagePayload = { } export type AccountInterfaceThemePayload = { - interface_theme: 'light' | 'dark' + interface_theme: 'dark' | 'light' } export type AccountNamePayload = { diff --git a/packages/contracts/generated/api/console/account/zod.gen.ts b/packages/contracts/generated/api/console/account/zod.gen.ts index befa7c27c6..18afef9541 100644 --- a/packages/contracts/generated/api/console/account/zod.gen.ts +++ b/packages/contracts/generated/api/console/account/zod.gen.ts @@ -131,7 +131,7 @@ export const zAccountInterfaceLanguagePayload = z.object({ * AccountInterfaceThemePayload */ export const zAccountInterfaceThemePayload = z.object({ - interface_theme: z.enum(['light', 'dark']), + interface_theme: z.enum(['dark', 'light']), }) /** diff --git a/packages/contracts/generated/api/console/activate/orpc.gen.ts b/packages/contracts/generated/api/console/activate/orpc.gen.ts index 870f45bd2e..3e74dd841b 100644 --- a/packages/contracts/generated/api/console/activate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/activate/orpc.gen.ts @@ -12,10 +12,16 @@ import { /** * Check if activation token is valid + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Check if activation token is valid', + deprecated: true, + description: + 'Check if activation token is valid\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getActivateCheck', diff --git a/packages/contracts/generated/api/console/admin/orpc.gen.ts b/packages/contracts/generated/api/console/admin/orpc.gen.ts deleted file mode 100644 index 93f012405a..0000000000 --- a/packages/contracts/generated/api/console/admin/orpc.gen.ts +++ /dev/null @@ -1,153 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { oc } from '@orpc/contract' -import * as z from 'zod' - -import { - zDeleteAdminDeleteExploreBannerByBannerIdPath, - zDeleteAdminDeleteExploreBannerByBannerIdResponse, - zDeleteAdminInsertExploreAppsByAppIdPath, - zDeleteAdminInsertExploreAppsByAppIdResponse, - zPostAdminBatchAddNotificationAccountsResponse, - zPostAdminInsertExploreAppsBody, - zPostAdminInsertExploreAppsResponse, - zPostAdminInsertExploreBannerBody, - zPostAdminInsertExploreBannerResponse, - zPostAdminUpsertNotificationBody, - zPostAdminUpsertNotificationResponse, -} from './zod.gen' - -/** - * Register target accounts for a notification by email address. JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) plus a 'notification_id' field. Emails that do not match any account are silently skipped. - */ -export const post = oc - .route({ - description: - 'Register target accounts for a notification by email address. JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. File upload: multipart/form-data with a \'file\' field (CSV or TXT, one email per line) plus a \'notification_id\' field. Emails that do not match any account are silently skipped.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAdminBatchAddNotificationAccounts', - path: '/admin/batch_add_notification_accounts', - tags: ['console'], - }) - .output(zPostAdminBatchAddNotificationAccountsResponse) - -export const batchAddNotificationAccounts = { - post, -} - -/** - * Delete an explore banner - */ -export const delete_ = oc - .route({ - description: 'Delete an explore banner', - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAdminDeleteExploreBannerByBannerId', - path: '/admin/delete-explore-banner/{banner_id}', - successStatus: 204, - tags: ['console'], - }) - .input(z.object({ params: zDeleteAdminDeleteExploreBannerByBannerIdPath })) - .output(zDeleteAdminDeleteExploreBannerByBannerIdResponse) - -export const byBannerId = { - delete: delete_, -} - -export const deleteExploreBanner = { - byBannerId, -} - -/** - * Remove an app from the explore list - */ -export const delete2 = oc - .route({ - description: 'Remove an app from the explore list', - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteAdminInsertExploreAppsByAppId', - path: '/admin/insert-explore-apps/{app_id}', - successStatus: 204, - tags: ['console'], - }) - .input(z.object({ params: zDeleteAdminInsertExploreAppsByAppIdPath })) - .output(zDeleteAdminInsertExploreAppsByAppIdResponse) - -export const byAppId = { - delete: delete2, -} - -/** - * Insert or update an app in the explore list - */ -export const post2 = oc - .route({ - description: 'Insert or update an app in the explore list', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAdminInsertExploreApps', - path: '/admin/insert-explore-apps', - tags: ['console'], - }) - .input(z.object({ body: zPostAdminInsertExploreAppsBody })) - .output(zPostAdminInsertExploreAppsResponse) - -export const insertExploreApps = { - post: post2, - byAppId, -} - -/** - * Insert an explore banner - */ -export const post3 = oc - .route({ - description: 'Insert an explore banner', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAdminInsertExploreBanner', - path: '/admin/insert-explore-banner', - successStatus: 201, - tags: ['console'], - }) - .input(z.object({ body: zPostAdminInsertExploreBannerBody })) - .output(zPostAdminInsertExploreBannerResponse) - -export const insertExploreBanner = { - post: post3, -} - -/** - * Create or update an in-product notification. Supply notification_id to update an existing one; omit it to create a new one. Pass at least one language variant in contents (zh / en / jp). - */ -export const post4 = oc - .route({ - description: - 'Create or update an in-product notification. Supply notification_id to update an existing one; omit it to create a new one. Pass at least one language variant in contents (zh / en / jp).', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAdminUpsertNotification', - path: '/admin/upsert_notification', - tags: ['console'], - }) - .input(z.object({ body: zPostAdminUpsertNotificationBody })) - .output(zPostAdminUpsertNotificationResponse) - -export const upsertNotification = { - post: post4, -} - -export const admin = { - batchAddNotificationAccounts, - deleteExploreBanner, - insertExploreApps, - insertExploreBanner, - upsertNotification, -} - -export const contract = { - admin, -} diff --git a/packages/contracts/generated/api/console/admin/types.gen.ts b/packages/contracts/generated/api/console/admin/types.gen.ts deleted file mode 100644 index d3fef01791..0000000000 --- a/packages/contracts/generated/api/console/admin/types.gen.ts +++ /dev/null @@ -1,157 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}/console/api` | (string & {}) -} - -export type InsertExploreAppPayload = { - app_id: string - can_trial?: boolean - category: string - copyright?: string | null - custom_disclaimer?: string | null - desc?: string | null - language: string - position: number - privacy_policy?: string | null - trial_limit?: number -} - -export type InsertExploreBannerPayload = { - 'category': string - 'description': string - 'img-src': string - 'language'?: string - 'link': string - 'sort': number - 'title': string -} - -export type UpsertNotificationPayload = { - contents: Array - end_time?: string | null - frequency?: string - notification_id?: string | null - start_time?: string | null - status?: string -} - -export type LangContentPayload = { - body: string - lang: string - subtitle?: string | null - title: string - title_pic_url?: string | null -} - -export type PostAdminBatchAddNotificationAccountsData = { - body?: never - path?: never - query?: never - url: '/admin/batch_add_notification_accounts' -} - -export type PostAdminBatchAddNotificationAccountsResponses = { - 200: { - [key: string]: unknown - } -} - -export type PostAdminBatchAddNotificationAccountsResponse - = PostAdminBatchAddNotificationAccountsResponses[keyof PostAdminBatchAddNotificationAccountsResponses] - -export type DeleteAdminDeleteExploreBannerByBannerIdData = { - body?: never - path: { - banner_id: string - } - query?: never - url: '/admin/delete-explore-banner/{banner_id}' -} - -export type DeleteAdminDeleteExploreBannerByBannerIdResponses = { - 204: { - [key: string]: unknown - } -} - -export type DeleteAdminDeleteExploreBannerByBannerIdResponse - = DeleteAdminDeleteExploreBannerByBannerIdResponses[keyof DeleteAdminDeleteExploreBannerByBannerIdResponses] - -export type PostAdminInsertExploreAppsData = { - body: InsertExploreAppPayload - path?: never - query?: never - url: '/admin/insert-explore-apps' -} - -export type PostAdminInsertExploreAppsErrors = { - 404: { - [key: string]: unknown - } -} - -export type PostAdminInsertExploreAppsError - = PostAdminInsertExploreAppsErrors[keyof PostAdminInsertExploreAppsErrors] - -export type PostAdminInsertExploreAppsResponses = { - 200: { - [key: string]: unknown - } - 201: { - [key: string]: unknown - } -} - -export type PostAdminInsertExploreAppsResponse - = PostAdminInsertExploreAppsResponses[keyof PostAdminInsertExploreAppsResponses] - -export type DeleteAdminInsertExploreAppsByAppIdData = { - body?: never - path: { - app_id: string - } - query?: never - url: '/admin/insert-explore-apps/{app_id}' -} - -export type DeleteAdminInsertExploreAppsByAppIdResponses = { - 204: { - [key: string]: unknown - } -} - -export type DeleteAdminInsertExploreAppsByAppIdResponse - = DeleteAdminInsertExploreAppsByAppIdResponses[keyof DeleteAdminInsertExploreAppsByAppIdResponses] - -export type PostAdminInsertExploreBannerData = { - body: InsertExploreBannerPayload - path?: never - query?: never - url: '/admin/insert-explore-banner' -} - -export type PostAdminInsertExploreBannerResponses = { - 201: { - [key: string]: unknown - } -} - -export type PostAdminInsertExploreBannerResponse - = PostAdminInsertExploreBannerResponses[keyof PostAdminInsertExploreBannerResponses] - -export type PostAdminUpsertNotificationData = { - body: UpsertNotificationPayload - path?: never - query?: never - url: '/admin/upsert_notification' -} - -export type PostAdminUpsertNotificationResponses = { - 200: { - [key: string]: unknown - } -} - -export type PostAdminUpsertNotificationResponse - = PostAdminUpsertNotificationResponses[keyof PostAdminUpsertNotificationResponses] diff --git a/packages/contracts/generated/api/console/admin/zod.gen.ts b/packages/contracts/generated/api/console/admin/zod.gen.ts deleted file mode 100644 index 9ebed93e1e..0000000000 --- a/packages/contracts/generated/api/console/admin/zod.gen.ts +++ /dev/null @@ -1,99 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import * as z from 'zod' - -/** - * InsertExploreAppPayload - */ -export const zInsertExploreAppPayload = z.object({ - app_id: z.string(), - can_trial: z.boolean().optional().default(false), - category: z.string(), - copyright: z.string().nullish(), - custom_disclaimer: z.string().nullish(), - desc: z.string().nullish(), - language: z.string(), - position: z.int(), - privacy_policy: z.string().nullish(), - trial_limit: z.int().optional().default(0), -}) - -/** - * InsertExploreBannerPayload - */ -export const zInsertExploreBannerPayload = z.object({ - 'category': z.string(), - 'description': z.string(), - 'img-src': z.string(), - 'language': z.string().optional().default('en-US'), - 'link': z.string(), - 'sort': z.int(), - 'title': z.string(), -}) - -/** - * LangContentPayload - */ -export const zLangContentPayload = z.object({ - body: z.string(), - lang: z.string(), - subtitle: z.string().nullish(), - title: z.string(), - title_pic_url: z.string().nullish(), -}) - -/** - * UpsertNotificationPayload - */ -export const zUpsertNotificationPayload = z.object({ - contents: z.array(zLangContentPayload).min(1), - end_time: z.string().nullish(), - frequency: z.string().optional().default('once'), - notification_id: z.string().nullish(), - start_time: z.string().nullish(), - status: z.string().optional().default('active'), -}) - -/** - * Accounts added successfully - */ -export const zPostAdminBatchAddNotificationAccountsResponse = z.record(z.string(), z.unknown()) - -export const zDeleteAdminDeleteExploreBannerByBannerIdPath = z.object({ - banner_id: z.string(), -}) - -/** - * Banner deleted successfully - */ -export const zDeleteAdminDeleteExploreBannerByBannerIdResponse = z.record(z.string(), z.unknown()) - -export const zPostAdminInsertExploreAppsBody = zInsertExploreAppPayload - -export const zPostAdminInsertExploreAppsResponse = z.union([ - z.record(z.string(), z.unknown()), - z.record(z.string(), z.unknown()), -]) - -export const zDeleteAdminInsertExploreAppsByAppIdPath = z.object({ - app_id: z.string(), -}) - -/** - * App removed successfully - */ -export const zDeleteAdminInsertExploreAppsByAppIdResponse = z.record(z.string(), z.unknown()) - -export const zPostAdminInsertExploreBannerBody = zInsertExploreBannerPayload - -/** - * Banner inserted successfully - */ -export const zPostAdminInsertExploreBannerResponse = z.record(z.string(), z.unknown()) - -export const zPostAdminUpsertNotificationBody = zUpsertNotificationPayload - -/** - * Notification upserted successfully - */ -export const zPostAdminUpsertNotificationResponse = z.record(z.string(), z.unknown()) diff --git a/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts index 91ccdbc408..52ee85667e 100644 --- a/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/all-workspaces/orpc.gen.ts @@ -5,8 +5,16 @@ import * as z from 'zod' import { zGetAllWorkspacesQuery, zGetAllWorkspacesResponse } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAllWorkspaces', diff --git a/packages/contracts/generated/api/console/api-based-extension/types.gen.ts b/packages/contracts/generated/api/console/api-based-extension/types.gen.ts index f7b12f5b3f..1b460d2166 100644 --- a/packages/contracts/generated/api/console/api-based-extension/types.gen.ts +++ b/packages/contracts/generated/api/console/api-based-extension/types.gen.ts @@ -59,7 +59,7 @@ export type DeleteApiBasedExtensionByIdData = { export type DeleteApiBasedExtensionByIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } diff --git a/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts b/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts index 43f38a5214..dd7fa0c51d 100644 --- a/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts +++ b/packages/contracts/generated/api/console/api-based-extension/zod.gen.ts @@ -43,7 +43,7 @@ export const zDeleteApiBasedExtensionByIdPath = z.object({ /** * Extension deleted successfully */ -export const zDeleteApiBasedExtensionByIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteApiBasedExtensionByIdResponse = z.record(z.string(), z.never()) export const zGetApiBasedExtensionByIdPath = z.object({ id: z.string(), diff --git a/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts b/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts index a113e39c15..48f5f69624 100644 --- a/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/orpc.gen.ts @@ -11,8 +11,16 @@ import { zPostApiKeyAuthDataSourceBindingResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postApiKeyAuthDataSourceBinding', @@ -26,8 +34,16 @@ export const binding = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete_ = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteApiKeyAuthDataSourceByBindingId', @@ -41,8 +57,16 @@ export const byBindingId = { delete: delete_, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getApiKeyAuthDataSource', diff --git a/packages/contracts/generated/api/console/app/orpc.gen.ts b/packages/contracts/generated/api/console/app/orpc.gen.ts index 7ccb933866..1a2a10f23c 100644 --- a/packages/contracts/generated/api/console/app/orpc.gen.ts +++ b/packages/contracts/generated/api/console/app/orpc.gen.ts @@ -7,10 +7,16 @@ import { zGetAppPromptTemplatesQuery, zGetAppPromptTemplatesResponse } from './z /** * Get advanced prompt templates based on app mode and model configuration + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get advanced prompt templates based on app mode and model configuration', + deprecated: true, + description: + 'Get advanced prompt templates based on app mode and model configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppPromptTemplates', diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 069976904d..a1a0faaefd 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -194,8 +194,6 @@ import { zGetAppsImportsByAppIdCheckDependenciesResponse, zGetAppsQuery, zGetAppsResponse, - zGetAppsWorkflowsOnlineUsersQuery, - zGetAppsWorkflowsOnlineUsersResponse, zPatchAppsByAppIdTraceConfigBody, zPatchAppsByAppIdTraceConfigPath, zPatchAppsByAppIdTraceConfigResponse, @@ -353,6 +351,8 @@ import { zPostAppsImportsByImportIdConfirmResponse, zPostAppsImportsResponse, zPostAppsResponse, + zPostAppsWorkflowsOnlineUsersBody, + zPostAppsWorkflowsOnlineUsersResponse, zPutAppsByAppIdBody, zPutAppsByAppIdPath, zPutAppsByAppIdResponse, @@ -427,20 +427,20 @@ export const imports = { /** * Get workflow online users */ -export const get2 = oc +export const post3 = oc .route({ description: 'Get workflow online users', inputStructure: 'detailed', - method: 'GET', - operationId: 'getAppsWorkflowsOnlineUsers', + method: 'POST', + operationId: 'postAppsWorkflowsOnlineUsers', path: '/apps/workflows/online-users', tags: ['console'], }) - .input(z.object({ query: zGetAppsWorkflowsOnlineUsersQuery })) - .output(zGetAppsWorkflowsOnlineUsersResponse) + .input(z.object({ body: zPostAppsWorkflowsOnlineUsersBody })) + .output(zPostAppsWorkflowsOnlineUsersResponse) export const onlineUsers = { - get: get2, + post: post3, } export const workflows = { @@ -452,7 +452,7 @@ export const workflows = { * * Get advanced chat workflow runs count statistics */ -export const get3 = oc +export const get2 = oc .route({ description: 'Get advanced chat workflow runs count statistics', inputStructure: 'detailed', @@ -471,7 +471,7 @@ export const get3 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse) export const count = { - get: get3, + get: get2, } /** @@ -479,7 +479,7 @@ export const count = { * * Get advanced chat workflow run list */ -export const get4 = oc +export const get3 = oc .route({ description: 'Get advanced chat workflow run list', inputStructure: 'detailed', @@ -498,7 +498,7 @@ export const get4 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsResponse) export const workflowRuns = { - get: get4, + get: get3, count, } @@ -506,10 +506,16 @@ export const workflowRuns = { * Preview human input form content and placeholders * * Get human input form preview for advanced chat workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post3 = oc +export const post4 = oc .route({ - description: 'Get human input form preview for advanced chat workflow', + deprecated: true, + description: + 'Get human input form preview for advanced chat workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreview', @@ -526,17 +532,23 @@ export const post3 = oc .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview = { - post: post3, + post: post4, } /** * Submit human input form preview * * Submit human input form preview for advanced chat workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post4 = oc +export const post5 = oc .route({ - description: 'Submit human input form preview for advanced chat workflow', + deprecated: true, + description: + 'Submit human input form preview for advanced chat workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRun', @@ -553,7 +565,7 @@ export const post4 = oc .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run = { - post: post4, + post: post5, } export const form = { @@ -577,10 +589,16 @@ export const humanInput = { * Run draft workflow iteration node * * Run draft workflow iteration node for advanced chat + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post5 = oc +export const post6 = oc .route({ - description: 'Run draft workflow iteration node for advanced chat', + deprecated: true, + description: + 'Run draft workflow iteration node for advanced chat\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun', @@ -597,7 +615,7 @@ export const post5 = oc .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run2 = { - post: post5, + post: post6, } export const byNodeId2 = { @@ -616,10 +634,16 @@ export const iteration = { * Run draft workflow loop node * * Run draft workflow loop node for advanced chat + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post6 = oc +export const post7 = oc .route({ - description: 'Run draft workflow loop node for advanced chat', + deprecated: true, + description: + 'Run draft workflow loop node for advanced chat\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRun', @@ -636,7 +660,7 @@ export const post6 = oc .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run3 = { - post: post6, + post: post7, } export const byNodeId3 = { @@ -655,10 +679,16 @@ export const loop = { * Run draft workflow * * Run draft workflow for advanced chat application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post7 = oc +export const post8 = oc .route({ - description: 'Run draft workflow for advanced chat application', + deprecated: true, + description: + 'Run draft workflow for advanced chat application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAdvancedChatWorkflowsDraftRun', @@ -675,7 +705,7 @@ export const post7 = oc .output(zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse) export const run4 = { - post: post7, + post: post8, } export const draft = { @@ -698,10 +728,16 @@ export const advancedChat = { * Get agent logs * * Get agent execution logs for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get5 = oc +export const get4 = oc .route({ - description: 'Get agent execution logs for an application', + deprecated: true, + description: + 'Get agent execution logs for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAgentLogs', @@ -713,7 +749,7 @@ export const get5 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get5, + get: get4, } export const agent = { @@ -722,10 +758,16 @@ export const agent = { /** * Get status of annotation reply action job + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get6 = oc +export const get5 = oc .route({ - description: 'Get status of annotation reply action job', + deprecated: true, + description: + 'Get status of annotation reply action job\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotationReplyByActionStatusByJobId', @@ -736,7 +778,7 @@ export const get6 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get6, + get: get5, } export const status = { @@ -745,10 +787,16 @@ export const status = { /** * Enable or disable annotation reply for an app + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post8 = oc +export const post9 = oc .route({ - description: 'Enable or disable annotation reply for an app', + deprecated: true, + description: + 'Enable or disable annotation reply for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationReplyByAction', @@ -764,7 +812,7 @@ export const post8 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post8, + post: post9, status, } @@ -774,10 +822,16 @@ export const annotationReply = { /** * Get annotation settings for an app + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get7 = oc +export const get6 = oc .route({ - description: 'Get annotation settings for an app', + deprecated: true, + description: + 'Get annotation settings for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotationSetting', @@ -788,15 +842,21 @@ export const get7 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get7, + get: get6, } /** * Update annotation settings for an app + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post9 = oc +export const post10 = oc .route({ - description: 'Update annotation settings for an app', + deprecated: true, + description: + 'Update annotation settings for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationSettingsByAnnotationSettingId', @@ -812,7 +872,7 @@ export const post9 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post9, + post: post10, } export const annotationSettings = { @@ -821,10 +881,16 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post10 = oc +export const post11 = oc .route({ - description: 'Batch import annotations from CSV file with rate limiting and security checks', + deprecated: true, + description: + 'Batch import annotations from CSV file with rate limiting and security checks\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationsBatchImport', @@ -835,15 +901,21 @@ export const post10 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post10, + post: post11, } /** * Get status of batch import job + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get8 = oc +export const get7 = oc .route({ - description: 'Get status of batch import job', + deprecated: true, + description: + 'Get status of batch import job\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotationsBatchImportStatusByJobId', @@ -854,7 +926,7 @@ export const get8 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get8, + get: get7, } export const batchImportStatus = { @@ -864,7 +936,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get9 = oc +export const get8 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -877,13 +949,13 @@ export const get9 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get9, + get: get8, } /** * Export all annotations for an app with CSV injection protection */ -export const get10 = oc +export const get9 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -896,13 +968,13 @@ export const get10 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get10, + get: get9, } /** * Get hit histories for an annotation */ -export const get11 = oc +export const get10 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -920,11 +992,19 @@ export const get11 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get11, + get: get10, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete_ = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdAnnotationsByAnnotationId', @@ -936,10 +1016,16 @@ export const delete_ = oc /** * Update or delete an annotation + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post11 = oc +export const post12 = oc .route({ - description: 'Update or delete an annotation', + deprecated: true, + description: + 'Update or delete an annotation\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotationsByAnnotationId', @@ -956,12 +1042,20 @@ export const post11 = oc export const byAnnotationId = { delete: delete_, - post: post11, + post: post12, hitHistories, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdAnnotations', @@ -973,10 +1067,16 @@ export const delete2 = oc /** * Get annotations for an app with pagination + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get12 = oc +export const get11 = oc .route({ - description: 'Get annotations for an app with pagination', + deprecated: true, + description: + 'Get annotations for an app with pagination\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdAnnotations', @@ -993,10 +1093,16 @@ export const get12 = oc /** * Create a new annotation for an app + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post12 = oc +export const post13 = oc .route({ - description: 'Create a new annotation for an app', + deprecated: true, + description: + 'Create a new annotation for an app\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAnnotations', @@ -1011,8 +1117,8 @@ export const post12 = oc export const annotations = { delete: delete2, - get: get12, - post: post12, + get: get11, + post: post13, batchImport, batchImportStatus, count: count2, @@ -1022,10 +1128,16 @@ export const annotations = { /** * Enable or disable app API + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post13 = oc +export const post14 = oc .route({ - description: 'Enable or disable app API', + deprecated: true, + description: + 'Enable or disable app API\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdApiEnable', @@ -1036,13 +1148,13 @@ export const post13 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post13, + post: post14, } /** * Transcript audio to text for chat messages */ -export const post14 = oc +export const post15 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1055,7 +1167,7 @@ export const post14 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post14, + post: post15, } /** @@ -1076,10 +1188,16 @@ export const delete3 = oc /** * Get chat conversation details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get13 = oc +export const get12 = oc .route({ - description: 'Get chat conversation details', + deprecated: true, + description: + 'Get chat conversation details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdChatConversationsByConversationId', @@ -1091,13 +1209,13 @@ export const get13 = oc export const byConversationId = { delete: delete3, - get: get13, + get: get12, } /** * Get chat conversations with pagination, filtering and summary */ -export const get14 = oc +export const get13 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1115,14 +1233,14 @@ export const get14 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get14, + get: get13, byConversationId, } /** * Get suggested questions for a message */ -export const get15 = oc +export const get14 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1135,7 +1253,7 @@ export const get15 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get15, + get: get14, } export const byMessageId = { @@ -1144,10 +1262,16 @@ export const byMessageId = { /** * Stop a running chat message generation + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post15 = oc +export const post16 = oc .route({ - description: 'Stop a running chat message generation', + deprecated: true, + description: + 'Stop a running chat message generation\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdChatMessagesByTaskIdStop', @@ -1158,7 +1282,7 @@ export const post15 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post15, + post: post16, } export const byTaskId = { @@ -1167,10 +1291,16 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get16 = oc +export const get15 = oc .route({ - description: 'Get chat messages for a conversation with pagination', + deprecated: true, + description: + 'Get chat messages for a conversation with pagination\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdChatMessages', @@ -1183,7 +1313,7 @@ export const get16 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get16, + get: get15, byMessageId, byTaskId, } @@ -1206,10 +1336,16 @@ export const delete4 = oc /** * Get completion conversation details with messages + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get17 = oc +export const get16 = oc .route({ - description: 'Get completion conversation details with messages', + deprecated: true, + description: + 'Get completion conversation details with messages\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdCompletionConversationsByConversationId', @@ -1221,13 +1357,13 @@ export const get17 = oc export const byConversationId2 = { delete: delete4, - get: get17, + get: get16, } /** * Get completion conversations with pagination and filtering */ -export const get18 = oc +export const get17 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1245,16 +1381,22 @@ export const get18 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get18, + get: get17, byConversationId: byConversationId2, } /** * Stop a running completion message generation + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post16 = oc +export const post17 = oc .route({ - description: 'Stop a running completion message generation', + deprecated: true, + description: + 'Stop a running completion message generation\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdCompletionMessagesByTaskIdStop', @@ -1265,7 +1407,7 @@ export const post16 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post16, + post: post17, } export const byTaskId2 = { @@ -1274,10 +1416,16 @@ export const byTaskId2 = { /** * Generate completion message for debugging + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post17 = oc +export const post18 = oc .route({ - description: 'Generate completion message for debugging', + deprecated: true, + description: + 'Generate completion message for debugging\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdCompletionMessages', @@ -1293,14 +1441,14 @@ export const post17 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post17, + post: post18, byTaskId: byTaskId2, } /** * Get conversation variables for an application */ -export const get19 = oc +export const get18 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -1318,7 +1466,7 @@ export const get19 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get19, + get: get18, } /** @@ -1327,11 +1475,16 @@ export const conversationVariables = { * Convert application to workflow mode * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post18 = oc +export const post19 = oc .route({ + deprecated: true, description: - 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', + 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdConvertToWorkflow', @@ -1348,17 +1501,23 @@ export const post18 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post18, + post: post19, } /** * Copy app * * Create a copy of an existing application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post19 = oc +export const post20 = oc .route({ - description: 'Create a copy of an existing application', + deprecated: true, + description: + 'Create a copy of an existing application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdCopy', @@ -1371,7 +1530,7 @@ export const post19 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post19, + post: post20, } /** @@ -1379,7 +1538,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get20 = oc +export const get19 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -1395,15 +1554,21 @@ export const get20 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get20, + get: get19, } /** * Export user feedback data for Google Sheets + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get21 = oc +export const get20 = oc .route({ - description: 'Export user feedback data for Google Sheets', + deprecated: true, + description: + 'Export user feedback data for Google Sheets\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdFeedbacksExport', @@ -1419,15 +1584,21 @@ export const get21 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get21, + get: get20, } /** * Create or update message feedback (like/dislike) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post20 = oc +export const post21 = oc .route({ - description: 'Create or update message feedback (like/dislike)', + deprecated: true, + description: + 'Create or update message feedback (like/dislike)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdFeedbacks', @@ -1438,16 +1609,22 @@ export const post20 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post20, + post: post21, export: export3, } /** * Update application icon + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post21 = oc +export const post22 = oc .route({ - description: 'Update application icon', + deprecated: true, + description: + 'Update application icon\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdIcon', @@ -1458,15 +1635,21 @@ export const post21 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post21, + post: post22, } /** * Get message details by ID + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get22 = oc +export const get21 = oc .route({ - description: 'Get message details by ID', + deprecated: true, + description: + 'Get message details by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdMessagesByMessageId', @@ -1477,7 +1660,7 @@ export const get22 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get22, + get: get21, } export const messages = { @@ -1488,10 +1671,16 @@ export const messages = { * Modify app model config * * Update application model configuration + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post22 = oc +export const post23 = oc .route({ - description: 'Update application model configuration', + deprecated: true, + description: + 'Update application model configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdModelConfig', @@ -1505,15 +1694,21 @@ export const post22 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post22, + post: post23, } /** * Check if app name is available + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post23 = oc +export const post24 = oc .route({ - description: 'Check if app name is available', + deprecated: true, + description: + 'Check if app name is available\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdName', @@ -1524,14 +1719,21 @@ export const post23 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post23, + post: post24, } /** * Publish app to Creators Platform + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post24 = oc +export const post25 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdPublishToCreatorsPlatform', @@ -1543,15 +1745,21 @@ export const post24 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post24, + post: post25, } /** * Get MCP server configuration for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get23 = oc +export const get22 = oc .route({ - description: 'Get MCP server configuration for an application', + deprecated: true, + description: + 'Get MCP server configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdServer', @@ -1563,10 +1771,16 @@ export const get23 = oc /** * Create MCP server configuration for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post25 = oc +export const post26 = oc .route({ - description: 'Create MCP server configuration for an application', + deprecated: true, + description: + 'Create MCP server configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdServer', @@ -1579,10 +1793,16 @@ export const post25 = oc /** * Update MCP server configuration for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const put = oc .route({ - description: 'Update MCP server configuration for an application', + deprecated: true, + description: + 'Update MCP server configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsByAppIdServer', @@ -1593,15 +1813,15 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get23, - post: post25, + get: get22, + post: post26, put, } /** * Reset access token for application site */ -export const post26 = oc +export const post27 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1614,13 +1834,13 @@ export const post26 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post26, + post: post27, } /** * Update application site configuration */ -export const post27 = oc +export const post28 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -1633,16 +1853,22 @@ export const post27 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post27, + post: post28, accessTokenReset, } /** * Enable or disable app site + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post28 = oc +export const post29 = oc .route({ - description: 'Enable or disable app site', + deprecated: true, + description: + 'Enable or disable app site\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdSiteEnable', @@ -1653,15 +1879,21 @@ export const post28 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post28, + post: post29, } /** * Get average response time statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get24 = oc +export const get23 = oc .route({ - description: 'Get average response time statistics for an application', + deprecated: true, + description: + 'Get average response time statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsAverageResponseTime', @@ -1677,15 +1909,21 @@ export const get24 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get24, + get: get23, } /** * Get average session interaction statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get25 = oc +export const get24 = oc .route({ - description: 'Get average session interaction statistics for an application', + deprecated: true, + description: + 'Get average session interaction statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsAverageSessionInteractions', @@ -1701,15 +1939,21 @@ export const get25 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get25, + get: get24, } /** * Get daily conversation statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get26 = oc +export const get25 = oc .route({ - description: 'Get daily conversation statistics for an application', + deprecated: true, + description: + 'Get daily conversation statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsDailyConversations', @@ -1725,15 +1969,21 @@ export const get26 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get26, + get: get25, } /** * Get daily terminal/end-user statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get27 = oc +export const get26 = oc .route({ - description: 'Get daily terminal/end-user statistics for an application', + deprecated: true, + description: + 'Get daily terminal/end-user statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsDailyEndUsers', @@ -1749,15 +1999,21 @@ export const get27 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get27, + get: get26, } /** * Get daily message statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get28 = oc +export const get27 = oc .route({ - description: 'Get daily message statistics for an application', + deprecated: true, + description: + 'Get daily message statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsDailyMessages', @@ -1773,15 +2029,21 @@ export const get28 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get28, + get: get27, } /** * Get daily token cost statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get29 = oc +export const get28 = oc .route({ - description: 'Get daily token cost statistics for an application', + deprecated: true, + description: + 'Get daily token cost statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsTokenCosts', @@ -1797,15 +2059,21 @@ export const get29 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get29, + get: get28, } /** * Get tokens per second statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get30 = oc +export const get29 = oc .route({ - description: 'Get tokens per second statistics for an application', + deprecated: true, + description: + 'Get tokens per second statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsTokensPerSecond', @@ -1821,15 +2089,21 @@ export const get30 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get30, + get: get29, } /** * Get user satisfaction rate statistics for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get31 = oc +export const get30 = oc .route({ - description: 'Get user satisfaction rate statistics for an application', + deprecated: true, + description: + 'Get user satisfaction rate statistics for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdStatisticsUserSatisfactionRate', @@ -1845,7 +2119,7 @@ export const get31 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get31, + get: get30, } export const statistics = { @@ -1861,10 +2135,16 @@ export const statistics = { /** * Get available TTS voices for a specific language + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get32 = oc +export const get31 = oc .route({ - description: 'Get available TTS voices for a specific language', + deprecated: true, + description: + 'Get available TTS voices for a specific language\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTextToAudioVoices', @@ -1880,15 +2160,21 @@ export const get32 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get32, + get: get31, } /** * Convert text to speech for chat messages + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post29 = oc +export const post30 = oc .route({ - description: 'Convert text to speech for chat messages', + deprecated: true, + description: + 'Convert text to speech for chat messages\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdTextToAudio', @@ -1901,7 +2187,7 @@ export const post29 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post29, + post: post30, voices, } @@ -1909,10 +2195,16 @@ export const textToAudio = { * Get app trace * * Get app tracing configuration + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get33 = oc +export const get32 = oc .route({ - description: 'Get app tracing configuration', + deprecated: true, + description: + 'Get app tracing configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTrace', @@ -1925,10 +2217,16 @@ export const get33 = oc /** * Update app tracing configuration + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post30 = oc +export const post31 = oc .route({ - description: 'Update app tracing configuration', + deprecated: true, + description: + 'Update app tracing configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdTrace', @@ -1939,8 +2237,8 @@ export const post30 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get33, - post: post30, + get: get32, + post: post31, } /** @@ -1969,10 +2267,16 @@ export const delete5 = oc /** * Get tracing configuration for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get34 = oc +export const get33 = oc .route({ - description: 'Get tracing configuration for an application', + deprecated: true, + description: + 'Get tracing configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdTraceConfig', @@ -1988,10 +2292,16 @@ export const get34 = oc * Update an existing trace app configuration * * Update an existing tracing configuration for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch = oc .route({ - description: 'Update an existing tracing configuration for an application', + deprecated: true, + description: + 'Update an existing tracing configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchAppsByAppIdTraceConfig', @@ -2008,10 +2318,16 @@ export const patch = oc * Create a new trace app configuration * * Create a new tracing configuration for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post31 = oc +export const post32 = oc .route({ - description: 'Create a new tracing configuration for an application', + deprecated: true, + description: + 'Create a new tracing configuration for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdTraceConfig', @@ -2027,15 +2343,15 @@ export const post31 = oc export const traceConfig = { delete: delete5, - get: get34, + get: get33, patch, - post: post31, + post: post32, } /** * Update app trigger (enable/disable) */ -export const post32 = oc +export const post33 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2053,13 +2369,13 @@ export const post32 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post32, + post: post33, } /** * Get app triggers list */ -export const get35 = oc +export const get34 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2072,7 +2388,7 @@ export const get35 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get35, + get: get34, } /** @@ -2080,7 +2396,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get36 = oc +export const get35 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2099,7 +2415,7 @@ export const get36 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get36, + get: get35, } /** @@ -2107,7 +2423,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get37 = oc +export const get36 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2126,7 +2442,7 @@ export const get37 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get37, + get: get36, } /** @@ -2134,7 +2450,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get38 = oc +export const get37 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2153,17 +2469,23 @@ export const get38 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get38, + get: get37, } /** * Stop workflow task * * Stop running workflow task + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post33 = oc +export const post34 = oc .route({ - description: 'Stop running workflow task', + deprecated: true, + description: + 'Stop running workflow task\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowRunsTasksByTaskIdStop', @@ -2175,7 +2497,7 @@ export const post33 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post33, + post: post34, } export const byTaskId3 = { @@ -2189,7 +2511,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get39 = oc +export const get38 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2202,7 +2524,7 @@ export const get39 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get39, + get: get38, } /** @@ -2210,7 +2532,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get40 = oc +export const get39 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2224,7 +2546,7 @@ export const get40 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get40, + get: get39, } /** @@ -2232,7 +2554,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get41 = oc +export const get40 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -2246,7 +2568,7 @@ export const get41 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get41, + get: get40, export: export4, nodeExecutions, } @@ -2256,7 +2578,7 @@ export const byRunId = { * * Get workflow run list */ -export const get42 = oc +export const get41 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -2275,7 +2597,7 @@ export const get42 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get42, + get: get41, count: count3, tasks, byRunId, @@ -2286,7 +2608,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get43 = oc +export const get42 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -2300,7 +2622,7 @@ export const get43 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get43, + get: get42, } /** @@ -2355,7 +2677,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post34 = oc +export const post35 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2375,7 +2697,7 @@ export const post34 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post34, + post: post35, byReplyId, } @@ -2384,7 +2706,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post35 = oc +export const post36 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2398,7 +2720,7 @@ export const post35 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post35, + post: post36, } /** @@ -2425,7 +2747,7 @@ export const delete7 = oc * * Get a specific workflow comment */ -export const get44 = oc +export const get43 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -2463,7 +2785,7 @@ export const put3 = oc export const byCommentId = { delete: delete7, - get: get44, + get: get43, put: put3, replies, resolve, @@ -2474,7 +2796,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get45 = oc +export const get44 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -2492,7 +2814,7 @@ export const get45 = oc * * Create a new workflow comment */ -export const post36 = oc +export const post37 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -2512,18 +2834,24 @@ export const post36 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get45, - post: post36, + get: get44, + post: post37, mentionUsers, byCommentId, } /** * Get workflow average app interaction statistics + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get46 = oc +export const get45 = oc .route({ - description: 'Get workflow average app interaction statistics', + deprecated: true, + description: + 'Get workflow average app interaction statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsAverageAppInteractions', @@ -2539,15 +2867,21 @@ export const get46 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get46, + get: get45, } /** * Get workflow daily runs statistics + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get47 = oc +export const get46 = oc .route({ - description: 'Get workflow daily runs statistics', + deprecated: true, + description: + 'Get workflow daily runs statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsDailyConversations', @@ -2563,15 +2897,21 @@ export const get47 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get47, + get: get46, } /** * Get workflow daily terminals statistics + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get48 = oc +export const get47 = oc .route({ - description: 'Get workflow daily terminals statistics', + deprecated: true, + description: + 'Get workflow daily terminals statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsDailyTerminals', @@ -2587,15 +2927,21 @@ export const get48 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get48, + get: get47, } /** * Get workflow daily token cost statistics + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get49 = oc +export const get48 = oc .route({ - description: 'Get workflow daily token cost statistics', + deprecated: true, + description: + 'Get workflow daily token cost statistics\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowStatisticsTokenCosts', @@ -2611,7 +2957,7 @@ export const get49 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get49, + get: get48, } export const statistics2 = { @@ -2630,10 +2976,16 @@ export const workflow = { * Get default block config * * Get default block configuration by type + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get50 = oc +export const get49 = oc .route({ - description: 'Get default block configuration by type', + deprecated: true, + description: + 'Get default block configuration by type\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockType', @@ -2650,17 +3002,23 @@ export const get50 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get50, + get: get49, } /** * Get default block config * * Get default block configurations for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get51 = oc +export const get50 = oc .route({ - description: 'Get default block configurations for workflow', + deprecated: true, + description: + 'Get default block configurations for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDefaultWorkflowBlockConfigs', @@ -2672,16 +3030,22 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get51, + get: get50, byBlockType, } /** * Get conversation variables for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get52 = oc +export const get51 = oc .route({ - description: 'Get conversation variables for workflow', + deprecated: true, + description: + 'Get conversation variables for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftConversationVariables', @@ -2693,10 +3057,16 @@ export const get52 = oc /** * Update conversation variables for workflow draft + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post37 = oc +export const post38 = oc .route({ - description: 'Update conversation variables for workflow draft', + deprecated: true, + description: + 'Update conversation variables for workflow draft\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftConversationVariables', @@ -2712,18 +3082,24 @@ export const post37 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get52, - post: post37, + get: get51, + post: post38, } /** * Get draft workflow * * Get environment variables for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get53 = oc +export const get52 = oc .route({ - description: 'Get environment variables for workflow', + deprecated: true, + description: + 'Get environment variables for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftEnvironmentVariables', @@ -2736,10 +3112,16 @@ export const get53 = oc /** * Update environment variables for workflow draft + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post38 = oc +export const post39 = oc .route({ - description: 'Update environment variables for workflow draft', + deprecated: true, + description: + 'Update environment variables for workflow draft\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftEnvironmentVariables', @@ -2755,16 +3137,22 @@ export const post38 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get53, - post: post38, + get: get52, + post: post39, } /** * Update draft workflow features + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post39 = oc +export const post40 = oc .route({ - description: 'Update draft workflow features', + deprecated: true, + description: + 'Update draft workflow features\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftFeatures', @@ -2780,17 +3168,23 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post39, + post: post40, } /** * Test human input delivery * * Test human input delivery for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post40 = oc +export const post41 = oc .route({ - description: 'Test human input delivery for workflow', + deprecated: true, + description: + 'Test human input delivery for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTest', @@ -2807,17 +3201,23 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post40, + post: post41, } /** * Preview human input form content and placeholders * * Get human input form preview for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post41 = oc +export const post42 = oc .route({ - description: 'Get human input form preview for workflow', + deprecated: true, + description: + 'Get human input form preview for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreview', @@ -2834,17 +3234,23 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview2 = { - post: post41, + post: post42, } /** * Submit human input form preview * * Submit human input form preview for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post42 = oc +export const post43 = oc .route({ - description: 'Submit human input form preview for workflow', + deprecated: true, + description: + 'Submit human input form preview for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRun', @@ -2861,7 +3267,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post42, + post: post43, } export const form2 = { @@ -2886,10 +3292,16 @@ export const humanInput2 = { * Run draft workflow iteration node * * Run draft workflow iteration node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post43 = oc +export const post44 = oc .route({ - description: 'Run draft workflow iteration node', + deprecated: true, + description: + 'Run draft workflow iteration node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRun', @@ -2906,7 +3318,7 @@ export const post43 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post43, + post: post44, } export const byNodeId5 = { @@ -2925,10 +3337,16 @@ export const iteration2 = { * Run draft workflow loop node * * Run draft workflow loop node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post44 = oc +export const post45 = oc .route({ - description: 'Run draft workflow loop node', + deprecated: true, + description: + 'Run draft workflow loop node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRun', @@ -2945,7 +3363,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post44, + post: post45, } export const byNodeId6 = { @@ -2963,7 +3381,7 @@ export const loop2 = { /** * Get last run result for draft workflow node */ -export const get54 = oc +export const get53 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -2976,17 +3394,23 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get54, + get: get53, } /** * Run draft workflow node * * Run draft workflow node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post45 = oc +export const post46 = oc .route({ - description: 'Run draft workflow node', + deprecated: true, + description: + 'Run draft workflow node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdRun', @@ -3003,17 +3427,23 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post45, + post: post46, } /** * Poll for trigger events and execute single node when event arrives * * Poll for trigger events and execute single node when event arrives + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post46 = oc +export const post47 = oc .route({ - description: 'Poll for trigger events and execute single node when event arrives', + deprecated: true, + description: + 'Poll for trigger events and execute single node when event arrives\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRun', @@ -3025,7 +3455,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post46, + post: post47, } export const trigger = { @@ -3050,10 +3480,16 @@ export const delete8 = oc /** * Get variables for a specific node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get55 = oc +export const get54 = oc .route({ - description: 'Get variables for a specific node', + deprecated: true, + description: + 'Get variables for a specific node\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdVariables', @@ -3065,7 +3501,7 @@ export const get55 = oc export const variables = { delete: delete8, - get: get55, + get: get54, } export const byNodeId7 = { @@ -3083,10 +3519,16 @@ export const nodes7 = { * Run draft workflow * * Run draft workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post47 = oc +export const post48 = oc .route({ - description: 'Run draft workflow', + deprecated: true, + description: + 'Run draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftRun', @@ -3103,15 +3545,21 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post47, + post: post48, } /** * Get system variables for workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get56 = oc +export const get55 = oc .route({ - description: 'Get system variables for workflow', + deprecated: true, + description: + 'Get system variables for workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftSystemVariables', @@ -3122,17 +3570,23 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get56, + get: get55, } /** * Poll for trigger events and execute full workflow when event arrives * * Poll for trigger events and execute full workflow when event arrives + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post48 = oc +export const post49 = oc .route({ - description: 'Poll for trigger events and execute full workflow when event arrives', + deprecated: true, + description: + 'Poll for trigger events and execute full workflow when event arrives\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftTriggerRun', @@ -3149,17 +3603,23 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post48, + post: post49, } /** * Full workflow debug when the start node is a trigger * * Full workflow debug when the start node is a trigger + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post49 = oc +export const post50 = oc .route({ - description: 'Full workflow debug when the start node is a trigger', + deprecated: true, + description: + 'Full workflow debug when the start node is a trigger\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraftTriggerRunAll', @@ -3176,7 +3636,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post49, + post: post50, } export const trigger2 = { @@ -3186,10 +3646,16 @@ export const trigger2 = { /** * Reset a workflow variable to its default value + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const put4 = oc .route({ - description: 'Reset a workflow variable to its default value', + deprecated: true, + description: + 'Reset a workflow variable to its default value\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsByAppIdWorkflowsDraftVariablesByVariableIdReset', @@ -3221,10 +3687,16 @@ export const delete9 = oc /** * Get a specific workflow variable + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get57 = oc +export const get56 = oc .route({ - description: 'Get a specific workflow variable', + deprecated: true, + description: + 'Get a specific workflow variable\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftVariablesByVariableId', @@ -3236,10 +3708,16 @@ export const get57 = oc /** * Update a workflow variable + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch2 = oc .route({ - description: 'Update a workflow variable', + deprecated: true, + description: + 'Update a workflow variable\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchAppsByAppIdWorkflowsDraftVariablesByVariableId', @@ -3256,7 +3734,7 @@ export const patch2 = oc export const byVariableId = { delete: delete9, - get: get57, + get: get56, patch: patch2, reset, } @@ -3281,10 +3759,16 @@ export const delete10 = oc * Get draft workflow * * Get draft workflow variables + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get58 = oc +export const get57 = oc .route({ - description: 'Get draft workflow variables', + deprecated: true, + description: + 'Get draft workflow variables\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraftVariables', @@ -3302,7 +3786,7 @@ export const get58 = oc export const variables2 = { delete: delete10, - get: get58, + get: get57, byVariableId, } @@ -3310,10 +3794,16 @@ export const variables2 = { * Get draft workflow * * Get draft workflow for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get59 = oc +export const get58 = oc .route({ - description: 'Get draft workflow for an application', + deprecated: true, + description: + 'Get draft workflow for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsDraft', @@ -3328,10 +3818,16 @@ export const get59 = oc * Sync draft workflow * * Sync draft workflow configuration + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post50 = oc +export const post51 = oc .route({ - description: 'Sync draft workflow configuration', + deprecated: true, + description: + 'Sync draft workflow configuration\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsDraft', @@ -3348,8 +3844,8 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get59, - post: post50, + get: get58, + post: post51, conversationVariables: conversationVariables2, environmentVariables, features, @@ -3367,10 +3863,16 @@ export const draft2 = { * Get published workflow * * Get published workflow for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get60 = oc +export const get59 = oc .route({ - description: 'Get published workflow for an application', + deprecated: true, + description: + 'Get published workflow for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsPublish', @@ -3383,9 +3885,16 @@ export const get60 = oc /** * Publish workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post51 = oc +export const post52 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsPublish', @@ -3402,15 +3911,22 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get60, - post: post51, + get: get59, + post: post52, } /** * Get webhook trigger for a node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get61 = oc +export const get60 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflowsTriggersWebhook', @@ -3427,7 +3943,7 @@ export const get61 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get61, + get: get60, } export const triggers2 = { @@ -3436,10 +3952,16 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post52 = oc +export const post53 = oc .route({ - description: 'Restore a published workflow version into the draft workflow', + deprecated: true, + description: + 'Restore a published workflow version into the draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdWorkflowsByWorkflowIdRestore', @@ -3450,14 +3972,21 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post52, + post: post53, } /** * Delete workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const delete11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsByAppIdWorkflowsByWorkflowId', @@ -3472,10 +4001,16 @@ export const delete11 = oc * Update workflow attributes * * Update workflow by ID + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch3 = oc .route({ - description: 'Update workflow by ID', + deprecated: true, + description: + 'Update workflow by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchAppsByAppIdWorkflowsByWorkflowId', @@ -3501,10 +4036,16 @@ export const byWorkflowId = { * Get published workflows * * Get all published workflows for an application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get62 = oc +export const get61 = oc .route({ - description: 'Get all published workflows for an application', + deprecated: true, + description: + 'Get all published workflows for an application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppIdWorkflows', @@ -3521,7 +4062,7 @@ export const get62 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get62, + get: get61, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -3552,10 +4093,16 @@ export const delete12 = oc * Get app detail * * Get application details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get63 = oc +export const get62 = oc .route({ - description: 'Get application details', + deprecated: true, + description: + 'Get application details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByAppId', @@ -3570,10 +4117,16 @@ export const get63 = oc * Update app * * Update application details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const put5 = oc .route({ - description: 'Update application details', + deprecated: true, + description: + 'Update application details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsByAppId', @@ -3586,7 +4139,7 @@ export const put5 = oc export const byAppId2 = { delete: delete12, - get: get63, + get: get62, put: put5, advancedChat, agent, @@ -3654,7 +4207,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get64 = oc +export const get63 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -3672,7 +4225,7 @@ export const get64 = oc * * Create a new API key for an app */ -export const post53 = oc +export const post54 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -3687,8 +4240,8 @@ export const post53 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get64, - post: post53, + get: get63, + post: post54, byApiKeyId, } @@ -3698,10 +4251,16 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const get65 = oc +export const get64 = oc .route({ - description: 'Refresh MCP server configuration and regenerate server code', + deprecated: true, + description: + 'Refresh MCP server configuration and regenerate server code\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsByServerIdServerRefresh', @@ -3712,7 +4271,7 @@ export const get65 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get65, + get: get64, } export const server2 = { @@ -3728,7 +4287,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get66 = oc +export const get65 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -3745,10 +4304,16 @@ export const get66 = oc * Create app * * Create a new application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ -export const post54 = oc +export const post55 = oc .route({ - description: 'Create a new application', + deprecated: true, + description: + 'Create a new application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postApps', @@ -3761,8 +4326,8 @@ export const post54 = oc .output(zPostAppsResponse) export const apps = { - get: get66, - post: post54, + get: get65, + post: post55, imports, workflows, byAppId: byAppId2, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index fe4c10329e..a4e3c97a0a 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -17,7 +17,7 @@ export type CreateAppPayload = { icon?: string | null icon_background?: string | null icon_type?: IconType - mode: 'chat' | 'agent-chat' | 'advanced-chat' | 'workflow' | 'completion' + mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' name: string } @@ -68,6 +68,14 @@ export type CheckDependenciesResult = { leaked_dependencies?: Array } +export type WorkflowOnlineUsersPayload = { + app_ids?: Array +} + +export type WorkflowOnlineUsersResponse = { + data: Array +} + export type AppDetailWithSite = { access_mode?: string | null api_base_url?: string | null @@ -104,12 +112,19 @@ export type UpdateAppPayload = { use_icon_as_answer_icon?: boolean | null } -export type AdvancedChatWorkflowRunPagination = { - [key: string]: unknown +export type AdvancedChatWorkflowRunPaginationResponse = { + data: Array + has_more: boolean + limit: number } -export type WorkflowRunCount = { - [key: string]: unknown +export type WorkflowRunCountResponse = { + failed: number + partial_succeeded: number + running: number + stopped: number + succeeded: number + total: number } export type HumanInputFormPreviewPayload = { @@ -309,7 +324,7 @@ export type AppExportResponse = { export type MessageFeedbackPayload = { content?: string | null message_id: string - rating?: 'like' | 'dislike' | null + rating?: 'dislike' | 'like' | null } export type AppIconPayload = { @@ -415,7 +430,7 @@ export type AppSiteUpdatePayload = { copyright?: string | null custom_disclaimer?: string | null customize_domain?: string | null - customize_token_strategy?: 'must' | 'allow' | 'not_allow' | null + customize_token_strategy?: 'allow' | 'must' | 'not_allow' | null default_language?: string | null description?: string | null icon?: string | null @@ -510,24 +525,43 @@ export type WorkflowArchivedLogPaginationResponse = { total: number } -export type WorkflowRunPagination = { - [key: string]: unknown +export type WorkflowRunPaginationResponse = { + data: Array + has_more: boolean + limit: number } -export type WorkflowRunDetail = { - [key: string]: unknown +export type WorkflowRunDetailResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + exceptions_count?: number | null + finished_at?: number | null + graph: unknown + id: string + inputs: unknown + outputs: unknown + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null } -export type WorkflowRunExport = { - [key: string]: unknown +export type WorkflowRunExportResponse = { + presigned_url?: string | null + presigned_url_expires_at?: string | null + status: string } -export type WorkflowRunNodeExecutionList = { - [key: string]: unknown +export type WorkflowRunNodeExecutionListResponse = { + data: Array } -export type WorkflowCommentBasic = { - [key: string]: unknown +export type WorkflowCommentBasicList = { + data: Array } export type WorkflowCommentCreatePayload = { @@ -538,7 +572,8 @@ export type WorkflowCommentCreatePayload = { } export type WorkflowCommentCreate = { - [key: string]: unknown + created_at?: number | null + id: string } export type WorkflowCommentMentionUsersPayload = { @@ -546,7 +581,20 @@ export type WorkflowCommentMentionUsersPayload = { } export type WorkflowCommentDetail = { - [key: string]: unknown + content: string + created_at?: number | null + created_by: string + created_by_account?: WorkflowCommentAccount + id: string + mentions: Array + position_x: number + position_y: number + replies: Array + resolved: boolean + resolved_at?: number | null + resolved_by?: string | null + resolved_by_account?: WorkflowCommentAccount + updated_at?: number | null } export type WorkflowCommentUpdatePayload = { @@ -557,7 +605,8 @@ export type WorkflowCommentUpdatePayload = { } export type WorkflowCommentUpdate = { - [key: string]: unknown + id: string + updated_at?: number | null } export type WorkflowCommentReplyPayload = { @@ -566,23 +615,49 @@ export type WorkflowCommentReplyPayload = { } export type WorkflowCommentReplyCreate = { - [key: string]: unknown + created_at?: number | null + id: string } export type WorkflowCommentReplyUpdate = { - [key: string]: unknown + id: string + updated_at?: number | null } export type WorkflowCommentResolve = { - [key: string]: unknown + id: string + resolved: boolean + resolved_at?: number | null + resolved_by?: string | null } -export type WorkflowPagination = { - [key: string]: unknown +export type WorkflowPaginationResponse = { + has_more: boolean + items: Array + limit: number + page: number } -export type Workflow = { - [key: string]: unknown +export type WorkflowResponse = { + conversation_variables: Array + created_at: number + created_by?: SimpleAccount + environment_variables: Array + features: { + [key: string]: unknown + } + graph: { + [key: string]: unknown + } + hash: string + id: string + marked_comment: string + marked_name: string + rag_pipeline_variables: Array + tool_published: boolean + updated_at: number + updated_by?: SimpleAccount + version: string } export type SyncDraftWorkflowPayload = { @@ -602,11 +677,13 @@ export type SyncDraftWorkflowPayload = { } export type SyncDraftWorkflowResponse = { - [key: string]: unknown + hash?: string + result?: string + updated_at?: string } export type WorkflowDraftVariableList = { - [key: string]: unknown + items?: Array } export type ConversationVariableUpdatePayload = { @@ -634,8 +711,29 @@ export type HumanInputDeliveryTestPayload = { } } -export type WorkflowRunNodeExecution = { - [key: string]: unknown +export type WorkflowRunNodeExecutionResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + execution_metadata?: unknown + extras?: unknown + finished_at?: number | null + id: string + index?: number | null + inputs?: unknown + inputs_truncated?: boolean | null + node_id?: string | null + node_type?: string | null + outputs?: unknown + outputs_truncated?: boolean | null + predecessor_node_id?: string | null + process_data?: unknown + process_data_truncated?: boolean | null + status?: string | null + title?: string | null } export type DraftWorkflowNodeRunPayload = { @@ -660,7 +758,7 @@ export type DraftWorkflowRunPayload = { } export type DraftWorkflowTriggerRunRequest = { - [key: string]: unknown + node_id: string } export type DraftWorkflowTriggerRunAllPayload = { @@ -668,11 +766,28 @@ export type DraftWorkflowTriggerRunAllPayload = { } export type WorkflowDraftVariableListWithoutValue = { - [key: string]: unknown + items?: Array + total?: { + [key: string]: unknown + } } export type WorkflowDraftVariable = { - [key: string]: unknown + description?: string + edited?: boolean + full_content?: { + [key: string]: unknown + } + id?: string + is_truncated?: boolean + name?: string + selector?: Array + type?: string + value?: { + [key: string]: unknown + } + value_type?: string + visible?: boolean } export type WorkflowDraftVariableUpdatePayload = { @@ -734,33 +849,15 @@ export type AppPartial = { workflow?: WorkflowPartial } -export type IconType = 'image' | 'emoji' | 'link' +export type IconType = 'emoji' | 'image' | 'link' export type ModelConfig = { - agent_mode_dict?: JsonValue - annotation_reply_dict?: JsonValue - chat_prompt_config_dict?: JsonValue - completion_prompt_config_dict?: JsonValue - created_at?: number | null - created_by?: string | null - dataset_configs_dict?: JsonValue - dataset_query_variable?: string | null - external_data_tools_list?: JsonValue - file_upload_dict?: JsonValue - model_dict?: JsonValue - more_like_this_dict?: JsonValue - opening_statement?: string | null - pre_prompt?: string | null - prompt_type?: string | null - retriever_resource_dict?: JsonValue - sensitive_word_avoidance_dict?: JsonValue - speech_to_text_dict?: JsonValue - suggested_questions_after_answer_dict?: JsonValue - suggested_questions_list?: JsonValue - text_to_speech_dict?: JsonValue - updated_at?: number | null - updated_by?: string | null - user_input_form_list?: JsonValue + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string } export type Tag = { @@ -779,7 +876,7 @@ export type WorkflowPartial = { updated_by?: string | null } -export type ImportStatus = 'completed' | 'completed-with-warnings' | 'pending' | 'failed' +export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending' export type PluginDependency = { current_identifier?: string | null @@ -787,6 +884,11 @@ export type PluginDependency = { value: unknown } +export type WorkflowOnlineUsersByApp = { + app_id: string + users: Array +} + export type DeletedTool = { provider_id: string tool_name: string @@ -818,6 +920,22 @@ export type Site = { use_icon_as_answer_icon?: boolean | null } +export type AdvancedChatWorkflowRunForListResponse = { + conversation_id?: string | null + created_at?: number | null + created_by_account?: SimpleAccount + elapsed_time?: number | null + exceptions_count?: number | null + finished_at?: number | null + id: string + message_id?: string | null + retry_index?: number | null + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + export type AnnotationHitHistory = { annotation_content?: string | null annotation_question?: string | null @@ -967,7 +1085,7 @@ export type MessageFile = { url?: string | null } -export type AppMcpServerStatus = 'normal' | 'active' | 'inactive' +export type AppMcpServerStatus = 'active' | 'inactive' | 'normal' export type WorkflowAppLogPartialResponse = { created_at?: number | null @@ -989,6 +1107,51 @@ export type WorkflowArchivedLogPartialResponse = { workflow_run?: WorkflowRunForArchivedLogResponse } +export type WorkflowRunForListResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + elapsed_time?: number | null + exceptions_count?: number | null + finished_at?: number | null + id: string + retry_index?: number | null + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type SimpleAccount = { + email: string + id: string + name: string +} + +export type SimpleEndUser = { + id: string + is_anonymous: boolean + session_id?: string | null + type: string +} + +export type WorkflowCommentBasic = { + content: string + created_at?: number | null + created_by: string + created_by_account?: WorkflowCommentAccount + id: string + mention_count: number + participants: Array + position_x: number + position_y: number + reply_count: number + resolved: boolean + resolved_at?: number | null + resolved_by?: string | null + resolved_by_account?: WorkflowCommentAccount + updated_at?: number | null +} + export type AccountWithRole = { avatar?: string | null created_at?: number | null @@ -1001,6 +1164,78 @@ export type AccountWithRole = { status: string } +export type WorkflowCommentAccount = { + readonly avatar_url: string | null + email: string + id: string + name: string +} + +export type WorkflowCommentMention = { + mentioned_user_account?: WorkflowCommentAccount + mentioned_user_id: string + reply_id?: string | null +} + +export type WorkflowCommentReply = { + content: string + created_at?: number | null + created_by: string + created_by_account?: WorkflowCommentAccount + id: string +} + +export type WorkflowConversationVariableResponse = { + description: string + id: string + name: string + value: { + [key: string]: unknown + } + value_type: string +} + +export type WorkflowEnvironmentVariableResponse = { + description: string + id: string + name: string + value: { + [key: string]: unknown + } + value_type: string +} + +export type PipelineVariableResponse = { + allowed_file_extensions?: Array | null + allowed_file_types?: Array | null + allowed_file_upload_methods?: Array | null + belong_to_node_id: string + default_value?: { + [key: string]: unknown + } + label: string + max_length?: number | null + options?: Array | null + placeholder?: string | null + required: boolean + tooltips?: string | null + type: string + unit?: string | null + variable: string +} + +export type WorkflowDraftVariableWithoutValue = { + description?: string + edited?: boolean + id?: string + is_truncated?: boolean + name?: string + selector?: Array + type?: string + value_type?: string + visible?: boolean +} + export type ModelConfigPartial = { created_at?: number | null created_by?: string | null @@ -1010,6 +1245,8 @@ export type ModelConfigPartial = { updated_by?: string | null } +export type LlmMode = 'chat' | 'completion' + export type Type = 'github' | 'marketplace' | 'package' export type Github = { @@ -1029,6 +1266,12 @@ export type Package = { version?: string | null } +export type WorkflowOnlineUser = { + avatar?: string | null + user_id: string + username: string +} + export type SimpleModelConfig = { model_dict?: JsonValue pre_prompt?: string | null @@ -1050,20 +1293,14 @@ export type SimpleMessageDetail = { query: string } -export type SimpleAccount = { - email: string - id: string - name: string -} - export type HumanInputFormDefinition = { - actions?: Array + actions?: Array display_in_ui?: boolean expiration_time: number form_content: string form_id: string form_token?: string | null - inputs?: Array + inputs?: Array node_id: string node_title: string resolved_default_values?: { @@ -1081,13 +1318,6 @@ export type HumanInputFormSubmissionData = { export type ExecutionContentType = 'human_input' -export type SimpleEndUser = { - id: string - is_anonymous: boolean - session_id?: string | null - type: string -} - export type WorkflowRunForLogResponse = { created_at?: number | null elapsed_time?: number | null @@ -1110,29 +1340,121 @@ export type WorkflowRunForArchivedLogResponse = { triggered_from?: string | null } -export type UserAction = { +export type UserActionConfig = { button_style?: ButtonStyle id: string title: string } -export type FormInput = { - default?: FormInputDefault +export type FormInputConfig = unknown + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type ParagraphInputConfig = { + default?: StringSource output_variable_name: string - type: FormInputType + type?: string } -export type ButtonStyle = 'primary' | 'default' | 'accent' | 'ghost' +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: string +} -export type FormInputDefault = { +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: string +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: string +} + +export type StringSource = { selector?: Array - type: PlaceholderType + type: ValueSourceType value?: string } -export type FormInputType = 'text_input' | 'paragraph' +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} -export type PlaceholderType = 'variable' | 'constant' +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type ValueSourceType = 'constant' | 'variable' + +export type WorkflowCommentBasicListWritable = { + data: Array +} + +export type WorkflowCommentDetailWritable = { + content: string + created_at?: number | null + created_by: string + created_by_account?: WorkflowCommentAccountWritable + id: string + mentions: Array + position_x: number + position_y: number + replies: Array + resolved: boolean + resolved_at?: number | null + resolved_by?: string | null + resolved_by_account?: WorkflowCommentAccountWritable + updated_at?: number | null +} + +export type WorkflowCommentBasicWritable = { + content: string + created_at?: number | null + created_by: string + created_by_account?: WorkflowCommentAccountWritable + id: string + mention_count: number + participants: Array + position_x: number + position_y: number + reply_count: number + resolved: boolean + resolved_at?: number | null + resolved_by?: string | null + resolved_by_account?: WorkflowCommentAccountWritable + updated_at?: number | null +} + +export type WorkflowCommentAccountWritable = { + email: string + id: string + name: string +} + +export type WorkflowCommentMentionWritable = { + mentioned_user_account?: WorkflowCommentAccountWritable + mentioned_user_id: string + reply_id?: string | null +} + +export type WorkflowCommentReplyWritable = { + content: string + created_at?: number | null + created_by: string + created_by_account?: WorkflowCommentAccountWritable + id: string +} export type GetAppsData = { body?: never @@ -1140,7 +1462,7 @@ export type GetAppsData = { query?: { is_created_by_me?: boolean | null limit?: number - mode?: 'completion' | 'chat' | 'advanced-chat' | 'workflow' | 'agent-chat' | 'channel' | 'all' + mode?: 'advanced-chat' | 'agent-chat' | 'all' | 'channel' | 'chat' | 'completion' | 'workflow' name?: string | null page?: number tag_ids?: Array | null @@ -1237,23 +1559,19 @@ export type PostAppsImportsByImportIdConfirmResponses = { export type PostAppsImportsByImportIdConfirmResponse = PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses] -export type GetAppsWorkflowsOnlineUsersData = { - body?: never +export type PostAppsWorkflowsOnlineUsersData = { + body: WorkflowOnlineUsersPayload path?: never - query: { - app_ids: string - } + query?: never url: '/apps/workflows/online-users' } -export type GetAppsWorkflowsOnlineUsersResponses = { - 200: { - [key: string]: unknown - } +export type PostAppsWorkflowsOnlineUsersResponses = { + 200: WorkflowOnlineUsersResponse } -export type GetAppsWorkflowsOnlineUsersResponse - = GetAppsWorkflowsOnlineUsersResponses[keyof GetAppsWorkflowsOnlineUsersResponses] +export type PostAppsWorkflowsOnlineUsersResponse + = PostAppsWorkflowsOnlineUsersResponses[keyof PostAppsWorkflowsOnlineUsersResponses] export type DeleteAppsByAppIdData = { body?: never @@ -1274,7 +1592,7 @@ export type DeleteAppsByAppIdError = DeleteAppsByAppIdErrors[keyof DeleteAppsByA export type DeleteAppsByAppIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1327,16 +1645,16 @@ export type GetAppsByAppIdAdvancedChatWorkflowRunsData = { app_id: string } query?: { - triggered_from?: 'debugging' | 'app-run' | null - status?: 'running' | 'succeeded' | 'failed' | 'stopped' | 'partial-succeeded' | null - last_id?: string | null + last_id?: string limit?: number + status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' + triggered_from?: 'app-run' | 'debugging' } url: '/apps/{app_id}/advanced-chat/workflow-runs' } export type GetAppsByAppIdAdvancedChatWorkflowRunsResponses = { - 200: AdvancedChatWorkflowRunPagination + 200: AdvancedChatWorkflowRunPaginationResponse } export type GetAppsByAppIdAdvancedChatWorkflowRunsResponse @@ -1348,15 +1666,15 @@ export type GetAppsByAppIdAdvancedChatWorkflowRunsCountData = { app_id: string } query?: { - triggered_from?: 'debugging' | 'app-run' | null - time_range?: string | null - status?: 'running' | 'succeeded' | 'failed' | 'stopped' | 'partial-succeeded' | null + status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' + time_range?: string + triggered_from?: 'app-run' | 'debugging' } url: '/apps/{app_id}/advanced-chat/workflow-runs/count' } export type GetAppsByAppIdAdvancedChatWorkflowRunsCountResponses = { - 200: WorkflowRunCount + 200: WorkflowRunCountResponse } export type GetAppsByAppIdAdvancedChatWorkflowRunsCountResponse @@ -1525,8 +1843,8 @@ export type GetAppsByAppIdAgentLogsResponse export type PostAppsByAppIdAnnotationReplyByActionData = { body: AnnotationReplyPayload path: { - app_id: string action: string + app_id: string } query?: never url: '/apps/{app_id}/annotation-reply/{action}' @@ -1553,8 +1871,8 @@ export type PostAppsByAppIdAnnotationReplyByActionResponse export type GetAppsByAppIdAnnotationReplyByActionStatusByJobIdData = { body?: never path: { - app_id: string action: string + app_id: string job_id: string } query?: never @@ -1609,8 +1927,8 @@ export type GetAppsByAppIdAnnotationSettingResponse export type PostAppsByAppIdAnnotationSettingsByAnnotationSettingIdData = { body: AnnotationSettingUpdatePayload path: { - app_id: string annotation_setting_id: string + app_id: string } query?: never url: '/apps/{app_id}/annotation-settings/{annotation_setting_id}' @@ -1835,8 +2153,8 @@ export type DeleteAppsByAppIdAnnotationsByAnnotationIdResponse export type PostAppsByAppIdAnnotationsByAnnotationIdData = { body: UpdateAnnotationPayload path: { - app_id: string annotation_id: string + app_id: string } query?: never url: '/apps/{app_id}/annotations/{annotation_id}' @@ -1854,7 +2172,7 @@ export type PostAppsByAppIdAnnotationsByAnnotationIdError export type PostAppsByAppIdAnnotationsByAnnotationIdResponses = { 200: Annotation 204: { - [key: string]: unknown + [key: string]: never } } @@ -1864,12 +2182,12 @@ export type PostAppsByAppIdAnnotationsByAnnotationIdResponse export type GetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesData = { body?: never path: { - app_id: string annotation_id: string + app_id: string } query?: { - page?: number limit?: number + page?: number } url: '/apps/{app_id}/annotations/{annotation_id}/hit-histories' } @@ -1949,12 +2267,12 @@ export type GetAppsByAppIdChatConversationsData = { app_id: string } query?: { - annotation_status?: 'annotated' | 'not_annotated' | 'all' + annotation_status?: 'all' | 'annotated' | 'not_annotated' end?: string | null keyword?: string | null limit?: number page?: number - sort_by?: 'created_at' | '-created_at' | 'updated_at' | '-updated_at' + sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' start?: string | null } url: '/apps/{app_id}/chat-conversations' @@ -2000,7 +2318,7 @@ export type DeleteAppsByAppIdChatConversationsByConversationIdError export type DeleteAppsByAppIdChatConversationsByConversationIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -2116,7 +2434,7 @@ export type GetAppsByAppIdCompletionConversationsData = { app_id: string } query?: { - annotation_status?: 'annotated' | 'not_annotated' | 'all' + annotation_status?: 'all' | 'annotated' | 'not_annotated' end?: string | null keyword?: string | null limit?: number @@ -2166,7 +2484,7 @@ export type DeleteAppsByAppIdCompletionConversationsByConversationIdError export type DeleteAppsByAppIdCompletionConversationsByConversationIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -2388,9 +2706,9 @@ export type GetAppsByAppIdFeedbacksExportData = { query?: { end_date?: string | null format?: 'csv' | 'json' - from_source?: 'user' | 'admin' | null + from_source?: 'admin' | 'user' | null has_comment?: boolean | null - rating?: 'like' | 'dislike' | null + rating?: 'dislike' | 'like' | null start_date?: string | null } url: '/apps/{app_id}/feedbacks/export' @@ -2969,7 +3287,7 @@ export type DeleteAppsByAppIdTraceConfigError export type DeleteAppsByAppIdTraceConfigResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -3149,16 +3467,16 @@ export type GetAppsByAppIdWorkflowRunsData = { app_id: string } query?: { - triggered_from?: 'debugging' | 'app-run' | null - status?: 'running' | 'succeeded' | 'failed' | 'stopped' | 'partial-succeeded' | null - last_id?: string | null + last_id?: string limit?: number + status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' + triggered_from?: 'app-run' | 'debugging' } url: '/apps/{app_id}/workflow-runs' } export type GetAppsByAppIdWorkflowRunsResponses = { - 200: WorkflowRunPagination + 200: WorkflowRunPaginationResponse } export type GetAppsByAppIdWorkflowRunsResponse @@ -3170,15 +3488,15 @@ export type GetAppsByAppIdWorkflowRunsCountData = { app_id: string } query?: { - triggered_from?: 'debugging' | 'app-run' | null - time_range?: string | null - status?: 'running' | 'succeeded' | 'failed' | 'stopped' | 'partial-succeeded' | null + status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' + time_range?: string + triggered_from?: 'app-run' | 'debugging' } url: '/apps/{app_id}/workflow-runs/count' } export type GetAppsByAppIdWorkflowRunsCountResponses = { - 200: WorkflowRunCount + 200: WorkflowRunCountResponse } export type GetAppsByAppIdWorkflowRunsCountResponse @@ -3235,7 +3553,7 @@ export type GetAppsByAppIdWorkflowRunsByRunIdError = GetAppsByAppIdWorkflowRunsByRunIdErrors[keyof GetAppsByAppIdWorkflowRunsByRunIdErrors] export type GetAppsByAppIdWorkflowRunsByRunIdResponses = { - 200: WorkflowRunDetail + 200: WorkflowRunDetailResponse } export type GetAppsByAppIdWorkflowRunsByRunIdResponse @@ -3252,7 +3570,7 @@ export type GetAppsByAppIdWorkflowRunsByRunIdExportData = { } export type GetAppsByAppIdWorkflowRunsByRunIdExportResponses = { - 200: WorkflowRunExport + 200: WorkflowRunExportResponse } export type GetAppsByAppIdWorkflowRunsByRunIdExportResponse @@ -3278,7 +3596,7 @@ export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsError = GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsErrors[keyof GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsErrors] export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponses = { - 200: WorkflowRunNodeExecutionList + 200: WorkflowRunNodeExecutionListResponse } export type GetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse @@ -3294,7 +3612,7 @@ export type GetAppsByAppIdWorkflowCommentsData = { } export type GetAppsByAppIdWorkflowCommentsResponses = { - 200: WorkflowCommentBasic + 200: WorkflowCommentBasicList } export type GetAppsByAppIdWorkflowCommentsResponse @@ -3344,7 +3662,7 @@ export type DeleteAppsByAppIdWorkflowCommentsByCommentIdData = { export type DeleteAppsByAppIdWorkflowCommentsByCommentIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -3415,7 +3733,7 @@ export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdData = { export type DeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -3556,7 +3874,7 @@ export type GetAppsByAppIdWorkflowsData = { } export type GetAppsByAppIdWorkflowsResponses = { - 200: WorkflowPagination + 200: WorkflowPaginationResponse } export type GetAppsByAppIdWorkflowsResponse @@ -3629,7 +3947,7 @@ export type GetAppsByAppIdWorkflowsDraftError = GetAppsByAppIdWorkflowsDraftErrors[keyof GetAppsByAppIdWorkflowsDraftErrors] export type GetAppsByAppIdWorkflowsDraftResponses = { - 200: Workflow + 200: WorkflowResponse } export type GetAppsByAppIdWorkflowsDraftResponse @@ -3911,7 +4229,7 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunError = GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunErrors[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunErrors] export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponses = { - 200: WorkflowRunNodeExecution + 200: WorkflowRunNodeExecutionResponse } export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse @@ -3940,7 +4258,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunError = PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunErrors[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunErrors] export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponses = { - 200: WorkflowRunNodeExecution + 200: WorkflowRunNodeExecutionResponse } export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse @@ -3980,8 +4298,8 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never path: { - node_id: string app_id: string + node_id: string } query?: never url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/variables' @@ -3989,7 +4307,7 @@ export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesData = { export type DeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -4127,7 +4445,7 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesData = { export type DeleteAppsByAppIdWorkflowsDraftVariablesResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -4140,8 +4458,8 @@ export type GetAppsByAppIdWorkflowsDraftVariablesData = { app_id: string } query?: { - page?: number limit?: number + page?: number } url: '/apps/{app_id}/workflows/draft/variables' } @@ -4174,7 +4492,7 @@ export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdError export type DeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -4255,7 +4573,7 @@ export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetError export type PutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponses = { 200: WorkflowDraftVariable 204: { - [key: string]: unknown + [key: string]: never } } @@ -4271,17 +4589,8 @@ export type GetAppsByAppIdWorkflowsPublishData = { url: '/apps/{app_id}/workflows/publish' } -export type GetAppsByAppIdWorkflowsPublishErrors = { - 404: { - [key: string]: unknown - } -} - -export type GetAppsByAppIdWorkflowsPublishError - = GetAppsByAppIdWorkflowsPublishErrors[keyof GetAppsByAppIdWorkflowsPublishErrors] - export type GetAppsByAppIdWorkflowsPublishResponses = { - 200: Workflow + 200: WorkflowResponse } export type GetAppsByAppIdWorkflowsPublishResponse @@ -4328,8 +4637,8 @@ export type GetAppsByAppIdWorkflowsTriggersWebhookResponse export type DeleteAppsByAppIdWorkflowsByWorkflowIdData = { body?: never path: { - workflow_id: string app_id: string + workflow_id: string } query?: never url: '/apps/{app_id}/workflows/{workflow_id}' @@ -4367,7 +4676,7 @@ export type PatchAppsByAppIdWorkflowsByWorkflowIdError = PatchAppsByAppIdWorkflowsByWorkflowIdErrors[keyof PatchAppsByAppIdWorkflowsByWorkflowIdErrors] export type PatchAppsByAppIdWorkflowsByWorkflowIdResponses = { - 200: Workflow + 200: WorkflowResponse } export type PatchAppsByAppIdWorkflowsByWorkflowIdResponse @@ -4448,8 +4757,8 @@ export type PostAppsByResourceIdApiKeysResponse export type DeleteAppsByResourceIdApiKeysByApiKeyIdData = { body?: never path: { - resource_id: string api_key_id: string + resource_id: string } query?: never url: '/apps/{resource_id}/api-keys/{api_key_id}' @@ -4457,7 +4766,7 @@ export type DeleteAppsByResourceIdApiKeysByApiKeyIdData = { export type DeleteAppsByResourceIdApiKeysByApiKeyIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index dcaeaed246..1f8ea393c7 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -17,9 +17,24 @@ export const zAppImportPayload = z.object({ yaml_url: z.string().nullish(), }) -export const zAdvancedChatWorkflowRunPagination = z.record(z.string(), z.unknown()) +/** + * WorkflowOnlineUsersPayload + */ +export const zWorkflowOnlineUsersPayload = z.object({ + app_ids: z.array(z.string()).optional(), +}) -export const zWorkflowRunCount = z.record(z.string(), z.unknown()) +/** + * WorkflowRunCountResponse + */ +export const zWorkflowRunCountResponse = z.object({ + failed: z.int(), + partial_succeeded: z.int(), + running: z.int(), + stopped: z.int(), + succeeded: z.int(), + total: z.int(), +}) /** * HumanInputFormPreviewPayload @@ -180,7 +195,7 @@ export const zAppExportResponse = z.object({ export const zMessageFeedbackPayload = z.object({ content: z.string().nullish(), message_id: z.string(), - rating: z.enum(['like', 'dislike']).nullish(), + rating: z.enum(['dislike', 'like']).nullish(), }) /** @@ -235,7 +250,7 @@ export const zAppSiteUpdatePayload = z.object({ copyright: z.string().nullish(), custom_disclaimer: z.string().nullish(), customize_domain: z.string().nullish(), - customize_token_strategy: z.enum(['must', 'allow', 'not_allow']).nullish(), + customize_token_strategy: z.enum(['allow', 'must', 'not_allow']).nullish(), default_language: z.string().nullish(), description: z.string().nullish(), icon: z.string().nullish(), @@ -339,15 +354,14 @@ export const zWorkflowTriggerListResponse = z.object({ data: z.array(zWorkflowTriggerResponse), }) -export const zWorkflowRunPagination = z.record(z.string(), z.unknown()) - -export const zWorkflowRunDetail = z.record(z.string(), z.unknown()) - -export const zWorkflowRunExport = z.record(z.string(), z.unknown()) - -export const zWorkflowRunNodeExecutionList = z.record(z.string(), z.unknown()) - -export const zWorkflowCommentBasic = z.record(z.string(), z.unknown()) +/** + * WorkflowRunExportResponse + */ +export const zWorkflowRunExportResponse = z.object({ + presigned_url: z.string().nullish(), + presigned_url_expires_at: z.string().nullish(), + status: z.string(), +}) /** * WorkflowCommentCreatePayload @@ -359,9 +373,13 @@ export const zWorkflowCommentCreatePayload = z.object({ position_y: z.number(), }) -export const zWorkflowCommentCreate = z.record(z.string(), z.unknown()) - -export const zWorkflowCommentDetail = z.record(z.string(), z.unknown()) +/** + * WorkflowCommentCreate + */ +export const zWorkflowCommentCreate = z.object({ + created_at: z.int().nullish(), + id: z.string(), +}) /** * WorkflowCommentUpdatePayload @@ -373,7 +391,13 @@ export const zWorkflowCommentUpdatePayload = z.object({ position_y: z.number().nullish(), }) -export const zWorkflowCommentUpdate = z.record(z.string(), z.unknown()) +/** + * WorkflowCommentUpdate + */ +export const zWorkflowCommentUpdate = z.object({ + id: z.string(), + updated_at: z.int().nullish(), +}) /** * WorkflowCommentReplyPayload @@ -383,15 +407,31 @@ export const zWorkflowCommentReplyPayload = z.object({ mentioned_user_ids: z.array(z.string()).optional(), }) -export const zWorkflowCommentReplyCreate = z.record(z.string(), z.unknown()) +/** + * WorkflowCommentReplyCreate + */ +export const zWorkflowCommentReplyCreate = z.object({ + created_at: z.int().nullish(), + id: z.string(), +}) -export const zWorkflowCommentReplyUpdate = z.record(z.string(), z.unknown()) +/** + * WorkflowCommentReplyUpdate + */ +export const zWorkflowCommentReplyUpdate = z.object({ + id: z.string(), + updated_at: z.int().nullish(), +}) -export const zWorkflowCommentResolve = z.record(z.string(), z.unknown()) - -export const zWorkflowPagination = z.record(z.string(), z.unknown()) - -export const zWorkflow = z.record(z.string(), z.unknown()) +/** + * WorkflowCommentResolve + */ +export const zWorkflowCommentResolve = z.object({ + id: z.string(), + resolved: z.boolean(), + resolved_at: z.int().nullish(), + resolved_by: z.string().nullish(), +}) /** * SyncDraftWorkflowPayload @@ -404,9 +444,11 @@ export const zSyncDraftWorkflowPayload = z.object({ hash: z.string().nullish(), }) -export const zSyncDraftWorkflowResponse = z.record(z.string(), z.unknown()) - -export const zWorkflowDraftVariableList = z.record(z.string(), z.unknown()) +export const zSyncDraftWorkflowResponse = z.object({ + hash: z.string().optional(), + result: z.string().optional(), + updated_at: z.string().optional(), +}) /** * ConversationVariableUpdatePayload @@ -437,8 +479,6 @@ export const zHumanInputDeliveryTestPayload = z.object({ inputs: z.record(z.string(), z.unknown()).optional(), }) -export const zWorkflowRunNodeExecution = z.record(z.string(), z.unknown()) - /** * DraftWorkflowNodeRunPayload */ @@ -458,7 +498,9 @@ export const zDraftWorkflowRunPayload = z.object({ start_node_id: z.string(), }) -export const zDraftWorkflowTriggerRunRequest = z.record(z.string(), z.unknown()) +export const zDraftWorkflowTriggerRunRequest = z.object({ + node_id: z.string(), +}) /** * DraftWorkflowTriggerRunAllPayload @@ -467,9 +509,23 @@ export const zDraftWorkflowTriggerRunAllPayload = z.object({ node_ids: z.array(z.string()), }) -export const zWorkflowDraftVariableListWithoutValue = z.record(z.string(), z.unknown()) +export const zWorkflowDraftVariable = z.object({ + description: z.string().optional(), + edited: z.boolean().optional(), + full_content: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + is_truncated: z.boolean().optional(), + name: z.string().optional(), + selector: z.array(z.string()).optional(), + type: z.string().optional(), + value: z.record(z.string(), z.unknown()).optional(), + value_type: z.string().optional(), + visible: z.boolean().optional(), +}) -export const zWorkflowDraftVariable = z.record(z.string(), z.unknown()) +export const zWorkflowDraftVariableList = z.object({ + items: z.array(zWorkflowDraftVariable).optional(), +}) /** * WorkflowDraftVariableUpdatePayload @@ -528,7 +584,7 @@ export const zApiKeyList = z.object({ /** * IconType */ -export const zIconType = z.enum(['image', 'emoji', 'link']) +export const zIconType = z.enum(['emoji', 'image', 'link']) /** * CreateAppPayload @@ -538,7 +594,7 @@ export const zCreateAppPayload = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), icon_type: zIconType.optional(), - mode: z.enum(['chat', 'agent-chat', 'advanced-chat', 'workflow', 'completion']), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']), name: z.string().min(1), }) @@ -586,36 +642,6 @@ export const zTag = z.object({ export const zJsonValue = z.unknown() -/** - * ModelConfig - */ -export const zModelConfig = z.object({ - agent_mode_dict: zJsonValue.optional(), - annotation_reply_dict: zJsonValue.optional(), - chat_prompt_config_dict: zJsonValue.optional(), - completion_prompt_config_dict: zJsonValue.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - dataset_configs_dict: zJsonValue.optional(), - dataset_query_variable: z.string().nullish(), - external_data_tools_list: zJsonValue.optional(), - file_upload_dict: zJsonValue.optional(), - model_dict: zJsonValue.optional(), - more_like_this_dict: zJsonValue.optional(), - opening_statement: z.string().nullish(), - pre_prompt: z.string().nullish(), - prompt_type: z.string().nullish(), - retriever_resource_dict: zJsonValue.optional(), - sensitive_word_avoidance_dict: zJsonValue.optional(), - speech_to_text_dict: zJsonValue.optional(), - suggested_questions_after_answer_dict: zJsonValue.optional(), - suggested_questions_list: zJsonValue.optional(), - text_to_speech_dict: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - user_input_form_list: zJsonValue.optional(), -}) - /** * WorkflowPartial */ @@ -627,34 +653,10 @@ export const zWorkflowPartial = z.object({ updated_by: z.string().nullish(), }) -/** - * AppDetail - */ -export const zAppDetail = z.object({ - access_mode: z.string().nullish(), - app_model_config: zModelConfig.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - id: z.string(), - mode_compatible_with_agent: z.string(), - name: z.string(), - tags: z.array(zTag).optional(), - tracing: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.optional(), -}) - /** * ImportStatus */ -export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'pending', 'failed']) +export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending']) /** * Import @@ -706,35 +708,6 @@ export const zSite = z.object({ use_icon_as_answer_icon: z.boolean().nullish(), }) -/** - * AppDetailWithSite - */ -export const zAppDetailWithSite = z.object({ - access_mode: z.string().nullish(), - api_base_url: z.string().nullish(), - app_model_config: zModelConfig.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - deleted_tools: z.array(zDeletedTool).optional(), - description: z.string().nullish(), - enable_api: z.boolean(), - enable_site: z.boolean(), - icon: z.string().nullish(), - icon_background: z.string().nullish(), - icon_type: z.string().nullish(), - id: z.string(), - max_active_requests: z.int().nullish(), - mode_compatible_with_agent: z.string(), - name: z.string(), - site: zSite.optional(), - tags: z.array(zTag).optional(), - tracing: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - use_icon_as_answer_icon: z.boolean().nullish(), - workflow: zWorkflowPartial.optional(), -}) - /** * AnnotationHitHistory */ @@ -767,25 +740,6 @@ export const zFeedbackStat = z.object({ like: z.int(), }) -/** - * ConversationDetail - */ -export const zConversationDetail = z.object({ - admin_feedback_stats: zFeedbackStat.optional(), - annotated: z.boolean(), - created_at: z.int().nullish(), - from_account_id: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - introduction: z.string().nullish(), - message_count: z.int(), - model_config: zModelConfig.optional(), - status: z.string(), - updated_at: z.int().nullish(), - user_feedback_stats: zFeedbackStat.optional(), -}) - /** * ConversationVariableResponse */ @@ -848,7 +802,7 @@ export const zMessageFile = z.object({ * * AppMCPServer Status Enum */ -export const zAppMcpServerStatus = z.enum(['normal', 'active', 'inactive']) +export const zAppMcpServerStatus = z.enum(['active', 'inactive', 'normal']) /** * AppMCPServerResponse @@ -864,6 +818,196 @@ export const zAppMcpServerResponse = z.object({ updated_at: z.int().nullish(), }) +/** + * SimpleAccount + */ +export const zSimpleAccount = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * AdvancedChatWorkflowRunForListResponse + */ +export const zAdvancedChatWorkflowRunForListResponse = z.object({ + conversation_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + elapsed_time: z.number().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + message_id: z.string().nullish(), + retry_index: z.int().nullish(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * AdvancedChatWorkflowRunPaginationResponse + */ +export const zAdvancedChatWorkflowRunPaginationResponse = z.object({ + data: z.array(zAdvancedChatWorkflowRunForListResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * ConversationAnnotation + */ +export const zConversationAnnotation = z.object({ + account: zSimpleAccount.optional(), + content: z.string(), + created_at: z.int().nullish(), + id: z.string(), + question: z.string().nullish(), +}) + +/** + * ConversationAnnotationHitHistory + */ +export const zConversationAnnotationHitHistory = z.object({ + annotation_create_account: zSimpleAccount.optional(), + created_at: z.int().nullish(), + id: z.string(), +}) + +/** + * Feedback + */ +export const zFeedback = z.object({ + content: z.string().nullish(), + from_account: zSimpleAccount.optional(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + rating: z.string(), +}) + +/** + * MessageDetail + */ +export const zMessageDetail = z.object({ + agent_thoughts: z.array(zAgentThought), + annotation: zConversationAnnotation.optional(), + annotation_hit_history: zConversationAnnotationHitHistory.optional(), + answer_tokens: z.int(), + conversation_id: z.string(), + created_at: z.int().nullish(), + error: z.string().nullish(), + feedbacks: z.array(zFeedback), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + inputs: z.record(z.string(), zJsonValue), + message: zJsonValue, + message_files: z.array(zMessageFile), + message_metadata_dict: zJsonValue, + message_tokens: z.int(), + parent_message_id: z.string().nullish(), + provider_response_latency: z.number(), + query: z.string(), + re_sign_file_url_answer: z.string(), + status: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * WorkflowRunForListResponse + */ +export const zWorkflowRunForListResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + elapsed_time: z.number().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + retry_index: z.int().nullish(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunPaginationResponse + */ +export const zWorkflowRunPaginationResponse = z.object({ + data: z.array(zWorkflowRunForListResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * SimpleEndUser + */ +export const zSimpleEndUser = z.object({ + id: z.string(), + is_anonymous: z.boolean(), + session_id: z.string().nullish(), + type: z.string(), +}) + +/** + * WorkflowRunDetailResponse + */ +export const zWorkflowRunDetailResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + created_by_end_user: zSimpleEndUser.optional(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + graph: z.unknown(), + id: z.string(), + inputs: z.unknown(), + outputs: z.unknown(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionResponse + */ +export const zWorkflowRunNodeExecutionResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + created_by_end_user: zSimpleEndUser.optional(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + execution_metadata: z.unknown().optional(), + extras: z.unknown().optional(), + finished_at: z.int().nullish(), + id: z.string(), + index: z.int().nullish(), + inputs: z.unknown().optional(), + inputs_truncated: z.boolean().nullish(), + node_id: z.string().nullish(), + node_type: z.string().nullish(), + outputs: z.unknown().optional(), + outputs_truncated: z.boolean().nullish(), + predecessor_node_id: z.string().nullish(), + process_data: z.unknown().optional(), + process_data_truncated: z.boolean().nullish(), + status: z.string().nullish(), + title: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionListResponse + */ +export const zWorkflowRunNodeExecutionListResponse = z.object({ + data: z.array(zWorkflowRunNodeExecutionResponse), +}) + /** * AccountWithRole */ @@ -886,6 +1030,174 @@ export const zWorkflowCommentMentionUsersPayload = z.object({ users: z.array(zAccountWithRole), }) +/** + * WorkflowCommentAccount + */ +export const zWorkflowCommentAccount = z.object({ + avatar_url: z.string().readonly().nullable(), + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * WorkflowCommentBasic + */ +export const zWorkflowCommentBasic = z.object({ + content: z.string(), + created_at: z.int().nullish(), + created_by: z.string(), + created_by_account: zWorkflowCommentAccount.optional(), + id: z.string(), + mention_count: z.int(), + participants: z.array(zWorkflowCommentAccount), + position_x: z.number(), + position_y: z.number(), + reply_count: z.int(), + resolved: z.boolean(), + resolved_at: z.int().nullish(), + resolved_by: z.string().nullish(), + resolved_by_account: zWorkflowCommentAccount.optional(), + updated_at: z.int().nullish(), +}) + +/** + * WorkflowCommentBasicList + */ +export const zWorkflowCommentBasicList = z.object({ + data: z.array(zWorkflowCommentBasic), +}) + +/** + * WorkflowCommentMention + */ +export const zWorkflowCommentMention = z.object({ + mentioned_user_account: zWorkflowCommentAccount.optional(), + mentioned_user_id: z.string(), + reply_id: z.string().nullish(), +}) + +/** + * WorkflowCommentReply + */ +export const zWorkflowCommentReply = z.object({ + content: z.string(), + created_at: z.int().nullish(), + created_by: z.string(), + created_by_account: zWorkflowCommentAccount.optional(), + id: z.string(), +}) + +/** + * WorkflowCommentDetail + */ +export const zWorkflowCommentDetail = z.object({ + content: z.string(), + created_at: z.int().nullish(), + created_by: z.string(), + created_by_account: zWorkflowCommentAccount.optional(), + id: z.string(), + mentions: z.array(zWorkflowCommentMention), + position_x: z.number(), + position_y: z.number(), + replies: z.array(zWorkflowCommentReply), + resolved: z.boolean(), + resolved_at: z.int().nullish(), + resolved_by: z.string().nullish(), + resolved_by_account: zWorkflowCommentAccount.optional(), + updated_at: z.int().nullish(), +}) + +/** + * WorkflowConversationVariableResponse + */ +export const zWorkflowConversationVariableResponse = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + value: z.record(z.string(), z.unknown()), + value_type: z.string(), +}) + +/** + * WorkflowEnvironmentVariableResponse + */ +export const zWorkflowEnvironmentVariableResponse = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + value: z.record(z.string(), z.unknown()), + value_type: z.string(), +}) + +/** + * PipelineVariableResponse + */ +export const zPipelineVariableResponse = z.object({ + allowed_file_extensions: z.array(z.string()).nullish(), + allowed_file_types: z.array(z.string()).nullish(), + allowed_file_upload_methods: z.array(z.string()).nullish(), + belong_to_node_id: z.string(), + default_value: z.record(z.string(), z.unknown()).optional(), + label: z.string(), + max_length: z.int().nullish(), + options: z.array(z.string()).nullish(), + placeholder: z.string().nullish(), + required: z.boolean(), + tooltips: z.string().nullish(), + type: z.string(), + unit: z.string().nullish(), + variable: z.string(), +}) + +/** + * WorkflowResponse + */ +export const zWorkflowResponse = z.object({ + conversation_variables: z.array(zWorkflowConversationVariableResponse), + created_at: z.int(), + created_by: zSimpleAccount.optional(), + environment_variables: z.array(zWorkflowEnvironmentVariableResponse), + features: z.record(z.string(), z.unknown()), + graph: z.record(z.string(), z.unknown()), + hash: z.string(), + id: z.string(), + marked_comment: z.string(), + marked_name: z.string(), + rag_pipeline_variables: z.array(zPipelineVariableResponse), + tool_published: z.boolean(), + updated_at: z.int(), + updated_by: zSimpleAccount.optional(), + version: z.string(), +}) + +/** + * WorkflowPaginationResponse + */ +export const zWorkflowPaginationResponse = z.object({ + has_more: z.boolean(), + items: z.array(zWorkflowResponse), + limit: z.int(), + page: z.int(), +}) + +export const zWorkflowDraftVariableWithoutValue = z.object({ + description: z.string().optional(), + edited: z.boolean().optional(), + id: z.string().optional(), + is_truncated: z.boolean().optional(), + name: z.string().optional(), + selector: z.array(z.string()).optional(), + type: z.string().optional(), + value_type: z.string().optional(), + visible: z.boolean().optional(), +}) + +export const zWorkflowDraftVariableListWithoutValue = z.object({ + items: z.array(zWorkflowDraftVariableWithoutValue).optional(), + total: z.record(z.string(), z.unknown()).optional(), +}) + /** * ModelConfigPartial */ @@ -935,6 +1247,109 @@ export const zAppPagination = z.object({ total: z.int(), }) +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) + +/** + * ModelConfig + */ +export const zModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), +}) + +/** + * AppDetail + */ +export const zAppDetail = z.object({ + access_mode: z.string().nullish(), + app_model_config: zModelConfig.optional(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + id: z.string(), + mode_compatible_with_agent: z.string(), + name: z.string(), + tags: z.array(zTag).optional(), + tracing: zJsonValue.optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.optional(), +}) + +/** + * AppDetailWithSite + */ +export const zAppDetailWithSite = z.object({ + access_mode: z.string().nullish(), + api_base_url: z.string().nullish(), + app_model_config: zModelConfig.optional(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + deleted_tools: z.array(zDeletedTool).optional(), + description: z.string().nullish(), + enable_api: z.boolean(), + enable_site: z.boolean(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.string().nullish(), + id: z.string(), + max_active_requests: z.int().nullish(), + mode_compatible_with_agent: z.string(), + name: z.string(), + site: zSite.optional(), + tags: z.array(zTag).optional(), + tracing: zJsonValue.optional(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), + use_icon_as_answer_icon: z.boolean().nullish(), + workflow: zWorkflowPartial.optional(), +}) + +/** + * ConversationDetail + */ +export const zConversationDetail = z.object({ + admin_feedback_stats: zFeedbackStat.optional(), + annotated: z.boolean(), + created_at: z.int().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + introduction: z.string().nullish(), + message_count: z.int(), + model_config: zModelConfig.optional(), + status: z.string(), + updated_at: z.int().nullish(), + user_feedback_stats: zFeedbackStat.optional(), +}) + +/** + * ConversationMessageDetail + */ +export const zConversationMessageDetail = z.object({ + created_at: z.int().nullish(), + first_message: zMessageDetail.optional(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + from_source: z.string(), + id: z.string(), + model_config: zModelConfig.optional(), + status: z.string(), +}) + /** * Type */ @@ -982,6 +1397,30 @@ export const zPackage = z.object({ version: z.string().nullish(), }) +/** + * WorkflowOnlineUser + */ +export const zWorkflowOnlineUser = z.object({ + avatar: z.string().nullish(), + user_id: z.string(), + username: z.string(), +}) + +/** + * WorkflowOnlineUsersByApp + */ +export const zWorkflowOnlineUsersByApp = z.object({ + app_id: z.string(), + users: z.array(zWorkflowOnlineUser), +}) + +/** + * WorkflowOnlineUsersResponse + */ +export const zWorkflowOnlineUsersResponse = z.object({ + data: z.array(zWorkflowOnlineUsersByApp), +}) + /** * SimpleModelConfig */ @@ -1045,26 +1484,6 @@ export const zSimpleMessageDetail = z.object({ query: z.string(), }) -/** - * SimpleAccount - */ -export const zSimpleAccount = z.object({ - email: z.string(), - id: z.string(), - name: z.string(), -}) - -/** - * ConversationAnnotation - */ -export const zConversationAnnotation = z.object({ - account: zSimpleAccount.optional(), - content: z.string(), - created_at: z.int().nullish(), - id: z.string(), - question: z.string().nullish(), -}) - /** * Conversation */ @@ -1097,69 +1516,6 @@ export const zConversationPagination = z.object({ total: z.int(), }) -/** - * ConversationAnnotationHitHistory - */ -export const zConversationAnnotationHitHistory = z.object({ - annotation_create_account: zSimpleAccount.optional(), - created_at: z.int().nullish(), - id: z.string(), -}) - -/** - * Feedback - */ -export const zFeedback = z.object({ - content: z.string().nullish(), - from_account: zSimpleAccount.optional(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - rating: z.string(), -}) - -/** - * MessageDetail - */ -export const zMessageDetail = z.object({ - agent_thoughts: z.array(zAgentThought), - annotation: zConversationAnnotation.optional(), - annotation_hit_history: zConversationAnnotationHitHistory.optional(), - answer_tokens: z.int(), - conversation_id: z.string(), - created_at: z.int().nullish(), - error: z.string().nullish(), - feedbacks: z.array(zFeedback), - from_account_id: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - inputs: z.record(z.string(), zJsonValue), - message: zJsonValue, - message_files: z.array(zMessageFile), - message_metadata_dict: zJsonValue, - message_tokens: z.int(), - parent_message_id: z.string().nullish(), - provider_response_latency: z.number(), - query: z.string(), - re_sign_file_url_answer: z.string(), - status: z.string(), - workflow_run_id: z.string().nullish(), -}) - -/** - * ConversationMessageDetail - */ -export const zConversationMessageDetail = z.object({ - created_at: z.int().nullish(), - first_message: zMessageDetail.optional(), - from_account_id: z.string().nullish(), - from_end_user_id: z.string().nullish(), - from_source: z.string(), - id: z.string(), - model_config: zModelConfig.optional(), - status: z.string(), -}) - /** * HumanInputFormSubmissionData */ @@ -1176,16 +1532,6 @@ export const zHumanInputFormSubmissionData = z.object({ */ export const zExecutionContentType = z.enum(['human_input']) -/** - * SimpleEndUser - */ -export const zSimpleEndUser = z.object({ - id: z.string(), - is_anonymous: z.boolean(), - session_id: z.string().nullish(), - type: z.string(), -}) - /** * WorkflowRunForLogResponse */ @@ -1262,71 +1608,37 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({ total: z.int(), }) +export const zFormInputConfig = z.unknown() + /** * ButtonStyle * * Button styles for user actions. */ -export const zButtonStyle = z.enum(['primary', 'default', 'accent', 'ghost']) +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) /** - * UserAction + * UserActionConfig * * User action configuration. */ -export const zUserAction = z.object({ +export const zUserActionConfig = z.object({ button_style: zButtonStyle.optional(), id: z.string().max(20), - title: z.string().max(20), -}) - -/** - * FormInputType - * - * Form input types. - */ -export const zFormInputType = z.enum(['text_input', 'paragraph']) - -/** - * PlaceholderType - * - * Default value types for form inputs. - */ -export const zPlaceholderType = z.enum(['variable', 'constant']) - -/** - * FormInputDefault - * - * Default configuration for form inputs. - */ -export const zFormInputDefault = z.object({ - selector: z.array(z.string()).optional(), - type: zPlaceholderType, - value: z.string().optional().default(''), -}) - -/** - * FormInput - * - * Form input definition. - */ -export const zFormInput = z.object({ - default: zFormInputDefault.optional(), - output_variable_name: z.string(), - type: zFormInputType, + title: z.string().max(100), }) /** * HumanInputFormDefinition */ export const zHumanInputFormDefinition = z.object({ - actions: z.array(zUserAction).optional(), + actions: z.array(zUserActionConfig).optional(), display_in_ui: z.boolean().optional().default(false), expiration_time: z.int(), form_content: z.string(), form_id: z.string(), form_token: z.string().nullish(), - inputs: z.array(zFormInput).optional(), + inputs: z.array(zFormInputConfig).optional(), node_id: z.string(), node_title: z.string(), resolved_default_values: z.record(z.string(), z.unknown()).optional(), @@ -1382,11 +1694,174 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({ limit: z.int(), }) +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.string().optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.string().optional().default('file-list'), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definiton, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.optional(), + output_variable_name: z.string(), + type: z.string().optional().default('paragraph'), +}) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.string().optional().default('select'), +}) + +/** + * WorkflowCommentAccount + */ +export const zWorkflowCommentAccountWritable = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * WorkflowCommentBasic + */ +export const zWorkflowCommentBasicWritable = z.object({ + content: z.string(), + created_at: z.int().nullish(), + created_by: z.string(), + created_by_account: zWorkflowCommentAccountWritable.optional(), + id: z.string(), + mention_count: z.int(), + participants: z.array(zWorkflowCommentAccountWritable), + position_x: z.number(), + position_y: z.number(), + reply_count: z.int(), + resolved: z.boolean(), + resolved_at: z.int().nullish(), + resolved_by: z.string().nullish(), + resolved_by_account: zWorkflowCommentAccountWritable.optional(), + updated_at: z.int().nullish(), +}) + +/** + * WorkflowCommentBasicList + */ +export const zWorkflowCommentBasicListWritable = z.object({ + data: z.array(zWorkflowCommentBasicWritable), +}) + +/** + * WorkflowCommentMention + */ +export const zWorkflowCommentMentionWritable = z.object({ + mentioned_user_account: zWorkflowCommentAccountWritable.optional(), + mentioned_user_id: z.string(), + reply_id: z.string().nullish(), +}) + +/** + * WorkflowCommentReply + */ +export const zWorkflowCommentReplyWritable = z.object({ + content: z.string(), + created_at: z.int().nullish(), + created_by: z.string(), + created_by_account: zWorkflowCommentAccountWritable.optional(), + id: z.string(), +}) + +/** + * WorkflowCommentDetail + */ +export const zWorkflowCommentDetailWritable = z.object({ + content: z.string(), + created_at: z.int().nullish(), + created_by: z.string(), + created_by_account: zWorkflowCommentAccountWritable.optional(), + id: z.string(), + mentions: z.array(zWorkflowCommentMentionWritable), + position_x: z.number(), + position_y: z.number(), + replies: z.array(zWorkflowCommentReplyWritable), + resolved: z.boolean(), + resolved_at: z.int().nullish(), + resolved_by: z.string().nullish(), + resolved_by_account: zWorkflowCommentAccountWritable.optional(), + updated_at: z.int().nullish(), +}) + export const zGetAppsQuery = z.object({ is_created_by_me: z.boolean().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), mode: z - .enum(['completion', 'chat', 'advanced-chat', 'workflow', 'agent-chat', 'channel', 'all']) + .enum(['advanced-chat', 'agent-chat', 'all', 'channel', 'chat', 'completion', 'workflow']) .optional() .default('all'), name: z.string().nullish(), @@ -1431,14 +1906,12 @@ export const zPostAppsImportsByImportIdConfirmPath = z.object({ */ export const zPostAppsImportsByImportIdConfirmResponse = zImport -export const zGetAppsWorkflowsOnlineUsersQuery = z.object({ - app_ids: z.string(), -}) +export const zPostAppsWorkflowsOnlineUsersBody = zWorkflowOnlineUsersPayload /** - * Success + * Workflow online users retrieved successfully */ -export const zGetAppsWorkflowsOnlineUsersResponse = z.record(z.string(), z.unknown()) +export const zPostAppsWorkflowsOnlineUsersResponse = zWorkflowOnlineUsersResponse export const zDeleteAppsByAppIdPath = z.object({ app_id: z.string(), @@ -1447,7 +1920,7 @@ export const zDeleteAppsByAppIdPath = z.object({ /** * App deleted successfully */ -export const zDeleteAppsByAppIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdResponse = z.record(z.string(), z.never()) export const zGetAppsByAppIdPath = z.object({ app_id: z.string(), @@ -1474,31 +1947,32 @@ export const zGetAppsByAppIdAdvancedChatWorkflowRunsPath = z.object({ }) export const zGetAppsByAppIdAdvancedChatWorkflowRunsQuery = z.object({ - triggered_from: z.enum(['debugging', 'app-run']).nullish(), - status: z.enum(['running', 'succeeded', 'failed', 'stopped', 'partial-succeeded']).nullish(), - last_id: z.string().nullish(), + last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), + status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).optional(), + triggered_from: z.enum(['app-run', 'debugging']).optional(), }) /** * Workflow runs retrieved successfully */ -export const zGetAppsByAppIdAdvancedChatWorkflowRunsResponse = zAdvancedChatWorkflowRunPagination +export const zGetAppsByAppIdAdvancedChatWorkflowRunsResponse + = zAdvancedChatWorkflowRunPaginationResponse export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountQuery = z.object({ - triggered_from: z.enum(['debugging', 'app-run']).nullish(), - time_range: z.string().nullish(), - status: z.enum(['running', 'succeeded', 'failed', 'stopped', 'partial-succeeded']).nullish(), + status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).optional(), + time_range: z.string().optional(), + triggered_from: z.enum(['app-run', 'debugging']).optional(), }) /** * Workflow runs count retrieved successfully */ -export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse = zWorkflowRunCount +export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse = zWorkflowRunCountResponse export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBody = zHumanInputFormPreviewPayload @@ -1593,8 +2067,8 @@ export const zGetAppsByAppIdAgentLogsResponse = z.array(z.record(z.string(), z.u export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPayload export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({ - app_id: z.string(), action: z.string(), + app_id: z.string(), }) /** @@ -1603,8 +2077,8 @@ export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({ export const zPostAppsByAppIdAnnotationReplyByActionResponse = z.record(z.string(), z.unknown()) export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath = z.object({ - app_id: z.string(), action: z.string(), + app_id: z.string(), job_id: z.string(), }) @@ -1629,8 +2103,8 @@ export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdBody = zAnnotationSettingUpdatePayload export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdPath = z.object({ - app_id: z.string(), annotation_setting_id: z.string(), + app_id: z.string(), }) /** @@ -1729,23 +2203,23 @@ export const zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse = z.record(z.st export const zPostAppsByAppIdAnnotationsByAnnotationIdBody = zUpdateAnnotationPayload export const zPostAppsByAppIdAnnotationsByAnnotationIdPath = z.object({ - app_id: z.string(), annotation_id: z.string(), + app_id: z.string(), }) export const zPostAppsByAppIdAnnotationsByAnnotationIdResponse = z.union([ zAnnotation, - z.record(z.string(), z.unknown()), + z.record(z.string(), z.never()), ]) export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesPath = z.object({ - app_id: z.string(), annotation_id: z.string(), + app_id: z.string(), }) export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesQuery = z.object({ - page: z.int().optional().default(1), limit: z.int().optional().default(20), + page: z.int().optional().default(1), }) /** @@ -1779,13 +2253,13 @@ export const zGetAppsByAppIdChatConversationsPath = z.object({ }) export const zGetAppsByAppIdChatConversationsQuery = z.object({ - annotation_status: z.enum(['annotated', 'not_annotated', 'all']).optional().default('all'), + annotation_status: z.enum(['all', 'annotated', 'not_annotated']).optional().default('all'), end: z.string().nullish(), keyword: z.string().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), sort_by: z - .enum(['created_at', '-created_at', 'updated_at', '-updated_at']) + .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() .default('-updated_at'), start: z.string().nullish(), @@ -1806,7 +2280,7 @@ export const zDeleteAppsByAppIdChatConversationsByConversationIdPath = z.object( */ export const zDeleteAppsByAppIdChatConversationsByConversationIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zGetAppsByAppIdChatConversationsByConversationIdPath = z.object({ @@ -1860,7 +2334,7 @@ export const zGetAppsByAppIdCompletionConversationsPath = z.object({ }) export const zGetAppsByAppIdCompletionConversationsQuery = z.object({ - annotation_status: z.enum(['annotated', 'not_annotated', 'all']).optional().default('all'), + annotation_status: z.enum(['all', 'annotated', 'not_annotated']).optional().default('all'), end: z.string().nullish(), keyword: z.string().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), @@ -1883,7 +2357,7 @@ export const zDeleteAppsByAppIdCompletionConversationsByConversationIdPath = z.o */ export const zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zGetAppsByAppIdCompletionConversationsByConversationIdPath = z.object({ @@ -1988,9 +2462,9 @@ export const zGetAppsByAppIdFeedbacksExportPath = z.object({ export const zGetAppsByAppIdFeedbacksExportQuery = z.object({ end_date: z.string().nullish(), format: z.enum(['csv', 'json']).optional().default('csv'), - from_source: z.enum(['user', 'admin']).nullish(), + from_source: z.enum(['admin', 'user']).nullish(), has_comment: z.boolean().nullish(), - rating: z.enum(['like', 'dislike']).nullish(), + rating: z.enum(['dislike', 'like']).nullish(), start_date: z.string().nullish(), }) @@ -2294,7 +2768,7 @@ export const zDeleteAppsByAppIdTraceConfigPath = z.object({ /** * Tracing configuration deleted successfully */ -export const zDeleteAppsByAppIdTraceConfigResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdTraceConfigResponse = z.record(z.string(), z.never()) export const zGetAppsByAppIdTraceConfigPath = z.object({ app_id: z.string(), @@ -2398,31 +2872,31 @@ export const zGetAppsByAppIdWorkflowRunsPath = z.object({ }) export const zGetAppsByAppIdWorkflowRunsQuery = z.object({ - triggered_from: z.enum(['debugging', 'app-run']).nullish(), - status: z.enum(['running', 'succeeded', 'failed', 'stopped', 'partial-succeeded']).nullish(), - last_id: z.string().nullish(), + last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), + status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).optional(), + triggered_from: z.enum(['app-run', 'debugging']).optional(), }) /** * Workflow runs retrieved successfully */ -export const zGetAppsByAppIdWorkflowRunsResponse = zWorkflowRunPagination +export const zGetAppsByAppIdWorkflowRunsResponse = zWorkflowRunPaginationResponse export const zGetAppsByAppIdWorkflowRunsCountPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdWorkflowRunsCountQuery = z.object({ - triggered_from: z.enum(['debugging', 'app-run']).nullish(), - time_range: z.string().nullish(), - status: z.enum(['running', 'succeeded', 'failed', 'stopped', 'partial-succeeded']).nullish(), + status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).optional(), + time_range: z.string().optional(), + triggered_from: z.enum(['app-run', 'debugging']).optional(), }) /** * Workflow runs count retrieved successfully */ -export const zGetAppsByAppIdWorkflowRunsCountResponse = zWorkflowRunCount +export const zGetAppsByAppIdWorkflowRunsCountResponse = zWorkflowRunCountResponse export const zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath = z.object({ app_id: z.string(), @@ -2445,7 +2919,7 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdPath = z.object({ /** * Workflow run detail retrieved successfully */ -export const zGetAppsByAppIdWorkflowRunsByRunIdResponse = zWorkflowRunDetail +export const zGetAppsByAppIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse export const zGetAppsByAppIdWorkflowRunsByRunIdExportPath = z.object({ app_id: z.string(), @@ -2455,7 +2929,7 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdExportPath = z.object({ /** * Export URL generated */ -export const zGetAppsByAppIdWorkflowRunsByRunIdExportResponse = zWorkflowRunExport +export const zGetAppsByAppIdWorkflowRunsByRunIdExportResponse = zWorkflowRunExportResponse export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ app_id: z.string(), @@ -2466,7 +2940,7 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ * Node executions retrieved successfully */ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse - = zWorkflowRunNodeExecutionList + = zWorkflowRunNodeExecutionListResponse export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ app_id: z.string(), @@ -2475,7 +2949,7 @@ export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ /** * Comments retrieved successfully */ -export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasic +export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasicList export const zPostAppsByAppIdWorkflowCommentsBody = zWorkflowCommentCreatePayload @@ -2506,10 +2980,7 @@ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ /** * Comment deleted successfully */ -export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdResponse = z.record(z.string(), z.never()) export const zGetAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ app_id: z.string(), @@ -2557,7 +3028,7 @@ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = */ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdBody @@ -2664,7 +3135,7 @@ export const zGetAppsByAppIdWorkflowsQuery = z.object({ /** * Published workflows retrieved successfully */ -export const zGetAppsByAppIdWorkflowsResponse = zWorkflowPagination +export const zGetAppsByAppIdWorkflowsResponse = zWorkflowPaginationResponse export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({ app_id: z.string(), @@ -2702,7 +3173,7 @@ export const zGetAppsByAppIdWorkflowsDraftPath = z.object({ /** * Draft workflow retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDraftResponse = zWorkflow +export const zGetAppsByAppIdWorkflowsDraftResponse = zWorkflowResponse export const zPostAppsByAppIdWorkflowsDraftBody = zSyncDraftWorkflowPayload @@ -2863,7 +3334,8 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ /** * Node last run retrieved successfully */ -export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse = zWorkflowRunNodeExecution +export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse + = zWorkflowRunNodeExecutionResponse export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunBody = zDraftWorkflowNodeRunPayload @@ -2875,7 +3347,8 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ /** * Node run started successfully */ -export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse = zWorkflowRunNodeExecution +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse + = zWorkflowRunNodeExecutionResponse export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath = z.object({ app_id: z.string(), @@ -2891,8 +3364,8 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse = z.r ) export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ - node_id: z.string(), app_id: z.string(), + node_id: z.string(), }) /** @@ -2900,7 +3373,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.obje */ export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ @@ -2963,15 +3436,15 @@ export const zDeleteAppsByAppIdWorkflowsDraftVariablesPath = z.object({ /** * Workflow variables deleted successfully */ -export const zDeleteAppsByAppIdWorkflowsDraftVariablesResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByAppIdWorkflowsDraftVariablesResponse = z.record(z.string(), z.never()) export const zGetAppsByAppIdWorkflowsDraftVariablesPath = z.object({ app_id: z.string(), }) export const zGetAppsByAppIdWorkflowsDraftVariablesQuery = z.object({ - page: z.int().gte(1).lte(100000).optional().default(1), limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).lte(100000).optional().default(1), }) /** @@ -2989,7 +3462,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.objec */ export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ @@ -3022,7 +3495,7 @@ export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetPath = z.obj export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([ zWorkflowDraftVariable, - z.record(z.string(), z.unknown()), + z.record(z.string(), z.never()), ]) export const zGetAppsByAppIdWorkflowsPublishPath = z.object({ @@ -3030,9 +3503,9 @@ export const zGetAppsByAppIdWorkflowsPublishPath = z.object({ }) /** - * Published workflow retrieved successfully + * Published workflow retrieved successfully, or null if not found */ -export const zGetAppsByAppIdWorkflowsPublishResponse = zWorkflow +export const zGetAppsByAppIdWorkflowsPublishResponse = zWorkflowResponse export const zPostAppsByAppIdWorkflowsPublishBody = zPublishWorkflowPayload @@ -3061,8 +3534,8 @@ export const zGetAppsByAppIdWorkflowsTriggersWebhookQuery = z.object({ export const zGetAppsByAppIdWorkflowsTriggersWebhookResponse = zWebhookTriggerResponse export const zDeleteAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ - workflow_id: z.string(), app_id: z.string(), + workflow_id: z.string(), }) /** @@ -3080,7 +3553,7 @@ export const zPatchAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ /** * Workflow updated successfully */ -export const zPatchAppsByAppIdWorkflowsByWorkflowIdResponse = zWorkflow +export const zPatchAppsByAppIdWorkflowsByWorkflowIdResponse = zWorkflowResponse export const zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath = z.object({ app_id: z.string(), @@ -3114,14 +3587,14 @@ export const zPostAppsByResourceIdApiKeysPath = z.object({ export const zPostAppsByResourceIdApiKeysResponse = zApiKeyItem export const zDeleteAppsByResourceIdApiKeysByApiKeyIdPath = z.object({ - resource_id: z.string(), api_key_id: z.string(), + resource_id: z.string(), }) /** * API key deleted successfully */ -export const zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse = z.record(z.string(), z.never()) export const zGetAppsByServerIdServerRefreshPath = z.object({ server_id: z.string(), diff --git a/packages/contracts/generated/api/console/auth/orpc.gen.ts b/packages/contracts/generated/api/console/auth/orpc.gen.ts index 7a95a96f10..a17a733d82 100644 --- a/packages/contracts/generated/api/console/auth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/auth/orpc.gen.ts @@ -30,8 +30,16 @@ import { zPostAuthPluginDatasourceByProviderIdUpdateResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAuthPluginDatasourceDefaultList', @@ -44,8 +52,16 @@ export const defaultList = { get, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAuthPluginDatasourceList', @@ -58,8 +74,16 @@ export const list = { get: get2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete_ = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAuthPluginDatasourceByProviderIdCustomClient', @@ -69,8 +93,16 @@ export const delete_ = oc .input(z.object({ params: zDeleteAuthPluginDatasourceByProviderIdCustomClientPath })) .output(zDeleteAuthPluginDatasourceByProviderIdCustomClientResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdCustomClient', @@ -90,8 +122,16 @@ export const customClient = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdDefault', @@ -110,8 +150,16 @@ export const default_ = { post: post2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdDelete', @@ -130,8 +178,16 @@ export const delete2 = { post: post3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdUpdate', @@ -150,8 +206,16 @@ export const update = { post: post4, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderIdUpdateName', @@ -170,8 +234,16 @@ export const updateName = { post: post5, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAuthPluginDatasourceByProviderId', @@ -181,8 +253,16 @@ export const get3 = oc .input(z.object({ params: zGetAuthPluginDatasourceByProviderIdPath })) .output(zGetAuthPluginDatasourceByProviderIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAuthPluginDatasourceByProviderId', diff --git a/packages/contracts/generated/api/console/billing/orpc.gen.ts b/packages/contracts/generated/api/console/billing/orpc.gen.ts index 09d25c072e..501f8a4e46 100644 --- a/packages/contracts/generated/api/console/billing/orpc.gen.ts +++ b/packages/contracts/generated/api/console/billing/orpc.gen.ts @@ -11,8 +11,16 @@ import { zPutBillingPartnersByPartnerKeyTenantsResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getBillingInvoices', @@ -27,10 +35,16 @@ export const invoices = { /** * Sync partner tenants bindings + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const put = oc .route({ - description: 'Sync partner tenants bindings', + deprecated: true, + description: + 'Sync partner tenants bindings\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putBillingPartnersByPartnerKeyTenants', @@ -57,8 +71,16 @@ export const partners = { byPartnerKey, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getBillingSubscription', diff --git a/packages/contracts/generated/api/console/compliance/orpc.gen.ts b/packages/contracts/generated/api/console/compliance/orpc.gen.ts index e68c87e7eb..ec7a9be60f 100644 --- a/packages/contracts/generated/api/console/compliance/orpc.gen.ts +++ b/packages/contracts/generated/api/console/compliance/orpc.gen.ts @@ -7,10 +7,16 @@ import { zGetComplianceDownloadQuery, zGetComplianceDownloadResponse } from './z /** * Get compliance document download link + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get compliance document download link', + deprecated: true, + description: + 'Get compliance document download link\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getComplianceDownload', diff --git a/packages/contracts/generated/api/console/data-source/orpc.gen.ts b/packages/contracts/generated/api/console/data-source/orpc.gen.ts index 209447236a..dceb32433d 100644 --- a/packages/contracts/generated/api/console/data-source/orpc.gen.ts +++ b/packages/contracts/generated/api/console/data-source/orpc.gen.ts @@ -12,8 +12,16 @@ import { zPatchDataSourceIntegratesResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDataSourceIntegratesByBindingIdByAction', @@ -23,8 +31,16 @@ export const get = oc .input(z.object({ params: zGetDataSourceIntegratesByBindingIdByActionPath })) .output(zGetDataSourceIntegratesByBindingIdByActionResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDataSourceIntegratesByBindingIdByAction', @@ -43,8 +59,16 @@ export const byBindingId = { byAction, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDataSourceIntegrates', @@ -53,8 +77,16 @@ export const get2 = oc }) .output(zGetDataSourceIntegratesResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDataSourceIntegrates', diff --git a/packages/contracts/generated/api/console/data-source/types.gen.ts b/packages/contracts/generated/api/console/data-source/types.gen.ts index db83d81ec1..b8e57e212a 100644 --- a/packages/contracts/generated/api/console/data-source/types.gen.ts +++ b/packages/contracts/generated/api/console/data-source/types.gen.ts @@ -39,8 +39,8 @@ export type PatchDataSourceIntegratesResponse export type GetDataSourceIntegratesByBindingIdByActionData = { body?: never path: { - binding_id: string action: string + binding_id: string } query?: never url: '/data-source/integrates/{binding_id}/{action}' @@ -58,8 +58,8 @@ export type GetDataSourceIntegratesByBindingIdByActionResponse export type PatchDataSourceIntegratesByBindingIdByActionData = { body?: never path: { - binding_id: string action: string + binding_id: string } query?: never url: '/data-source/integrates/{binding_id}/{action}' diff --git a/packages/contracts/generated/api/console/data-source/zod.gen.ts b/packages/contracts/generated/api/console/data-source/zod.gen.ts index 1684b7e637..b98caa256c 100644 --- a/packages/contracts/generated/api/console/data-source/zod.gen.ts +++ b/packages/contracts/generated/api/console/data-source/zod.gen.ts @@ -13,8 +13,8 @@ export const zGetDataSourceIntegratesResponse = z.record(z.string(), z.unknown() export const zPatchDataSourceIntegratesResponse = z.record(z.string(), z.unknown()) export const zGetDataSourceIntegratesByBindingIdByActionPath = z.object({ - binding_id: z.string(), action: z.string(), + binding_id: z.string(), }) /** @@ -23,8 +23,8 @@ export const zGetDataSourceIntegratesByBindingIdByActionPath = z.object({ export const zGetDataSourceIntegratesByBindingIdByActionResponse = z.record(z.string(), z.unknown()) export const zPatchDataSourceIntegratesByBindingIdByActionPath = z.object({ - binding_id: z.string(), action: z.string(), + binding_id: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/datasets/orpc.gen.ts b/packages/contracts/generated/api/console/datasets/orpc.gen.ts index 37a0b7cb8c..6a2bcb7720 100644 --- a/packages/contracts/generated/api/console/datasets/orpc.gen.ts +++ b/packages/contracts/generated/api/console/datasets/orpc.gen.ts @@ -187,10 +187,16 @@ import { /** * Get dataset API base information + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get dataset API base information', + deprecated: true, + description: + 'Get dataset API base information\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsApiBaseInfo', @@ -253,8 +259,16 @@ export const apiKeys = { byApiKeyId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsBatchImportStatusByJobId', @@ -264,8 +278,16 @@ export const get3 = oc .input(z.object({ params: zGetDatasetsBatchImportStatusByJobIdPath })) .output(zGetDatasetsBatchImportStatusByJobIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsBatchImportStatusByJobId', @@ -291,10 +313,16 @@ export const batchImportStatus = { /** * Create external knowledge dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post3 = oc .route({ - description: 'Create external knowledge dataset', + deprecated: true, + description: + 'Create external knowledge dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsExternal', @@ -311,10 +339,16 @@ export const external = { /** * Check if external knowledge API is being used + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get4 = oc .route({ - description: 'Check if external knowledge API is being used', + deprecated: true, + description: + 'Check if external knowledge API is being used\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheck', @@ -328,8 +362,16 @@ export const useCheck = { get: get4, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiId', @@ -341,10 +383,16 @@ export const delete2 = oc /** * Get external knowledge API template details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get5 = oc .route({ - description: 'Get external knowledge API template details', + deprecated: true, + description: + 'Get external knowledge API template details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsExternalKnowledgeApiByExternalKnowledgeApiId', @@ -354,8 +402,16 @@ export const get5 = oc .input(z.object({ params: zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath })) .output(zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsExternalKnowledgeApiByExternalKnowledgeApiId', @@ -379,10 +435,16 @@ export const byExternalKnowledgeApiId = { /** * Get external knowledge API templates + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ - description: 'Get external knowledge API templates', + deprecated: true, + description: + 'Get external knowledge API templates\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsExternalKnowledgeApi', @@ -392,8 +454,16 @@ export const get6 = oc .input(z.object({ query: zGetDatasetsExternalKnowledgeApiQuery.optional() })) .output(zGetDatasetsExternalKnowledgeApiResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsExternalKnowledgeApi', @@ -411,10 +481,16 @@ export const externalKnowledgeApi = { /** * Estimate dataset indexing cost + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post5 = oc .route({ - description: 'Estimate dataset indexing cost', + deprecated: true, + description: + 'Estimate dataset indexing cost\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsIndexingEstimate', @@ -430,10 +506,16 @@ export const indexingEstimate = { /** * Initialize dataset with documents + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post6 = oc .route({ - description: 'Initialize dataset with documents', + deprecated: true, + description: + 'Initialize dataset with documents\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsInit', @@ -448,8 +530,16 @@ export const init = { post: post6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsMetadataBuiltIn', @@ -466,8 +556,16 @@ export const metadata = { builtIn, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsNotionIndexingEstimate', @@ -476,8 +574,16 @@ export const get8 = oc }) .output(zGetDatasetsNotionIndexingEstimateResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsNotionIndexingEstimate', @@ -494,10 +600,16 @@ export const notionIndexingEstimate = { /** * Get dataset document processing rules + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get9 = oc .route({ - description: 'Get dataset document processing rules', + deprecated: true, + description: + 'Get dataset document processing rules\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsProcessRule', @@ -513,10 +625,16 @@ export const processRule = { /** * Get mock dataset retrieval settings by vector type + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get10 = oc .route({ - description: 'Get mock dataset retrieval settings by vector type', + deprecated: true, + description: + 'Get mock dataset retrieval settings by vector type\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsRetrievalSettingByVectorType', @@ -532,10 +650,16 @@ export const byVectorType = { /** * Get dataset retrieval settings + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get11 = oc .route({ - description: 'Get dataset retrieval settings', + deprecated: true, + description: + 'Get dataset retrieval settings\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsRetrievalSetting', @@ -549,8 +673,16 @@ export const retrievalSetting = { byVectorType, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdApiKeysByStatus', @@ -570,10 +702,16 @@ export const apiKeys2 = { /** * Get dataset auto disable logs + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get12 = oc .route({ - description: 'Get dataset auto disable logs', + deprecated: true, + description: + 'Get dataset auto disable logs\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdAutoDisableLogs', @@ -587,8 +725,16 @@ export const autoDisableLogs = { get: get12, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get13 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdBatchByBatchIndexingEstimate', @@ -602,8 +748,16 @@ export const indexingEstimate2 = { get: get13, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get14 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdBatchByBatchIndexingStatus', @@ -630,10 +784,16 @@ export const batch = { * Stream a ZIP archive containing the requested uploaded documents * * Download selected dataset documents as a single ZIP archive (upload-file only) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post9 = oc .route({ - description: 'Download selected dataset documents as a single ZIP archive (upload-file only)', + deprecated: true, + description: + 'Download selected dataset documents as a single ZIP archive (upload-file only)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsDownloadZip', @@ -660,11 +820,16 @@ export const downloadZip = { * This endpoint checks if the dataset configuration supports summary generation * (indexing_technique must be 'high_quality' and summary_index_setting.enable must be true), * then asynchronously generates summary indexes for the provided documents. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post10 = oc .route({ + deprecated: true, description: - 'Generate summary index for documents\nThis endpoint checks if the dataset configuration supports summary generation\n(indexing_technique must be \'high_quality\' and summary_index_setting.enable must be true),\nthen asynchronously generates summary indexes for the provided documents.', + 'Generate summary index for documents\nThis endpoint checks if the dataset configuration supports summary generation\n(indexing_technique must be \'high_quality\' and summary_index_setting.enable must be true),\nthen asynchronously generates summary indexes for the provided documents.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsGenerateSummary', @@ -684,8 +849,16 @@ export const generateSummary = { post: post10, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsMetadata', @@ -704,8 +877,16 @@ export const metadata2 = { post: post11, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsStatusByActionBatch', @@ -729,10 +910,16 @@ export const status = { /** * Get a signed download URL for a dataset document's original uploaded file + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get15 = oc .route({ - description: 'Get a signed download URL for a dataset document\'s original uploaded file', + deprecated: true, + description: + 'Get a signed download URL for a dataset document\'s original uploaded file\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdDownload', @@ -748,10 +935,16 @@ export const download = { /** * Estimate document indexing cost + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get16 = oc .route({ - description: 'Estimate document indexing cost', + deprecated: true, + description: + 'Estimate document indexing cost\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimate', @@ -767,10 +960,16 @@ export const indexingEstimate3 = { /** * Get document indexing status + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get17 = oc .route({ - description: 'Get document indexing status', + deprecated: true, + description: + 'Get document indexing status\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatus', @@ -786,10 +985,16 @@ export const indexingStatus2 = { /** * Update document metadata + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const put = oc .route({ - description: 'Update document metadata', + deprecated: true, + description: + 'Update document metadata\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putDatasetsByDatasetIdDocumentsByDocumentIdMetadata', @@ -808,8 +1013,16 @@ export const metadata3 = { put, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get18 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdNotionSync', @@ -827,8 +1040,16 @@ export const notion = { sync, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get19 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLog', @@ -844,9 +1065,16 @@ export const pipelineExecutionLog = { /** * pause document + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPause', @@ -863,9 +1091,16 @@ export const pause = { /** * recover document + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResume', @@ -882,10 +1117,16 @@ export const resume = { /** * Update document processing status (pause/resume) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch5 = oc .route({ - description: 'Update document processing status (pause/resume)', + deprecated: true, + description: + 'Update document processing status (pause/resume)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByAction', @@ -925,8 +1166,16 @@ export const rename = { post: post12, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByAction', @@ -940,8 +1189,16 @@ export const byAction3 = { patch: patch6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post13 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegment', @@ -961,8 +1218,16 @@ export const segment = { byAction: byAction3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get20 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImport', @@ -972,8 +1237,16 @@ export const get20 = oc .input(z.object({ params: zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPath })) .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post14 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImport', @@ -993,8 +1266,16 @@ export const batchImport = { post: post14, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: @@ -1012,8 +1293,16 @@ export const delete3 = oc zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse, ) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: @@ -1037,8 +1326,16 @@ export const byChildChunkId = { patch: patch7, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get21 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', @@ -1052,8 +1349,16 @@ export const get21 = oc ) .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', @@ -1067,8 +1372,16 @@ export const patch8 = oc ) .output(zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post15 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', @@ -1090,8 +1403,16 @@ export const childChunks = { byChildChunkId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', @@ -1103,8 +1424,16 @@ export const delete4 = oc ) .output(zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', @@ -1125,8 +1454,16 @@ export const bySegmentId = { childChunks, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocumentsByDocumentIdSegments', @@ -1136,8 +1473,16 @@ export const delete5 = oc .input(z.object({ params: zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath })) .output(zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get22 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegments', @@ -1166,11 +1511,16 @@ export const segments = { * - error: Number of summaries with errors * - not_started: Number of segments without summary records * - summaries: List of summary records with status and content preview + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get23 = oc .route({ + deprecated: true, description: - 'Get summary index generation status for a document\nReturns:\n- total_segments: Total number of segments in the document\n- summary_status: Dictionary with status counts\n - completed: Number of summaries completed\n - generating: Number of summaries being generated\n - error: Number of summaries with errors\n - not_started: Number of segments without summary records\n- summaries: List of summary records with status and content preview', + 'Get summary index generation status for a document\nReturns:\n- total_segments: Total number of segments in the document\n- summary_status: Dictionary with status counts\n - completed: Number of summaries completed\n - generating: Number of summaries being generated\n - error: Number of summaries with errors\n - not_started: Number of segments without summary records\n- summaries: List of summary records with status and content preview\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatus', @@ -1187,9 +1537,16 @@ export const summaryStatus = { /** * sync website document + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get24 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSync', @@ -1204,8 +1561,16 @@ export const websiteSync = { get: get24, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocumentsByDocumentId', @@ -1217,10 +1582,16 @@ export const delete6 = oc /** * Get document details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get25 = oc .route({ - description: 'Get document details', + deprecated: true, + description: + 'Get document details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentId', @@ -1252,8 +1623,16 @@ export const byDocumentId = { websiteSync, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocuments', @@ -1265,10 +1644,16 @@ export const delete7 = oc /** * Get documents in a dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get26 = oc .route({ - description: 'Get documents in a dataset', + deprecated: true, + description: + 'Get documents in a dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocuments', @@ -1283,8 +1668,16 @@ export const get26 = oc ) .output(zGetDatasetsByDatasetIdDocumentsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post16 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocuments', @@ -1312,10 +1705,16 @@ export const documents = { /** * Get dataset error documents + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get27 = oc .route({ - description: 'Get dataset error documents', + deprecated: true, + description: + 'Get dataset error documents\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdErrorDocs', @@ -1331,10 +1730,16 @@ export const errorDocs = { /** * Test external knowledge retrieval for dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post17 = oc .route({ - description: 'Test external knowledge retrieval for dataset', + deprecated: true, + description: + 'Test external knowledge retrieval for dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdExternalHitTesting', @@ -1355,10 +1760,16 @@ export const externalHitTesting = { /** * Test dataset knowledge retrieval + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post18 = oc .route({ - description: 'Test dataset knowledge retrieval', + deprecated: true, + description: + 'Test dataset knowledge retrieval\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdHitTesting', @@ -1379,10 +1790,16 @@ export const hitTesting = { /** * Get dataset indexing status + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get28 = oc .route({ - description: 'Get dataset indexing status', + deprecated: true, + description: + 'Get dataset indexing status\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdIndexingStatus', @@ -1396,8 +1813,16 @@ export const indexingStatus3 = { get: get28, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post19 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdMetadataBuiltInByAction', @@ -1415,8 +1840,16 @@ export const builtIn2 = { byAction: byAction4, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdMetadataByMetadataId', @@ -1426,8 +1859,16 @@ export const delete8 = oc .input(z.object({ params: zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath })) .output(zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdMetadataByMetadataId', @@ -1447,8 +1888,16 @@ export const byMetadataId = { patch: patch10, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get29 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdMetadata', @@ -1458,8 +1907,16 @@ export const get29 = oc .input(z.object({ params: zGetDatasetsByDatasetIdMetadataPath })) .output(zGetDatasetsByDatasetIdMetadataResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post20 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdMetadata', @@ -1481,8 +1938,16 @@ export const metadata4 = { byMetadataId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get30 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdNotionSync', @@ -1502,10 +1967,16 @@ export const notion2 = { /** * Get dataset permission user list + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get31 = oc .route({ - description: 'Get dataset permission user list', + deprecated: true, + description: + 'Get dataset permission user list\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdPermissionPartUsers', @@ -1521,10 +1992,16 @@ export const permissionPartUsers = { /** * Get dataset query history + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get32 = oc .route({ - description: 'Get dataset query history', + deprecated: true, + description: + 'Get dataset query history\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdQueries', @@ -1540,10 +2017,16 @@ export const queries = { /** * Get applications related to dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get33 = oc .route({ - description: 'Get applications related to dataset', + deprecated: true, + description: + 'Get applications related to dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdRelatedApps', @@ -1559,9 +2042,16 @@ export const relatedApps = { /** * retry document + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post21 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdRetry', @@ -1583,10 +2073,16 @@ export const retry = { /** * Check if dataset is in use + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get34 = oc .route({ - description: 'Check if dataset is in use', + deprecated: true, + description: + 'Check if dataset is in use\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdUseCheck', @@ -1600,8 +2096,16 @@ export const useCheck2 = { get: get34, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetId', @@ -1613,10 +2117,16 @@ export const delete9 = oc /** * Get dataset details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get35 = oc .route({ - description: 'Get dataset details', + deprecated: true, + description: + 'Get dataset details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetId', @@ -1628,10 +2138,16 @@ export const get35 = oc /** * Update dataset details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch11 = oc .route({ - description: 'Update dataset details', + deprecated: true, + description: + 'Update dataset details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetId', @@ -1734,10 +2250,16 @@ export const byResourceId = { /** * Get list of datasets + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get37 = oc .route({ - description: 'Get list of datasets', + deprecated: true, + description: + 'Get list of datasets\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasets', @@ -1749,10 +2271,16 @@ export const get37 = oc /** * Create a new dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post23 = oc .route({ - description: 'Create a new dataset', + deprecated: true, + description: + 'Create a new dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasets', diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 89a68593b7..4b1ff07660 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -41,7 +41,45 @@ export type ExternalDatasetCreatePayload = { } export type DatasetDetail = { - [key: string]: unknown + app_count?: number + author_name?: string + built_in_field_enabled?: boolean + chunk_structure?: string + created_at?: { + [key: string]: unknown + } + created_by?: string + data_source_type?: string + description?: string + doc_form?: string + doc_metadata?: Array + document_count?: number + embedding_available?: boolean + embedding_model?: string + embedding_model_provider?: string + enable_api?: boolean + external_knowledge_info?: ExternalKnowledgeInfo + external_retrieval_model?: ExternalRetrievalModel + icon_info?: DatasetIconInfo + id?: string + indexing_technique?: string + is_multimodal?: boolean + is_published?: boolean + name?: string + permission?: string + pipeline_id?: string + provider?: string + retrieval_model_dict?: DatasetRetrievalModel + runtime_mode?: string + summary_index_setting?: AnonymousInlineModelB1954337D565 + tags?: Array + total_available_documents?: number + total_documents?: number + updated_at?: { + [key: string]: unknown + } + updated_by?: string + word_count?: number } export type ExternalKnowledgeApiPayload = { @@ -71,7 +109,7 @@ export type KnowledgeConfig = { duplicate?: boolean embedding_model?: string | null embedding_model_provider?: string | null - indexing_technique: 'high_quality' | 'economy' + indexing_technique: 'economy' | 'high_quality' is_multimodal?: boolean name?: string | null original_document_id?: string | null @@ -224,7 +262,7 @@ export type HitTestingResponse = { export type MetadataArgs = { name: string - type: 'string' | 'number' | 'time' + type: 'number' | 'string' | 'time' } export type MetadataUpdatePayload = { @@ -232,18 +270,77 @@ export type MetadataUpdatePayload = { } export type DatasetQueryDetail = { - [key: string]: unknown + created_at?: { + [key: string]: unknown + } + created_by?: string + created_by_role?: string + id?: string + queries?: DatasetContent + source?: string + source_app_id?: string } export type RelatedAppList = { - [key: string]: unknown + data?: Array + total?: number } export type DocumentRetryPayload = { document_ids: Array } -export type DatasetPermissionEnum = 'only_me' | 'all_team_members' | 'partial_members' +export type DatasetPermissionEnum = 'all_team_members' | 'only_me' | 'partial_members' + +export type DatasetDocMetadata = { + id?: string + name?: string + type?: string +} + +export type ExternalKnowledgeInfo = { + external_knowledge_api_endpoint?: string + external_knowledge_api_id?: string + external_knowledge_api_name?: string + external_knowledge_id?: string +} + +export type ExternalRetrievalModel = { + score_threshold?: number + score_threshold_enabled?: boolean + top_k?: number +} + +export type DatasetIconInfo = { + icon?: string + icon_background?: string + icon_type?: string + icon_url?: string +} + +export type DatasetRetrievalModel = { + reranking_enable?: boolean + reranking_mode?: string + reranking_model?: DatasetRerankingModel + score_threshold?: number + score_threshold_enabled?: boolean + search_method?: string + top_k?: number + weights?: DatasetWeightedScore +} + +export type AnonymousInlineModelB1954337D565 = { + enable?: boolean + model_name?: string + model_provider_name?: string + summary_prompt?: string +} + +export type Tag = { + id: string + name: string + type: string +} export type DataSource = { info_list: InfoList @@ -255,6 +352,7 @@ export type ProcessRule = { } export type RetrievalModel = { + metadata_filtering_conditions?: MetadataFilteringCondition reranking_enable: boolean reranking_mode?: string | null reranking_model?: RerankingModel @@ -298,8 +396,38 @@ export type HitTestingRecord = { tsne_position?: unknown } +export type DatasetContent = { + content?: string + content_type?: string + file_info?: DatasetFileInfo +} + +export type AppDetailKernel = { + description?: string + icon?: string + icon_background?: string + icon_type?: string + icon_url?: { + [key: string]: unknown + } + id?: string + mode?: string + name?: string +} + +export type DatasetRerankingModel = { + reranking_model_name?: string + reranking_provider_name?: string +} + +export type DatasetWeightedScore = { + keyword_setting?: DatasetKeywordSetting + vector_setting?: DatasetVectorSetting + weight_type?: string +} + export type InfoList = { - data_source_type: 'upload_file' | 'notion_import' | 'website_crawl' + data_source_type: 'notion_import' | 'upload_file' | 'website_crawl' file_info_list?: FileInfo notion_info_list?: Array | null website_info_list?: WebsiteInfo @@ -312,21 +440,26 @@ export type Rule = { subchunk_segmentation?: Segmentation } +export type MetadataFilteringCondition = { + conditions?: Array | null + logical_operator?: 'and' | 'or' | null +} + export type RerankingModel = { reranking_model_name?: string | null reranking_provider_name?: string | null } export type RetrievalMethod - = | 'semantic_search' - | 'full_text_search' + = | 'full_text_search' | 'hybrid_search' | 'keyword_search' + | 'semantic_search' export type WeightModel = { keyword_setting?: WeightKeywordSetting vector_setting?: WeightVectorSetting - weight_type?: 'semantic_first' | 'keyword_first' | 'customized' | null + weight_type?: 'customized' | 'keyword_first' | 'semantic_first' | null } export type MetadataDetail = { @@ -377,6 +510,25 @@ export type HitTestingSegment = { word_count?: number | null } +export type DatasetFileInfo = { + extension?: string + id?: string + mime_type?: string + name?: string + size?: number + source_url?: string +} + +export type DatasetKeywordSetting = { + keyword_weight?: number +} + +export type DatasetVectorSetting = { + embedding_model_name?: string + embedding_provider_name?: string + vector_weight?: number +} + export type FileInfo = { file_ids: Array } @@ -405,6 +557,30 @@ export type Segmentation = { separator?: string } +export type Condition = { + comparison_operator: + | '<' + | '=' + | '>' + | 'after' + | 'before' + | 'contains' + | 'empty' + | 'end with' + | 'in' + | 'is' + | 'is not' + | 'not contains' + | 'not empty' + | 'not in' + | 'start with' + | '≠' + | '≤' + | '≥' + name: string + value?: unknown +} + export type WeightKeywordSetting = { keyword_weight: number } @@ -440,12 +616,12 @@ export type GetDatasetsData = { body?: never path?: never query?: { - page?: string - limit?: string ids?: string - keyword?: string - tag_ids?: string include_all?: string + keyword?: string + limit?: string + page?: string + tag_ids?: string } url: '/datasets' } @@ -544,7 +720,7 @@ export type DeleteDatasetsApiKeysByApiKeyIdData = { export type DeleteDatasetsApiKeysByApiKeyIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -616,9 +792,9 @@ export type GetDatasetsExternalKnowledgeApiData = { body?: never path?: never query?: { - page?: string - limit?: string keyword?: string + limit?: string + page?: string } url: '/datasets/external-knowledge-api' } @@ -989,8 +1165,8 @@ export type GetDatasetsByDatasetIdAutoDisableLogsResponse export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateData = { body?: never path: { - dataset_id: string batch: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/batch/{batch}/indexing-estimate' @@ -1008,8 +1184,8 @@ export type GetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse export type GetDatasetsByDatasetIdBatchByBatchIndexingStatusData = { body?: never path: { - dataset_id: string batch: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/batch/{batch}/indexing-status' @@ -1048,11 +1224,11 @@ export type GetDatasetsByDatasetIdDocumentsData = { dataset_id: string } query?: { - page?: string - limit?: string - keyword?: string - sort?: string fetch?: string + keyword?: string + limit?: string + page?: string + sort?: string status?: string } url: '/datasets/{dataset_id}/documents' @@ -1155,8 +1331,8 @@ export type PostDatasetsByDatasetIdDocumentsMetadataResponse export type PatchDatasetsByDatasetIdDocumentsStatusByActionBatchData = { body?: never path: { - dataset_id: string action: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/documents/status/{action}/batch' @@ -1408,9 +1584,9 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeRespons export type PatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionData = { body?: never path: { + action: string dataset_id: string document_id: string - action: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/processing/{action}' @@ -1476,9 +1652,9 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponse export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionData = { body?: never path: { + action: string dataset_id: string document_id: string - action: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/segment/{action}' @@ -1673,10 +1849,10 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi = { body?: never path: { + child_chunk_id: string dataset_id: string document_id: string segment_id: string - child_chunk_id: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' @@ -1696,10 +1872,10 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil = { body: ChildChunkUpdatePayload path: { + child_chunk_id: string dataset_id: string document_id: string segment_id: string - child_chunk_id: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' @@ -1904,8 +2080,8 @@ export type PostDatasetsByDatasetIdMetadataResponse export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { body?: never path: { - dataset_id: string action: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/metadata/built-in/{action}' @@ -2118,8 +2294,8 @@ export type PostDatasetsByResourceIdApiKeysResponse export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdData = { body?: never path: { - resource_id: string api_key_id: string + resource_id: string } query?: never url: '/datasets/{resource_id}/api-keys/{api_key_id}' @@ -2127,7 +2303,7 @@ export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdData = { export type DeleteDatasetsByResourceIdApiKeysByApiKeyIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 2ac2cbfd1f..e26f412da9 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -38,8 +38,6 @@ export const zExternalDatasetCreatePayload = z.object({ name: z.string().min(1).max(100), }) -export const zDatasetDetail = z.record(z.string(), z.unknown()) - /** * ExternalKnowledgeApiPayload */ @@ -151,7 +149,7 @@ export const zExternalHitTestingPayload = z.object({ */ export const zMetadataArgs = z.object({ name: z.string(), - type: z.enum(['string', 'number', 'time']), + type: z.enum(['number', 'string', 'time']), }) /** @@ -161,10 +159,6 @@ export const zMetadataUpdatePayload = z.object({ name: z.string(), }) -export const zDatasetQueryDetail = z.record(z.string(), z.unknown()) - -export const zRelatedAppList = z.record(z.string(), z.unknown()) - /** * DocumentRetryPayload */ @@ -175,7 +169,7 @@ export const zDocumentRetryPayload = z.object({ /** * DatasetPermissionEnum */ -export const zDatasetPermissionEnum = z.enum(['only_me', 'all_team_members', 'partial_members']) +export const zDatasetPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_members']) /** * DatasetCreatePayload @@ -210,6 +204,48 @@ export const zDatasetUpdatePayload = z.object({ summary_index_setting: z.record(z.string(), z.unknown()).nullish(), }) +export const zDatasetDocMetadata = z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), +}) + +export const zExternalKnowledgeInfo = z.object({ + external_knowledge_api_endpoint: z.string().optional(), + external_knowledge_api_id: z.string().optional(), + external_knowledge_api_name: z.string().optional(), + external_knowledge_id: z.string().optional(), +}) + +export const zExternalRetrievalModel = z.object({ + score_threshold: z.number().optional(), + score_threshold_enabled: z.boolean().optional(), + top_k: z.int().optional(), +}) + +export const zDatasetIconInfo = z.object({ + icon: z.string().optional(), + icon_background: z.string().optional(), + icon_type: z.string().optional(), + icon_url: z.string().optional(), +}) + +export const zAnonymousInlineModelB1954337D565 = z.object({ + enable: z.boolean().optional(), + model_name: z.string().optional(), + model_provider_name: z.string().optional(), + summary_prompt: z.string().optional(), +}) + +/** + * Tag + */ +export const zTag = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), +}) + /** * DatasetResponse */ @@ -273,6 +309,27 @@ export const zDatasetAndDocumentResponse = z.object({ documents: z.array(zDocumentResponse), }) +export const zAppDetailKernel = z.object({ + description: z.string().optional(), + icon: z.string().optional(), + icon_background: z.string().optional(), + icon_type: z.string().optional(), + icon_url: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + mode: z.string().optional(), + name: z.string().optional(), +}) + +export const zRelatedAppList = z.object({ + data: z.array(zAppDetailKernel).optional(), + total: z.int().optional(), +}) + +export const zDatasetRerankingModel = z.object({ + reranking_model_name: z.string().optional(), + reranking_provider_name: z.string().optional(), +}) + /** * RerankingModel */ @@ -285,10 +342,10 @@ export const zRerankingModel = z.object({ * RetrievalMethod */ export const zRetrievalMethod = z.enum([ - 'semantic_search', 'full_text_search', 'hybrid_search', 'keyword_search', + 'semantic_search', ]) /** @@ -340,6 +397,96 @@ export const zHitTestingFile = z.object({ source_url: z.string().nullish(), }) +export const zDatasetFileInfo = z.object({ + extension: z.string().optional(), + id: z.string().optional(), + mime_type: z.string().optional(), + name: z.string().optional(), + size: z.int().optional(), + source_url: z.string().optional(), +}) + +export const zDatasetContent = z.object({ + content: z.string().optional(), + content_type: z.string().optional(), + file_info: zDatasetFileInfo.optional(), +}) + +export const zDatasetQueryDetail = z.object({ + created_at: z.record(z.string(), z.unknown()).optional(), + created_by: z.string().optional(), + created_by_role: z.string().optional(), + id: z.string().optional(), + queries: zDatasetContent.optional(), + source: z.string().optional(), + source_app_id: z.string().optional(), +}) + +export const zDatasetKeywordSetting = z.object({ + keyword_weight: z.number().optional(), +}) + +export const zDatasetVectorSetting = z.object({ + embedding_model_name: z.string().optional(), + embedding_provider_name: z.string().optional(), + vector_weight: z.number().optional(), +}) + +export const zDatasetWeightedScore = z.object({ + keyword_setting: zDatasetKeywordSetting.optional(), + vector_setting: zDatasetVectorSetting.optional(), + weight_type: z.string().optional(), +}) + +export const zDatasetRetrievalModel = z.object({ + reranking_enable: z.boolean().optional(), + reranking_mode: z.string().optional(), + reranking_model: zDatasetRerankingModel.optional(), + score_threshold: z.number().optional(), + score_threshold_enabled: z.boolean().optional(), + search_method: z.string().optional(), + top_k: z.int().optional(), + weights: zDatasetWeightedScore.optional(), +}) + +export const zDatasetDetail = z.object({ + app_count: z.int().optional(), + author_name: z.string().optional(), + built_in_field_enabled: z.boolean().optional(), + chunk_structure: z.string().optional(), + created_at: z.record(z.string(), z.unknown()).optional(), + created_by: z.string().optional(), + data_source_type: z.string().optional(), + description: z.string().optional(), + doc_form: z.string().optional(), + doc_metadata: z.array(zDatasetDocMetadata).optional(), + document_count: z.int().optional(), + embedding_available: z.boolean().optional(), + embedding_model: z.string().optional(), + embedding_model_provider: z.string().optional(), + enable_api: z.boolean().optional(), + external_knowledge_info: zExternalKnowledgeInfo.optional(), + external_retrieval_model: zExternalRetrievalModel.optional(), + icon_info: zDatasetIconInfo.optional(), + id: z.string().optional(), + indexing_technique: z.string().optional(), + is_multimodal: z.boolean().optional(), + is_published: z.boolean().optional(), + name: z.string().optional(), + permission: z.string().optional(), + pipeline_id: z.string().optional(), + provider: z.string().optional(), + retrieval_model_dict: zDatasetRetrievalModel.optional(), + runtime_mode: z.string().optional(), + summary_index_setting: zAnonymousInlineModelB1954337D565.optional(), + tags: z.array(zTag).optional(), + total_available_documents: z.int().optional(), + total_documents: z.int().optional(), + updated_at: z.record(z.string(), z.unknown()).optional(), + updated_by: z.string().optional(), + word_count: z.int().optional(), +}) + /** * FileInfo */ @@ -392,6 +539,46 @@ export const zProcessRule = z.object({ rules: zRule.optional(), }) +/** + * Condition + * + * Condition detail + */ +export const zCondition = z.object({ + comparison_operator: z.enum([ + '<', + '=', + '>', + 'after', + 'before', + 'contains', + 'empty', + 'end with', + 'in', + 'is', + 'is not', + 'not contains', + 'not empty', + 'not in', + 'start with', + '≠', + '≤', + '≥', + ]), + name: z.string(), + value: z.unknown().optional(), +}) + +/** + * MetadataFilteringCondition + * + * Metadata Filtering Condition. + */ +export const zMetadataFilteringCondition = z.object({ + conditions: z.array(zCondition).nullish(), + logical_operator: z.enum(['and', 'or']).nullish().default('and'), +}) + /** * WeightKeywordSetting */ @@ -414,13 +601,14 @@ export const zWeightVectorSetting = z.object({ export const zWeightModel = z.object({ keyword_setting: zWeightKeywordSetting.optional(), vector_setting: zWeightVectorSetting.optional(), - weight_type: z.enum(['semantic_first', 'keyword_first', 'customized']).nullish(), + weight_type: z.enum(['customized', 'keyword_first', 'semantic_first']).nullish(), }) /** * RetrievalModel */ export const zRetrievalModel = z.object({ + metadata_filtering_conditions: zMetadataFilteringCondition.optional(), reranking_enable: z.boolean(), reranking_mode: z.string().nullish(), reranking_model: zRerankingModel.optional(), @@ -533,7 +721,7 @@ export const zNotionInfo = z.object({ * InfoList */ export const zInfoList = z.object({ - data_source_type: z.enum(['upload_file', 'notion_import', 'website_crawl']), + data_source_type: z.enum(['notion_import', 'upload_file', 'website_crawl']), file_info_list: zFileInfo.optional(), notion_info_list: z.array(zNotionInfo).nullish(), website_info_list: zWebsiteInfo.optional(), @@ -556,7 +744,7 @@ export const zKnowledgeConfig = z.object({ duplicate: z.boolean().optional().default(true), embedding_model: z.string().nullish(), embedding_model_provider: z.string().nullish(), - indexing_technique: z.enum(['high_quality', 'economy']), + indexing_technique: z.enum(['economy', 'high_quality']), is_multimodal: z.boolean().optional().default(false), name: z.string().nullish(), original_document_id: z.string().nullish(), @@ -566,12 +754,12 @@ export const zKnowledgeConfig = z.object({ }) export const zGetDatasetsQuery = z.object({ - page: z.string().optional(), - limit: z.string().optional(), ids: z.string().optional(), - keyword: z.string().optional(), - tag_ids: z.string().optional(), include_all: z.string().optional(), + keyword: z.string().optional(), + limit: z.string().optional(), + page: z.string().optional(), + tag_ids: z.string().optional(), }) /** @@ -608,7 +796,7 @@ export const zDeleteDatasetsApiKeysByApiKeyIdPath = z.object({ /** * API key deleted successfully */ -export const zDeleteDatasetsApiKeysByApiKeyIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteDatasetsApiKeysByApiKeyIdResponse = z.record(z.string(), z.never()) export const zGetDatasetsBatchImportStatusByJobIdPath = z.object({ job_id: z.string(), @@ -638,9 +826,9 @@ export const zPostDatasetsExternalBody = zExternalDatasetCreatePayload export const zPostDatasetsExternalResponse = zDatasetDetail export const zGetDatasetsExternalKnowledgeApiQuery = z.object({ - page: z.string().optional(), - limit: z.string().optional(), keyword: z.string().optional(), + limit: z.string().optional(), + page: z.string().optional(), }) /** @@ -809,8 +997,8 @@ export const zGetDatasetsByDatasetIdAutoDisableLogsPath = z.object({ export const zGetDatasetsByDatasetIdAutoDisableLogsResponse = z.record(z.string(), z.unknown()) export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimatePath = z.object({ - dataset_id: z.string(), batch: z.string(), + dataset_id: z.string(), }) /** @@ -822,8 +1010,8 @@ export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse = z.rec ) export const zGetDatasetsByDatasetIdBatchByBatchIndexingStatusPath = z.object({ - dataset_id: z.string(), batch: z.string(), + dataset_id: z.string(), }) /** @@ -848,11 +1036,11 @@ export const zGetDatasetsByDatasetIdDocumentsPath = z.object({ }) export const zGetDatasetsByDatasetIdDocumentsQuery = z.object({ - page: z.string().optional(), - limit: z.string().optional(), - keyword: z.string().optional(), - sort: z.string().optional(), fetch: z.string().optional(), + keyword: z.string().optional(), + limit: z.string().optional(), + page: z.string().optional(), + sort: z.string().optional(), status: z.string().optional(), }) @@ -912,8 +1100,8 @@ export const zPostDatasetsByDatasetIdDocumentsMetadataPath = z.object({ export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = z.record(z.string(), z.unknown()) export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath = z.object({ - dataset_id: z.string(), action: z.string(), + dataset_id: z.string(), }) /** @@ -1062,9 +1250,9 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeRespo ) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionPath = z.object({ + action: z.string(), dataset_id: z.string(), document_id: z.string(), - action: z.string(), }) /** @@ -1103,9 +1291,9 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponse = z.re ) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionPath = z.object({ + action: z.string(), dataset_id: z.string(), document_id: z.string(), - action: z.string(), }) /** @@ -1246,10 +1434,10 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = z.object({ + child_chunk_id: z.string(), dataset_id: z.string(), document_id: z.string(), segment_id: z.string(), - child_chunk_id: z.string(), }) /** @@ -1263,10 +1451,10 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = z.object({ + child_chunk_id: z.string(), dataset_id: z.string(), document_id: z.string(), segment_id: z.string(), - child_chunk_id: z.string(), }) /** @@ -1362,8 +1550,8 @@ export const zPostDatasetsByDatasetIdMetadataPath = z.object({ export const zPostDatasetsByDatasetIdMetadataResponse = z.record(z.string(), z.unknown()) export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ - dataset_id: z.string(), action: z.string(), + dataset_id: z.string(), }) /** @@ -1477,14 +1665,11 @@ export const zPostDatasetsByResourceIdApiKeysPath = z.object({ export const zPostDatasetsByResourceIdApiKeysResponse = zApiKeyItem export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdPath = z.object({ - resource_id: z.string(), api_key_id: z.string(), + resource_id: z.string(), }) /** * API key deleted successfully */ -export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdResponse = z.record(z.string(), z.never()) diff --git a/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts b/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts index 54edabc29f..e1d0c2c319 100644 --- a/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/orpc.gen.ts @@ -10,8 +10,16 @@ import { zPostEmailCodeLoginValidityResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailCodeLoginValidity', @@ -25,8 +33,16 @@ export const validity = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailCodeLogin', diff --git a/packages/contracts/generated/api/console/email-code-login/types.gen.ts b/packages/contracts/generated/api/console/email-code-login/types.gen.ts index 851a8d568d..f1413a611d 100644 --- a/packages/contracts/generated/api/console/email-code-login/types.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/types.gen.ts @@ -13,6 +13,7 @@ export type EmailCodeLoginPayload = { code: string email: string language?: string | null + timezone?: string | null token: string } diff --git a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts index 0ff511c722..83f1abed45 100644 --- a/packages/contracts/generated/api/console/email-code-login/zod.gen.ts +++ b/packages/contracts/generated/api/console/email-code-login/zod.gen.ts @@ -17,6 +17,7 @@ export const zEmailCodeLoginPayload = z.object({ code: z.string(), email: z.string(), language: z.string().nullish(), + timezone: z.string().nullish(), token: z.string(), }) diff --git a/packages/contracts/generated/api/console/email-register/orpc.gen.ts b/packages/contracts/generated/api/console/email-register/orpc.gen.ts index 0bd724aba9..b00fdb1c63 100644 --- a/packages/contracts/generated/api/console/email-register/orpc.gen.ts +++ b/packages/contracts/generated/api/console/email-register/orpc.gen.ts @@ -8,8 +8,16 @@ import { zPostEmailRegisterValidityResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailRegisterSendEmail', @@ -22,8 +30,16 @@ export const sendEmail = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailRegisterValidity', @@ -36,8 +52,16 @@ export const validity = { post: post2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailRegister', diff --git a/packages/contracts/generated/api/console/explore/orpc.gen.ts b/packages/contracts/generated/api/console/explore/orpc.gen.ts index 4b37a0a4fd..4933c1ec12 100644 --- a/packages/contracts/generated/api/console/explore/orpc.gen.ts +++ b/packages/contracts/generated/api/console/explore/orpc.gen.ts @@ -11,8 +11,16 @@ import { zGetExploreBannersResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getExploreAppsByAppId', @@ -44,9 +52,16 @@ export const apps = { /** * Get banner list + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getExploreBanners', diff --git a/packages/contracts/generated/api/console/explore/types.gen.ts b/packages/contracts/generated/api/console/explore/types.gen.ts index db56dbdac0..329c1f7722 100644 --- a/packages/contracts/generated/api/console/explore/types.gen.ts +++ b/packages/contracts/generated/api/console/explore/types.gen.ts @@ -13,7 +13,7 @@ export type RecommendedAppResponse = { app?: RecommendedAppInfoResponse app_id: string can_trial?: boolean | null - category?: string | null + categories?: Array copyright?: string | null custom_disclaimer?: string | null description?: string | null @@ -35,7 +35,7 @@ export type GetExploreAppsData = { body?: never path?: never query?: { - language?: string | null + language?: string } url: '/explore/apps' } diff --git a/packages/contracts/generated/api/console/explore/zod.gen.ts b/packages/contracts/generated/api/console/explore/zod.gen.ts index 2ceb54e7bd..c65c47be91 100644 --- a/packages/contracts/generated/api/console/explore/zod.gen.ts +++ b/packages/contracts/generated/api/console/explore/zod.gen.ts @@ -21,7 +21,7 @@ export const zRecommendedAppResponse = z.object({ app: zRecommendedAppInfoResponse.optional(), app_id: z.string(), can_trial: z.boolean().nullish(), - category: z.string().nullish(), + categories: z.array(z.string()).optional(), copyright: z.string().nullish(), custom_disclaimer: z.string().nullish(), description: z.string().nullish(), @@ -39,7 +39,7 @@ export const zRecommendedAppListResponse = z.object({ }) export const zGetExploreAppsQuery = z.object({ - language: z.string().nullish(), + language: z.string().optional(), }) /** diff --git a/packages/contracts/generated/api/console/features/orpc.gen.ts b/packages/contracts/generated/api/console/features/orpc.gen.ts index e24ec3d964..e859a3c684 100644 --- a/packages/contracts/generated/api/console/features/orpc.gen.ts +++ b/packages/contracts/generated/api/console/features/orpc.gen.ts @@ -8,10 +8,16 @@ import { zGetFeaturesResponse } from './zod.gen' * Get feature configuration for current tenant * * Get feature configuration for current tenant + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get feature configuration for current tenant', + deprecated: true, + description: + 'Get feature configuration for current tenant\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFeatures', diff --git a/packages/contracts/generated/api/console/features/types.gen.ts b/packages/contracts/generated/api/console/features/types.gen.ts index eed18d5344..4c3ba1c00f 100644 --- a/packages/contracts/generated/api/console/features/types.gen.ts +++ b/packages/contracts/generated/api/console/features/types.gen.ts @@ -5,7 +5,9 @@ export type ClientOptions = { } export type FeatureResponse = { - [key: string]: unknown + features?: { + [key: string]: unknown + } } export type GetFeaturesData = { diff --git a/packages/contracts/generated/api/console/features/zod.gen.ts b/packages/contracts/generated/api/console/features/zod.gen.ts index 1e967fb879..7bfc4debdd 100644 --- a/packages/contracts/generated/api/console/features/zod.gen.ts +++ b/packages/contracts/generated/api/console/features/zod.gen.ts @@ -2,7 +2,9 @@ import * as z from 'zod' -export const zFeatureResponse = z.record(z.string(), z.unknown()) +export const zFeatureResponse = z.object({ + features: z.record(z.string(), z.unknown()).optional(), +}) /** * Success diff --git a/packages/contracts/generated/api/console/files/orpc.gen.ts b/packages/contracts/generated/api/console/files/orpc.gen.ts index 2ee949edc2..a46dcca46e 100644 --- a/packages/contracts/generated/api/console/files/orpc.gen.ts +++ b/packages/contracts/generated/api/console/files/orpc.gen.ts @@ -11,8 +11,16 @@ import { zPostFilesUploadResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFilesSupportType', @@ -51,8 +59,16 @@ export const upload = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFilesByFileIdPreview', diff --git a/packages/contracts/generated/api/console/form/orpc.gen.ts b/packages/contracts/generated/api/console/form/orpc.gen.ts index f6d76b28c0..0d30da9d00 100644 --- a/packages/contracts/generated/api/console/form/orpc.gen.ts +++ b/packages/contracts/generated/api/console/form/orpc.gen.ts @@ -14,10 +14,16 @@ import { * Get human input form definition by form token * * GET /console/api/form/human_input/ + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'GET /console/api/form/human_input/', + deprecated: true, + description: + 'GET /console/api/form/human_input/\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -40,11 +46,16 @@ export const get = oc * }, * "action": "Approve" * } + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ + deprecated: true, description: - 'POST /console/api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', + 'POST /console/api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', diff --git a/packages/contracts/generated/api/console/info/orpc.gen.ts b/packages/contracts/generated/api/console/info/orpc.gen.ts index 4eb342e9cf..0ef5bd4856 100644 --- a/packages/contracts/generated/api/console/info/orpc.gen.ts +++ b/packages/contracts/generated/api/console/info/orpc.gen.ts @@ -4,8 +4,16 @@ import { oc } from '@orpc/contract' import { zPostInfoResponse } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInfo', diff --git a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts index c2b0b2eb37..f0efa7abbc 100644 --- a/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/orpc.gen.ts @@ -67,8 +67,16 @@ import { zPostInstalledAppsResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdAudioToText', @@ -82,8 +90,16 @@ export const audioToText = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdChatMessagesByTaskIdStop', @@ -101,8 +117,16 @@ export const byTaskId = { stop, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdChatMessages', @@ -122,8 +146,16 @@ export const chatMessages = { byTaskId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStop', @@ -141,8 +173,16 @@ export const byTaskId2 = { stop: stop2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdCompletionMessages', @@ -162,8 +202,16 @@ export const completionMessages = { byTaskId: byTaskId2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdConversationsByCIdName', @@ -182,8 +230,16 @@ export const name = { post: post6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchInstalledAppsByInstalledAppIdConversationsByCIdPin', @@ -197,8 +253,16 @@ export const pin = { patch, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchInstalledAppsByInstalledAppIdConversationsByCIdUnpin', @@ -212,8 +276,16 @@ export const unpin = { patch: patch2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete_ = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteInstalledAppsByInstalledAppIdConversationsByCId', @@ -230,8 +302,16 @@ export const byCId = { unpin, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdConversations', @@ -251,8 +331,16 @@ export const conversations = { byCId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacks', @@ -271,8 +359,16 @@ export const feedbacks = { post: post7, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThis', @@ -291,8 +387,16 @@ export const moreLikeThis = { get: get2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestions', @@ -316,8 +420,16 @@ export const byMessageId = { suggestedQuestions, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMessages', @@ -339,9 +451,16 @@ export const messages = { /** * Get app meta + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdMeta', @@ -358,9 +477,16 @@ export const meta = { /** * Retrieve app parameters + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdParameters', @@ -375,8 +501,16 @@ export const parameters = { get: get6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteInstalledAppsByInstalledAppIdSavedMessagesByMessageId', @@ -390,8 +524,16 @@ export const byMessageId2 = { delete: delete2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInstalledAppsByInstalledAppIdSavedMessages', @@ -406,8 +548,16 @@ export const get7 = oc ) .output(zGetInstalledAppsByInstalledAppIdSavedMessagesResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdSavedMessages', @@ -428,8 +578,16 @@ export const savedMessages = { byMessageId: byMessageId2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdTextToAudio', @@ -450,9 +608,16 @@ export const textToAudio = { /** * Run workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdWorkflowsRun', @@ -474,9 +639,16 @@ export const run = { /** * Stop workflow task + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStop', @@ -504,8 +676,16 @@ export const workflows = { tasks, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteInstalledAppsByInstalledAppId', @@ -515,8 +695,16 @@ export const delete3 = oc .input(z.object({ params: zDeleteInstalledAppsByInstalledAppIdPath })) .output(zDeleteInstalledAppsByInstalledAppIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchInstalledAppsByInstalledAppId', @@ -551,8 +739,16 @@ export const get8 = oc }) .output(zGetInstalledAppsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post12 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstalledApps', diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 897fc29b8b..22f9903bc8 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -43,7 +43,7 @@ export type ConversationRenamePayload = { export type MessageFeedbackPayload = { content?: string | null message_id: string - rating?: 'like' | 'dislike' | null + rating?: 'dislike' | 'like' | null } export type SavedMessageCreatePayload = { @@ -267,8 +267,8 @@ export type GetInstalledAppsByInstalledAppIdConversationsResponse export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdData = { body?: never path: { - installed_app_id: string c_id: string + installed_app_id: string } query?: never url: '/installed-apps/{installed_app_id}/conversations/{c_id}' @@ -286,8 +286,8 @@ export type DeleteInstalledAppsByInstalledAppIdConversationsByCIdResponse export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameData = { body: ConversationRenamePayload path: { - installed_app_id: string c_id: string + installed_app_id: string } query?: never url: '/installed-apps/{installed_app_id}/conversations/{c_id}/name' @@ -305,8 +305,8 @@ export type PostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse export type PatchInstalledAppsByInstalledAppIdConversationsByCIdPinData = { body?: never path: { - installed_app_id: string c_id: string + installed_app_id: string } query?: never url: '/installed-apps/{installed_app_id}/conversations/{c_id}/pin' @@ -324,8 +324,8 @@ export type PatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponse export type PatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinData = { body?: never path: { - installed_app_id: string c_id: string + installed_app_id: string } query?: never url: '/installed-apps/{installed_app_id}/conversations/{c_id}/unpin' diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index c8683e092c..d688bba246 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -41,7 +41,7 @@ export const zConversationRenamePayload = z.object({ export const zMessageFeedbackPayload = z.object({ content: z.string().nullish(), message_id: z.string(), - rating: z.enum(['like', 'dislike']).nullish(), + rating: z.enum(['dislike', 'like']).nullish(), }) /** @@ -216,8 +216,8 @@ export const zGetInstalledAppsByInstalledAppIdConversationsResponse = z.record( ) export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath = z.object({ - installed_app_id: z.string(), c_id: z.string(), + installed_app_id: z.string(), }) /** @@ -232,8 +232,8 @@ export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameBody = zConversationRenamePayload export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNamePath = z.object({ - installed_app_id: z.string(), c_id: z.string(), + installed_app_id: z.string(), }) /** @@ -245,8 +245,8 @@ export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse = ) export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinPath = z.object({ - installed_app_id: z.string(), c_id: z.string(), + installed_app_id: z.string(), }) /** @@ -258,8 +258,8 @@ export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponse = ) export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinPath = z.object({ - installed_app_id: z.string(), c_id: z.string(), + installed_app_id: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts b/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts index 3aff6a9a3b..f91220f369 100644 --- a/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/orpc.gen.ts @@ -12,10 +12,16 @@ import { /** * Get instruction generation template + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Get instruction generation template', + deprecated: true, + description: + 'Get instruction generation template\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstructionGenerateTemplate', @@ -31,10 +37,16 @@ export const template = { /** * Generate instruction for workflow nodes or general use + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post2 = oc .route({ - description: 'Generate instruction for workflow nodes or general use', + deprecated: true, + description: + 'Generate instruction for workflow nodes or general use\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postInstructionGenerate', diff --git a/packages/contracts/generated/api/console/instruction-generate/types.gen.ts b/packages/contracts/generated/api/console/instruction-generate/types.gen.ts index 1dd3530d44..c17d0c451b 100644 --- a/packages/contracts/generated/api/console/instruction-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/types.gen.ts @@ -19,33 +19,15 @@ export type InstructionTemplatePayload = { } export type ModelConfig = { - agent_mode_dict?: JsonValue - annotation_reply_dict?: JsonValue - chat_prompt_config_dict?: JsonValue - completion_prompt_config_dict?: JsonValue - created_at?: number | null - created_by?: string | null - dataset_configs_dict?: JsonValue - dataset_query_variable?: string | null - external_data_tools_list?: JsonValue - file_upload_dict?: JsonValue - model_dict?: JsonValue - more_like_this_dict?: JsonValue - opening_statement?: string | null - pre_prompt?: string | null - prompt_type?: string | null - retriever_resource_dict?: JsonValue - sensitive_word_avoidance_dict?: JsonValue - speech_to_text_dict?: JsonValue - suggested_questions_after_answer_dict?: JsonValue - suggested_questions_list?: JsonValue - text_to_speech_dict?: JsonValue - updated_at?: number | null - updated_by?: string | null - user_input_form_list?: JsonValue + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string } -export type JsonValue = unknown +export type LlmMode = 'chat' | 'completion' export type PostInstructionGenerateData = { body: InstructionGeneratePayload diff --git a/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts b/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts index 35135fdcf7..b24c9b178f 100644 --- a/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/instruction-generate/zod.gen.ts @@ -9,36 +9,21 @@ export const zInstructionTemplatePayload = z.object({ type: z.string(), }) -export const zJsonValue = z.unknown() +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) /** * ModelConfig */ export const zModelConfig = z.object({ - agent_mode_dict: zJsonValue.optional(), - annotation_reply_dict: zJsonValue.optional(), - chat_prompt_config_dict: zJsonValue.optional(), - completion_prompt_config_dict: zJsonValue.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - dataset_configs_dict: zJsonValue.optional(), - dataset_query_variable: z.string().nullish(), - external_data_tools_list: zJsonValue.optional(), - file_upload_dict: zJsonValue.optional(), - model_dict: zJsonValue.optional(), - more_like_this_dict: zJsonValue.optional(), - opening_statement: z.string().nullish(), - pre_prompt: z.string().nullish(), - prompt_type: z.string().nullish(), - retriever_resource_dict: zJsonValue.optional(), - sensitive_word_avoidance_dict: zJsonValue.optional(), - speech_to_text_dict: zJsonValue.optional(), - suggested_questions_after_answer_dict: zJsonValue.optional(), - suggested_questions_list: zJsonValue.optional(), - text_to_speech_dict: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - user_input_form_list: zJsonValue.optional(), + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/login/orpc.gen.ts b/packages/contracts/generated/api/console/login/orpc.gen.ts index b8e647a11d..0fb7cbf4bb 100644 --- a/packages/contracts/generated/api/console/login/orpc.gen.ts +++ b/packages/contracts/generated/api/console/login/orpc.gen.ts @@ -7,9 +7,16 @@ import { zPostLoginBody, zPostLoginResponse } from './zod.gen' /** * Authenticate user and login + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postLogin', diff --git a/packages/contracts/generated/api/console/logout/orpc.gen.ts b/packages/contracts/generated/api/console/logout/orpc.gen.ts index 02ecd2c82d..0fe1f3b909 100644 --- a/packages/contracts/generated/api/console/logout/orpc.gen.ts +++ b/packages/contracts/generated/api/console/logout/orpc.gen.ts @@ -4,8 +4,16 @@ import { oc } from '@orpc/contract' import { zPostLogoutResponse } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postLogout', diff --git a/packages/contracts/generated/api/console/mcp/orpc.gen.ts b/packages/contracts/generated/api/console/mcp/orpc.gen.ts index 211e8cb2f2..6508e34a42 100644 --- a/packages/contracts/generated/api/console/mcp/orpc.gen.ts +++ b/packages/contracts/generated/api/console/mcp/orpc.gen.ts @@ -4,8 +4,16 @@ import { oc } from '@orpc/contract' import { zGetMcpOauthCallbackResponse } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMcpOauthCallback', diff --git a/packages/contracts/generated/api/console/notification/orpc.gen.ts b/packages/contracts/generated/api/console/notification/orpc.gen.ts index f7125346cf..c7d55612af 100644 --- a/packages/contracts/generated/api/console/notification/orpc.gen.ts +++ b/packages/contracts/generated/api/console/notification/orpc.gen.ts @@ -6,10 +6,16 @@ import { zGetNotificationResponse, zPostNotificationDismissResponse } from './zo /** * Mark a notification as dismissed for the current user. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Mark a notification as dismissed for the current user.', + deprecated: true, + description: + 'Mark a notification as dismissed for the current user.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postNotificationDismiss', @@ -24,11 +30,16 @@ export const dismiss = { /** * Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ + deprecated: true, description: - 'Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.', + 'Return the active in-product notification for the current user in their interface language (falls back to English if unavailable). The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getNotification', diff --git a/packages/contracts/generated/api/console/notion/orpc.gen.ts b/packages/contracts/generated/api/console/notion/orpc.gen.ts index b8de9e89f2..8c5dc03c9e 100644 --- a/packages/contracts/generated/api/console/notion/orpc.gen.ts +++ b/packages/contracts/generated/api/console/notion/orpc.gen.ts @@ -12,8 +12,16 @@ import { zPostNotionPagesByPageIdByPageTypePreviewResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getNotionPagesByPageIdByPageTypePreview', @@ -23,8 +31,16 @@ export const get = oc .input(z.object({ params: zGetNotionPagesByPageIdByPageTypePreviewPath })) .output(zGetNotionPagesByPageIdByPageTypePreviewResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postNotionPagesByPageIdByPageTypePreview', @@ -56,8 +72,16 @@ export const pages = { byPageId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getNotionPreImportPages', diff --git a/packages/contracts/generated/api/console/oauth/orpc.gen.ts b/packages/contracts/generated/api/console/oauth/orpc.gen.ts index b9a57e3e88..5ef03ccb96 100644 --- a/packages/contracts/generated/api/console/oauth/orpc.gen.ts +++ b/packages/contracts/generated/api/console/oauth/orpc.gen.ts @@ -38,10 +38,16 @@ import { /** * Handle OAuth callback and complete login process + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Handle OAuth callback and complete login process', + deprecated: true, + description: + 'Handle OAuth callback and complete login process\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthAuthorizeByProvider', @@ -94,10 +100,16 @@ export const binding = { /** * Handle OAuth callback from data source provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get3 = oc .route({ - description: 'Handle OAuth callback from data source provider', + deprecated: true, + description: + 'Handle OAuth callback from data source provider\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthDataSourceCallbackByProvider', @@ -171,10 +183,16 @@ export const dataSource = { /** * Initiate OAuth login process + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ - description: 'Initiate OAuth login process', + deprecated: true, + description: + 'Initiate OAuth login process\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthLoginByProvider', @@ -197,8 +215,16 @@ export const login = { byProvider: byProvider5, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderIdDatasourceCallback', @@ -212,8 +238,16 @@ export const callback2 = { get: get7, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderIdDatasourceGetAuthorizationUrl', @@ -236,8 +270,16 @@ export const byProviderId = { datasource, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderToolAuthorizationUrl', @@ -251,8 +293,16 @@ export const authorizationUrl = { get: get9, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderToolCallback', @@ -273,9 +323,16 @@ export const tool = { /** * Handle OAuth callback for trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getOauthPluginByProviderTriggerCallback', @@ -304,8 +361,16 @@ export const plugin = { byProvider: byProvider6, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProviderAccount', @@ -318,8 +383,16 @@ export const account = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProviderAuthorize', @@ -332,8 +405,16 @@ export const authorize2 = { post: post2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProviderToken', @@ -346,8 +427,16 @@ export const token = { post: post3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postOauthProvider', diff --git a/packages/contracts/generated/api/console/oauth/types.gen.ts b/packages/contracts/generated/api/console/oauth/types.gen.ts index 7091cdab7c..15bad82f5e 100644 --- a/packages/contracts/generated/api/console/oauth/types.gen.ts +++ b/packages/contracts/generated/api/console/oauth/types.gen.ts @@ -134,8 +134,8 @@ export type GetOauthDataSourceByProviderResponse export type GetOauthDataSourceByProviderByBindingIdSyncData = { body?: never path: { - provider: string binding_id: string + provider: string } query?: never url: '/oauth/data-source/{provider}/{binding_id}/sync' diff --git a/packages/contracts/generated/api/console/oauth/zod.gen.ts b/packages/contracts/generated/api/console/oauth/zod.gen.ts index 22f2c4bd76..a96b7e3382 100644 --- a/packages/contracts/generated/api/console/oauth/zod.gen.ts +++ b/packages/contracts/generated/api/console/oauth/zod.gen.ts @@ -74,8 +74,8 @@ export const zGetOauthDataSourceByProviderPath = z.object({ export const zGetOauthDataSourceByProviderResponse = zOAuthDataSourceResponse export const zGetOauthDataSourceByProviderByBindingIdSyncPath = z.object({ - provider: z.string(), binding_id: z.string(), + provider: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts new file mode 100644 index 0000000000..06a4d79563 --- /dev/null +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -0,0 +1,95 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { account } from './account/orpc.gen' +import { activate } from './activate/orpc.gen' +import { allWorkspaces } from './all-workspaces/orpc.gen' +import { apiBasedExtension } from './api-based-extension/orpc.gen' +import { apiKeyAuth } from './api-key-auth/orpc.gen' +import { app } from './app/orpc.gen' +import { apps } from './apps/orpc.gen' +import { auth } from './auth/orpc.gen' +import { billing } from './billing/orpc.gen' +import { codeBasedExtension } from './code-based-extension/orpc.gen' +import { compliance } from './compliance/orpc.gen' +import { dataSource } from './data-source/orpc.gen' +import { datasets } from './datasets/orpc.gen' +import { emailCodeLogin } from './email-code-login/orpc.gen' +import { emailRegister } from './email-register/orpc.gen' +import { explore } from './explore/orpc.gen' +import { features } from './features/orpc.gen' +import { files } from './files/orpc.gen' +import { forgotPassword } from './forgot-password/orpc.gen' +import { form } from './form/orpc.gen' +import { info } from './info/orpc.gen' +import { installedApps } from './installed-apps/orpc.gen' +import { instructionGenerate } from './instruction-generate/orpc.gen' +import { login } from './login/orpc.gen' +import { logout } from './logout/orpc.gen' +import { mcp } from './mcp/orpc.gen' +import { notification } from './notification/orpc.gen' +import { notion } from './notion/orpc.gen' +import { oauth } from './oauth/orpc.gen' +import { rag } from './rag/orpc.gen' +import { refreshToken } from './refresh-token/orpc.gen' +import { remoteFiles } from './remote-files/orpc.gen' +import { resetPassword } from './reset-password/orpc.gen' +import { ruleCodeGenerate } from './rule-code-generate/orpc.gen' +import { ruleGenerate } from './rule-generate/orpc.gen' +import { ruleStructuredOutputGenerate } from './rule-structured-output-generate/orpc.gen' +import { spec } from './spec/orpc.gen' +import { systemFeatures } from './system-features/orpc.gen' +import { tagBindings } from './tag-bindings/orpc.gen' +import { tags } from './tags/orpc.gen' +import { test } from './test/orpc.gen' +import { trialApps } from './trial-apps/orpc.gen' +import { website } from './website/orpc.gen' +import { workflow } from './workflow/orpc.gen' +import { workspaces } from './workspaces/orpc.gen' + +export const contract = { + account, + activate, + allWorkspaces, + apiBasedExtension, + apiKeyAuth, + app, + apps, + auth, + billing, + codeBasedExtension, + compliance, + dataSource, + datasets, + emailCodeLogin, + emailRegister, + explore, + features, + files, + forgotPassword, + form, + info, + installedApps, + instructionGenerate, + login, + logout, + mcp, + notification, + notion, + oauth, + rag, + refreshToken, + remoteFiles, + resetPassword, + ruleCodeGenerate, + ruleGenerate, + ruleStructuredOutputGenerate, + spec, + systemFeatures, + tagBindings, + tags, + test, + trialApps, + website, + workflow, + workspaces, +} diff --git a/packages/contracts/generated/api/console/rag/orpc.gen.ts b/packages/contracts/generated/api/console/rag/orpc.gen.ts index a642a91ba1..c522f1d060 100644 --- a/packages/contracts/generated/api/console/rag/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rag/orpc.gen.ts @@ -118,8 +118,16 @@ import { zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete_ = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelineCustomizedTemplatesByTemplateId', @@ -129,8 +137,16 @@ export const delete_ = oc .input(z.object({ params: zDeleteRagPipelineCustomizedTemplatesByTemplateIdPath })) .output(zDeleteRagPipelineCustomizedTemplatesByTemplateIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchRagPipelineCustomizedTemplatesByTemplateId', @@ -140,8 +156,16 @@ export const patch = oc .input(z.object({ params: zPatchRagPipelineCustomizedTemplatesByTemplateIdPath })) .output(zPatchRagPipelineCustomizedTemplatesByTemplateIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelineCustomizedTemplatesByTemplateId', @@ -165,8 +189,16 @@ export const customized = { templates, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelineDataset', @@ -180,8 +212,16 @@ export const dataset = { post: post2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelineEmptyDataset', @@ -194,8 +234,16 @@ export const emptyDataset = { post: post3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelineTemplatesByTemplateId', @@ -209,8 +257,16 @@ export const byTemplateId2 = { get, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelineTemplates', @@ -231,8 +287,16 @@ export const pipeline = { templates: templates2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesDatasourcePlugins', @@ -245,8 +309,16 @@ export const datasourcePlugins = { get: get3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesImportsByImportIdConfirm', @@ -264,8 +336,16 @@ export const byImportId = { confirm, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesImportsByPipelineIdCheckDependencies', @@ -283,8 +363,16 @@ export const byPipelineId = { checkDependencies, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesImports', @@ -300,8 +388,16 @@ export const imports = { byPipelineId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesRecommendedPlugins', @@ -314,8 +410,16 @@ export const recommendedPlugins = { get: get5, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesTransformDatasetsByDatasetId', @@ -337,8 +441,16 @@ export const transform = { datasets, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post7 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdCustomizedPublish', @@ -361,8 +473,16 @@ export const customized2 = { publish, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdExports', @@ -378,9 +498,16 @@ export const exports_ = { /** * Stop workflow task + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStop', @@ -465,9 +592,16 @@ export const workflowRuns = { /** * Get default block config + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockType', @@ -488,9 +622,16 @@ export const byBlockType = { /** * Get default block config + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigs', @@ -508,9 +649,16 @@ export const defaultWorkflowBlockConfigs = { /** * Run rag pipeline datasource + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRun', @@ -540,9 +688,16 @@ export const nodes = { /** * Set datasource variables + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspect', @@ -567,8 +722,16 @@ export const datasource = { variablesInspect, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get12 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariables', @@ -584,9 +747,16 @@ export const environmentVariables = { /** * Run draft workflow iteration node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRun', @@ -620,9 +790,16 @@ export const iteration = { /** * Run draft workflow loop node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post12 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRun', @@ -671,9 +848,16 @@ export const lastRun = { /** * Run draft workflow node + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post13 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRun', @@ -693,8 +877,16 @@ export const run4 = { post: post13, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables', @@ -706,8 +898,16 @@ export const delete2 = oc ) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get14 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables', @@ -734,9 +934,16 @@ export const nodes4 = { /** * Get first step parameters of rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get15 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParameters', @@ -759,9 +966,16 @@ export const preProcessing = { /** * Get second step parameters of rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get16 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftProcessingParameters', @@ -782,9 +996,16 @@ export const processing = { /** * Run draft workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post14 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraftRun', @@ -804,8 +1025,16 @@ export const run5 = { post: post14, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get17 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftSystemVariables', @@ -819,8 +1048,16 @@ export const systemVariables = { get: get17, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const put = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdReset', @@ -836,8 +1073,16 @@ export const reset = { put, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableId', @@ -849,8 +1094,16 @@ export const delete3 = oc ) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get18 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableId', @@ -860,8 +1113,16 @@ export const get18 = oc .input(z.object({ params: zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath })) .output(zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableId', @@ -880,8 +1141,16 @@ export const byVariableId = { reset, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsDraftVariables', @@ -891,8 +1160,16 @@ export const delete4 = oc .input(z.object({ params: zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath })) .output(zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get19 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraftVariables', @@ -910,9 +1187,16 @@ export const variables2 = { /** * Get draft rag pipeline's workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get20 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsDraft', @@ -925,9 +1209,16 @@ export const get20 = oc /** * Sync draft workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post15 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsDraft', @@ -955,9 +1246,16 @@ export const draft = { /** * Get published pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get21 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsPublish', @@ -970,9 +1268,16 @@ export const get21 = oc /** * Publish workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post16 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublish', @@ -990,9 +1295,16 @@ export const publish2 = { /** * Run datasource content preview + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post17 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreview', @@ -1014,9 +1326,16 @@ export const preview = { /** * Run rag pipeline datasource + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post18 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRun', @@ -1051,9 +1370,16 @@ export const datasource2 = { /** * Get first step parameters of rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get22 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParameters', @@ -1076,9 +1402,16 @@ export const preProcessing2 = { /** * Get second step parameters of rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get23 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflowsPublishedProcessingParameters', @@ -1101,9 +1434,16 @@ export const processing2 = { /** * Run published workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post19 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsPublishedRun', @@ -1130,8 +1470,16 @@ export const published = { run: run7, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post20 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestore', @@ -1147,9 +1495,16 @@ export const restore = { /** * Delete a published workflow version that is not currently active on the pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const delete5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteRagPipelinesByPipelineIdWorkflowsByWorkflowId', @@ -1162,9 +1517,16 @@ export const delete5 = oc /** * Update workflow attributes + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchRagPipelinesByPipelineIdWorkflowsByWorkflowId', @@ -1183,9 +1545,16 @@ export const byWorkflowId = { /** * Get published workflows + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get24 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRagPipelinesByPipelineIdWorkflows', diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index e300ff443c..b25272ed7a 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -28,6 +28,64 @@ export type Payload = { name: string } +export type WorkflowRunPaginationResponse = { + data: Array + has_more: boolean + limit: number +} + +export type WorkflowRunDetailResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + exceptions_count?: number | null + finished_at?: number | null + graph: unknown + id: string + inputs: unknown + outputs: unknown + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type WorkflowRunNodeExecutionListResponse = { + data: Array +} + +export type WorkflowPaginationResponse = { + has_more: boolean + items: Array + limit: number + page: number +} + +export type WorkflowResponse = { + conversation_variables: Array + created_at: number + created_by?: SimpleAccount + environment_variables: Array + features: { + [key: string]: unknown + } + graph: { + [key: string]: unknown + } + hash: string + id: string + marked_comment: string + marked_name: string + rag_pipeline_variables: Array + tool_published: boolean + updated_at: number + updated_by?: SimpleAccount + version: string +} + export type DatasourceNodeRunPayload = { credential_id?: string | null datasource_type: string @@ -45,6 +103,31 @@ export type DatasourceVariablesPayload = { start_node_title: string } +export type WorkflowRunNodeExecutionResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + execution_metadata?: unknown + extras?: unknown + finished_at?: number | null + id: string + index?: number | null + inputs?: unknown + inputs_truncated?: boolean | null + node_id?: string | null + node_type?: string | null + outputs?: unknown + outputs_truncated?: boolean | null + predecessor_node_id?: string | null + process_data?: unknown + process_data_truncated?: boolean | null + status?: string | null + title?: string | null +} + export type NodeRunPayload = { inputs?: { [key: string]: unknown @@ -86,10 +169,76 @@ export type PublishedWorkflowRunPayload = { } is_preview?: boolean original_document_id?: string | null - response_mode?: 'streaming' | 'blocking' + response_mode?: 'blocking' | 'streaming' start_node_id: string } +export type WorkflowRunForListResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + elapsed_time?: number | null + exceptions_count?: number | null + finished_at?: number | null + id: string + retry_index?: number | null + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type SimpleAccount = { + email: string + id: string + name: string +} + +export type SimpleEndUser = { + id: string + is_anonymous: boolean + session_id?: string | null + type: string +} + +export type WorkflowConversationVariableResponse = { + description: string + id: string + name: string + value: { + [key: string]: unknown + } + value_type: string +} + +export type WorkflowEnvironmentVariableResponse = { + description: string + id: string + name: string + value: { + [key: string]: unknown + } + value_type: string +} + +export type PipelineVariableResponse = { + allowed_file_extensions?: Array | null + allowed_file_types?: Array | null + allowed_file_upload_methods?: Array | null + belong_to_node_id: string + default_value?: { + [key: string]: unknown + } + label: string + max_length?: number | null + options?: Array | null + placeholder?: string | null + required: boolean + tooltips?: string | null + type: string + unit?: string | null + variable: string +} + export type DeleteRagPipelineCustomizedTemplatesByTemplateIdData = { body?: never path: { @@ -358,9 +507,7 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsData = { } export type GetRagPipelinesByPipelineIdWorkflowRunsResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRunPaginationResponse } export type GetRagPipelinesByPipelineIdWorkflowRunsResponse @@ -396,9 +543,7 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdData = { } export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRunDetailResponse } export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse @@ -415,9 +560,7 @@ export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsData = { } export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRunNodeExecutionListResponse } export type GetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse @@ -432,12 +575,19 @@ export type GetRagPipelinesByPipelineIdWorkflowsData = { url: '/rag/pipelines/{pipeline_id}/workflows' } -export type GetRagPipelinesByPipelineIdWorkflowsResponses = { - 200: { +export type GetRagPipelinesByPipelineIdWorkflowsErrors = { + 403: { [key: string]: unknown } } +export type GetRagPipelinesByPipelineIdWorkflowsError + = GetRagPipelinesByPipelineIdWorkflowsErrors[keyof GetRagPipelinesByPipelineIdWorkflowsErrors] + +export type GetRagPipelinesByPipelineIdWorkflowsResponses = { + 200: WorkflowPaginationResponse +} + export type GetRagPipelinesByPipelineIdWorkflowsResponse = GetRagPipelinesByPipelineIdWorkflowsResponses[keyof GetRagPipelinesByPipelineIdWorkflowsResponses] @@ -462,8 +612,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsRespo export type GetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeData = { body?: never path: { - pipeline_id: string block_type: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/default-workflow-block-configs/{block_type}' @@ -487,12 +637,19 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftData = { url: '/rag/pipelines/{pipeline_id}/workflows/draft' } -export type GetRagPipelinesByPipelineIdWorkflowsDraftResponses = { - 200: { +export type GetRagPipelinesByPipelineIdWorkflowsDraftErrors = { + 404: { [key: string]: unknown } } +export type GetRagPipelinesByPipelineIdWorkflowsDraftError + = GetRagPipelinesByPipelineIdWorkflowsDraftErrors[keyof GetRagPipelinesByPipelineIdWorkflowsDraftErrors] + +export type GetRagPipelinesByPipelineIdWorkflowsDraftResponses = { + 200: WorkflowResponse +} + export type GetRagPipelinesByPipelineIdWorkflowsDraftResponse = GetRagPipelinesByPipelineIdWorkflowsDraftResponses[keyof GetRagPipelinesByPipelineIdWorkflowsDraftResponses] @@ -517,8 +674,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftResponse export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunData = { body: DatasourceNodeRunPayload path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/datasource/nodes/{node_id}/run' @@ -543,9 +700,7 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspect } export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRunNodeExecutionResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponse @@ -572,8 +727,8 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesRespons export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunData = { body: NodeRunPayload path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/iteration/nodes/{node_id}/run' @@ -591,8 +746,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunR export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunData = { body: NodeRunPayload path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/loop/nodes/{node_id}/run' @@ -610,17 +765,15 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunRespon export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunData = { body?: never path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/last-run' } export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRunNodeExecutionResponse } export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse @@ -629,17 +782,15 @@ export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunRespons export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunData = { body: NodeRunRequiredPayload path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/run' } export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowRunNodeExecutionResponse } export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse @@ -648,8 +799,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' @@ -667,8 +818,8 @@ export type DeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesRe export type GetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesData = { body?: never path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' @@ -877,9 +1028,7 @@ export type GetRagPipelinesByPipelineIdWorkflowsPublishData = { } export type GetRagPipelinesByPipelineIdWorkflowsPublishResponses = { - 200: { - [key: string]: unknown - } + 200: WorkflowResponse } export type GetRagPipelinesByPipelineIdWorkflowsPublishResponse @@ -906,8 +1055,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishResponse export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewData = { body: Parser path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/preview' @@ -926,8 +1075,8 @@ export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeI export type PostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunData = { body: DatasourceNodeRunPayload path: { - pipeline_id: string node_id: string + pipeline_id: string } query?: never url: '/rag/pipelines/{pipeline_id}/workflows/published/datasource/nodes/{node_id}/run' @@ -1025,10 +1174,23 @@ export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdData = { url: '/rag/pipelines/{pipeline_id}/workflows/{workflow_id}' } -export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses = { - 200: { +export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdErrors = { + 400: { [key: string]: unknown } + 403: { + [key: string]: unknown + } + 404: { + [key: string]: unknown + } +} + +export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdError + = PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdErrors[keyof PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdErrors] + +export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponses = { + 200: WorkflowResponse } export type PatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index b28d7fafc7..6832c6eb09 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -94,10 +94,185 @@ export const zPublishedWorkflowRunPayload = z.object({ inputs: z.record(z.string(), z.unknown()), is_preview: z.boolean().optional().default(false), original_document_id: z.string().nullish(), - response_mode: z.enum(['streaming', 'blocking']).optional().default('streaming'), + response_mode: z.enum(['blocking', 'streaming']).optional().default('streaming'), start_node_id: z.string(), }) +/** + * SimpleAccount + */ +export const zSimpleAccount = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * WorkflowRunForListResponse + */ +export const zWorkflowRunForListResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + elapsed_time: z.number().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + retry_index: z.int().nullish(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunPaginationResponse + */ +export const zWorkflowRunPaginationResponse = z.object({ + data: z.array(zWorkflowRunForListResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * SimpleEndUser + */ +export const zSimpleEndUser = z.object({ + id: z.string(), + is_anonymous: z.boolean(), + session_id: z.string().nullish(), + type: z.string(), +}) + +/** + * WorkflowRunDetailResponse + */ +export const zWorkflowRunDetailResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + created_by_end_user: zSimpleEndUser.optional(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + graph: z.unknown(), + id: z.string(), + inputs: z.unknown(), + outputs: z.unknown(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionResponse + */ +export const zWorkflowRunNodeExecutionResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + created_by_end_user: zSimpleEndUser.optional(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + execution_metadata: z.unknown().optional(), + extras: z.unknown().optional(), + finished_at: z.int().nullish(), + id: z.string(), + index: z.int().nullish(), + inputs: z.unknown().optional(), + inputs_truncated: z.boolean().nullish(), + node_id: z.string().nullish(), + node_type: z.string().nullish(), + outputs: z.unknown().optional(), + outputs_truncated: z.boolean().nullish(), + predecessor_node_id: z.string().nullish(), + process_data: z.unknown().optional(), + process_data_truncated: z.boolean().nullish(), + status: z.string().nullish(), + title: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionListResponse + */ +export const zWorkflowRunNodeExecutionListResponse = z.object({ + data: z.array(zWorkflowRunNodeExecutionResponse), +}) + +/** + * WorkflowConversationVariableResponse + */ +export const zWorkflowConversationVariableResponse = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + value: z.record(z.string(), z.unknown()), + value_type: z.string(), +}) + +/** + * WorkflowEnvironmentVariableResponse + */ +export const zWorkflowEnvironmentVariableResponse = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + value: z.record(z.string(), z.unknown()), + value_type: z.string(), +}) + +/** + * PipelineVariableResponse + */ +export const zPipelineVariableResponse = z.object({ + allowed_file_extensions: z.array(z.string()).nullish(), + allowed_file_types: z.array(z.string()).nullish(), + allowed_file_upload_methods: z.array(z.string()).nullish(), + belong_to_node_id: z.string(), + default_value: z.record(z.string(), z.unknown()).optional(), + label: z.string(), + max_length: z.int().nullish(), + options: z.array(z.string()).nullish(), + placeholder: z.string().nullish(), + required: z.boolean(), + tooltips: z.string().nullish(), + type: z.string(), + unit: z.string().nullish(), + variable: z.string(), +}) + +/** + * WorkflowResponse + */ +export const zWorkflowResponse = z.object({ + conversation_variables: z.array(zWorkflowConversationVariableResponse), + created_at: z.int(), + created_by: zSimpleAccount.optional(), + environment_variables: z.array(zWorkflowEnvironmentVariableResponse), + features: z.record(z.string(), z.unknown()), + graph: z.record(z.string(), z.unknown()), + hash: z.string(), + id: z.string(), + marked_comment: z.string(), + marked_name: z.string(), + rag_pipeline_variables: z.array(zPipelineVariableResponse), + tool_published: z.boolean(), + updated_at: z.int(), + updated_by: zSimpleAccount.optional(), + version: z.string(), +}) + +/** + * WorkflowPaginationResponse + */ +export const zWorkflowPaginationResponse = z.object({ + has_more: z.boolean(), + items: z.array(zWorkflowResponse), + limit: z.int(), + page: z.int(), +}) + export const zDeleteRagPipelineCustomizedTemplatesByTemplateIdPath = z.object({ template_id: z.string(), }) @@ -238,9 +413,9 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsPath = z.object({ }) /** - * Success + * Workflow runs retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowRunsResponse = z.record(z.string(), z.unknown()) +export const zGetRagPipelinesByPipelineIdWorkflowRunsResponse = zWorkflowRunPaginationResponse export const zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopPath = z.object({ pipeline_id: z.string(), @@ -261,12 +436,9 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdPath = z.object({ }) /** - * Success + * Workflow run detail retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ pipeline_id: z.string(), @@ -274,21 +446,19 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsPath = }) /** - * Success + * Node executions retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsResponse + = zWorkflowRunNodeExecutionListResponse export const zGetRagPipelinesByPipelineIdWorkflowsPath = z.object({ pipeline_id: z.string(), }) /** - * Success + * Published workflows retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsResponse = z.record(z.string(), z.unknown()) +export const zGetRagPipelinesByPipelineIdWorkflowsResponse = zWorkflowPaginationResponse export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({ pipeline_id: z.string(), @@ -304,8 +474,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsRes export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = z.object({ - pipeline_id: z.string(), block_type: z.string(), + pipeline_id: z.string(), }) /** @@ -319,9 +489,9 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ }) /** - * Success + * Draft workflow retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftResponse = z.record(z.string(), z.unknown()) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftResponse = zWorkflowResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ pipeline_id: z.string(), @@ -336,8 +506,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdR = zDatasourceNodeRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -354,10 +524,10 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspe }) /** - * Success + * Datasource variables set successfully */ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectResponse - = z.record(z.string(), z.unknown()) + = zWorkflowRunNodeExecutionResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath = z.object({ pipeline_id: z.string(), @@ -375,8 +545,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRu = zNodeRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -388,8 +558,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRu export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunBody = zNodeRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -401,37 +571,33 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResp ) export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** - * Success + * Node last run retrieved successfully */ -export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunResponse + = zWorkflowRunNodeExecutionResponse export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunBody = zNodeRunRequiredPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** - * Success + * Node run started successfully */ -export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse = z.record( - z.string(), - z.unknown(), -) +export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse + = zWorkflowRunNodeExecutionResponse export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -443,8 +609,8 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables ) export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -584,12 +750,9 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ }) /** - * Success + * Published workflow retrieved successfully, or null if not exist */ -export const zGetRagPipelinesByPipelineIdWorkflowsPublishResponse = z.record( - z.string(), - z.unknown(), -) +export const zGetRagPipelinesByPipelineIdWorkflowsPublishResponse = zWorkflowResponse export const zPostRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ pipeline_id: z.string(), @@ -608,8 +771,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -623,8 +786,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunPath = z.object({ - pipeline_id: z.string(), node_id: z.string(), + pipeline_id: z.string(), }) /** @@ -688,12 +851,9 @@ export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object( }) /** - * Success + * Workflow updated successfully */ -export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = z.record( - z.string(), - z.unknown(), -) +export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = zWorkflowResponse export const zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestorePath = z.object({ pipeline_id: z.string(), diff --git a/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts b/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts index 4faa4d7d23..90d8aae968 100644 --- a/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts +++ b/packages/contracts/generated/api/console/refresh-token/orpc.gen.ts @@ -4,8 +4,16 @@ import { oc } from '@orpc/contract' import { zPostRefreshTokenResponse } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRefreshToken', diff --git a/packages/contracts/generated/api/console/remote-files/orpc.gen.ts b/packages/contracts/generated/api/console/remote-files/orpc.gen.ts index 977af4a09c..d8e493b759 100644 --- a/packages/contracts/generated/api/console/remote-files/orpc.gen.ts +++ b/packages/contracts/generated/api/console/remote-files/orpc.gen.ts @@ -9,8 +9,16 @@ import { zPostRemoteFilesUploadResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRemoteFilesUpload', @@ -23,8 +31,16 @@ export const upload = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRemoteFilesByUrl', diff --git a/packages/contracts/generated/api/console/reset-password/orpc.gen.ts b/packages/contracts/generated/api/console/reset-password/orpc.gen.ts index 93701280db..2c049efb9d 100644 --- a/packages/contracts/generated/api/console/reset-password/orpc.gen.ts +++ b/packages/contracts/generated/api/console/reset-password/orpc.gen.ts @@ -5,8 +5,16 @@ import * as z from 'zod' import { zPostResetPasswordBody, zPostResetPasswordResponse } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postResetPassword', diff --git a/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts index 1c5252525c..fb3909f748 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/orpc.gen.ts @@ -7,10 +7,16 @@ import { zPostRuleCodeGenerateBody, zPostRuleCodeGenerateResponse } from './zod. /** * Generate code rules using LLM + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Generate code rules using LLM', + deprecated: true, + description: + 'Generate code rules using LLM\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRuleCodeGenerate', diff --git a/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts index dc1b045285..c5fafa90a9 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/types.gen.ts @@ -12,33 +12,15 @@ export type RuleCodeGeneratePayload = { } export type ModelConfig = { - agent_mode_dict?: JsonValue - annotation_reply_dict?: JsonValue - chat_prompt_config_dict?: JsonValue - completion_prompt_config_dict?: JsonValue - created_at?: number | null - created_by?: string | null - dataset_configs_dict?: JsonValue - dataset_query_variable?: string | null - external_data_tools_list?: JsonValue - file_upload_dict?: JsonValue - model_dict?: JsonValue - more_like_this_dict?: JsonValue - opening_statement?: string | null - pre_prompt?: string | null - prompt_type?: string | null - retriever_resource_dict?: JsonValue - sensitive_word_avoidance_dict?: JsonValue - speech_to_text_dict?: JsonValue - suggested_questions_after_answer_dict?: JsonValue - suggested_questions_list?: JsonValue - text_to_speech_dict?: JsonValue - updated_at?: number | null - updated_by?: string | null - user_input_form_list?: JsonValue + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string } -export type JsonValue = unknown +export type LlmMode = 'chat' | 'completion' export type PostRuleCodeGenerateData = { body: RuleCodeGeneratePayload diff --git a/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts index 40b840dc8a..f98d2f5dc0 100644 --- a/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-code-generate/zod.gen.ts @@ -2,36 +2,21 @@ import * as z from 'zod' -export const zJsonValue = z.unknown() +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) /** * ModelConfig */ export const zModelConfig = z.object({ - agent_mode_dict: zJsonValue.optional(), - annotation_reply_dict: zJsonValue.optional(), - chat_prompt_config_dict: zJsonValue.optional(), - completion_prompt_config_dict: zJsonValue.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - dataset_configs_dict: zJsonValue.optional(), - dataset_query_variable: z.string().nullish(), - external_data_tools_list: zJsonValue.optional(), - file_upload_dict: zJsonValue.optional(), - model_dict: zJsonValue.optional(), - more_like_this_dict: zJsonValue.optional(), - opening_statement: z.string().nullish(), - pre_prompt: z.string().nullish(), - prompt_type: z.string().nullish(), - retriever_resource_dict: zJsonValue.optional(), - sensitive_word_avoidance_dict: zJsonValue.optional(), - speech_to_text_dict: zJsonValue.optional(), - suggested_questions_after_answer_dict: zJsonValue.optional(), - suggested_questions_list: zJsonValue.optional(), - text_to_speech_dict: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - user_input_form_list: zJsonValue.optional(), + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts index 7bd233de2b..1351b459cc 100644 --- a/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/orpc.gen.ts @@ -7,10 +7,16 @@ import { zPostRuleGenerateBody, zPostRuleGenerateResponse } from './zod.gen' /** * Generate rule configuration using LLM + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Generate rule configuration using LLM', + deprecated: true, + description: + 'Generate rule configuration using LLM\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRuleGenerate', diff --git a/packages/contracts/generated/api/console/rule-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-generate/types.gen.ts index 265ca5013d..01f44c096c 100644 --- a/packages/contracts/generated/api/console/rule-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/types.gen.ts @@ -11,33 +11,15 @@ export type RuleGeneratePayload = { } export type ModelConfig = { - agent_mode_dict?: JsonValue - annotation_reply_dict?: JsonValue - chat_prompt_config_dict?: JsonValue - completion_prompt_config_dict?: JsonValue - created_at?: number | null - created_by?: string | null - dataset_configs_dict?: JsonValue - dataset_query_variable?: string | null - external_data_tools_list?: JsonValue - file_upload_dict?: JsonValue - model_dict?: JsonValue - more_like_this_dict?: JsonValue - opening_statement?: string | null - pre_prompt?: string | null - prompt_type?: string | null - retriever_resource_dict?: JsonValue - sensitive_word_avoidance_dict?: JsonValue - speech_to_text_dict?: JsonValue - suggested_questions_after_answer_dict?: JsonValue - suggested_questions_list?: JsonValue - text_to_speech_dict?: JsonValue - updated_at?: number | null - updated_by?: string | null - user_input_form_list?: JsonValue + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string } -export type JsonValue = unknown +export type LlmMode = 'chat' | 'completion' export type PostRuleGenerateData = { body: RuleGeneratePayload diff --git a/packages/contracts/generated/api/console/rule-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-generate/zod.gen.ts index 7a346a58fc..aae7b67f0f 100644 --- a/packages/contracts/generated/api/console/rule-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-generate/zod.gen.ts @@ -2,36 +2,21 @@ import * as z from 'zod' -export const zJsonValue = z.unknown() +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) /** * ModelConfig */ export const zModelConfig = z.object({ - agent_mode_dict: zJsonValue.optional(), - annotation_reply_dict: zJsonValue.optional(), - chat_prompt_config_dict: zJsonValue.optional(), - completion_prompt_config_dict: zJsonValue.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - dataset_configs_dict: zJsonValue.optional(), - dataset_query_variable: z.string().nullish(), - external_data_tools_list: zJsonValue.optional(), - file_upload_dict: zJsonValue.optional(), - model_dict: zJsonValue.optional(), - more_like_this_dict: zJsonValue.optional(), - opening_statement: z.string().nullish(), - pre_prompt: z.string().nullish(), - prompt_type: z.string().nullish(), - retriever_resource_dict: zJsonValue.optional(), - sensitive_word_avoidance_dict: zJsonValue.optional(), - speech_to_text_dict: zJsonValue.optional(), - suggested_questions_after_answer_dict: zJsonValue.optional(), - suggested_questions_list: zJsonValue.optional(), - text_to_speech_dict: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - user_input_form_list: zJsonValue.optional(), + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts index 276442f1c9..3fef5f3c3f 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/orpc.gen.ts @@ -10,10 +10,16 @@ import { /** * Generate structured output rules using LLM + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Generate structured output rules using LLM', + deprecated: true, + description: + 'Generate structured output rules using LLM\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postRuleStructuredOutputGenerate', diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts index f6124c6956..0ab9d90904 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/types.gen.ts @@ -10,33 +10,15 @@ export type RuleStructuredOutputPayload = { } export type ModelConfig = { - agent_mode_dict?: JsonValue - annotation_reply_dict?: JsonValue - chat_prompt_config_dict?: JsonValue - completion_prompt_config_dict?: JsonValue - created_at?: number | null - created_by?: string | null - dataset_configs_dict?: JsonValue - dataset_query_variable?: string | null - external_data_tools_list?: JsonValue - file_upload_dict?: JsonValue - model_dict?: JsonValue - more_like_this_dict?: JsonValue - opening_statement?: string | null - pre_prompt?: string | null - prompt_type?: string | null - retriever_resource_dict?: JsonValue - sensitive_word_avoidance_dict?: JsonValue - speech_to_text_dict?: JsonValue - suggested_questions_after_answer_dict?: JsonValue - suggested_questions_list?: JsonValue - text_to_speech_dict?: JsonValue - updated_at?: number | null - updated_by?: string | null - user_input_form_list?: JsonValue + completion_params?: { + [key: string]: unknown + } + mode: LlmMode + name: string + provider: string } -export type JsonValue = unknown +export type LlmMode = 'chat' | 'completion' export type PostRuleStructuredOutputGenerateData = { body: RuleStructuredOutputPayload diff --git a/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts b/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts index 231b5a072c..ddcabbba49 100644 --- a/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts +++ b/packages/contracts/generated/api/console/rule-structured-output-generate/zod.gen.ts @@ -2,36 +2,21 @@ import * as z from 'zod' -export const zJsonValue = z.unknown() +/** + * LLMMode + * + * Enum class for large language model mode. + */ +export const zLlmMode = z.enum(['chat', 'completion']) /** * ModelConfig */ export const zModelConfig = z.object({ - agent_mode_dict: zJsonValue.optional(), - annotation_reply_dict: zJsonValue.optional(), - chat_prompt_config_dict: zJsonValue.optional(), - completion_prompt_config_dict: zJsonValue.optional(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - dataset_configs_dict: zJsonValue.optional(), - dataset_query_variable: z.string().nullish(), - external_data_tools_list: zJsonValue.optional(), - file_upload_dict: zJsonValue.optional(), - model_dict: zJsonValue.optional(), - more_like_this_dict: zJsonValue.optional(), - opening_statement: z.string().nullish(), - pre_prompt: z.string().nullish(), - prompt_type: z.string().nullish(), - retriever_resource_dict: zJsonValue.optional(), - sensitive_word_avoidance_dict: zJsonValue.optional(), - speech_to_text_dict: zJsonValue.optional(), - suggested_questions_after_answer_dict: zJsonValue.optional(), - suggested_questions_list: zJsonValue.optional(), - text_to_speech_dict: zJsonValue.optional(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), - user_input_form_list: zJsonValue.optional(), + completion_params: z.record(z.string(), z.unknown()).optional(), + mode: zLlmMode, + name: z.string(), + provider: z.string(), }) /** diff --git a/packages/contracts/generated/api/console/spec/orpc.gen.ts b/packages/contracts/generated/api/console/spec/orpc.gen.ts index bd2e750e6d..9af554b5c5 100644 --- a/packages/contracts/generated/api/console/spec/orpc.gen.ts +++ b/packages/contracts/generated/api/console/spec/orpc.gen.ts @@ -8,10 +8,16 @@ import { zGetSpecSchemaDefinitionsResponse } from './zod.gen' * Get system JSON Schema definitions specification * * Used for frontend component type mapping + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Used for frontend component type mapping', + deprecated: true, + description: + 'Used for frontend component type mapping\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSpecSchemaDefinitions', diff --git a/packages/contracts/generated/api/console/system-features/orpc.gen.ts b/packages/contracts/generated/api/console/system-features/orpc.gen.ts index 5c0a475585..a3667f791f 100644 --- a/packages/contracts/generated/api/console/system-features/orpc.gen.ts +++ b/packages/contracts/generated/api/console/system-features/orpc.gen.ts @@ -14,11 +14,16 @@ import { zGetSystemFeaturesResponse } from './zod.gen' * Authentication would create circular dependency (can't login without dashboard loading). * * Only non-sensitive configuration data should be returned by this endpoint. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ + deprecated: true, description: - 'Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can\'t login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.', + 'Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can\'t login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSystemFeatures', diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 0fbea39beb..1f14d19c51 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -5,7 +5,9 @@ export type ClientOptions = { } export type SystemFeatureResponse = { - [key: string]: unknown + features?: { + [key: string]: unknown + } } export type GetSystemFeaturesData = { diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index affb2a10a3..58f353b512 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -2,7 +2,9 @@ import * as z from 'zod' -export const zSystemFeatureResponse = z.record(z.string(), z.unknown()) +export const zSystemFeatureResponse = z.object({ + features: z.record(z.string(), z.unknown()).optional(), +}) /** * Success diff --git a/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts b/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts index 8b58d2c47d..154c75d917 100644 --- a/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/orpc.gen.ts @@ -4,48 +4,24 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { - zDeleteTagBindingsByIdBody, - zDeleteTagBindingsByIdPath, - zDeleteTagBindingsByIdResponse, zPostTagBindingsBody, - zPostTagBindingsCreateBody, - zPostTagBindingsCreateResponse, zPostTagBindingsRemoveBody, zPostTagBindingsRemoveResponse, zPostTagBindingsResponse, } from './zod.gen' /** - * Deprecated legacy alias. Use POST /tag-bindings instead. + * Remove one or more tag bindings from a target. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. * * @deprecated */ export const post = oc .route({ deprecated: true, - description: 'Deprecated legacy alias. Use POST /tag-bindings instead.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postTagBindingsCreate', - path: '/tag-bindings/create', - tags: ['console'], - }) - .input(z.object({ body: zPostTagBindingsCreateBody })) - .output(zPostTagBindingsCreateResponse) - -export const create = { - post, -} - -/** - * Deprecated legacy alias. Use DELETE /tag-bindings/{id} instead. - * - * @deprecated - */ -export const post2 = oc - .route({ - deprecated: true, - description: 'Deprecated legacy alias. Use DELETE /tag-bindings/{id} instead.', + description: + 'Remove one or more tag bindings from a target.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTagBindingsRemove', @@ -56,26 +32,19 @@ export const post2 = oc .output(zPostTagBindingsRemoveResponse) export const remove = { - post: post2, + post, } -export const delete_ = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'deleteTagBindingsById', - path: '/tag-bindings/{id}', - tags: ['console'], - }) - .input(z.object({ body: zDeleteTagBindingsByIdBody, params: zDeleteTagBindingsByIdPath })) - .output(zDeleteTagBindingsByIdResponse) - -export const byId = { - delete: delete_, -} - -export const post3 = oc +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTagBindings', @@ -86,10 +55,8 @@ export const post3 = oc .output(zPostTagBindingsResponse) export const tagBindings = { - post: post3, - create, + post: post2, remove, - byId, } export const contract = { diff --git a/packages/contracts/generated/api/console/tag-bindings/types.gen.ts b/packages/contracts/generated/api/console/tag-bindings/types.gen.ts index e9426766ed..1896ccd366 100644 --- a/packages/contracts/generated/api/console/tag-bindings/types.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/types.gen.ts @@ -11,17 +11,12 @@ export type TagBindingPayload = { } export type TagBindingRemovePayload = { - tag_id: string + tag_ids: Array target_id: string type: TagType } -export type TagBindingItemDeletePayload = { - target_id: string - type: TagType -} - -export type TagType = 'knowledge' | 'app' +export type TagType = 'app' | 'knowledge' export type PostTagBindingsData = { body: TagBindingPayload @@ -38,22 +33,6 @@ export type PostTagBindingsResponses = { export type PostTagBindingsResponse = PostTagBindingsResponses[keyof PostTagBindingsResponses] -export type PostTagBindingsCreateData = { - body: TagBindingPayload - path?: never - query?: never - url: '/tag-bindings/create' -} - -export type PostTagBindingsCreateResponses = { - 200: { - [key: string]: unknown - } -} - -export type PostTagBindingsCreateResponse - = PostTagBindingsCreateResponses[keyof PostTagBindingsCreateResponses] - export type PostTagBindingsRemoveData = { body: TagBindingRemovePayload path?: never @@ -69,21 +48,3 @@ export type PostTagBindingsRemoveResponses = { export type PostTagBindingsRemoveResponse = PostTagBindingsRemoveResponses[keyof PostTagBindingsRemoveResponses] - -export type DeleteTagBindingsByIdData = { - body: TagBindingItemDeletePayload - path: { - id: string - } - query?: never - url: '/tag-bindings/{id}' -} - -export type DeleteTagBindingsByIdResponses = { - 200: { - [key: string]: unknown - } -} - -export type DeleteTagBindingsByIdResponse - = DeleteTagBindingsByIdResponses[keyof DeleteTagBindingsByIdResponses] diff --git a/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts b/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts index 3dead1ec1b..72e2608b95 100644 --- a/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts @@ -7,7 +7,7 @@ import * as z from 'zod' * * Tag type */ -export const zTagType = z.enum(['knowledge', 'app']) +export const zTagType = z.enum(['app', 'knowledge']) /** * TagBindingPayload @@ -22,15 +22,7 @@ export const zTagBindingPayload = z.object({ * TagBindingRemovePayload */ export const zTagBindingRemovePayload = z.object({ - tag_id: z.string(), - target_id: z.string(), - type: zTagType, -}) - -/** - * TagBindingItemDeletePayload - */ -export const zTagBindingItemDeletePayload = z.object({ + tag_ids: z.array(z.string()).min(1), target_id: z.string(), type: zTagType, }) @@ -42,27 +34,9 @@ export const zPostTagBindingsBody = zTagBindingPayload */ export const zPostTagBindingsResponse = z.record(z.string(), z.unknown()) -export const zPostTagBindingsCreateBody = zTagBindingPayload - -/** - * Success - */ -export const zPostTagBindingsCreateResponse = z.record(z.string(), z.unknown()) - export const zPostTagBindingsRemoveBody = zTagBindingRemovePayload /** * Success */ export const zPostTagBindingsRemoveResponse = z.record(z.string(), z.unknown()) - -export const zDeleteTagBindingsByIdBody = zTagBindingItemDeletePayload - -export const zDeleteTagBindingsByIdPath = z.object({ - id: z.string(), -}) - -/** - * Success - */ -export const zDeleteTagBindingsByIdResponse = z.record(z.string(), z.unknown()) diff --git a/packages/contracts/generated/api/console/tags/orpc.gen.ts b/packages/contracts/generated/api/console/tags/orpc.gen.ts index 937ccce634..bc2ac4ef5c 100644 --- a/packages/contracts/generated/api/console/tags/orpc.gen.ts +++ b/packages/contracts/generated/api/console/tags/orpc.gen.ts @@ -15,8 +15,16 @@ import { zPostTagsResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete_ = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteTagsByTagId', @@ -26,8 +34,16 @@ export const delete_ = oc .input(z.object({ params: zDeleteTagsByTagIdPath })) .output(zDeleteTagsByTagIdResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchTagsByTagId', @@ -53,8 +69,16 @@ export const get = oc .input(z.object({ query: zGetTagsQuery.optional() })) .output(zGetTagsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTags', diff --git a/packages/contracts/generated/api/console/tags/types.gen.ts b/packages/contracts/generated/api/console/tags/types.gen.ts index f3c3b1eb3f..3e1bac75c4 100644 --- a/packages/contracts/generated/api/console/tags/types.gen.ts +++ b/packages/contracts/generated/api/console/tags/types.gen.ts @@ -16,14 +16,18 @@ export type TagBasePayload = { type: TagType } -export type TagType = 'knowledge' | 'app' +export type TagUpdateRequestPayload = { + name: string +} + +export type TagType = 'app' | 'knowledge' export type GetTagsData = { body?: never path?: never query?: { - type?: string keyword?: string + type?: string } url: '/tags' } @@ -67,7 +71,7 @@ export type DeleteTagsByTagIdResponses = { export type DeleteTagsByTagIdResponse = DeleteTagsByTagIdResponses[keyof DeleteTagsByTagIdResponses] export type PatchTagsByTagIdData = { - body: TagBasePayload + body: TagUpdateRequestPayload path: { tag_id: string } diff --git a/packages/contracts/generated/api/console/tags/zod.gen.ts b/packages/contracts/generated/api/console/tags/zod.gen.ts index 4bb8e1783c..65c5b06b63 100644 --- a/packages/contracts/generated/api/console/tags/zod.gen.ts +++ b/packages/contracts/generated/api/console/tags/zod.gen.ts @@ -12,12 +12,19 @@ export const zTagResponse = z.object({ type: z.string().nullish(), }) +/** + * TagUpdateRequestPayload + */ +export const zTagUpdateRequestPayload = z.object({ + name: z.string().min(1).max(50), +}) + /** * TagType * * Tag type */ -export const zTagType = z.enum(['knowledge', 'app']) +export const zTagType = z.enum(['app', 'knowledge']) /** * TagBasePayload @@ -28,8 +35,8 @@ export const zTagBasePayload = z.object({ }) export const zGetTagsQuery = z.object({ - type: z.string().optional(), keyword: z.string().optional(), + type: z.string().optional(), }) /** @@ -53,7 +60,7 @@ export const zDeleteTagsByTagIdPath = z.object({ */ export const zDeleteTagsByTagIdResponse = z.record(z.string(), z.unknown()) -export const zPatchTagsByTagIdBody = zTagBasePayload +export const zPatchTagsByTagIdBody = zTagUpdateRequestPayload export const zPatchTagsByTagIdPath = z.object({ tag_id: z.string(), diff --git a/packages/contracts/generated/api/console/test/orpc.gen.ts b/packages/contracts/generated/api/console/test/orpc.gen.ts index 1bdf526b70..27b46e3381 100644 --- a/packages/contracts/generated/api/console/test/orpc.gen.ts +++ b/packages/contracts/generated/api/console/test/orpc.gen.ts @@ -7,10 +7,16 @@ import { zPostTestRetrievalBody, zPostTestRetrievalResponse } from './zod.gen' /** * Bedrock retrieval test (internal use only) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Bedrock retrieval test (internal use only)', + deprecated: true, + description: + 'Bedrock retrieval test (internal use only)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTestRetrieval', diff --git a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts index eca85c206f..4a10ee7cb8 100644 --- a/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/orpc.gen.ts @@ -34,8 +34,16 @@ import { zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdAudioToText', @@ -49,8 +57,16 @@ export const audioToText = { post, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdChatMessages', @@ -69,8 +85,16 @@ export const chatMessages = { post: post2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdCompletionMessages', @@ -89,8 +113,16 @@ export const completionMessages = { post: post3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdDatasets', @@ -104,8 +136,16 @@ export const datasets = { get, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdMessagesByMessageIdSuggestedQuestions', @@ -129,9 +169,16 @@ export const messages = { /** * Retrieve app parameters + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdParameters', @@ -150,11 +197,16 @@ export const parameters = { * Retrieve app site info * * Returns the site configuration for the application including theme, icons, and text. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get4 = oc .route({ + deprecated: true, description: - 'Returns the site configuration for the application including theme, icons, and text.', + 'Returns the site configuration for the application including theme, icons, and text.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdSite', @@ -169,8 +221,16 @@ export const site = { get: get4, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdTextToAudio', @@ -191,9 +251,16 @@ export const textToAudio = { /** * Run workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdWorkflowsRun', @@ -215,9 +282,16 @@ export const run = { /** * Stop workflow task + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTrialAppsByAppIdWorkflowsTasksByTaskIdStop', @@ -242,9 +316,16 @@ export const tasks = { /** * Get workflow detail + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppIdWorkflows', @@ -263,9 +344,16 @@ export const workflows = { /** * Get app detail + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getTrialAppsByAppId', diff --git a/packages/contracts/generated/api/console/website/orpc.gen.ts b/packages/contracts/generated/api/console/website/orpc.gen.ts index 698f656967..5ed1fcd8ab 100644 --- a/packages/contracts/generated/api/console/website/orpc.gen.ts +++ b/packages/contracts/generated/api/console/website/orpc.gen.ts @@ -13,10 +13,16 @@ import { /** * Get website crawl status + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get website crawl status', + deprecated: true, + description: + 'Get website crawl status\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWebsiteCrawlStatusByJobId', @@ -41,10 +47,16 @@ export const status = { /** * Crawl website content + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Crawl website content', + deprecated: true, + description: + 'Crawl website content\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWebsiteCrawl', diff --git a/packages/contracts/generated/api/console/website/types.gen.ts b/packages/contracts/generated/api/console/website/types.gen.ts index e47b11a819..c8ea0de7b6 100644 --- a/packages/contracts/generated/api/console/website/types.gen.ts +++ b/packages/contracts/generated/api/console/website/types.gen.ts @@ -8,7 +8,7 @@ export type WebsiteCrawlPayload = { options: { [key: string]: unknown } - provider: 'firecrawl' | 'watercrawl' | 'jinareader' + provider: 'firecrawl' | 'jinareader' | 'watercrawl' url: string } @@ -41,7 +41,7 @@ export type GetWebsiteCrawlStatusByJobIdData = { job_id: string } query: { - provider: 'firecrawl' | 'watercrawl' | 'jinareader' + provider: 'firecrawl' | 'jinareader' | 'watercrawl' } url: '/website/crawl/status/{job_id}' } diff --git a/packages/contracts/generated/api/console/website/zod.gen.ts b/packages/contracts/generated/api/console/website/zod.gen.ts index a7590ec9ee..88f1d4a7c8 100644 --- a/packages/contracts/generated/api/console/website/zod.gen.ts +++ b/packages/contracts/generated/api/console/website/zod.gen.ts @@ -7,7 +7,7 @@ import * as z from 'zod' */ export const zWebsiteCrawlPayload = z.object({ options: z.record(z.string(), z.unknown()), - provider: z.enum(['firecrawl', 'watercrawl', 'jinareader']), + provider: z.enum(['firecrawl', 'jinareader', 'watercrawl']), url: z.string(), }) @@ -23,7 +23,7 @@ export const zGetWebsiteCrawlStatusByJobIdPath = z.object({ }) export const zGetWebsiteCrawlStatusByJobIdQuery = z.object({ - provider: z.enum(['firecrawl', 'watercrawl', 'jinareader']), + provider: z.enum(['firecrawl', 'jinareader', 'watercrawl']), }) /** diff --git a/packages/contracts/generated/api/console/workflow/orpc.gen.ts b/packages/contracts/generated/api/console/workflow/orpc.gen.ts index bf139e6ac1..1470137236 100644 --- a/packages/contracts/generated/api/console/workflow/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workflow/orpc.gen.ts @@ -16,11 +16,16 @@ import { * GET /console/api/workflow//events * * Returns Server-Sent Events stream. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ + deprecated: true, description: - 'GET /console/api/workflow//events\n\nReturns Server-Sent Events stream.', + 'GET /console/api/workflow//events\n\nReturns Server-Sent Events stream.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByWorkflowRunIdEvents', @@ -38,6 +43,7 @@ export const events = { /** * Get workflow pause details * + * Get workflow pause details * GET /console/api/workflow//pause-details * * Returns information about why and where the workflow is paused. @@ -45,7 +51,7 @@ export const events = { export const get2 = oc .route({ description: - 'GET /console/api/workflow//pause-details\n\nReturns information about why and where the workflow is paused.', + 'Get workflow pause details\nGET /console/api/workflow//pause-details\n\nReturns information about why and where the workflow is paused.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByWorkflowRunIdPauseDetails', diff --git a/packages/contracts/generated/api/console/workflow/types.gen.ts b/packages/contracts/generated/api/console/workflow/types.gen.ts index a3fae60eae..cf794515d4 100644 --- a/packages/contracts/generated/api/console/workflow/types.gen.ts +++ b/packages/contracts/generated/api/console/workflow/types.gen.ts @@ -4,6 +4,23 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } +export type WorkflowPauseDetailsResponse = { + paused_at?: string | null + paused_nodes: Array +} + +export type PausedNodeResponse = { + node_id: string + node_title: string + pause_type: HumanInputPauseTypeResponse +} + +export type HumanInputPauseTypeResponse = { + backstage_input_url?: string | null + form_id: string + type: string +} + export type GetWorkflowByWorkflowRunIdEventsData = { body?: never path: { @@ -31,11 +48,18 @@ export type GetWorkflowByWorkflowRunIdPauseDetailsData = { url: '/workflow/{workflow_run_id}/pause-details' } -export type GetWorkflowByWorkflowRunIdPauseDetailsResponses = { - 200: { +export type GetWorkflowByWorkflowRunIdPauseDetailsErrors = { + 404: { [key: string]: unknown } } +export type GetWorkflowByWorkflowRunIdPauseDetailsError + = GetWorkflowByWorkflowRunIdPauseDetailsErrors[keyof GetWorkflowByWorkflowRunIdPauseDetailsErrors] + +export type GetWorkflowByWorkflowRunIdPauseDetailsResponses = { + 200: WorkflowPauseDetailsResponse +} + export type GetWorkflowByWorkflowRunIdPauseDetailsResponse = GetWorkflowByWorkflowRunIdPauseDetailsResponses[keyof GetWorkflowByWorkflowRunIdPauseDetailsResponses] diff --git a/packages/contracts/generated/api/console/workflow/zod.gen.ts b/packages/contracts/generated/api/console/workflow/zod.gen.ts index 315085f60f..6a737a683f 100644 --- a/packages/contracts/generated/api/console/workflow/zod.gen.ts +++ b/packages/contracts/generated/api/console/workflow/zod.gen.ts @@ -2,6 +2,32 @@ import * as z from 'zod' +/** + * HumanInputPauseTypeResponse + */ +export const zHumanInputPauseTypeResponse = z.object({ + backstage_input_url: z.string().nullish(), + form_id: z.string(), + type: z.string(), +}) + +/** + * PausedNodeResponse + */ +export const zPausedNodeResponse = z.object({ + node_id: z.string(), + node_title: z.string(), + pause_type: zHumanInputPauseTypeResponse, +}) + +/** + * WorkflowPauseDetailsResponse + */ +export const zWorkflowPauseDetailsResponse = z.object({ + paused_at: z.string().nullish(), + paused_nodes: z.array(zPausedNodeResponse), +}) + export const zGetWorkflowByWorkflowRunIdEventsPath = z.object({ workflow_run_id: z.string(), }) @@ -16,6 +42,6 @@ export const zGetWorkflowByWorkflowRunIdPauseDetailsPath = z.object({ }) /** - * Success + * Workflow pause details retrieved successfully */ -export const zGetWorkflowByWorkflowRunIdPauseDetailsResponse = z.record(z.string(), z.unknown()) +export const zGetWorkflowByWorkflowRunIdPauseDetailsResponse = zWorkflowPauseDetailsResponse diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 4d16e3120f..f4460bb181 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -298,10 +298,16 @@ import { /** * Get specific agent provider details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Get specific agent provider details', + deprecated: true, + description: + 'Get specific agent provider details\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentAgentProviderByProviderName', @@ -321,10 +327,16 @@ export const agentProvider = { /** * Get list of available agent providers + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get2 = oc .route({ - description: 'Get list of available agent providers', + deprecated: true, + description: + 'Get list of available agent providers\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentAgentProviders', @@ -351,8 +363,16 @@ export const datasetOperators = { get: get3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentDefaultModel', @@ -362,8 +382,16 @@ export const get4 = oc .input(z.object({ query: zGetWorkspacesCurrentDefaultModelQuery })) .output(zGetWorkspacesCurrentDefaultModelResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentDefaultModel', @@ -381,13 +409,15 @@ export const defaultModel = { /** * Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead. * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * * @deprecated */ export const post2 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead.', + 'Deprecated legacy alias for creating a plugin endpoint. Use POST /workspaces/current/endpoints instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentEndpointsCreate', @@ -464,10 +494,16 @@ export const enable = { /** * List endpoints for a specific plugin + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get5 = oc .route({ - description: 'List endpoints for a specific plugin', + deprecated: true, + description: + 'List endpoints for a specific plugin\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentEndpointsListPlugin', @@ -483,10 +519,16 @@ export const plugin = { /** * List plugin endpoints with pagination + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ - description: 'List plugin endpoints with pagination', + deprecated: true, + description: + 'List plugin endpoints with pagination\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentEndpointsList', @@ -504,13 +546,15 @@ export const list = { /** * Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead. * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * * @deprecated */ export const post6 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead.', + 'Deprecated legacy alias for updating a plugin endpoint. Use PATCH /workspaces/current/endpoints/{id} instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentEndpointsUpdate', @@ -541,10 +585,16 @@ export const delete2 = oc /** * Update a plugin endpoint + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch = oc .route({ - description: 'Update a plugin endpoint', + deprecated: true, + description: + 'Update a plugin endpoint\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchWorkspacesCurrentEndpointsById', @@ -566,10 +616,16 @@ export const byId = { /** * Create a new plugin endpoint + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post7 = oc .route({ - description: 'Create a new plugin endpoint', + deprecated: true, + description: + 'Create a new plugin endpoint\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentEndpoints', @@ -590,8 +646,16 @@ export const endpoints = { byId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentMembersInviteEmail', @@ -605,8 +669,16 @@ export const inviteEmail = { post: post8, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentMembersOwnerTransferCheck', @@ -620,8 +692,16 @@ export const ownerTransferCheck = { post: post9, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentMembersSendOwnerTransferConfirmEmail', @@ -635,8 +715,16 @@ export const sendOwnerTransferConfirmEmail = { post: post10, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentMembersByMemberIdOwnerTransfer', @@ -655,8 +743,16 @@ export const ownerTransfer = { post: post11, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const put = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentMembersByMemberIdUpdateRole', @@ -675,8 +771,16 @@ export const updateRole = { put, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentMembersByMemberId', @@ -710,8 +814,16 @@ export const members = { byMemberId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get8 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderCheckoutUrl', @@ -725,8 +837,16 @@ export const checkoutUrl = { get: get8, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post12 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderCredentialsSwitch', @@ -745,8 +865,16 @@ export const switch_ = { post: post12, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post13 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderCredentialsValidate', @@ -765,8 +893,16 @@ export const validate = { post: post13, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentModelProvidersByProviderCredentials', @@ -781,8 +917,16 @@ export const delete4 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderCredentialsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get9 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderCredentials', @@ -797,8 +941,16 @@ export const get9 = oc ) .output(zGetWorkspacesCurrentModelProvidersByProviderCredentialsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post14 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderCredentials', @@ -813,8 +965,16 @@ export const post14 = oc ) .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const put2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentModelProvidersByProviderCredentials', @@ -838,8 +998,16 @@ export const credentials = { validate, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post15 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitch', @@ -858,8 +1026,16 @@ export const switch2 = { post: post15, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post16 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidate', @@ -878,8 +1054,16 @@ export const validate2 = { post: post16, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete5 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentModelProvidersByProviderModelsCredentials', @@ -894,8 +1078,16 @@ export const delete5 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderModelsCredentials', @@ -910,8 +1102,16 @@ export const get10 = oc ) .output(zGetWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post17 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModelsCredentials', @@ -926,8 +1126,16 @@ export const post17 = oc ) .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const put3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentModelProvidersByProviderModelsCredentials', @@ -951,8 +1159,16 @@ export const credentials2 = { validate: validate2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch2 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchWorkspacesCurrentModelProvidersByProviderModelsDisable', @@ -971,8 +1187,16 @@ export const disable2 = { patch: patch2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const patch3 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchWorkspacesCurrentModelProvidersByProviderModelsEnable', @@ -991,8 +1215,16 @@ export const enable2 = { patch: patch3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post18 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -1015,8 +1247,16 @@ export const credentialsValidate = { post: post18, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post19 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -1048,8 +1288,16 @@ export const loadBalancingConfigs = { byConfigId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderModelsParameterRules', @@ -1068,8 +1316,16 @@ export const parameterRules = { get: get11, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete6 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentModelProvidersByProviderModels', @@ -1084,8 +1340,16 @@ export const delete6 = oc ) .output(zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get12 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProvidersByProviderModels', @@ -1095,8 +1359,16 @@ export const get12 = oc .input(z.object({ params: zGetWorkspacesCurrentModelProvidersByProviderModelsPath })) .output(zGetWorkspacesCurrentModelProvidersByProviderModelsResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post20 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderModels', @@ -1122,8 +1394,16 @@ export const models = { parameterRules, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post21 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentModelProvidersByProviderPreferredProviderType', @@ -1149,8 +1429,16 @@ export const byProvider = { preferredProviderType, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get13 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelProviders', @@ -1165,8 +1453,16 @@ export const modelProviders = { byProvider, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get14 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelsModelTypesByModelType', @@ -1192,11 +1488,16 @@ export const models2 = { * Get workspace permission settings * * Returns permission flags that control workspace features like member invitations and owner transfer. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get15 = oc .route({ + deprecated: true, description: - 'Returns permission flags that control workspace features like member invitations and owner transfer.', + 'Returns permission flags that control workspace features like member invitations and owner transfer.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPermission', @@ -1210,8 +1511,16 @@ export const permission = { get: get15, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get16 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginAsset', @@ -1225,8 +1534,16 @@ export const asset = { get: get16, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get17 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginDebuggingKey', @@ -1239,8 +1556,16 @@ export const debuggingKey = { get: get17, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get18 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginFetchManifest', @@ -1254,8 +1579,16 @@ export const fetchManifest = { get: get18, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get19 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginIcon', @@ -1269,8 +1602,16 @@ export const icon = { get: get19, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post22 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginInstallGithub', @@ -1284,8 +1625,16 @@ export const github = { post: post22, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post23 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginInstallMarketplace', @@ -1299,8 +1648,16 @@ export const marketplace = { post: post23, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post24 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginInstallPkg', @@ -1320,8 +1677,16 @@ export const install = { pkg, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post25 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginListInstallationsIds', @@ -1339,8 +1704,16 @@ export const installations = { ids, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post26 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginListLatestVersions', @@ -1354,8 +1727,16 @@ export const latestVersions = { post: post26, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get20 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginList', @@ -1371,8 +1752,16 @@ export const list2 = { latestVersions, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get21 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginMarketplacePkg', @@ -1390,8 +1779,16 @@ export const marketplace2 = { pkg: pkg2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get22 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginParametersDynamicOptions', @@ -1407,9 +1804,16 @@ export const dynamicOptions = { /** * Fetch dynamic options using credentials directly (for edit mode) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post27 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginParametersDynamicOptionsWithCredentials', @@ -1431,8 +1835,16 @@ export const parameters = { dynamicOptionsWithCredentials, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post28 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginPermissionChange', @@ -1446,8 +1858,16 @@ export const change = { post: post28, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get23 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginPermissionFetch', @@ -1465,8 +1885,16 @@ export const permission2 = { fetch: fetch_, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post29 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginPreferencesAutoupgradeExclude', @@ -1484,8 +1912,16 @@ export const autoupgrade = { exclude, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post30 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginPreferencesChange', @@ -1499,8 +1935,16 @@ export const change2 = { post: post30, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get24 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginPreferencesFetch', @@ -1519,8 +1963,16 @@ export const preferences = { fetch: fetch2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get25 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginReadme', @@ -1534,8 +1986,16 @@ export const readme = { get: get25, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post31 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginTasksDeleteAll', @@ -1548,8 +2008,16 @@ export const deleteAll = { post: post31, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post32 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifier', @@ -1563,8 +2031,16 @@ export const byIdentifier = { post: post32, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post33 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginTasksByTaskIdDelete', @@ -1579,8 +2055,16 @@ export const delete7 = { byIdentifier, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get26 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginTasksByTaskId', @@ -1595,8 +2079,16 @@ export const byTaskId = { delete: delete7, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get27 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentPluginTasks', @@ -1612,8 +2104,16 @@ export const tasks = { byTaskId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post34 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUninstall', @@ -1627,8 +2127,16 @@ export const uninstall = { post: post34, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post35 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUpgradeGithub', @@ -1642,8 +2150,16 @@ export const github2 = { post: post35, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post36 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUpgradeMarketplace', @@ -1662,8 +2178,16 @@ export const upgrade = { marketplace: marketplace3, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post37 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUploadBundle', @@ -1676,8 +2200,16 @@ export const bundle = { post: post37, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post38 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUploadGithub', @@ -1691,8 +2223,16 @@ export const github3 = { post: post38, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post39 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentPluginUploadPkg', @@ -1729,8 +2269,16 @@ export const plugin2 = { upload, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get28 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolLabels', @@ -1743,8 +2291,16 @@ export const toolLabels = { get: get28, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post40 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiAdd', @@ -1758,8 +2314,16 @@ export const add = { post: post40, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post41 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiDelete', @@ -1773,8 +2337,16 @@ export const delete8 = { post: post41, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get29 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderApiGet', @@ -1787,8 +2359,16 @@ export const get30 = { get: get29, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get31 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderApiRemote', @@ -1801,8 +2381,16 @@ export const remote = { get: get31, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post42 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiSchema', @@ -1816,8 +2404,16 @@ export const schema = { post: post42, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post43 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiTestPre', @@ -1835,8 +2431,16 @@ export const test = { pre, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get32 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderApiTools', @@ -1849,8 +2453,16 @@ export const tools = { get: get32, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post44 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderApiUpdate', @@ -1875,8 +2487,16 @@ export const api = { update: update2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post45 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderAdd', @@ -1895,8 +2515,16 @@ export const add2 = { post: post45, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get33 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfo', @@ -1910,8 +2538,16 @@ export const info = { get: get33, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get34 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: @@ -1942,8 +2578,16 @@ export const credential = { schema: schema2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get35 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderCredentials', @@ -1957,8 +2601,16 @@ export const credentials3 = { get: get35, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post46 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredential', @@ -1977,8 +2629,16 @@ export const defaultCredential = { post: post46, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post47 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderDelete', @@ -1997,8 +2657,16 @@ export const delete9 = { post: post47, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get36 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderIcon', @@ -2012,8 +2680,16 @@ export const icon2 = { get: get36, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get37 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderInfo', @@ -2027,8 +2703,16 @@ export const info2 = { get: get37, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get38 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchema', @@ -2044,8 +2728,16 @@ export const clientSchema = { get: get38, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete10 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient', @@ -2059,8 +2751,16 @@ export const delete10 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get39 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient', @@ -2072,8 +2772,16 @@ export const get39 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post48 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClient', @@ -2099,8 +2807,16 @@ export const oauth = { customClient, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get40 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderBuiltinByProviderTools', @@ -2114,8 +2830,16 @@ export const tools2 = { get: get40, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post49 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderBuiltinByProviderUpdate', @@ -2151,8 +2875,16 @@ export const builtin = { byProvider: byProvider2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post50 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderMcpAuth', @@ -2166,8 +2898,16 @@ export const auth = { post: post50, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get41 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderMcpToolsByProviderId', @@ -2185,8 +2925,16 @@ export const tools3 = { byProviderId, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get42 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderMcpUpdateByProviderId', @@ -2204,8 +2952,16 @@ export const update4 = { byProviderId: byProviderId2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const delete11 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentToolProviderMcp', @@ -2215,8 +2971,16 @@ export const delete11 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post51 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderMcp', @@ -2226,8 +2990,16 @@ export const post51 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const put4 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PUT', operationId: 'putWorkspacesCurrentToolProviderMcp', @@ -2246,8 +3018,16 @@ export const mcp = { update: update4, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post52 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderWorkflowCreate', @@ -2261,8 +3041,16 @@ export const create2 = { post: post52, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post53 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderWorkflowDelete', @@ -2276,8 +3064,16 @@ export const delete12 = { post: post53, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get43 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderWorkflowGet', @@ -2290,8 +3086,16 @@ export const get44 = { get: get43, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get45 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviderWorkflowTools', @@ -2304,8 +3108,16 @@ export const tools4 = { get: get45, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post54 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentToolProviderWorkflowUpdate', @@ -2334,8 +3146,16 @@ export const toolProvider = { workflow, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get46 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolProviders', @@ -2348,8 +3168,16 @@ export const toolProviders = { get: get46, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get47 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsApi', @@ -2362,8 +3190,16 @@ export const api2 = { get: get47, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get48 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsBuiltin', @@ -2376,8 +3212,16 @@ export const builtin2 = { get: get48, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get49 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsMcp', @@ -2390,8 +3234,16 @@ export const mcp2 = { get: get49, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get50 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentToolsWorkflow', @@ -2411,8 +3263,16 @@ export const tools5 = { workflow: workflow2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get51 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderIcon', @@ -2428,9 +3288,16 @@ export const icon3 = { /** * Get info for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get52 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderInfo', @@ -2447,9 +3314,16 @@ export const info3 = { /** * Remove custom OAuth client configuration + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const delete13 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteWorkspacesCurrentTriggerProviderByProviderOauthClient', @@ -2462,9 +3336,16 @@ export const delete13 = oc /** * Get OAuth client configuration for a provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get53 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderOauthClient', @@ -2477,9 +3358,16 @@ export const get53 = oc /** * Configure custom OAuth client for a provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post55 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderByProviderOauthClient', @@ -2507,9 +3395,16 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post56 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -2539,9 +3434,16 @@ export const build = { /** * Add a new subscription instance for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post57 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreate', @@ -2563,9 +3465,16 @@ export const create3 = { /** * Get the request logs for a subscription instance for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get54 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: @@ -2594,9 +3503,16 @@ export const logs = { /** * Update a subscription instance for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post58 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -2626,9 +3542,16 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post59 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -2658,9 +3581,16 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get55 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: @@ -2694,9 +3624,16 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get56 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderSubscriptionsList', @@ -2713,9 +3650,16 @@ export const list3 = { /** * Initiate OAuth authorization flow for a trigger provider + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get57 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorize', @@ -2740,9 +3684,16 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post60 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: @@ -2786,9 +3737,16 @@ export const byProvider3 = { /** * Delete a subscription instance + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post61 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDelete', @@ -2809,9 +3767,16 @@ export const delete14 = { /** * Update a subscription instance + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post62 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdate', @@ -2847,9 +3812,16 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get58 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentTriggers', @@ -2863,8 +3835,16 @@ export const triggers = { get: get58, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post63 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCurrent', @@ -2893,8 +3873,16 @@ export const current = { triggers, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post64 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCustomConfigWebappLogoUpload', @@ -2911,8 +3899,16 @@ export const webappLogo = { upload: upload2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post65 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesCustomConfig', @@ -2927,8 +3923,16 @@ export const customConfig = { webappLogo, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post66 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesInfo', @@ -2942,8 +3946,16 @@ export const info4 = { post: post66, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const post67 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkspacesSwitch', @@ -2957,8 +3969,16 @@ export const switch3 = { post: post67, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get59 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLang', @@ -2988,8 +4008,16 @@ export const byTenantId = { modelProviders: modelProviders2, } +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get60 = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspaces', diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index a90db98f22..3d2dde4482 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -201,7 +201,7 @@ export type LoadBalancingCredentialPayload = { } export type ParserPreferredProviderType = { - preferred_provider_type: 'system' | 'custom' + preferred_provider_type: 'custom' | 'system' } export type ParserGithubInstall = { @@ -487,9 +487,9 @@ export type Inner = { provider?: string | null } -export type TenantAccountRole = 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' +export type TenantAccountRole = 'admin' | 'dataset_operator' | 'editor' | 'normal' | 'owner' -export type ModelType = 'llm' | 'text-embedding' | 'rerank' | 'speech2text' | 'moderation' | 'tts' +export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' export type LoadBalancingPayload = { configs?: Array<{ @@ -498,9 +498,9 @@ export type LoadBalancingPayload = { enabled?: boolean | null } -export type DebugPermission = 'everyone' | 'admins' | 'noone' +export type DebugPermission = 'admins' | 'everyone' | 'noone' -export type InstallPermission = 'everyone' | 'admins' | 'noone' +export type InstallPermission = 'admins' | 'everyone' | 'noone' export type PluginAutoUpgradeSettingsPayload = { exclude_plugins?: Array @@ -515,7 +515,7 @@ export type PluginPermissionSettingsPayload = { install_permission?: InstallPermission } -export type ApiProviderSchemaType = 'openapi' | 'swagger' | 'openai_plugin' | 'openai_actions' +export type ApiProviderSchemaType = 'openai_actions' | 'openai_plugin' | 'openapi' | 'swagger' export type CredentialType = 'api-key' | 'oauth2' | 'unauthorized' @@ -527,9 +527,9 @@ export type WorkflowToolParameterConfiguration = { export type StrategySetting = 'disabled' | 'fix_only' | 'latest' -export type UpgradeMode = 'all' | 'partial' | 'exclude' +export type UpgradeMode = 'all' | 'exclude' | 'partial' -export type ToolParameterForm = 'schema' | 'form' | 'llm' +export type ToolParameterForm = 'form' | 'llm' | 'schema' export type GetWorkspacesData = { body?: never @@ -1354,8 +1354,8 @@ export type PostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConf = { body: LoadBalancingCredentialPayload path: { - provider: string config_id: string + provider: string } query?: never url: '/workspaces/current/model-providers/{provider}/models/load-balancing-configs/{config_id}/credentials-validate' @@ -1844,8 +1844,8 @@ export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse export type PostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierData = { body?: never path: { - task_id: string identifier: string + task_id: string } query?: never url: '/workspaces/current/plugin/tasks/{task_id}/delete/{identifier}' @@ -2140,8 +2140,8 @@ export type GetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByC = { body?: never path: { - provider: string credential_type: string + provider: string } query?: never url: '/workspaces/current/tool-provider/builtin/{provider}/credential/schema/{credential_type}' @@ -3004,10 +3004,10 @@ export type PostWorkspacesSwitchResponse export type GetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangData = { body?: never path: { - tenant_id: string - provider: string icon_type: string lang: string + provider: string + tenant_id: string } query?: never url: '/workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang}' diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index a381824da7..cf7a3d97c5 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -173,7 +173,7 @@ export const zParserCredentialValidate = z.object({ * ParserPreferredProviderType */ export const zParserPreferredProviderType = z.object({ - preferred_provider_type: z.enum(['system', 'custom']), + preferred_provider_type: z.enum(['custom', 'system']), }) /** @@ -431,7 +431,7 @@ export const zAccountWithRoleList = z.object({ /** * TenantAccountRole */ -export const zTenantAccountRole = z.enum(['owner', 'admin', 'editor', 'normal', 'dataset_operator']) +export const zTenantAccountRole = z.enum(['admin', 'dataset_operator', 'editor', 'normal', 'owner']) /** * MemberInvitePayload @@ -449,10 +449,10 @@ export const zMemberInvitePayload = z.object({ */ export const zModelType = z.enum([ 'llm', - 'text-embedding', + 'moderation', 'rerank', 'speech2text', - 'moderation', + 'text-embedding', 'tts', ]) @@ -559,12 +559,12 @@ export const zParserPostModels = z.object({ /** * DebugPermission */ -export const zDebugPermission = z.enum(['everyone', 'admins', 'noone']) +export const zDebugPermission = z.enum(['admins', 'everyone', 'noone']) /** * InstallPermission */ -export const zInstallPermission = z.enum(['everyone', 'admins', 'noone']) +export const zInstallPermission = z.enum(['admins', 'everyone', 'noone']) /** * ParserPermissionChange @@ -588,10 +588,10 @@ export const zPluginPermissionSettingsPayload = z.object({ * Enum class for api provider schema type. */ export const zApiProviderSchemaType = z.enum([ + 'openai_actions', + 'openai_plugin', 'openapi', 'swagger', - 'openai_plugin', - 'openai_actions', ]) /** @@ -657,7 +657,7 @@ export const zStrategySetting = z.enum(['disabled', 'fix_only', 'latest']) /** * UpgradeMode */ -export const zUpgradeMode = z.enum(['all', 'partial', 'exclude']) +export const zUpgradeMode = z.enum(['all', 'exclude', 'partial']) /** * PluginAutoUpgradeSettingsPayload @@ -681,7 +681,7 @@ export const zParserPreferencesChange = z.object({ /** * ToolParameterForm */ -export const zToolParameterForm = z.enum(['schema', 'form', 'llm']) +export const zToolParameterForm = z.enum(['form', 'llm', 'schema']) /** * WorkflowToolParameterConfiguration @@ -1214,8 +1214,8 @@ export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingCo export const zPostWorkspacesCurrentModelProvidersByProviderModelsLoadBalancingConfigsByConfigIdCredentialsValidatePath = z.object({ - provider: z.string(), config_id: z.string(), + provider: z.string(), }) /** @@ -1484,8 +1484,8 @@ export const zPostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse = z.record( ) export const zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierPath = z.object({ - task_id: z.string(), identifier: z.string(), + task_id: z.string(), }) /** @@ -1623,8 +1623,8 @@ export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoRes export const zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialSchemaByCredentialTypePath = z.object({ - provider: z.string(), credential_type: z.string(), + provider: z.string(), }) /** @@ -2135,10 +2135,10 @@ export const zPostWorkspacesSwitchBody = zSwitchWorkspacePayload export const zPostWorkspacesSwitchResponse = z.record(z.string(), z.unknown()) export const zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangPath = z.object({ - tenant_id: z.string(), - provider: z.string(), icon_type: z.string(), lang: z.string(), + provider: z.string(), + tenant_id: z.string(), }) /** diff --git a/packages/contracts/generated/api/readiness.json b/packages/contracts/generated/api/readiness.json new file mode 100644 index 0000000000..78fed3ccfa --- /dev/null +++ b/packages/contracts/generated/api/readiness.json @@ -0,0 +1,17 @@ +{ + "surfaces": { + "console": { + "notReady": 474, + "total": 570 + }, + "service": { + "notReady": 72, + "total": 88 + }, + "web": { + "notReady": 36, + "total": 41 + } + }, + "warning": "Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate." +} diff --git a/packages/contracts/generated/api/service/orpc.gen.ts b/packages/contracts/generated/api/service/orpc.gen.ts index a5a45a6452..647cdd87b5 100644 --- a/packages/contracts/generated/api/service/orpc.gen.ts +++ b/packages/contracts/generated/api/service/orpc.gen.ts @@ -199,8 +199,16 @@ import { zPutConversationsByCIdVariablesByVariableIdResponse, } from './zod.gen' +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get = oc .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getRoot', @@ -218,11 +226,16 @@ export const root = { * * Get all feedbacks for the application * Returns paginated list of all feedback submitted for messages in this app. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get2 = oc .route({ + deprecated: true, description: - 'Get all feedbacks for the application\nReturns paginated list of all feedback submitted for messages in this app.', + 'Get all feedbacks for the application\nReturns paginated list of all feedback submitted for messages in this app.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppFeedbacks', @@ -245,10 +258,16 @@ export const app = { * Get the status of an annotation reply action job * * Get the status of an annotation reply action job + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get3 = oc .route({ - description: 'Get the status of an annotation reply action job', + deprecated: true, + description: + 'Get the status of an annotation reply action job\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsAnnotationReplyByActionStatusByJobId', @@ -271,10 +290,16 @@ export const status = { * Enable or disable annotation reply feature * * Enable or disable annotation reply feature + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Enable or disable annotation reply feature', + deprecated: true, + description: + 'Enable or disable annotation reply feature\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsAnnotationReplyByAction', @@ -398,11 +423,16 @@ export const apps = { * * Convert audio to text using speech-to-text * Accepts an audio file upload and returns the transcribed text. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post3 = oc .route({ + deprecated: true, description: - 'Convert audio to text using speech-to-text\nAccepts an audio file upload and returns the transcribed text.', + 'Convert audio to text using speech-to-text\nAccepts an audio file upload and returns the transcribed text.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAudioToText', @@ -420,10 +450,16 @@ export const audioToText = { * Stop a running chat message generation * * Stop a running chat message generation + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post4 = oc .route({ - description: 'Stop a running chat message generation', + deprecated: true, + description: + 'Stop a running chat message generation\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessagesByTaskIdStop', @@ -448,11 +484,16 @@ export const byTaskId = { * Send a message in a chat conversation * This endpoint handles chat messages for chat, agent chat, and advanced chat applications. * Supports conversation management and both blocking and streaming response modes. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post5 = oc .route({ + deprecated: true, description: - 'Send a message in a chat conversation\nThis endpoint handles chat messages for chat, agent chat, and advanced chat applications.\nSupports conversation management and both blocking and streaming response modes.', + 'Send a message in a chat conversation\nThis endpoint handles chat messages for chat, agent chat, and advanced chat applications.\nSupports conversation management and both blocking and streaming response modes.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessages', @@ -472,10 +513,16 @@ export const chatMessages = { * Stop a running completion task * * Stop a running completion task + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post6 = oc .route({ - description: 'Stop a running completion task', + deprecated: true, + description: + 'Stop a running completion task\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessagesByTaskIdStop', @@ -500,11 +547,16 @@ export const byTaskId2 = { * Create a completion for the given prompt * This endpoint generates a completion based on the provided inputs and query. * Supports both blocking and streaming response modes. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post7 = oc .route({ + deprecated: true, description: - 'Create a completion for the given prompt\nThis endpoint generates a completion based on the provided inputs and query.\nSupports both blocking and streaming response modes.', + 'Create a completion for the given prompt\nThis endpoint generates a completion based on the provided inputs and query.\nSupports both blocking and streaming response modes.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessages', @@ -524,10 +576,16 @@ export const completionMessages = { * Rename a conversation or auto-generate a name * * Rename a conversation or auto-generate a name + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post8 = oc .route({ - description: 'Rename a conversation or auto-generate a name', + deprecated: true, + description: + 'Rename a conversation or auto-generate a name\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postConversationsByCIdName', @@ -634,11 +692,16 @@ export const byCId = { * * List all conversations for the current user * Supports pagination using last_id and limit parameters. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ + deprecated: true, description: - 'List all conversations for the current user\nSupports pagination using last_id and limit parameters.', + 'List all conversations for the current user\nSupports pagination using last_id and limit parameters.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getConversations', @@ -659,11 +722,16 @@ export const conversations = { * * Upload a file to a knowledgebase pipeline * Accepts a single file upload via multipart/form-data. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post9 = oc .route({ + deprecated: true, description: - 'Upload a file to a knowledgebase pipeline\nAccepts a single file upload via multipart/form-data.', + 'Upload a file to a knowledgebase pipeline\nAccepts a single file upload via multipart/form-data.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsPipelineFileUpload', @@ -703,11 +771,11 @@ export const binding = { } /** - * Unbind a tag from a dataset + * Unbind tags from a dataset */ export const post11 = oc .route({ - description: 'Unbind a tag from a dataset', + description: 'Unbind tags from a dataset', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsTagsUnbinding', @@ -745,10 +813,16 @@ export const delete3 = oc * Get all knowledge type tags * * Get all knowledge type tags + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get7 = oc .route({ - description: 'Get all knowledge type tags', + deprecated: true, + description: + 'Get all knowledge type tags\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsTags', @@ -760,10 +834,16 @@ export const get7 = oc /** * Update a knowledge type tag + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch = oc .route({ - description: 'Update a knowledge type tag', + deprecated: true, + description: + 'Update a knowledge type tag\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsTags', @@ -777,10 +857,16 @@ export const patch = oc * Add a knowledge type tag * * Add a knowledge type tag + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post12 = oc .route({ - description: 'Add a knowledge type tag', + deprecated: true, + description: + 'Add a knowledge type tag\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsTags', @@ -802,10 +888,16 @@ export const tags = { /** * Create a new document by uploading a file + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post13 = oc .route({ - description: 'Create a new document by uploading a file', + deprecated: true, + description: + 'Create a new document by uploading a file\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByFile', @@ -817,10 +909,16 @@ export const post13 = oc /** * Create a new document by uploading a file + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post14 = oc .route({ - description: 'Create a new document by uploading a file', + deprecated: true, + description: + 'Create a new document by uploading a file\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByFile', @@ -836,10 +934,16 @@ export const createByFile = { /** * Create a new document by providing text content + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post15 = oc .route({ - description: 'Create a new document by providing text content', + deprecated: true, + description: + 'Create a new document by providing text content\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByText', @@ -857,13 +961,15 @@ export const post15 = oc /** * Deprecated legacy alias for creating a new document by providing text content. Use /datasets/{dataset_id}/document/create-by-text instead. * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * * @deprecated */ export const post16 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for creating a new document by providing text content. Use /datasets/{dataset_id}/document/create-by-text instead.', + 'Deprecated legacy alias for creating a new document by providing text content. Use /datasets/{dataset_id}/document/create-by-text instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByText', @@ -889,10 +995,16 @@ export const document_ = { /** * Download selected uploaded documents as a single ZIP archive + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post17 = oc .route({ - description: 'Download selected uploaded documents as a single ZIP archive', + deprecated: true, + description: + 'Download selected uploaded documents as a single ZIP archive\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsDownloadZip', @@ -915,10 +1027,16 @@ export const downloadZip = { * Update metadata for multiple documents * * Update metadata for multiple documents + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post18 = oc .route({ - description: 'Update metadata for multiple documents', + deprecated: true, + description: + 'Update metadata for multiple documents\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsMetadata', @@ -955,11 +1073,16 @@ export const metadata = { * NotFound: If the dataset with the given ID does not exist. * Forbidden: If the user does not have permission. * InvalidActionError: If the action is invalid or cannot be performed. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch2 = oc .route({ + deprecated: true, description: - 'Batch update document status\nArgs:\n tenant_id: tenant id\n dataset_id: dataset id\n action: action to perform (Literal["enable", "disable", "archive", "un_archive"])\n\nReturns:\n dict: A dictionary with a key \'result\' and a value \'success\'\n int: HTTP status code 200 indicating that the operation was successful.\n\nRaises:\n NotFound: If the dataset with the given ID does not exist.\n Forbidden: If the user does not have permission.\n InvalidActionError: If the action is invalid or cannot be performed.', + 'Batch update document status\nArgs:\n tenant_id: tenant id\n dataset_id: dataset id\n action: action to perform (Literal["enable", "disable", "archive", "un_archive"])\n\nReturns:\n dict: A dictionary with a key \'result\' and a value \'success\'\n int: HTTP status code 200 indicating that the operation was successful.\n\nRaises:\n NotFound: If the dataset with the given ID does not exist.\n Forbidden: If the user does not have permission.\n InvalidActionError: If the action is invalid or cannot be performed.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsStatusByAction', @@ -980,10 +1103,16 @@ export const status2 = { /** * Get indexing status for documents in a batch + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get8 = oc .route({ - description: 'Get indexing status for documents in a batch', + deprecated: true, + description: + 'Get indexing status for documents in a batch\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByBatchIndexingStatus', @@ -1003,10 +1132,16 @@ export const byBatch = { /** * Get a signed download URL for a document's original uploaded file + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get9 = oc .route({ - description: 'Get a signed download URL for a document\'s original uploaded file', + deprecated: true, + description: + 'Get a signed download URL for a document\'s original uploaded file\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdDownload', @@ -1046,10 +1181,16 @@ export const delete4 = oc /** * Update a specific child chunk + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch3 = oc .route({ - description: 'Update a specific child chunk', + deprecated: true, + description: + 'Update a specific child chunk\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: @@ -1075,10 +1216,16 @@ export const byChildChunkId = { /** * List child chunks for a segment + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get10 = oc .route({ - description: 'List child chunks for a segment', + deprecated: true, + description: + 'List child chunks for a segment\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', @@ -1096,10 +1243,16 @@ export const get10 = oc /** * Create a new child chunk for a segment + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post19 = oc .route({ - description: 'Create a new child chunk for a segment', + deprecated: true, + description: + 'Create a new child chunk for a segment\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', @@ -1140,10 +1293,16 @@ export const delete5 = oc /** * Get a specific segment by ID + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get11 = oc .route({ - description: 'Get a specific segment by ID', + deprecated: true, + description: + 'Get a specific segment by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', @@ -1155,10 +1314,16 @@ export const get11 = oc /** * Update a specific segment + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post20 = oc .route({ - description: 'Update a specific segment', + deprecated: true, + description: + 'Update a specific segment\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', @@ -1182,10 +1347,16 @@ export const bySegmentId = { /** * List segments in a document + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get12 = oc .route({ - description: 'List segments in a document', + deprecated: true, + description: + 'List segments in a document\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegments', @@ -1202,10 +1373,16 @@ export const get12 = oc /** * Create segments in a document + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post21 = oc .route({ - description: 'Create segments in a document', + deprecated: true, + description: + 'Create segments in a document\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegments', @@ -1229,13 +1406,15 @@ export const segments = { /** * Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * * @deprecated */ export const post22 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead.', + 'Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile', @@ -1248,13 +1427,15 @@ export const post22 = oc /** * Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * * @deprecated */ export const post23 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead.', + 'Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile', @@ -1270,10 +1451,16 @@ export const updateByFile = { /** * Update an existing document by providing text content + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post24 = oc .route({ - description: 'Update an existing document by providing text content', + deprecated: true, + description: + 'Update an existing document by providing text content\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText', @@ -1291,13 +1478,15 @@ export const post24 = oc /** * Deprecated legacy alias for updating an existing document by providing text content. Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead. * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * * @deprecated */ export const post25 = oc .route({ deprecated: true, description: - 'Deprecated legacy alias for updating an existing document by providing text content. Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead.', + 'Deprecated legacy alias for updating an existing document by providing text content. Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText', @@ -1337,10 +1526,16 @@ export const delete6 = oc /** * Get a specific document by ID + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get13 = oc .route({ - description: 'Get a specific document by ID', + deprecated: true, + description: + 'Get a specific document by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentId', @@ -1352,10 +1547,16 @@ export const get13 = oc /** * Update an existing document by uploading a file + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch4 = oc .route({ - description: 'Update an existing document by uploading a file', + deprecated: true, + description: + 'Update an existing document by uploading a file\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentId', @@ -1377,10 +1578,16 @@ export const byDocumentId = { /** * List all documents in a dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get14 = oc .route({ - description: 'List all documents in a dataset', + deprecated: true, + description: + 'List all documents in a dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocuments', @@ -1404,11 +1611,16 @@ export const documents = { * * Perform hit testing on a dataset * Tests retrieval performance for the specified dataset. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post26 = oc .route({ + deprecated: true, description: - 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.', + 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdHitTesting', @@ -1432,10 +1644,16 @@ export const hitTesting = { * Enable or disable built-in metadata field * * Enable or disable built-in metadata field + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post27 = oc .route({ - description: 'Enable or disable built-in metadata field', + deprecated: true, + description: + 'Enable or disable built-in metadata field\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdMetadataBuiltInByAction', @@ -1454,10 +1672,16 @@ export const byAction3 = { * Get all built-in metadata fields * * Get all built-in metadata fields + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get15 = oc .route({ - description: 'Get all built-in metadata fields', + deprecated: true, + description: + 'Get all built-in metadata fields\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdMetadataBuiltIn', @@ -1496,10 +1720,16 @@ export const delete7 = oc * Update metadata name * * Update metadata name + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch5 = oc .route({ - description: 'Update metadata name', + deprecated: true, + description: + 'Update metadata name\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdMetadataByMetadataId', @@ -1524,10 +1754,16 @@ export const byMetadataId = { * Get all metadata for a dataset * * Get all metadata for a dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get16 = oc .route({ - description: 'Get all metadata for a dataset', + deprecated: true, + description: + 'Get all metadata for a dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdMetadata', @@ -1542,10 +1778,16 @@ export const get16 = oc * Create metadata for a dataset * * Create metadata for a dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post28 = oc .route({ - description: 'Create metadata for a dataset', + deprecated: true, + description: + 'Create metadata for a dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdMetadata', @@ -1573,10 +1815,16 @@ export const metadata2 = { * Resource for getting datasource plugins * * List all datasource plugins for a rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get17 = oc .route({ - description: 'List all datasource plugins for a rag pipeline', + deprecated: true, + description: + 'List all datasource plugins for a rag pipeline\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdPipelineDatasourcePlugins', @@ -1600,10 +1848,16 @@ export const datasourcePlugins = { * Resource for getting datasource plugins * * Run a datasource node for a rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post29 = oc .route({ - description: 'Run a datasource node for a rag pipeline', + deprecated: true, + description: + 'Run a datasource node for a rag pipeline\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRun', @@ -1634,10 +1888,16 @@ export const datasource = { * Resource for running a rag pipeline * * Run a datasource node for a rag pipeline + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post30 = oc .route({ - description: 'Run a datasource node for a rag pipeline', + deprecated: true, + description: + 'Run a datasource node for a rag pipeline\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdPipelineRun', @@ -1663,11 +1923,16 @@ export const pipeline2 = { * * Perform hit testing on a dataset * Tests retrieval performance for the specified dataset. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post31 = oc .route({ + deprecated: true, description: - 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.', + 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdRetrieve', @@ -1691,10 +1956,16 @@ export const retrieve = { * Get all knowledge type tags * * Get tags bound to a specific dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get18 = oc .route({ - description: 'Get tags bound to a specific dataset', + deprecated: true, + description: + 'Get tags bound to a specific dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdTags', @@ -1742,10 +2013,16 @@ export const delete8 = oc /** * Get a specific dataset by ID + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get19 = oc .route({ - description: 'Get a specific dataset by ID', + deprecated: true, + description: + 'Get a specific dataset by ID\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetId', @@ -1757,10 +2034,16 @@ export const get19 = oc /** * Update an existing dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch6 = oc .route({ - description: 'Update an existing dataset', + deprecated: true, + description: + 'Update an existing dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetId', @@ -1787,10 +2070,16 @@ export const byDatasetId = { * Resource for getting datasets * * List all datasets + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get20 = oc .route({ - description: 'List all datasets', + deprecated: true, + description: + 'List all datasets\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasets', @@ -1804,10 +2093,16 @@ export const get20 = oc * Resource for creating datasets * * Create a new dataset + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post32 = oc .route({ - description: 'Create a new dataset', + deprecated: true, + description: + 'Create a new dataset\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasets', @@ -1832,11 +2127,16 @@ export const datasets = { * Get an end user by ID * This endpoint is scoped to the current app token's tenant/app to prevent * cross-tenant/app access when an end-user ID is known. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get21 = oc .route({ + deprecated: true, description: - 'Get an end user by ID\nThis endpoint is scoped to the current app token\'s tenant/app to prevent\ncross-tenant/app access when an end-user ID is known.', + 'Get an end user by ID\nThis endpoint is scoped to the current app token\'s tenant/app to prevent\ncross-tenant/app access when an end-user ID is known.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getEndUsersByEndUserId', @@ -1885,11 +2185,16 @@ export const upload = { * Preview or download a file uploaded via Service API * Provides secure file preview/download functionality. * Files can only be accessed if they belong to messages within the requesting app's context. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get22 = oc .route({ + deprecated: true, description: - 'Preview or download a file uploaded via Service API\nProvides secure file preview/download functionality.\nFiles can only be accessed if they belong to messages within the requesting app\'s context.', + 'Preview or download a file uploaded via Service API\nProvides secure file preview/download functionality.\nFiles can only be accessed if they belong to messages within the requesting app\'s context.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFilesByFileIdPreview', @@ -1920,10 +2225,16 @@ export const files = { /** * Get a paused human input form by token + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get23 = oc .route({ - description: 'Get a paused human input form by token', + deprecated: true, + description: + 'Get a paused human input form by token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -1935,10 +2246,16 @@ export const get23 = oc /** * Submit a paused human input form by token + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post34 = oc .route({ - description: 'Submit a paused human input form by token', + deprecated: true, + description: + 'Submit a paused human input form by token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', @@ -1971,11 +2288,16 @@ export const form = { * * Get basic application information * Returns basic information about the application including name, description, tags, and mode. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get24 = oc .route({ + deprecated: true, description: - 'Get basic application information\nReturns basic information about the application including name, description, tags, and mode.', + 'Get basic application information\nReturns basic information about the application including name, description, tags, and mode.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getInfo', @@ -1994,11 +2316,16 @@ export const info = { * * Submit feedback for a message * Allows users to rate messages as like/dislike and provide optional feedback content. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post35 = oc .route({ + deprecated: true, description: - 'Submit feedback for a message\nAllows users to rate messages as like/dislike and provide optional feedback content.', + 'Submit feedback for a message\nAllows users to rate messages as like/dislike and provide optional feedback content.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postMessagesByMessageIdFeedbacks', @@ -2023,11 +2350,16 @@ export const feedbacks2 = { * * Get suggested follow-up questions for a message * Returns AI-generated follow-up questions based on the message content. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get25 = oc .route({ + deprecated: true, description: - 'Get suggested follow-up questions for a message\nReturns AI-generated follow-up questions based on the message content.', + 'Get suggested follow-up questions for a message\nReturns AI-generated follow-up questions based on the message content.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessagesByMessageIdSuggested', @@ -2052,11 +2384,16 @@ export const byMessageId = { * * List messages in a conversation * Retrieves messages with pagination support using first_id. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get26 = oc .route({ + deprecated: true, description: - 'List messages in a conversation\nRetrieves messages with pagination support using first_id.', + 'List messages in a conversation\nRetrieves messages with pagination support using first_id.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessages', @@ -2077,11 +2414,16 @@ export const messages = { * * Get application metadata * Returns metadata about the application including configuration and settings. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get27 = oc .route({ + deprecated: true, description: - 'Get application metadata\nReturns metadata about the application including configuration and settings.', + 'Get application metadata\nReturns metadata about the application including configuration and settings.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMeta', @@ -2100,11 +2442,16 @@ export const meta = { * * Retrieve application input parameters and configuration * Returns the input form parameters and configuration for the application. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get28 = oc .route({ + deprecated: true, description: - 'Retrieve application input parameters and configuration\nReturns the input form parameters and configuration for the application.', + 'Retrieve application input parameters and configuration\nReturns the input form parameters and configuration for the application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getParameters', @@ -2123,11 +2470,16 @@ export const parameters = { * * Get application site configuration * Returns the site configuration for the application including theme, icons, and text. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get29 = oc .route({ + deprecated: true, description: - 'Get application site configuration\nReturns the site configuration for the application including theme, icons, and text.', + 'Get application site configuration\nReturns the site configuration for the application including theme, icons, and text.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSite', @@ -2146,11 +2498,16 @@ export const site = { * * Convert text to audio using text-to-speech * Converts the provided text to audio using the specified voice. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post36 = oc .route({ + deprecated: true, description: - 'Convert text to audio using text-to-speech\nConverts the provided text to audio using the specified voice.', + 'Convert text to audio using text-to-speech\nConverts the provided text to audio using the specified voice.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTextToAudio', @@ -2167,10 +2524,16 @@ export const textToAudio = { /** * Get workflow execution events stream after resume + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get30 = oc .route({ - description: 'Get workflow execution events stream after resume', + deprecated: true, + description: + 'Get workflow execution events stream after resume\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByTaskIdEvents', @@ -2202,11 +2565,16 @@ export const workflow = { * * Get workflow execution logs * Returns paginated workflow execution logs with filtering options. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get31 = oc .route({ + deprecated: true, description: - 'Get workflow execution logs\nReturns paginated workflow execution logs with filtering options.', + 'Get workflow execution logs\nReturns paginated workflow execution logs with filtering options.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowsLogs', @@ -2226,11 +2594,16 @@ export const logs = { * * Get workflow run details * Returns detailed information about a specific workflow run. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get32 = oc .route({ + deprecated: true, description: - 'Get workflow run details\nReturns detailed information about a specific workflow run.', + 'Get workflow run details\nReturns detailed information about a specific workflow run.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowsRunByWorkflowRunId', @@ -2251,11 +2624,16 @@ export const byWorkflowRunId = { * Execute a workflow * Runs a workflow with the provided inputs and returns the results. * Supports both blocking and streaming response modes. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post37 = oc .route({ + deprecated: true, description: - 'Execute a workflow\nRuns a workflow with the provided inputs and returns the results.\nSupports both blocking and streaming response modes.', + 'Execute a workflow\nRuns a workflow with the provided inputs and returns the results.\nSupports both blocking and streaming response modes.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsRun', @@ -2275,10 +2653,16 @@ export const run3 = { * Stop a running workflow task * * Stop a running workflow task + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post38 = oc .route({ - description: 'Stop a running workflow task', + deprecated: true, + description: + 'Stop a running workflow task\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsTasksByTaskIdStop', @@ -2306,11 +2690,16 @@ export const tasks = { * * Execute a specific workflow by ID * Executes a specific workflow version identified by its ID. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post39 = oc .route({ + deprecated: true, description: - 'Execute a specific workflow by ID\nExecutes a specific workflow version identified by its ID.', + 'Execute a specific workflow by ID\nExecutes a specific workflow version identified by its ID.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsByWorkflowIdRun', @@ -2346,11 +2735,16 @@ export const workflows = { * * Get available models by model type * Returns a list of available models for the specified model type. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get33 = oc .route({ + deprecated: true, description: - 'Get available models by model type\nReturns a list of available models for the specified model type.', + 'Get available models by model type\nReturns a list of available models for the specified model type.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelsModelTypesByModelType', diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index f491c1e3f9..ba6f822087 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -72,10 +72,34 @@ export type CompletionRequestPayload = { retriever_from?: string } +export type Condition = { + comparison_operator: + | '<' + | '=' + | '>' + | 'after' + | 'before' + | 'contains' + | 'empty' + | 'end with' + | 'in' + | 'is' + | 'is not' + | 'not contains' + | 'not empty' + | 'not in' + | 'start with' + | '≠' + | '≤' + | '≥' + name: string + value?: unknown +} + export type ConversationListQuery = { last_id?: string | null limit?: number - sort_by?: 'created_at' | '-created_at' | 'updated_at' | '-updated_at' + sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } export type ConversationRenamePayload = { @@ -109,13 +133,20 @@ export type ConversationVariablesQuery = { variable_name?: string | null } +export type DataSetTag = { + binding_count?: string | null + id: string + name: string + type: string +} + export type DatasetCreatePayload = { description?: string embedding_model?: string | null embedding_model_provider?: string | null external_knowledge_api_id?: string | null external_knowledge_id?: string | null - indexing_technique?: 'high_quality' | 'economy' | null + indexing_technique?: 'economy' | 'high_quality' | null name: string permission?: DatasetPermissionEnum provider?: string @@ -125,6 +156,16 @@ export type DatasetCreatePayload = { } | null } +export type DatasetListQuery = { + include_all?: boolean + keyword?: string | null + limit?: number + page?: number + tag_ids?: Array +} + +export type DatasetPermissionEnum = 'all_team_members' | 'only_me' | 'partial_members' + export type DatasetUpdatePayload = { description?: string | null embedding_model?: string | null @@ -134,7 +175,7 @@ export type DatasetUpdatePayload = { external_retrieval_model?: { [key: string]: unknown } | null - indexing_technique?: 'high_quality' | 'economy' | null + indexing_technique?: 'economy' | 'high_quality' | null name?: string | null partial_member_list?: Array<{ [key: string]: string @@ -143,10 +184,32 @@ export type DatasetUpdatePayload = { retrieval_model?: RetrievalModel } +export type DatasourceNodeRunPayload = { + credential_id?: string | null + datasource_type: string + inputs: { + [key: string]: unknown + } + is_published: boolean +} + export type DocumentBatchDownloadZipPayload = { document_ids: Array } +export type DocumentListQuery = { + keyword?: string | null + limit?: number + page?: number + status?: string | null +} + +export type DocumentMetadataOperation = { + document_id: string + metadata_list: Array + partial_update?: boolean +} + export type DocumentTextCreatePayload = { doc_form?: string doc_language?: string @@ -211,9 +274,11 @@ export type HumanInputFormSubmitPayload = { } } +export type JsonValue = unknown + export type MessageFeedbackPayload = { content?: string | null - rating?: 'like' | 'dislike' | null + rating?: 'dislike' | 'like' | null } export type MessageListQuery = { @@ -224,7 +289,18 @@ export type MessageListQuery = { export type MetadataArgs = { name: string - type: 'string' | 'number' | 'time' + type: 'number' | 'string' | 'time' +} + +export type MetadataDetail = { + id: string + name: string + value?: unknown +} + +export type MetadataFilteringCondition = { + conditions?: Array | null + logical_operator?: 'and' | 'or' | null } export type MetadataOperationData = { @@ -235,6 +311,59 @@ export type MetadataUpdatePayload = { name: string } +export type PipelineRunApiEntity = { + datasource_info_list: Array<{ + [key: string]: unknown + }> + datasource_type: string + inputs: { + [key: string]: unknown + } + is_published: boolean + response_mode: string + start_node_id: string +} + +export type PreProcessingRule = { + enabled: boolean + id: string +} + +export type ProcessRule = { + mode: 'automatic' | 'custom' | 'hierarchical' + rules?: Rule +} + +export type RerankingModel = { + reranking_model_name?: string | null + reranking_provider_name?: string | null +} + +export type RetrievalMethod + = | 'full_text_search' + | 'hybrid_search' + | 'keyword_search' + | 'semantic_search' + +export type RetrievalModel = { + metadata_filtering_conditions?: MetadataFilteringCondition + reranking_enable: boolean + reranking_mode?: string | null + reranking_model?: RerankingModel + score_threshold?: number | null + score_threshold_enabled: boolean + search_method: RetrievalMethod + top_k: number + weights?: WeightModel +} + +export type Rule = { + parent_mode?: 'full-doc' | 'paragraph' | null + pre_processing_rules?: Array | null + segmentation?: Segmentation + subchunk_segmentation?: Segmentation +} + export type SegmentCreatePayload = { segments?: Array<{ [key: string]: unknown @@ -246,10 +375,39 @@ export type SegmentListQuery = { status?: Array } +export type SegmentUpdateArgs = { + answer?: string | null + attachment_ids?: Array | null + content?: string | null + enabled?: boolean | null + keywords?: Array | null + regenerate_child_chunks?: boolean + summary?: string | null +} + export type SegmentUpdatePayload = { segment: SegmentUpdateArgs } +export type Segmentation = { + chunk_overlap?: number + max_tokens: number + separator?: string +} + +export type SimpleAccount = { + email: string + id: string + name: string +} + +export type SimpleEndUser = { + id: string + is_anonymous: boolean + session_id?: string | null + type: string +} + export type TagBindingPayload = { tag_ids: Array target_id: string @@ -264,7 +422,8 @@ export type TagDeletePayload = { } export type TagUnbindingPayload = { - tag_id: string + tag_id?: string | null + tag_ids?: Array target_id: string } @@ -280,6 +439,22 @@ export type TextToAudioPayload = { voice?: string | null } +export type WeightKeywordSetting = { + keyword_weight: number +} + +export type WeightModel = { + keyword_setting?: WeightKeywordSetting + vector_setting?: WeightVectorSetting + weight_type?: 'customized' | 'keyword_first' | 'semantic_first' | null +} + +export type WeightVectorSetting = { + embedding_model_name: string + embedding_provider_name: string + vector_weight: number +} + export type WorkflowAppLogPaginationResponse = { data: Array has_more: boolean @@ -288,6 +463,17 @@ export type WorkflowAppLogPaginationResponse = { total: number } +export type WorkflowAppLogPartialResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + created_from?: string | null + details?: unknown + id: string + workflow_run?: WorkflowRunForLogResponse +} + export type WorkflowLogQuery = { created_at__after?: string | null created_at__before?: string | null @@ -296,7 +482,21 @@ export type WorkflowLogQuery = { keyword?: string | null limit?: number page?: number - status?: 'succeeded' | 'failed' | 'stopped' | null + status?: 'failed' | 'stopped' | 'succeeded' | null +} + +export type WorkflowRunForLogResponse = { + created_at?: number | null + elapsed_time?: unknown + error?: string | null + exceptions_count?: number | null + finished_at?: number | null + id: string + status?: string | null + total_steps?: number | null + total_tokens?: number | null + triggered_from?: string | null + version?: string | null } export type WorkflowRunPayload = { @@ -325,131 +525,6 @@ export type WorkflowRunResponse = { workflow_id: string } -export type DatasetPermissionEnum = 'only_me' | 'all_team_members' | 'partial_members' - -export type RerankingModel = { - reranking_model_name?: string | null - reranking_provider_name?: string | null -} - -export type RetrievalMethod - = | 'semantic_search' - | 'full_text_search' - | 'hybrid_search' - | 'keyword_search' - -export type RetrievalModel = { - reranking_enable: boolean - reranking_mode?: string | null - reranking_model?: RerankingModel - score_threshold?: number | null - score_threshold_enabled: boolean - search_method: RetrievalMethod - top_k: number - weights?: WeightModel -} - -export type WeightKeywordSetting = { - keyword_weight: number -} - -export type WeightModel = { - keyword_setting?: WeightKeywordSetting - vector_setting?: WeightVectorSetting - weight_type?: 'semantic_first' | 'keyword_first' | 'customized' | null -} - -export type WeightVectorSetting = { - embedding_model_name: string - embedding_provider_name: string - vector_weight: number -} - -export type PreProcessingRule = { - enabled: boolean - id: string -} - -export type ProcessRule = { - mode: 'automatic' | 'custom' | 'hierarchical' - rules?: Rule -} - -export type Rule = { - parent_mode?: 'full-doc' | 'paragraph' | null - pre_processing_rules?: Array | null - segmentation?: Segmentation - subchunk_segmentation?: Segmentation -} - -export type Segmentation = { - chunk_overlap?: number - max_tokens: number - separator?: string -} - -export type JsonValue = unknown - -export type DocumentMetadataOperation = { - document_id: string - metadata_list: Array - partial_update?: boolean -} - -export type MetadataDetail = { - id: string - name: string - value?: unknown -} - -export type SegmentUpdateArgs = { - answer?: string | null - attachment_ids?: Array | null - content?: string | null - enabled?: boolean | null - keywords?: Array | null - regenerate_child_chunks?: boolean - summary?: string | null -} - -export type SimpleAccount = { - email: string - id: string - name: string -} - -export type SimpleEndUser = { - id: string - is_anonymous: boolean - session_id?: string | null - type: string -} - -export type WorkflowAppLogPartialResponse = { - created_at?: number | null - created_by_account?: SimpleAccount - created_by_end_user?: SimpleEndUser - created_by_role?: string | null - created_from?: string | null - details?: unknown - id: string - workflow_run?: WorkflowRunForLogResponse -} - -export type WorkflowRunForLogResponse = { - created_at?: number | null - elapsed_time?: unknown - error?: string | null - exceptions_count?: number | null - finished_at?: number | null - id: string - status?: string | null - total_steps?: number | null - total_tokens?: number | null - triggered_from?: string | null - version?: string | null -} - export type GetRootData = { body?: never path?: never @@ -619,7 +694,7 @@ export type DeleteAppsAnnotationsByAnnotationIdError export type DeleteAppsAnnotationsByAnnotationIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -827,7 +902,7 @@ export type GetConversationsData = { query?: { last_id?: string | null limit?: number - sort_by?: 'created_at' | '-created_at' | 'updated_at' | '-updated_at' + sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } url: '/conversations' } @@ -874,7 +949,7 @@ export type DeleteConversationsByCIdError export type DeleteConversationsByCIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1078,7 +1153,7 @@ export type DeleteDatasetsTagsError = DeleteDatasetsTagsErrors[keyof DeleteDatas export type DeleteDatasetsTagsResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1181,7 +1256,7 @@ export type PostDatasetsTagsBindingError export type PostDatasetsTagsBindingResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1209,7 +1284,7 @@ export type PostDatasetsTagsUnbindingError export type PostDatasetsTagsUnbindingResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1242,7 +1317,7 @@ export type DeleteDatasetsByDatasetIdError export type DeleteDatasetsByDatasetIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1531,8 +1606,8 @@ export type PostDatasetsByDatasetIdDocumentsMetadataResponse export type PatchDatasetsByDatasetIdDocumentsStatusByActionData = { body?: never path: { - dataset_id: string action: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/documents/status/{action}' @@ -1568,8 +1643,8 @@ export type PatchDatasetsByDatasetIdDocumentsStatusByActionResponse export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusData = { body?: never path: { - dataset_id: string batch: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/documents/{batch}/indexing-status' @@ -1623,7 +1698,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdError export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1822,7 +1897,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErr export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1832,9 +1907,9 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdRes export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = { body?: never path: { - segment_id: string dataset_id: string document_id: string + segment_id: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}' @@ -1965,10 +2040,10 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi = { body?: never path: { + child_chunk_id: string dataset_id: string document_id: string segment_id: string - child_chunk_id: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' @@ -1990,7 +2065,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -2001,10 +2076,10 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil = { body: ChildChunkUpdatePayload path: { + child_chunk_id: string dataset_id: string document_id: string segment_id: string - child_chunk_id: string } query?: never url: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' @@ -2277,8 +2352,8 @@ export type GetDatasetsByDatasetIdMetadataBuiltInResponse export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { body?: never path: { - dataset_id: string action: string + dataset_id: string } query?: never url: '/datasets/{dataset_id}/metadata/built-in/{action}' @@ -2329,7 +2404,7 @@ export type DeleteDatasetsByDatasetIdMetadataByMetadataIdError export type DeleteDatasetsByDatasetIdMetadataByMetadataIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -2907,9 +2982,9 @@ export type GetWorkflowByTaskIdEventsData = { task_id: string } query?: { - user?: string - include_state_snapshot?: string continue_on_pause?: string + include_state_snapshot?: string + user?: string } url: '/workflow/{task_id}/events' } @@ -2946,7 +3021,7 @@ export type GetWorkflowsLogsData = { keyword?: string | null limit?: number page?: number - status?: 'succeeded' | 'failed' | 'stopped' | null + status?: 'failed' | 'stopped' | 'succeeded' | null } url: '/workflows/logs' } diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 2c2400c0cb..8a1b7c5a3d 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -89,6 +89,36 @@ export const zCompletionRequestPayload = z.object({ retriever_from: z.string().optional().default('dev'), }) +/** + * Condition + * + * Condition detail + */ +export const zCondition = z.object({ + comparison_operator: z.enum([ + '<', + '=', + '>', + 'after', + 'before', + 'contains', + 'empty', + 'end with', + 'in', + 'is', + 'is not', + 'not contains', + 'not empty', + 'not in', + 'start with', + '≠', + '≤', + '≥', + ]), + name: z.string(), + value: z.unknown().optional(), +}) + /** * ConversationListQuery */ @@ -96,7 +126,7 @@ export const zConversationListQuery = z.object({ last_id: z.string().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), sort_by: z - .enum(['created_at', '-created_at', 'updated_at', '-updated_at']) + .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() .default('-updated_at'), }) @@ -147,6 +177,42 @@ export const zConversationVariablesQuery = z.object({ variable_name: z.string().min(1).max(255).nullish(), }) +/** + * DataSetTag + */ +export const zDataSetTag = z.object({ + binding_count: z.string().nullish(), + id: z.string(), + name: z.string(), + type: z.string(), +}) + +/** + * DatasetListQuery + */ +export const zDatasetListQuery = z.object({ + include_all: z.boolean().optional().default(false), + keyword: z.string().nullish(), + limit: z.int().optional().default(20), + page: z.int().optional().default(1), + tag_ids: z.array(z.string()).optional(), +}) + +/** + * DatasetPermissionEnum + */ +export const zDatasetPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_members']) + +/** + * DatasourceNodeRunPayload + */ +export const zDatasourceNodeRunPayload = z.object({ + credential_id: z.string().nullish(), + datasource_type: z.string(), + inputs: z.record(z.string(), z.unknown()), + is_published: z.boolean(), +}) + /** * DocumentBatchDownloadZipPayload * @@ -156,6 +222,16 @@ export const zDocumentBatchDownloadZipPayload = z.object({ document_ids: z.array(z.uuid()).min(1).max(100), }) +/** + * DocumentListQuery + */ +export const zDocumentListQuery = z.object({ + keyword: z.string().nullish(), + limit: z.int().optional().default(20), + page: z.int().optional().default(1), + status: z.string().nullish(), +}) + /** * FeedbackListQuery */ @@ -191,12 +267,22 @@ export const zFileResponse = z.object({ user_id: z.string().nullish(), }) +export const zJsonValue = z.unknown() + +/** + * HumanInputFormSubmitPayload + */ +export const zHumanInputFormSubmitPayload = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue), +}) + /** * MessageFeedbackPayload */ export const zMessageFeedbackPayload = z.object({ content: z.string().nullish(), - rating: z.enum(['like', 'dislike']).nullish(), + rating: z.enum(['dislike', 'like']).nullish(), }) /** @@ -213,7 +299,44 @@ export const zMessageListQuery = z.object({ */ export const zMetadataArgs = z.object({ name: z.string(), - type: z.enum(['string', 'number', 'time']), + type: z.enum(['number', 'string', 'time']), +}) + +/** + * MetadataDetail + */ +export const zMetadataDetail = z.object({ + id: z.string(), + name: z.string(), + value: z.unknown().optional(), +}) + +/** + * DocumentMetadataOperation + */ +export const zDocumentMetadataOperation = z.object({ + document_id: z.string(), + metadata_list: z.array(zMetadataDetail), + partial_update: z.boolean().optional().default(false), +}) + +/** + * MetadataFilteringCondition + * + * Metadata Filtering Condition. + */ +export const zMetadataFilteringCondition = z.object({ + conditions: z.array(zCondition).nullish(), + logical_operator: z.enum(['and', 'or']).nullish().default('and'), +}) + +/** + * MetadataOperationData + * + * Metadata operation data + */ +export const zMetadataOperationData = z.object({ + operation_data: z.array(zDocumentMetadataOperation), }) /** @@ -223,6 +346,44 @@ export const zMetadataUpdatePayload = z.object({ name: z.string(), }) +/** + * PipelineRunApiEntity + */ +export const zPipelineRunApiEntity = z.object({ + datasource_info_list: z.array(z.record(z.string(), z.unknown())), + datasource_type: z.string(), + inputs: z.record(z.string(), z.unknown()), + is_published: z.boolean(), + response_mode: z.string(), + start_node_id: z.string(), +}) + +/** + * PreProcessingRule + */ +export const zPreProcessingRule = z.object({ + enabled: z.boolean(), + id: z.string(), +}) + +/** + * RerankingModel + */ +export const zRerankingModel = z.object({ + reranking_model_name: z.string().nullish(), + reranking_provider_name: z.string().nullish(), +}) + +/** + * RetrievalMethod + */ +export const zRetrievalMethod = z.enum([ + 'full_text_search', + 'hybrid_search', + 'keyword_search', + 'semantic_search', +]) + /** * SegmentCreatePayload */ @@ -239,205 +400,23 @@ export const zSegmentListQuery = z.object({ }) /** - * TagBindingPayload + * SegmentUpdateArgs */ -export const zTagBindingPayload = z.object({ - tag_ids: z.array(z.string()), - target_id: z.string(), -}) - -/** - * TagCreatePayload - */ -export const zTagCreatePayload = z.object({ - name: z.string().min(1).max(50), -}) - -/** - * TagDeletePayload - */ -export const zTagDeletePayload = z.object({ - tag_id: z.string(), -}) - -/** - * TagUnbindingPayload - */ -export const zTagUnbindingPayload = z.object({ - tag_id: z.string(), - target_id: z.string(), -}) - -/** - * TagUpdatePayload - */ -export const zTagUpdatePayload = z.object({ - name: z.string().min(1).max(50), - tag_id: z.string(), -}) - -/** - * TextToAudioPayload - */ -export const zTextToAudioPayload = z.object({ - message_id: z.string().nullish(), - streaming: z.boolean().nullish(), - text: z.string().nullish(), - voice: z.string().nullish(), -}) - -/** - * WorkflowLogQuery - */ -export const zWorkflowLogQuery = z.object({ - created_at__after: z.string().nullish(), - created_at__before: z.string().nullish(), - created_by_account: z.string().nullish(), - created_by_end_user_session_id: z.string().nullish(), - keyword: z.string().nullish(), - limit: z.int().gte(1).lte(100).optional().default(20), - page: z.int().gte(1).lte(99999).optional().default(1), - status: z.enum(['succeeded', 'failed', 'stopped']).nullish(), -}) - -/** - * WorkflowRunPayload - */ -export const zWorkflowRunPayload = z.object({ - files: z.array(z.record(z.string(), z.unknown())).nullish(), - inputs: z.record(z.string(), z.unknown()), - response_mode: z.enum(['blocking', 'streaming']).nullish(), -}) - -/** - * WorkflowRunResponse - */ -export const zWorkflowRunResponse = z.object({ - created_at: z.int().nullish(), - elapsed_time: z.unknown().optional(), - error: z.string().nullish(), - finished_at: z.int().nullish(), - id: z.string(), - inputs: z.unknown().optional(), - outputs: z.record(z.string(), z.unknown()).optional(), - status: z.string(), - total_steps: z.int().nullish(), - total_tokens: z.int().nullish(), - workflow_id: z.string(), -}) - -/** - * DatasetPermissionEnum - */ -export const zDatasetPermissionEnum = z.enum(['only_me', 'all_team_members', 'partial_members']) - -/** - * RerankingModel - */ -export const zRerankingModel = z.object({ - reranking_model_name: z.string().nullish(), - reranking_provider_name: z.string().nullish(), -}) - -/** - * RetrievalMethod - */ -export const zRetrievalMethod = z.enum([ - 'semantic_search', - 'full_text_search', - 'hybrid_search', - 'keyword_search', -]) - -/** - * WeightKeywordSetting - */ -export const zWeightKeywordSetting = z.object({ - keyword_weight: z.number(), -}) - -/** - * WeightVectorSetting - */ -export const zWeightVectorSetting = z.object({ - embedding_model_name: z.string(), - embedding_provider_name: z.string(), - vector_weight: z.number(), -}) - -/** - * WeightModel - */ -export const zWeightModel = z.object({ - keyword_setting: zWeightKeywordSetting.optional(), - vector_setting: zWeightVectorSetting.optional(), - weight_type: z.enum(['semantic_first', 'keyword_first', 'customized']).nullish(), -}) - -/** - * RetrievalModel - */ -export const zRetrievalModel = z.object({ - reranking_enable: z.boolean(), - reranking_mode: z.string().nullish(), - reranking_model: zRerankingModel.optional(), - score_threshold: z.number().nullish(), - score_threshold_enabled: z.boolean(), - search_method: zRetrievalMethod, - top_k: z.int(), - weights: zWeightModel.optional(), -}) - -/** - * DatasetCreatePayload - */ -export const zDatasetCreatePayload = z.object({ - description: z.string().max(400).optional().default(''), - embedding_model: z.string().nullish(), - embedding_model_provider: z.string().nullish(), - external_knowledge_api_id: z.string().nullish(), - external_knowledge_id: z.string().nullish(), - indexing_technique: z.enum(['high_quality', 'economy']).nullish(), - name: z.string().min(1).max(40), - permission: zDatasetPermissionEnum.optional(), - provider: z.string().optional().default('vendor'), - retrieval_model: zRetrievalModel.optional(), - summary_index_setting: z.record(z.string(), z.unknown()).nullish(), -}) - -/** - * DatasetUpdatePayload - */ -export const zDatasetUpdatePayload = z.object({ - description: z.string().max(400).nullish(), - embedding_model: z.string().nullish(), - embedding_model_provider: z.string().nullish(), - external_knowledge_api_id: z.string().nullish(), - external_knowledge_id: z.string().nullish(), - external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), - indexing_technique: z.enum(['high_quality', 'economy']).nullish(), - name: z.string().min(1).max(40).nullish(), - partial_member_list: z.array(z.record(z.string(), z.string())).nullish(), - permission: zDatasetPermissionEnum.optional(), - retrieval_model: zRetrievalModel.optional(), -}) - -/** - * HitTestingPayload - */ -export const zHitTestingPayload = z.object({ +export const zSegmentUpdateArgs = z.object({ + answer: z.string().nullish(), attachment_ids: z.array(z.string()).nullish(), - external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), - query: z.string().max(250), - retrieval_model: zRetrievalModel.optional(), + content: z.string().nullish(), + enabled: z.boolean().nullish(), + keywords: z.array(z.string()).nullish(), + regenerate_child_chunks: z.boolean().optional().default(false), + summary: z.string().nullish(), }) /** - * PreProcessingRule + * SegmentUpdatePayload */ -export const zPreProcessingRule = z.object({ - enabled: z.boolean(), - id: z.string(), +export const zSegmentUpdatePayload = z.object({ + segment: zSegmentUpdateArgs, }) /** @@ -467,6 +446,150 @@ export const zProcessRule = z.object({ rules: zRule.optional(), }) +/** + * SimpleAccount + */ +export const zSimpleAccount = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * SimpleEndUser + */ +export const zSimpleEndUser = z.object({ + id: z.string(), + is_anonymous: z.boolean(), + session_id: z.string().nullish(), + type: z.string(), +}) + +/** + * TagBindingPayload + */ +export const zTagBindingPayload = z.object({ + tag_ids: z.array(z.string()), + target_id: z.string(), +}) + +/** + * TagCreatePayload + */ +export const zTagCreatePayload = z.object({ + name: z.string().min(1).max(50), +}) + +/** + * TagDeletePayload + */ +export const zTagDeletePayload = z.object({ + tag_id: z.string(), +}) + +/** + * TagUnbindingPayload + * + * Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally. + */ +export const zTagUnbindingPayload = z.object({ + tag_id: z.string().nullish(), + tag_ids: z.array(z.string()).optional(), + target_id: z.string(), +}) + +/** + * TagUpdatePayload + */ +export const zTagUpdatePayload = z.object({ + name: z.string().min(1).max(50), + tag_id: z.string(), +}) + +/** + * TextToAudioPayload + */ +export const zTextToAudioPayload = z.object({ + message_id: z.string().nullish(), + streaming: z.boolean().nullish(), + text: z.string().nullish(), + voice: z.string().nullish(), +}) + +/** + * WeightKeywordSetting + */ +export const zWeightKeywordSetting = z.object({ + keyword_weight: z.number(), +}) + +/** + * WeightVectorSetting + */ +export const zWeightVectorSetting = z.object({ + embedding_model_name: z.string(), + embedding_provider_name: z.string(), + vector_weight: z.number(), +}) + +/** + * WeightModel + */ +export const zWeightModel = z.object({ + keyword_setting: zWeightKeywordSetting.optional(), + vector_setting: zWeightVectorSetting.optional(), + weight_type: z.enum(['customized', 'keyword_first', 'semantic_first']).nullish(), +}) + +/** + * RetrievalModel + */ +export const zRetrievalModel = z.object({ + metadata_filtering_conditions: zMetadataFilteringCondition.optional(), + reranking_enable: z.boolean(), + reranking_mode: z.string().nullish(), + reranking_model: zRerankingModel.optional(), + score_threshold: z.number().nullish(), + score_threshold_enabled: z.boolean(), + search_method: zRetrievalMethod, + top_k: z.int(), + weights: zWeightModel.optional(), +}) + +/** + * DatasetCreatePayload + */ +export const zDatasetCreatePayload = z.object({ + description: z.string().max(400).optional().default(''), + embedding_model: z.string().nullish(), + embedding_model_provider: z.string().nullish(), + external_knowledge_api_id: z.string().nullish(), + external_knowledge_id: z.string().nullish(), + indexing_technique: z.enum(['economy', 'high_quality']).nullish(), + name: z.string().min(1).max(40), + permission: zDatasetPermissionEnum.optional(), + provider: z.string().optional().default('vendor'), + retrieval_model: zRetrievalModel.optional(), + summary_index_setting: z.record(z.string(), z.unknown()).nullish(), +}) + +/** + * DatasetUpdatePayload + */ +export const zDatasetUpdatePayload = z.object({ + description: z.string().max(400).nullish(), + embedding_model: z.string().nullish(), + embedding_model_provider: z.string().nullish(), + external_knowledge_api_id: z.string().nullish(), + external_knowledge_id: z.string().nullish(), + external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), + indexing_technique: z.enum(['economy', 'high_quality']).nullish(), + name: z.string().min(1).max(40).nullish(), + partial_member_list: z.array(z.record(z.string(), z.string())).nullish(), + permission: zDatasetPermissionEnum.optional(), + retrieval_model: zRetrievalModel.optional(), +}) + /** * DocumentTextCreatePayload */ @@ -495,80 +618,28 @@ export const zDocumentTextUpdate = z.object({ text: z.string().nullish(), }) -export const zJsonValue = z.unknown() - /** - * HumanInputFormSubmitPayload + * HitTestingPayload */ -export const zHumanInputFormSubmitPayload = z.object({ - action: z.string(), - inputs: z.record(z.string(), zJsonValue), -}) - -/** - * MetadataDetail - */ -export const zMetadataDetail = z.object({ - id: z.string(), - name: z.string(), - value: z.unknown().optional(), -}) - -/** - * DocumentMetadataOperation - */ -export const zDocumentMetadataOperation = z.object({ - document_id: z.string(), - metadata_list: z.array(zMetadataDetail), - partial_update: z.boolean().optional().default(false), -}) - -/** - * MetadataOperationData - * - * Metadata operation data - */ -export const zMetadataOperationData = z.object({ - operation_data: z.array(zDocumentMetadataOperation), -}) - -/** - * SegmentUpdateArgs - */ -export const zSegmentUpdateArgs = z.object({ - answer: z.string().nullish(), +export const zHitTestingPayload = z.object({ attachment_ids: z.array(z.string()).nullish(), - content: z.string().nullish(), - enabled: z.boolean().nullish(), - keywords: z.array(z.string()).nullish(), - regenerate_child_chunks: z.boolean().optional().default(false), - summary: z.string().nullish(), + external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), + query: z.string().max(250), + retrieval_model: zRetrievalModel.optional(), }) /** - * SegmentUpdatePayload + * WorkflowLogQuery */ -export const zSegmentUpdatePayload = z.object({ - segment: zSegmentUpdateArgs, -}) - -/** - * SimpleAccount - */ -export const zSimpleAccount = z.object({ - email: z.string(), - id: z.string(), - name: z.string(), -}) - -/** - * SimpleEndUser - */ -export const zSimpleEndUser = z.object({ - id: z.string(), - is_anonymous: z.boolean(), - session_id: z.string().nullish(), - type: z.string(), +export const zWorkflowLogQuery = z.object({ + created_at__after: z.string().nullish(), + created_at__before: z.string().nullish(), + created_by_account: z.string().nullish(), + created_by_end_user_session_id: z.string().nullish(), + keyword: z.string().nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).lte(99999).optional().default(1), + status: z.enum(['failed', 'stopped', 'succeeded']).nullish(), }) /** @@ -613,6 +684,32 @@ export const zWorkflowAppLogPaginationResponse = z.object({ total: z.int(), }) +/** + * WorkflowRunPayload + */ +export const zWorkflowRunPayload = z.object({ + files: z.array(z.record(z.string(), z.unknown())).nullish(), + inputs: z.record(z.string(), z.unknown()), + response_mode: z.enum(['blocking', 'streaming']).nullish(), +}) + +/** + * WorkflowRunResponse + */ +export const zWorkflowRunResponse = z.object({ + created_at: z.int().nullish(), + elapsed_time: z.unknown().optional(), + error: z.string().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + inputs: z.unknown().optional(), + outputs: z.record(z.string(), z.unknown()).optional(), + status: z.string(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + workflow_id: z.string(), +}) + /** * Success */ @@ -671,7 +768,7 @@ export const zDeleteAppsAnnotationsByAnnotationIdPath = z.object({ /** * Annotation deleted successfully */ -export const zDeleteAppsAnnotationsByAnnotationIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteAppsAnnotationsByAnnotationIdResponse = z.record(z.string(), z.never()) export const zPutAppsAnnotationsByAnnotationIdBody = zAnnotationCreatePayload @@ -725,7 +822,7 @@ export const zGetConversationsQuery = z.object({ last_id: z.string().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), sort_by: z - .enum(['created_at', '-created_at', 'updated_at', '-updated_at']) + .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() .default('-updated_at'), }) @@ -742,7 +839,7 @@ export const zDeleteConversationsByCIdPath = z.object({ /** * Conversation deleted successfully */ -export const zDeleteConversationsByCIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteConversationsByCIdResponse = z.record(z.string(), z.never()) export const zPostConversationsByCIdNameBody = zConversationRenamePayload @@ -805,7 +902,7 @@ export const zDeleteDatasetsTagsBody = zTagDeletePayload /** * Tag deleted successfully */ -export const zDeleteDatasetsTagsResponse = z.record(z.string(), z.unknown()) +export const zDeleteDatasetsTagsResponse = z.record(z.string(), z.never()) /** * Tags retrieved successfully @@ -831,14 +928,14 @@ export const zPostDatasetsTagsBindingBody = zTagBindingPayload /** * Tags bound successfully */ -export const zPostDatasetsTagsBindingResponse = z.record(z.string(), z.unknown()) +export const zPostDatasetsTagsBindingResponse = z.record(z.string(), z.never()) export const zPostDatasetsTagsUnbindingBody = zTagUnbindingPayload /** - * Tag unbound successfully + * Tags unbound successfully */ -export const zPostDatasetsTagsUnbindingResponse = z.record(z.string(), z.unknown()) +export const zPostDatasetsTagsUnbindingResponse = z.record(z.string(), z.never()) export const zDeleteDatasetsByDatasetIdPath = z.object({ dataset_id: z.string(), @@ -847,7 +944,7 @@ export const zDeleteDatasetsByDatasetIdPath = z.object({ /** * Dataset deleted successfully */ -export const zDeleteDatasetsByDatasetIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteDatasetsByDatasetIdResponse = z.record(z.string(), z.never()) export const zGetDatasetsByDatasetIdPath = z.object({ dataset_id: z.string(), @@ -956,8 +1053,8 @@ export const zPostDatasetsByDatasetIdDocumentsMetadataPath = z.object({ export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = z.record(z.string(), z.unknown()) export const zPatchDatasetsByDatasetIdDocumentsStatusByActionPath = z.object({ - dataset_id: z.string(), action: z.string(), + dataset_id: z.string(), }) /** @@ -969,8 +1066,8 @@ export const zPatchDatasetsByDatasetIdDocumentsStatusByActionResponse = z.record ) export const zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusPath = z.object({ - dataset_id: z.string(), batch: z.string(), + dataset_id: z.string(), }) /** @@ -991,7 +1088,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ @@ -1077,13 +1174,13 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdP */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ - segment_id: z.string(), dataset_id: z.string(), document_id: z.string(), + segment_id: z.string(), }) /** @@ -1149,27 +1246,27 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = z.object({ + child_chunk_id: z.string(), dataset_id: z.string(), document_id: z.string(), segment_id: z.string(), - child_chunk_id: z.string(), }) /** * Child chunk deleted successfully */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse - = z.record(z.string(), z.unknown()) + = z.record(z.string(), z.never()) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdBody = zChildChunkUpdatePayload export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdPath = z.object({ + child_chunk_id: z.string(), dataset_id: z.string(), document_id: z.string(), segment_id: z.string(), - child_chunk_id: z.string(), }) /** @@ -1275,8 +1372,8 @@ export const zGetDatasetsByDatasetIdMetadataBuiltInPath = z.object({ export const zGetDatasetsByDatasetIdMetadataBuiltInResponse = z.record(z.string(), z.unknown()) export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ - dataset_id: z.string(), action: z.string(), + dataset_id: z.string(), }) /** @@ -1297,7 +1394,7 @@ export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ */ export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.record( z.string(), - z.unknown(), + z.never(), ) export const zPatchDatasetsByDatasetIdMetadataByMetadataIdBody = zMetadataUpdatePayload @@ -1483,9 +1580,9 @@ export const zGetWorkflowByTaskIdEventsPath = z.object({ }) export const zGetWorkflowByTaskIdEventsQuery = z.object({ - user: z.string().optional(), - include_state_snapshot: z.string().optional(), continue_on_pause: z.string().optional(), + include_state_snapshot: z.string().optional(), + user: z.string().optional(), }) /** @@ -1501,7 +1598,7 @@ export const zGetWorkflowsLogsQuery = z.object({ keyword: z.string().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).lte(99999).optional().default(1), - status: z.enum(['succeeded', 'failed', 'stopped']).nullish(), + status: z.enum(['failed', 'stopped', 'succeeded']).nullish(), }) /** diff --git a/packages/contracts/generated/api/web/orpc.gen.ts b/packages/contracts/generated/api/web/orpc.gen.ts index 459d556145..eea2efcb7c 100644 --- a/packages/contracts/generated/api/web/orpc.gen.ts +++ b/packages/contracts/generated/api/web/orpc.gen.ts @@ -85,10 +85,16 @@ import { * Convert audio to text * * Convert audio file to text using speech-to-text service. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post = oc .route({ - description: 'Convert audio file to text using speech-to-text service.', + deprecated: true, + description: + 'Convert audio file to text using speech-to-text service.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postAudioToText', @@ -104,10 +110,16 @@ export const audioToText = { /** * Stop a running chat message task. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post2 = oc .route({ - description: 'Stop a running chat message task.', + deprecated: true, + description: + 'Stop a running chat message task.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessagesByTaskIdStop', @@ -127,10 +139,16 @@ export const byTaskId = { /** * Create a chat message for conversational applications. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post3 = oc .route({ - description: 'Create a chat message for conversational applications.', + deprecated: true, + description: + 'Create a chat message for conversational applications.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessages', @@ -147,10 +165,16 @@ export const chatMessages = { /** * Stop a running completion message task. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post4 = oc .route({ - description: 'Stop a running completion message task.', + deprecated: true, + description: + 'Stop a running completion message task.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessagesByTaskIdStop', @@ -170,10 +194,16 @@ export const byTaskId2 = { /** * Create a completion message for text generation applications. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post5 = oc .route({ - description: 'Create a completion message for text generation applications.', + deprecated: true, + description: + 'Create a completion message for text generation applications.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessages', @@ -190,10 +220,16 @@ export const completionMessages = { /** * Rename a specific conversation with a custom name or auto-generate one. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post6 = oc .route({ - description: 'Rename a specific conversation with a custom name or auto-generate one.', + deprecated: true, + description: + 'Rename a specific conversation with a custom name or auto-generate one.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postConversationsByCIdName', @@ -214,10 +250,16 @@ export const name = { /** * Pin a specific conversation to keep it at the top of the list. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch = oc .route({ - description: 'Pin a specific conversation to keep it at the top of the list.', + deprecated: true, + description: + 'Pin a specific conversation to keep it at the top of the list.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchConversationsByCIdPin', @@ -233,10 +275,16 @@ export const pin = { /** * Unpin a specific conversation to remove it from the top of the list. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const patch2 = oc .route({ - description: 'Unpin a specific conversation to remove it from the top of the list.', + deprecated: true, + description: + 'Unpin a specific conversation to remove it from the top of the list.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchConversationsByCIdUnpin', @@ -275,10 +323,16 @@ export const byCId = { /** * Retrieve paginated list of conversations for a chat application. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get = oc .route({ - description: 'Retrieve paginated list of conversations for a chat application.', + deprecated: true, + description: + 'Retrieve paginated list of conversations for a chat application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getConversations', @@ -295,10 +349,16 @@ export const conversations = { /** * Verify email code and complete login + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post7 = oc .route({ - description: 'Verify email code and complete login', + deprecated: true, + description: + 'Verify email code and complete login\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailCodeLoginValidity', @@ -314,10 +374,16 @@ export const validity = { /** * Send email verification code for login + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post8 = oc .route({ - description: 'Send email verification code for login', + deprecated: true, + description: + 'Send email verification code for login\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postEmailCodeLogin', @@ -382,10 +448,16 @@ export const files = { /** * Reset user password with verification token + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post10 = oc .route({ - description: 'Reset user password with verification token', + deprecated: true, + description: + 'Reset user password with verification token\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postForgotPasswordResets', @@ -401,10 +473,16 @@ export const resets = { /** * Verify password reset token validity + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post11 = oc .route({ - description: 'Verify password reset token validity', + deprecated: true, + description: + 'Verify password reset token validity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postForgotPasswordValidity', @@ -420,10 +498,16 @@ export const validity2 = { /** * Send password reset email + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post12 = oc .route({ - description: 'Send password reset email', + deprecated: true, + description: + 'Send password reset email\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postForgotPassword', @@ -443,10 +527,16 @@ export const forgotPassword = { * Get human input form definition by token * * GET /api/form/human_input/ + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get2 = oc .route({ - description: 'GET /api/form/human_input/', + deprecated: true, + description: + 'GET /api/form/human_input/\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', @@ -469,11 +559,16 @@ export const get2 = oc * }, * "action": "Approve" * } + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post13 = oc .route({ + deprecated: true, description: - 'POST /api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}', + 'POST /api/form/human_input/\n\nRequest body:\n{\n "inputs": {\n "content": "User input content"\n },\n "action": "Approve"\n}\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', @@ -499,10 +594,16 @@ export const form = { /** * Check login status + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get3 = oc .route({ - description: 'Check login status', + deprecated: true, + description: + 'Check login status\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getLoginStatus', @@ -519,10 +620,16 @@ export const status = { * Authenticate user and login * * Authenticate user for web application access + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post14 = oc .route({ - description: 'Authenticate user for web application access', + deprecated: true, + description: + 'Authenticate user for web application access\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postLogin', @@ -540,10 +647,16 @@ export const login = { /** * Logout user from web application + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post15 = oc .route({ - description: 'Logout user from web application', + deprecated: true, + description: + 'Logout user from web application\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postLogout', @@ -558,10 +671,16 @@ export const logout = { /** * Submit feedback (like/dislike) for a specific message. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post16 = oc .route({ - description: 'Submit feedback (like/dislike) for a specific message.', + deprecated: true, + description: + 'Submit feedback (like/dislike) for a specific message.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postMessagesByMessageIdFeedbacks', @@ -582,10 +701,16 @@ export const feedbacks = { /** * Generate a new completion similar to an existing message (completion apps only). + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get4 = oc .route({ - description: 'Generate a new completion similar to an existing message (completion apps only).', + deprecated: true, + description: + 'Generate a new completion similar to an existing message (completion apps only).\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessagesByMessageIdMoreLikeThis', @@ -606,10 +731,16 @@ export const moreLikeThis = { /** * Get suggested follow-up questions after a message (chat apps only). + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get5 = oc .route({ - description: 'Get suggested follow-up questions after a message (chat apps only).', + deprecated: true, + description: + 'Get suggested follow-up questions after a message (chat apps only).\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessagesByMessageIdSuggestedQuestions', @@ -631,10 +762,16 @@ export const byMessageId = { /** * Retrieve paginated list of messages from a conversation in a chat application. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get6 = oc .route({ - description: 'Retrieve paginated list of messages from a conversation in a chat application.', + deprecated: true, + description: + 'Retrieve paginated list of messages from a conversation in a chat application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessages', @@ -653,10 +790,16 @@ export const messages = { * Get app meta * * Retrieve the metadata for a specific app. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get7 = oc .route({ - description: 'Retrieve the metadata for a specific app.', + deprecated: true, + description: + 'Retrieve the metadata for a specific app.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getMeta', @@ -674,10 +817,16 @@ export const meta = { * Retrieve app parameters * * Retrieve the parameters for a specific app. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get8 = oc .route({ - description: 'Retrieve the parameters for a specific app.', + deprecated: true, + description: + 'Retrieve the parameters for a specific app.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getParameters', @@ -693,10 +842,16 @@ export const parameters = { /** * Get authentication passport for web application access + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get9 = oc .route({ - description: 'Get authentication passport for web application access', + deprecated: true, + description: + 'Get authentication passport for web application access\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getPassport', @@ -813,10 +968,16 @@ export const byMessageId2 = { /** * Retrieve paginated list of saved messages for a completion application. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get11 = oc .route({ - description: 'Retrieve paginated list of saved messages for a completion application.', + deprecated: true, + description: + 'Retrieve paginated list of saved messages for a completion application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSavedMessages', @@ -828,10 +989,16 @@ export const get11 = oc /** * Save a specific message for later reference. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post18 = oc .route({ - description: 'Save a specific message for later reference.', + deprecated: true, + description: + 'Save a specific message for later reference.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postSavedMessages', @@ -851,10 +1018,16 @@ export const savedMessages = { * Retrieve app site info * * Retrieve app site information and configuration. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get12 = oc .route({ - description: 'Retrieve app site information and configuration.', + deprecated: true, + description: + 'Retrieve app site information and configuration.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSite', @@ -887,11 +1060,16 @@ export const site = { * Authentication would create circular dependency (can't authenticate without webapp loading). * * Only non-sensitive configuration data should be returned by this endpoint. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get13 = oc .route({ + deprecated: true, description: - 'Get system feature flags and configuration\nReturns the current system feature flags and configuration\nthat control various functionalities across the platform.\n\nReturns:\n dict: System feature configuration object\n\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for webapp initialization.\n\nAuthentication would create circular dependency (can\'t authenticate without webapp loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.', + 'Get system feature flags and configuration\nReturns the current system feature flags and configuration\nthat control various functionalities across the platform.\n\nReturns:\n dict: System feature configuration object\n\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for webapp initialization.\n\nAuthentication would create circular dependency (can\'t authenticate without webapp loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getSystemFeatures', @@ -909,10 +1087,16 @@ export const systemFeatures = { * Convert text to audio * * Convert text to audio using text-to-speech service. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post19 = oc .route({ - description: 'Convert text to audio using text-to-speech service.', + deprecated: true, + description: + 'Convert text to audio using text-to-speech service.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postTextToAudio', @@ -929,10 +1113,16 @@ export const textToAudio = { /** * Retrieve the access mode for a web application (public or restricted). + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get14 = oc .route({ - description: 'Retrieve the access mode for a web application (public or restricted).', + deprecated: true, + description: + 'Retrieve the access mode for a web application (public or restricted).\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWebappAccessMode', @@ -948,10 +1138,16 @@ export const accessMode = { /** * Check if user has permission to access a web application. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get15 = oc .route({ - description: 'Check if user has permission to access a web application.', + deprecated: true, + description: + 'Check if user has permission to access a web application.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWebappPermission', @@ -976,10 +1172,16 @@ export const webapp = { * GET /api/workflow//events * * Returns Server-Sent Events stream. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const get16 = oc .route({ - description: 'GET /api/workflow//events\n\nReturns Server-Sent Events stream.', + deprecated: true, + description: + 'GET /api/workflow//events\n\nReturns Server-Sent Events stream.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByTaskIdEvents', @@ -1006,10 +1208,16 @@ export const workflow = { * Run workflow * * Execute a workflow with provided inputs and files. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post20 = oc .route({ - description: 'Execute a workflow with provided inputs and files.', + deprecated: true, + description: + 'Execute a workflow with provided inputs and files.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsRun', @@ -1028,10 +1236,16 @@ export const run = { * Stop workflow task * * Stop a running workflow task. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated */ export const post21 = oc .route({ - description: 'Stop a running workflow task.', + deprecated: true, + description: + 'Stop a running workflow task.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsTasksByTaskIdStop', diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index f2009b966b..fed7ed8452 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -4,6 +4,11 @@ export type ClientOptions = { baseUrl: `${string}://${string}/api` | (string & {}) } +export type AppAccessModeQuery = { + appCode?: string | null + appId?: string | null +} + export type ChatMessagePayload = { conversation_id?: string | null files?: Array<{ @@ -30,6 +35,18 @@ export type CompletionMessagePayload = { retriever_from?: string } +export type ConversationListQuery = { + last_id?: string | null + limit?: number + pinned?: boolean | null + sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' +} + +export type ConversationRenamePayload = { + auto_generate?: boolean + name?: string | null +} + export type EmailCodeLoginSendPayload = { email: string language?: string | null @@ -91,6 +108,17 @@ export type LoginPayload = { password: string } +export type MessageFeedbackPayload = { + content?: string | null + rating?: 'dislike' | 'like' | null +} + +export type MessageListQuery = { + conversation_id: string + first_id?: string | null + limit?: number +} + export type MessageMoreLikeThisQuery = { response_mode: 'blocking' | 'streaming' } @@ -100,6 +128,19 @@ export type RemoteFileInfo = { file_type: string } +export type RemoteFileUploadPayload = { + url: string +} + +export type SavedMessageCreatePayload = { + message_id: string +} + +export type SavedMessageListQuery = { + last_id?: string | null + limit?: number +} + export type TextToAudioPayload = { message_id?: string | null streaming?: boolean | null @@ -310,8 +351,8 @@ export type GetConversationsData = { query?: { last_id?: string limit?: number - pinned?: 'true' | 'false' - sort_by?: 'created_at' | '-created_at' | 'updated_at' | '-updated_at' + pinned?: 'false' | 'true' + sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } url: '/conversations' } @@ -376,7 +417,7 @@ export type DeleteConversationsByCIdError export type DeleteConversationsByCIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -389,8 +430,8 @@ export type PostConversationsByCIdNameData = { c_id: string } query?: { - name?: string auto_generate?: boolean + name?: string } url: '/conversations/{c_id}/name' } @@ -828,8 +869,8 @@ export type PostMessagesByMessageIdFeedbacksData = { message_id: string } query?: { - rating?: 'like' | 'dislike' content?: string + rating?: 'dislike' | 'like' } url: '/messages/{message_id}/feedbacks' } @@ -1209,7 +1250,7 @@ export type DeleteSavedMessagesByMessageIdError export type DeleteSavedMessagesByMessageIdResponses = { 204: { - [key: string]: unknown + [key: string]: never } } @@ -1310,8 +1351,8 @@ export type GetWebappAccessModeData = { body?: never path?: never query?: { - appId?: string appCode?: string + appId?: string } url: '/webapp/access-mode' } diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index ec4bd19aff..3d4785891f 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -2,6 +2,14 @@ import * as z from 'zod' +/** + * AppAccessModeQuery + */ +export const zAppAccessModeQuery = z.object({ + appCode: z.string().nullish(), + appId: z.string().nullish(), +}) + /** * ChatMessagePayload */ @@ -26,6 +34,27 @@ export const zCompletionMessagePayload = z.object({ retriever_from: z.string().optional().default('web_app'), }) +/** + * ConversationListQuery + */ +export const zConversationListQuery = z.object({ + last_id: z.string().nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), + pinned: z.boolean().nullish(), + sort_by: z + .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) + .optional() + .default('-updated_at'), +}) + +/** + * ConversationRenamePayload + */ +export const zConversationRenamePayload = z.object({ + auto_generate: z.boolean().optional().default(false), + name: z.string().nullish(), +}) + /** * EmailCodeLoginSendPayload */ @@ -111,6 +140,23 @@ export const zLoginPayload = z.object({ password: z.string(), }) +/** + * MessageFeedbackPayload + */ +export const zMessageFeedbackPayload = z.object({ + content: z.string().nullish(), + rating: z.enum(['dislike', 'like']).nullish(), +}) + +/** + * MessageListQuery + */ +export const zMessageListQuery = z.object({ + conversation_id: z.string(), + first_id: z.string().nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), +}) + /** * MessageMoreLikeThisQuery */ @@ -126,6 +172,28 @@ export const zRemoteFileInfo = z.object({ file_type: z.string(), }) +/** + * RemoteFileUploadPayload + */ +export const zRemoteFileUploadPayload = z.object({ + url: z.url().min(1).max(2083), +}) + +/** + * SavedMessageCreatePayload + */ +export const zSavedMessageCreatePayload = z.object({ + message_id: z.string(), +}) + +/** + * SavedMessageListQuery + */ +export const zSavedMessageListQuery = z.object({ + last_id: z.string().nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), +}) + /** * TextToAudioPayload */ @@ -184,9 +252,9 @@ export const zPostCompletionMessagesByTaskIdStopResponse = z.record(z.string(), export const zGetConversationsQuery = z.object({ last_id: z.string().optional(), limit: z.int().optional().default(20), - pinned: z.enum(['true', 'false']).optional(), + pinned: z.enum(['false', 'true']).optional(), sort_by: z - .enum(['created_at', '-created_at', 'updated_at', '-updated_at']) + .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() .default('-updated_at'), }) @@ -203,15 +271,15 @@ export const zDeleteConversationsByCIdPath = z.object({ /** * Conversation deleted successfully */ -export const zDeleteConversationsByCIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteConversationsByCIdResponse = z.record(z.string(), z.never()) export const zPostConversationsByCIdNamePath = z.object({ c_id: z.string(), }) export const zPostConversationsByCIdNameQuery = z.object({ - name: z.string().optional(), auto_generate: z.boolean().optional().default(false), + name: z.string().optional(), }) /** @@ -328,8 +396,8 @@ export const zPostMessagesByMessageIdFeedbacksPath = z.object({ }) export const zPostMessagesByMessageIdFeedbacksQuery = z.object({ - rating: z.enum(['like', 'dislike']).optional(), content: z.string().optional(), + rating: z.enum(['dislike', 'like']).optional(), }) /** @@ -414,7 +482,7 @@ export const zDeleteSavedMessagesByMessageIdPath = z.object({ /** * Message removed successfully */ -export const zDeleteSavedMessagesByMessageIdResponse = z.record(z.string(), z.unknown()) +export const zDeleteSavedMessagesByMessageIdResponse = z.record(z.string(), z.never()) /** * Success @@ -434,8 +502,8 @@ export const zPostTextToAudioBody = zTextToAudioPayload export const zPostTextToAudioResponse = z.record(z.string(), z.unknown()) export const zGetWebappAccessModeQuery = z.object({ - appId: z.string().optional(), appCode: z.string().optional(), + appId: z.string().optional(), }) /** diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 73eb850001..6b9b76470a 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -7,63 +7,6 @@ import { zConsoleSsoOAuth2LoginResponse, zConsoleSsoOidcLoginResponse, zConsoleSsoSamlLoginResponse, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse, - zEnterpriseAppDeployConsoleCreateAppInstanceBody, - zEnterpriseAppDeployConsoleCreateAppInstanceResponse, - zEnterpriseAppDeployConsoleCreateDeploymentBody, - zEnterpriseAppDeployConsoleCreateDeploymentPath, - zEnterpriseAppDeployConsoleCreateDeploymentResponse, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse, - zEnterpriseAppDeployConsoleCreateReleaseBody, - zEnterpriseAppDeployConsoleCreateReleasePath, - zEnterpriseAppDeployConsoleCreateReleaseResponse, - zEnterpriseAppDeployConsoleDeleteAppInstancePath, - zEnterpriseAppDeployConsoleDeleteAppInstanceResponse, - zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath, - zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse, - zEnterpriseAppDeployConsoleGetAppInstanceAccessPath, - zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse, - zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath, - zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse, - zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath, - zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse, - zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath, - zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse, - zEnterpriseAppDeployConsoleListAppInstancesQuery, - zEnterpriseAppDeployConsoleListAppInstancesResponse, - zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath, - zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse, - zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse, - zEnterpriseAppDeployConsoleListReleasesPath, - zEnterpriseAppDeployConsoleListReleasesQuery, - zEnterpriseAppDeployConsoleListReleasesResponse, - zEnterpriseAppDeployConsoleListRuntimeInstancesPath, - zEnterpriseAppDeployConsoleListRuntimeInstancesResponse, - zEnterpriseAppDeployConsolePreviewReleaseBody, - zEnterpriseAppDeployConsolePreviewReleasePath, - zEnterpriseAppDeployConsolePreviewReleaseResponse, - zEnterpriseAppDeployConsoleSearchAccessSubjectsPath, - zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery, - zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse, - zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody, - zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath, - zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse, - zEnterpriseAppDeployConsoleUpdateAccessChannelsBody, - zEnterpriseAppDeployConsoleUpdateAccessChannelsPath, - zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse, - zEnterpriseAppDeployConsoleUpdateAppInstanceBody, - zEnterpriseAppDeployConsoleUpdateAppInstancePath, - zEnterpriseAppDeployConsoleUpdateAppInstanceResponse, - zEnterpriseAppDeployConsoleUpdateDeveloperApiBody, - zEnterpriseAppDeployConsoleUpdateDeveloperApiPath, - zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse, zWebAppAuthGetGroupSubjectsQuery, zWebAppAuthGetGroupSubjectsResponse, zWebAppAuthGetWebAppAccessModeQuery, @@ -78,344 +21,6 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' -export const listAppInstances = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListAppInstances', - path: '/enterprise/app-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ query: zEnterpriseAppDeployConsoleListAppInstancesQuery.optional() })) - .output(zEnterpriseAppDeployConsoleListAppInstancesResponse) - -export const createAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateAppInstance', - path: '/enterprise/app-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ body: zEnterpriseAppDeployConsoleCreateAppInstanceBody })) - .output(zEnterpriseAppDeployConsoleCreateAppInstanceResponse) - -export const deleteAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'EnterpriseAppDeployConsole_DeleteAppInstance', - path: '/enterprise/app-instances/{appInstanceId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleDeleteAppInstancePath })) - .output(zEnterpriseAppDeployConsoleDeleteAppInstanceResponse) - -export const updateAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateAppInstance', - path: '/enterprise/app-instances/{appInstanceId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateAppInstanceBody, - params: zEnterpriseAppDeployConsoleUpdateAppInstancePath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateAppInstanceResponse) - -export const getAppInstanceAccess = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceAccess', - path: '/enterprise/app-instances/{appInstanceId}/access', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceAccessPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse) - -export const updateAccessChannels = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateAccessChannels', - path: '/enterprise/app-instances/{appInstanceId}/access-channels', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateAccessChannelsBody, - params: zEnterpriseAppDeployConsoleUpdateAccessChannelsPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse) - -export const searchAccessSubjects = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_SearchAccessSubjects', - path: '/enterprise/app-instances/{appInstanceId}/access-subjects:search', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - params: zEnterpriseAppDeployConsoleSearchAccessSubjectsPath, - query: zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery.optional(), - }), - ) - .output(zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse) - -export const createDeveloperApiKey = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateDeveloperApiKey', - path: '/enterprise/app-instances/{appInstanceId}/api-keys', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody, - params: zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse) - -export const deleteDeveloperApiKey = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'EnterpriseAppDeployConsole_DeleteDeveloperApiKey', - path: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath })) - .output(zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse) - -export const listDeploymentBindingOptions = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListDeploymentBindingOptions', - path: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath })) - .output(zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse) - -export const createDeployment = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateDeployment', - path: '/enterprise/app-instances/{appInstanceId}/deployments', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateDeploymentBody, - params: zEnterpriseAppDeployConsoleCreateDeploymentPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateDeploymentResponse) - -export const updateDeveloperApi = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateDeveloperApi', - path: '/enterprise/app-instances/{appInstanceId}/developer-api', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateDeveloperApiBody, - params: zEnterpriseAppDeployConsoleUpdateDeveloperApiPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse) - -export const getEnvironmentAccessPolicy = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetEnvironmentAccessPolicy', - path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath })) - .output(zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse) - -export const updateEnvironmentAccessPolicy = oc - .route({ - inputStructure: 'detailed', - method: 'PUT', - operationId: 'EnterpriseAppDeployConsole_UpdateEnvironmentAccessPolicy', - path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody, - params: zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse) - -export const getAppInstanceOverview = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceOverview', - path: '/enterprise/app-instances/{appInstanceId}/overview', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse) - -export const listReleases = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListReleases', - path: '/enterprise/app-instances/{appInstanceId}/releases', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - params: zEnterpriseAppDeployConsoleListReleasesPath, - query: zEnterpriseAppDeployConsoleListReleasesQuery.optional(), - }), - ) - .output(zEnterpriseAppDeployConsoleListReleasesResponse) - -export const createRelease = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateRelease', - path: '/enterprise/app-instances/{appInstanceId}/releases', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateReleaseBody, - params: zEnterpriseAppDeployConsoleCreateReleasePath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateReleaseResponse) - -export const previewRelease = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_PreviewRelease', - path: '/enterprise/app-instances/{appInstanceId}/releases:preview', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsolePreviewReleaseBody, - params: zEnterpriseAppDeployConsolePreviewReleasePath, - }), - ) - .output(zEnterpriseAppDeployConsolePreviewReleaseResponse) - -export const listRuntimeInstances = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListRuntimeInstances', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleListRuntimeInstancesPath })) - .output(zEnterpriseAppDeployConsoleListRuntimeInstancesResponse) - -export const cancelRuntimeDeployment = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CancelRuntimeDeployment', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}/deployment:cancel', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody, - params: zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse) - -export const undeployRuntimeInstance = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_UndeployRuntimeInstance', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}:undeploy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody, - params: zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath, - }), - ) - .output(zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse) - -export const getAppInstanceSettings = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceSettings', - path: '/enterprise/app-instances/{appInstanceId}/settings', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse) - -export const listDeploymentEnvironmentOptions = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListDeploymentEnvironmentOptions', - path: '/enterprise/deployment-environment-options', - tags: ['EnterpriseAppDeployConsole'], - }) - .output(zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse) - -export const enterpriseAppDeployConsole = { - listAppInstances, - createAppInstance, - deleteAppInstance, - updateAppInstance, - getAppInstanceAccess, - updateAccessChannels, - searchAccessSubjects, - createDeveloperApiKey, - deleteDeveloperApiKey, - listDeploymentBindingOptions, - createDeployment, - updateDeveloperApi, - getEnvironmentAccessPolicy, - updateEnvironmentAccessPolicy, - getAppInstanceOverview, - listReleases, - createRelease, - previewRelease, - listRuntimeInstances, - cancelRuntimeDeployment, - undeployRuntimeInstance, - getAppInstanceSettings, - listDeploymentEnvironmentOptions, -} - export const oAuth2Login = oc .route({ inputStructure: 'detailed', @@ -528,7 +133,6 @@ export const webAppAuth = { } export const contract = { - enterpriseAppDeployConsole, consoleSso, webAppAuth, } diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 56228f2738..b747c4baa8 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -4,46 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type AccessChannels = { - enabled?: boolean - webappRows?: Array - cli?: CliAccess -} - -export type AccessModeOption = { - mode?: string - label?: string - disabled?: boolean - selected?: boolean -} - -export type AccessPolicyDetail = { - accessMode?: string - subjects?: Array - options?: Array -} - -export type AccessStatus = { - accessChannelsEnabled?: boolean - webappUrl?: string - cliUrl?: string - developerApiEnabled?: boolean - apiKeyCount?: number -} - -export type AccessSubject = { - subjectId?: string - subjectType?: string -} - -export type AccessSubjectDisplay = { - id?: string - subjectType?: string - name?: string - avatarUrl?: string - memberCount?: string -} - export type Account = { id?: string email?: string @@ -70,104 +30,9 @@ export type AccountInWorkspace = { role?: string } -export type AckDeploymentReply = { - accepted?: boolean - newVersion?: string -} - -export type AckDeploymentReq = { - deploymentId?: string - instanceId?: string - expectedVersion?: string - status?: string - observedReleaseId?: string - lastError?: LastError -} - -export type AppInstanceBasicInfo = { +export type AddGroupAppsRequest = { id?: string - name?: string - description?: string - sourceAppId?: string - sourceAppName?: string - mode?: string - createdAt?: string -} - -export type AppInstanceCard = { - id?: string - name?: string - icon?: string - mode?: string - sourceAppName?: string - statuses?: Array - lastDeployedAt?: string -} - -export type AppRunnerBatchRuntimeArtifactReply = { - results?: Array -} - -export type AppRunnerBatchRuntimeArtifactRequest = { - artifacts?: Array -} - -export type AppRunnerBootstrapAssignment = { - appId?: string - environmentId?: string - workflowId?: string - instanceId?: string - workspaceId?: string - instanceVersion?: string - bindingSnapshotVersion?: string - executionTokenVersion?: string - executionToken?: string - releaseId?: string -} - -export type AppRunnerBootstrapReply = { - runnerId?: string - assignmentRevision?: string - assignments?: Array -} - -export type AppRunnerBootstrapRequest = { - runner?: AppRunnerRunnerInfo -} - -export type AppRunnerRunnerInfo = { - hostname?: string -} - -export type AppRunnerRuntimeArtifactReply = { - dslYaml?: string - bindingSnapshotVersion?: string - bindingSnapshot?: { - [key: string]: unknown - } -} - -export type AppRunnerRuntimeArtifactRequest = { - instanceId?: string - releaseId?: string - bindingSnapshotVersion?: string -} - -export type AppRunnerRuntimeArtifactResult = { - instanceId?: string - releaseId?: string - artifact?: AppRunnerRuntimeArtifactReply - errorCode?: string - errorMessage?: string -} - -export type AppRunnerTokenExchangeReply = { - accessToken?: string - expiresAt?: string -} - -export type AppRunnerTokenExchangeRequest = { - joinToken?: string + app_ids?: Array } export type AuthSettingsReply = { @@ -193,15 +58,6 @@ export type AuthSettingsReq = { ssoSettings?: SsoSettings } -export type BootstrapProgress = { - currentStep?: string - completedSteps?: Array - attemptCount?: number - lastAttemptAt?: string - lastErrorCode?: string - lastErrorMessage?: string -} - export type BrandingInfo = { enabled?: boolean applicationTitle?: string @@ -210,15 +66,6 @@ export type BrandingInfo = { favicon?: string } -export type CancelRuntimeDeploymentReply = { - status?: string -} - -export type CancelRuntimeDeploymentReq = { - appInstanceId?: string - runtimeInstanceId?: string -} - export type CheckPasswordStatusReply = { requirePasswordChange?: boolean changeReason?: number @@ -230,82 +77,10 @@ export type ClearDefaultWorkspaceReply = { [key: string]: unknown } -export type CliAccess = { - url?: string -} - -export type ConsoleEnvironment = { - id?: string - name?: string - runtime?: string - type?: string - status?: string -} - -export type ConsoleRelease = { - id?: string - name?: string - shortCommitId?: string - createdAt?: string -} - -export type ConsoleUser = { - id?: string - name?: string -} - -export type CreateAppInstanceReply = { - appInstanceId?: string - initialRelease?: ConsoleRelease -} - -export type CreateAppInstanceReq = { - sourceAppId?: string - name?: string - description?: string -} - export type CreateBearerTokenResponse = { token?: string } -export type CreateDeploymentReply = { - runtimeInstanceId?: string - deploymentId?: string - status?: string -} - -export type CreateDeploymentReq = { - appInstanceId?: string - environmentId?: string - releaseId?: string - bindings?: Array -} - -export type CreateDeveloperApiKeyReply = { - apiKey?: DeveloperApiKeyRow - token?: string -} - -export type CreateDeveloperApiKeyReq = { - appInstanceId?: string - environmentId?: string - name?: string -} - -export type CreateEnvironmentReply = { - environment?: Environment -} - -export type CreateEnvironmentReq = { - name?: string - description?: string - mode?: number - backend?: number - k8s?: K8sEnvironmentConfig - host?: HostEnvironmentConfig -} - export type CreateMemberReply = { id?: string password?: string @@ -329,12 +104,7 @@ export type CreateNewGroupsRes = { groups?: Array } -export type CreateReleaseReply = { - release?: ConsoleRelease -} - -export type CreateReleaseReq = { - appInstanceId?: string +export type CreateResourceGroupRequest = { name?: string description?: string } @@ -394,27 +164,10 @@ export type DashboardSsosamlLoginReply = { url?: string } -export type DeleteAppInstanceReply = { - [key: string]: unknown -} - -export type DeleteDeveloperApiKeyReply = { - [key: string]: unknown -} - -export type DeleteEnvironmentReply = { - [key: string]: unknown -} - export type DeleteGroupsRes = { message?: string } -export type DeleteGuard = { - canDelete?: boolean - disabledReason?: string -} - export type DeleteMemberReply = { account?: Account } @@ -431,70 +184,6 @@ export type DeleteWorkspaceReply = { [key: string]: unknown } -export type DeployedEnvironment = { - environmentId?: string - environmentName?: string -} - -export type DeploymentBindingOptionSlot = { - slot?: string - kind?: string - label?: string - required?: boolean - candidates?: Array - envVarCandidates?: Array -} - -export type DeploymentCredentialOption = { - credentialId?: string - displayName?: string - pluginId?: string - pluginName?: string - pluginVersion?: string -} - -export type DeploymentEnvVarOption = { - envVarId?: string - name?: string - valueType?: string - displayValue?: string -} - -export type DeploymentEnvironmentOption = { - id?: string - name?: string - type?: string - backend?: string - status?: string - managedBy?: string - deployable?: boolean - disabledReason?: string -} - -export type DeploymentRuntimeBinding = { - slot?: string - credentialId?: string - envVarId?: string -} - -export type DeploymentStatusRow = { - environment?: ConsoleEnvironment - release?: ConsoleRelease - status?: string -} - -export type DeveloperApiAccess = { - enabled?: boolean - apiKeys?: Array -} - -export type DeveloperApiKeyRow = { - id?: string - name?: string - environment?: ConsoleEnvironment - maskedKey?: string -} - export type EndpointReply = { mode?: number metricsEndpoint?: OtelExporterEndpoint @@ -507,55 +196,6 @@ export type EnterpriseSystemUserSettingReply = { enableEmailPasswordLogin?: boolean } -export type Environment = { - id?: string - name?: string - description?: string - mode?: number - namespace?: string - apiServer?: string - status?: number - statusMessage?: string - bootstrapProgress?: BootstrapProgress - managedBy?: string - createdAt?: string - updatedAt?: string - backend?: number - host?: string -} - -export type EnvironmentAccessRow = { - environment?: ConsoleEnvironment - currentRelease?: ConsoleRelease - accessMode?: string - accessModeLabel?: string - hint?: string -} - -export type EnvironmentFilter = { - id?: string - name?: string - kind?: string -} - -export type GetAppInstanceAccessReply = { - permissions?: Array - accessChannels?: AccessChannels - developerApi?: DeveloperApiAccess -} - -export type GetAppInstanceOverviewReply = { - instance?: AppInstanceBasicInfo - deployments?: Array - access?: AccessStatus -} - -export type GetAppInstanceSettingsReply = { - name?: string - description?: string - deleteGuard?: DeleteGuard -} - export type GetBearerTokenResponse = { maskedToken?: string } @@ -571,14 +211,6 @@ export type GetDefaultWorkspaceReply = { workspace?: Workspace } -export type GetEnvironmentAccessPolicyReply = { - policy?: AccessPolicyDetail -} - -export type GetEnvironmentReply = { - environment?: Environment -} - export type GetGroupSubjectsRes = { subjects?: Array } @@ -587,15 +219,6 @@ export type GetGroupsRes = { groups?: Array } -export type GetInstanceReply = { - instanceId?: string - status?: string - desiredReleaseId?: string - observedReleaseId?: string - currentDeploymentId?: string - version?: string -} - export type GetJoinedGroupsRes = { groups?: Array } @@ -652,16 +275,22 @@ export type GetWorkspaceReply = { workspace?: Workspace } +export type GroupAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + token_usage?: string + rpm?: string + concurrency?: string +} + export type HealthzReply = { message?: string status?: string } -export type HostEnvironmentConfig = { - machineId?: string - joinTokenHash?: string -} - export type InfoConfigReply = { SSOEnforcedForSignin?: boolean SSOEnforcedForSigninProtocol?: string @@ -677,6 +306,11 @@ export type InfoConfigReply = { PluginInstallationPermission?: PluginInstallationPermissionInfo } +export type InnerAdmission = { + marker?: string + concurrencyGroupIds?: Array +} + export type InnerBatchGetWebAppAccessModesByIdReq = { appIds?: Array } @@ -698,42 +332,10 @@ export type InnerBatchIsUserAllowedToAccessWebAppRes = { } } -export type InnerCheckAppDeployAccessReply = { - allowed?: boolean - matchedPolicyId?: string - matchedScopeType?: string - reason?: string - cacheTtlSeconds?: number -} - -export type InnerCheckAppDeployAccessReq = { - appInstanceId?: string - environmentId?: string - principalType?: string - principalId?: string -} - export type InnerCleanAppRes = { message?: string } -export type InnerGetTokenRouteReply = { - environmentId?: string - namespace?: string - serviceName?: string - servicePort?: number - environmentStatus?: string - appId?: string - tenantId?: string - instanceId?: string - observedReleaseId?: string - instanceStatus?: string -} - -export type InnerGetTokenRouteReq = { - token?: string -} - export type InnerGetWebAppAccessModeByCodeRes = { accessMode?: string } @@ -742,10 +344,34 @@ export type InnerGetWebAppAccessModeByIdRes = { accessMode?: string } +export type InnerGroupConfig = { + id?: string + enabled?: boolean + membershipId?: string + limits?: Array +} + export type InnerIsUserAllowedToAccessWebAppRes = { result?: boolean } +export type InnerReleaseAdmissionRequest = { + admission?: InnerAdmission +} + +export type InnerReleaseAdmissionResponse = { + [key: string]: unknown +} + +export type InnerResolveResponse = { + appId?: string + groups?: Array + blocked?: boolean + blockGroupId?: string + blockReason?: string + admission?: InnerAdmission +} + export type InnerTryAddAccountToDefaultWorkspaceReply = { workspaceId?: string joined?: boolean @@ -770,20 +396,6 @@ export type JoinWorkspaceReq = { role?: string } -export type K8sEnvironmentConfig = { - namespace?: string - apiServer?: string - caBundle?: string - bearerToken?: string -} - -export type LastError = { - phase?: string - code?: string - message?: string - releaseId?: string -} - export type LicenseInfo = { uuid?: string expiredAt?: string @@ -798,28 +410,21 @@ export type LicenseStatus = { workspaces?: ResourceQuota } +export type LimitConfig = { + type?: number + threshold?: string + action?: number + reached?: boolean +} + export type LimitFields = { workspaceMembers?: number workspaces?: ResourceQuota } -export type ListAppInstancesReply = { - filters?: Array - data?: Array - pagination?: Pagination -} - -export type ListDeploymentBindingOptionsReply = { - slots?: Array -} - -export type ListDeploymentEnvironmentOptionsReply = { - environments?: Array -} - -export type ListEnvironmentsReply = { - data?: Array - pagination?: Pagination +export type ListGroupAppsResponse = { + items?: Array + total?: string } export type ListMembersReply = { @@ -827,13 +432,9 @@ export type ListMembersReply = { pagination?: Pagination } -export type ListReleasesReply = { - data?: Array - pagination?: Pagination -} - -export type ListRuntimeInstancesReply = { - data?: Array +export type ListResourceGroupsResponse = { + items?: Array + total?: string } export type ListSecretKeysReply = { @@ -981,31 +582,6 @@ export type PluginInstallationSettingsReply = { restrictToMarketplaceOnly?: boolean } -export type PreviewReleaseReply = { - release?: ConsoleRelease - bindings?: Array -} - -export type PreviewReleaseReq = { - appInstanceId?: string - releaseId?: string -} - -export type ReleaseRow = { - id?: string - name?: string - createdAt?: string - createdBy?: ConsoleUser - deployedTo?: Array -} - -export type ReleaseRuntimeBinding = { - kind?: string - label?: string - displayValue?: string - valueType?: string -} - export type ResetMemberPasswordReply = { id?: string password?: string @@ -1034,21 +610,35 @@ export type ResetUserPasswordReq = { id?: string } -export type ResolveCredentialsReply = { - resolved?: Array +export type ResourceGroupDetail = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number + created_at?: string + updated_at?: string } -export type ResolveCredentialsReq = { - instanceId?: string - deploymentId?: string - slots?: Array -} - -export type ResolvedCredential = { - slot?: string - credentialId?: string - envVarId?: string - value?: string +export type ResourceGroupItem = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + concurrency_limit?: number + token_quota?: string + token_usage?: string + app_count?: string + rpm_status?: number + conc_status?: number + created_at?: string + updated_at?: string } export type ResourceQuota = { @@ -1057,36 +647,6 @@ export type ResourceQuota = { enabled?: boolean } -export type RetryEnvironmentReply = { - environment?: Environment -} - -export type RetryEnvironmentReq = { - id?: string -} - -export type RuntimeEndpoints = { - run?: string - health?: string -} - -export type RuntimeInstanceDetail = { - deploymentName?: string - replicas?: number - runtimeMode?: string - runtimeNote?: string - endpoints?: RuntimeEndpoints - bindings?: Array -} - -export type RuntimeInstanceRow = { - id?: string - environment?: ConsoleEnvironment - status?: string - currentRelease?: ConsoleRelease - detail?: RuntimeInstanceDetail -} - export type SamlConfig = { idpSsoUrl?: string certificate?: string @@ -1119,8 +679,21 @@ export type ScimSettings = { lastSyncTime?: string } -export type SearchAccessSubjectsReply = { - data?: Array +export type SearchAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + icon?: string + icon_type?: string + icon_background?: string + created_by_name?: string +} + +export type SearchAppsResponse = { + items?: Array + total?: string } export type SearchForWhilteListCandidatesRes = { @@ -1145,11 +718,6 @@ export type SetDefaultWorkspaceReq = { id?: string } -export type StatusCount = { - status?: string - count?: number -} - export type Subject = { subjectId?: string subjectType?: string @@ -1185,42 +753,10 @@ export type TestConnectionReply = { error?: string } -export type TestEnvironmentConnectionReply = { - ok?: boolean - reachableServerVersion?: string - namespaceExists?: boolean - missingPermissions?: Array - error?: string - probedAt?: string -} - -export type TestEnvironmentConnectionReq = { - id?: string -} - export type ToggleEndpointRequest = { enabled?: boolean } -export type UndeployRuntimeInstanceReply = { - deploymentId?: string - status?: string -} - -export type UndeployRuntimeInstanceReq = { - appInstanceId?: string - runtimeInstanceId?: string -} - -export type UpdateAccessChannelsReply = { - accessChannels?: AccessChannels -} - -export type UpdateAccessChannelsReq = { - appInstanceId?: string - enabled?: boolean -} - export type UpdateAccessModeReq = { appId?: string accessMode?: string @@ -1230,16 +766,6 @@ export type UpdateAccessModeRes = { message?: string } -export type UpdateAppInstanceReply = { - appInstanceId?: string -} - -export type UpdateAppInstanceReq = { - appInstanceId?: string - name?: string - description?: string -} - export type UpdateBrandingInfoReq = { enabled?: boolean applicationTitle?: string @@ -1248,36 +774,6 @@ export type UpdateBrandingInfoReq = { favicon?: string } -export type UpdateDeveloperApiReply = { - developerApi?: DeveloperApiAccess -} - -export type UpdateDeveloperApiReq = { - appInstanceId?: string - enabled?: boolean -} - -export type UpdateEnvironmentAccessPolicyReply = { - permission?: EnvironmentAccessRow -} - -export type UpdateEnvironmentAccessPolicyReq = { - appInstanceId?: string - environmentId?: string - accessMode?: string - subjects?: Array -} - -export type UpdateEnvironmentReply = { - environment?: Environment -} - -export type UpdateEnvironmentReq = { - id?: string - name?: string - description?: string -} - export type UpdateGroupSubjectsReq = { groupId?: string subjects?: Array @@ -1358,6 +854,19 @@ export type UpdatePluginInstallationSettingsRequest = { restrictToMarketplaceOnly?: boolean } +export type UpdateResourceGroupRequest = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number +} + export type UpdateUserReply = { account?: AccountDetail } @@ -1410,11 +919,6 @@ export type UpdateWorkspaceReq = { status?: string } -export type WebAppAccessRow = { - environment?: ConsoleEnvironment - url?: string -} - export type WebAppAuthInfo = { allowSso?: boolean allowEmailCodeLogin?: boolean @@ -1459,385 +963,6 @@ export type Pagination = { totalPages?: number } -export type EnterpriseAppDeployConsoleListAppInstancesData = { - body?: never - path?: never - query?: { - environmentId?: string - notDeployed?: boolean - query?: string - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/app-instances' -} - -export type EnterpriseAppDeployConsoleListAppInstancesResponses = { - 200: ListAppInstancesReply -} - -export type EnterpriseAppDeployConsoleListAppInstancesResponse - = EnterpriseAppDeployConsoleListAppInstancesResponses[keyof EnterpriseAppDeployConsoleListAppInstancesResponses] - -export type EnterpriseAppDeployConsoleCreateAppInstanceData = { - body: CreateAppInstanceReq - path?: never - query?: never - url: '/enterprise/app-instances' -} - -export type EnterpriseAppDeployConsoleCreateAppInstanceResponses = { - 200: CreateAppInstanceReply -} - -export type EnterpriseAppDeployConsoleCreateAppInstanceResponse - = EnterpriseAppDeployConsoleCreateAppInstanceResponses[keyof EnterpriseAppDeployConsoleCreateAppInstanceResponses] - -export type EnterpriseAppDeployConsoleDeleteAppInstanceData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}' -} - -export type EnterpriseAppDeployConsoleDeleteAppInstanceResponses = { - 200: DeleteAppInstanceReply -} - -export type EnterpriseAppDeployConsoleDeleteAppInstanceResponse - = EnterpriseAppDeployConsoleDeleteAppInstanceResponses[keyof EnterpriseAppDeployConsoleDeleteAppInstanceResponses] - -export type EnterpriseAppDeployConsoleUpdateAppInstanceData = { - body: UpdateAppInstanceReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}' -} - -export type EnterpriseAppDeployConsoleUpdateAppInstanceResponses = { - 200: UpdateAppInstanceReply -} - -export type EnterpriseAppDeployConsoleUpdateAppInstanceResponse - = EnterpriseAppDeployConsoleUpdateAppInstanceResponses[keyof EnterpriseAppDeployConsoleUpdateAppInstanceResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/access' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessResponses = { - 200: GetAppInstanceAccessReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessResponse - = EnterpriseAppDeployConsoleGetAppInstanceAccessResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceAccessResponses] - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsData = { - body: UpdateAccessChannelsReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/access-channels' -} - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsResponses = { - 200: UpdateAccessChannelsReply -} - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsResponse - = EnterpriseAppDeployConsoleUpdateAccessChannelsResponses[keyof EnterpriseAppDeployConsoleUpdateAccessChannelsResponses] - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsData = { - body?: never - path: { - appInstanceId: string - } - query?: { - keyword?: string - subjectTypes?: Array - } - url: '/enterprise/app-instances/{appInstanceId}/access-subjects:search' -} - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsResponses = { - 200: SearchAccessSubjectsReply -} - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsResponse - = EnterpriseAppDeployConsoleSearchAccessSubjectsResponses[keyof EnterpriseAppDeployConsoleSearchAccessSubjectsResponses] - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyData = { - body: CreateDeveloperApiKeyReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/api-keys' -} - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses = { - 200: CreateDeveloperApiKeyReply -} - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse - = EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses] - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyData = { - body?: never - path: { - appInstanceId: string - apiKeyId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}' -} - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses = { - 200: DeleteDeveloperApiKeyReply -} - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse - = EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses] - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options' -} - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses = { - 200: ListDeploymentBindingOptionsReply -} - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse - = EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses[keyof EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses] - -export type EnterpriseAppDeployConsoleCreateDeploymentData = { - body: CreateDeploymentReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/deployments' -} - -export type EnterpriseAppDeployConsoleCreateDeploymentResponses = { - 200: CreateDeploymentReply -} - -export type EnterpriseAppDeployConsoleCreateDeploymentResponse - = EnterpriseAppDeployConsoleCreateDeploymentResponses[keyof EnterpriseAppDeployConsoleCreateDeploymentResponses] - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiData = { - body: UpdateDeveloperApiReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/developer-api' -} - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiResponses = { - 200: UpdateDeveloperApiReply -} - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiResponse - = EnterpriseAppDeployConsoleUpdateDeveloperApiResponses[keyof EnterpriseAppDeployConsoleUpdateDeveloperApiResponses] - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyData = { - body?: never - path: { - appInstanceId: string - environmentId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy' -} - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses = { - 200: GetEnvironmentAccessPolicyReply -} - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse - = EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses[keyof EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses] - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyData = { - body: UpdateEnvironmentAccessPolicyReq - path: { - appInstanceId: string - environmentId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy' -} - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses = { - 200: UpdateEnvironmentAccessPolicyReply -} - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse - = EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses[keyof EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/overview' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses = { - 200: GetAppInstanceOverviewReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewResponse - = EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses] - -export type EnterpriseAppDeployConsoleListReleasesData = { - body?: never - path: { - appInstanceId: string - } - query?: { - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/app-instances/{appInstanceId}/releases' -} - -export type EnterpriseAppDeployConsoleListReleasesResponses = { - 200: ListReleasesReply -} - -export type EnterpriseAppDeployConsoleListReleasesResponse - = EnterpriseAppDeployConsoleListReleasesResponses[keyof EnterpriseAppDeployConsoleListReleasesResponses] - -export type EnterpriseAppDeployConsoleCreateReleaseData = { - body: CreateReleaseReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/releases' -} - -export type EnterpriseAppDeployConsoleCreateReleaseResponses = { - 200: CreateReleaseReply -} - -export type EnterpriseAppDeployConsoleCreateReleaseResponse - = EnterpriseAppDeployConsoleCreateReleaseResponses[keyof EnterpriseAppDeployConsoleCreateReleaseResponses] - -export type EnterpriseAppDeployConsolePreviewReleaseData = { - body: PreviewReleaseReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/releases:preview' -} - -export type EnterpriseAppDeployConsolePreviewReleaseResponses = { - 200: PreviewReleaseReply -} - -export type EnterpriseAppDeployConsolePreviewReleaseResponse - = EnterpriseAppDeployConsolePreviewReleaseResponses[keyof EnterpriseAppDeployConsolePreviewReleaseResponses] - -export type EnterpriseAppDeployConsoleListRuntimeInstancesData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances' -} - -export type EnterpriseAppDeployConsoleListRuntimeInstancesResponses = { - 200: ListRuntimeInstancesReply -} - -export type EnterpriseAppDeployConsoleListRuntimeInstancesResponse - = EnterpriseAppDeployConsoleListRuntimeInstancesResponses[keyof EnterpriseAppDeployConsoleListRuntimeInstancesResponses] - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentData = { - body: CancelRuntimeDeploymentReq - path: { - appInstanceId: string - runtimeInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}/deployment:cancel' -} - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses = { - 200: CancelRuntimeDeploymentReply -} - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse - = EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses[keyof EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses] - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceData = { - body: UndeployRuntimeInstanceReq - path: { - appInstanceId: string - runtimeInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}:undeploy' -} - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses = { - 200: UndeployRuntimeInstanceReply -} - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse - = EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses[keyof EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/settings' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses = { - 200: GetAppInstanceSettingsReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsResponse - = EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses] - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsData = { - body?: never - path?: never - query?: never - url: '/enterprise/deployment-environment-options' -} - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses = { - 200: ListDeploymentEnvironmentOptionsReply -} - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse - = EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses[keyof EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses] - export type ConsoleSsoOAuth2LoginData = { body?: never path?: never diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index 1e7e3d44ae..cef500a906 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2,44 +2,6 @@ import * as z from 'zod' -export const zAccessModeOption = z.object({ - mode: z.string().optional(), - label: z.string().optional(), - disabled: z.boolean().optional(), - selected: z.boolean().optional(), -}) - -export const zAccessStatus = z.object({ - accessChannelsEnabled: z.boolean().optional(), - webappUrl: z.string().optional(), - cliUrl: z.string().optional(), - developerApiEnabled: z.boolean().optional(), - apiKeyCount: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zAccessSubject = z.object({ - subjectId: z.string().optional(), - subjectType: z.string().optional(), -}) - -export const zAccessSubjectDisplay = z.object({ - id: z.string().optional(), - subjectType: z.string().optional(), - name: z.string().optional(), - avatarUrl: z.string().optional(), - memberCount: z.string().optional(), -}) - -export const zAccessPolicyDetail = z.object({ - accessMode: z.string().optional(), - subjects: z.array(zAccessSubjectDisplay).optional(), - options: z.array(zAccessModeOption).optional(), -}) - /** * Account represents a basic user account */ @@ -75,101 +37,9 @@ export const zAccountDetail = z.object({ groups: z.array(zAccountDetailGroup).optional(), }) -export const zAckDeploymentReply = z.object({ - accepted: z.boolean().optional(), - newVersion: z.string().optional(), -}) - -export const zAppInstanceBasicInfo = z.object({ +export const zAddGroupAppsRequest = z.object({ id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - sourceAppId: z.string().optional(), - sourceAppName: z.string().optional(), - mode: z.string().optional(), - createdAt: z.iso.datetime().optional(), -}) - -export const zAppRunnerBootstrapAssignment = z.object({ - appId: z.string().optional(), - environmentId: z.string().optional(), - workflowId: z.string().optional(), - instanceId: z.string().optional(), - workspaceId: z.string().optional(), - instanceVersion: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), - executionTokenVersion: z.string().optional(), - executionToken: z.string().optional(), - releaseId: z.string().optional(), -}) - -export const zAppRunnerBootstrapReply = z.object({ - runnerId: z.string().optional(), - assignmentRevision: z.string().optional(), - assignments: z.array(zAppRunnerBootstrapAssignment).optional(), -}) - -export const zAppRunnerRunnerInfo = z.object({ - hostname: z.string().optional(), -}) - -export const zAppRunnerBootstrapRequest = z.object({ - runner: zAppRunnerRunnerInfo.optional(), -}) - -export const zAppRunnerRuntimeArtifactReply = z.object({ - dslYaml: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), - bindingSnapshot: z.record(z.string(), z.unknown()).optional(), -}) - -export const zAppRunnerRuntimeArtifactRequest = z.object({ - instanceId: z.string().optional(), - releaseId: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), -}) - -export const zAppRunnerBatchRuntimeArtifactRequest = z.object({ - artifacts: z.array(zAppRunnerRuntimeArtifactRequest).optional(), -}) - -export const zAppRunnerRuntimeArtifactResult = z.object({ - instanceId: z.string().optional(), - releaseId: z.string().optional(), - artifact: zAppRunnerRuntimeArtifactReply.optional(), - errorCode: z.string().optional(), - errorMessage: z.string().optional(), -}) - -export const zAppRunnerBatchRuntimeArtifactReply = z.object({ - results: z.array(zAppRunnerRuntimeArtifactResult).optional(), -}) - -export const zAppRunnerTokenExchangeReply = z.object({ - accessToken: z.string().optional(), - expiresAt: z.iso.datetime().optional(), -}) - -export const zAppRunnerTokenExchangeRequest = z.object({ - joinToken: z.string().optional(), -}) - -/** - * BootstrapProgress is step-list-agnostic. Reconcilers emit step names as - * strings owned by each executor (e.g. "connectivity", "namespace"), so adding - * or removing steps does not break the API. - */ -export const zBootstrapProgress = z.object({ - currentStep: z.string().optional(), - completedSteps: z.array(z.string()).optional(), - attemptCount: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - lastAttemptAt: z.iso.datetime().optional(), - lastErrorCode: z.string().optional(), - lastErrorMessage: z.string().optional(), + app_ids: z.array(z.string()).optional(), }) export const zBrandingInfo = z.object({ @@ -180,15 +50,6 @@ export const zBrandingInfo = z.object({ favicon: z.string().optional(), }) -export const zCancelRuntimeDeploymentReply = z.object({ - status: z.string().optional(), -}) - -export const zCancelRuntimeDeploymentReq = z.object({ - appInstanceId: z.string().optional(), - runtimeInstanceId: z.string().optional(), -}) - export const zCheckPasswordStatusReply = z.object({ requirePasswordChange: z.boolean().optional(), changeReason: z.int().optional(), @@ -202,57 +63,10 @@ export const zCheckPasswordStatusReply = z.object({ export const zClearDefaultWorkspaceReply = z.record(z.string(), z.unknown()) -export const zCliAccess = z.object({ - url: z.string().optional(), -}) - -export const zConsoleEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), - runtime: z.string().optional(), - type: z.string().optional(), - status: z.string().optional(), -}) - -export const zConsoleRelease = z.object({ - id: z.string().optional(), - name: z.string().optional(), - shortCommitId: z.string().optional(), - createdAt: z.iso.datetime().optional(), -}) - -export const zConsoleUser = z.object({ - id: z.string().optional(), - name: z.string().optional(), -}) - -export const zCreateAppInstanceReply = z.object({ - appInstanceId: z.string().optional(), - initialRelease: zConsoleRelease.optional(), -}) - -export const zCreateAppInstanceReq = z.object({ - sourceAppId: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zCreateBearerTokenResponse = z.object({ token: z.string().optional(), }) -export const zCreateDeploymentReply = z.object({ - runtimeInstanceId: z.string().optional(), - deploymentId: z.string().optional(), - status: z.string().optional(), -}) - -export const zCreateDeveloperApiKeyReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - name: z.string().optional(), -}) - export const zCreateMemberReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -275,12 +89,7 @@ export const zCreateNewGroupsReq = z.object({ groups: z.array(zCreateNewGroupsReqGroup).optional(), }) -export const zCreateReleaseReply = z.object({ - release: zConsoleRelease.optional(), -}) - -export const zCreateReleaseReq = z.object({ - appInstanceId: z.string().optional(), +export const zCreateResourceGroupRequest = z.object({ name: z.string().optional(), description: z.string().optional(), }) @@ -342,21 +151,10 @@ export const zDashboardSsosamlLoginReply = z.object({ url: z.string().optional(), }) -export const zDeleteAppInstanceReply = z.record(z.string(), z.unknown()) - -export const zDeleteDeveloperApiKeyReply = z.record(z.string(), z.unknown()) - -export const zDeleteEnvironmentReply = z.record(z.string(), z.unknown()) - export const zDeleteGroupsRes = z.object({ message: z.string().optional(), }) -export const zDeleteGuard = z.object({ - canDelete: z.boolean().optional(), - disabledReason: z.string().optional(), -}) - export const zDeleteMemberReply = z.object({ account: zAccount.optional(), }) @@ -371,82 +169,6 @@ export const zDeleteUserReply = z.object({ export const zDeleteWorkspaceReply = z.record(z.string(), z.unknown()) -export const zDeployedEnvironment = z.object({ - environmentId: z.string().optional(), - environmentName: z.string().optional(), -}) - -export const zDeploymentCredentialOption = z.object({ - credentialId: z.string().optional(), - displayName: z.string().optional(), - pluginId: z.string().optional(), - pluginName: z.string().optional(), - pluginVersion: z.string().optional(), -}) - -export const zDeploymentEnvVarOption = z.object({ - envVarId: z.string().optional(), - name: z.string().optional(), - valueType: z.string().optional(), - displayValue: z.string().optional(), -}) - -export const zDeploymentBindingOptionSlot = z.object({ - slot: z.string().optional(), - kind: z.string().optional(), - label: z.string().optional(), - required: z.boolean().optional(), - candidates: z.array(zDeploymentCredentialOption).optional(), - envVarCandidates: z.array(zDeploymentEnvVarOption).optional(), -}) - -export const zDeploymentEnvironmentOption = z.object({ - id: z.string().optional(), - name: z.string().optional(), - type: z.string().optional(), - backend: z.string().optional(), - status: z.string().optional(), - managedBy: z.string().optional(), - deployable: z.boolean().optional(), - disabledReason: z.string().optional(), -}) - -export const zDeploymentRuntimeBinding = z.object({ - slot: z.string().optional(), - credentialId: z.string().optional(), - envVarId: z.string().optional(), -}) - -export const zCreateDeploymentReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - releaseId: z.string().optional(), - bindings: z.array(zDeploymentRuntimeBinding).optional(), -}) - -export const zDeploymentStatusRow = z.object({ - environment: zConsoleEnvironment.optional(), - release: zConsoleRelease.optional(), - status: z.string().optional(), -}) - -export const zDeveloperApiKeyRow = z.object({ - id: z.string().optional(), - name: z.string().optional(), - environment: zConsoleEnvironment.optional(), - maskedKey: z.string().optional(), -}) - -export const zCreateDeveloperApiKeyReply = z.object({ - apiKey: zDeveloperApiKeyRow.optional(), - token: z.string().optional(), -}) - -export const zDeveloperApiAccess = z.object({ - enabled: z.boolean().optional(), - apiKeys: z.array(zDeveloperApiKeyRow).optional(), -}) - /** * System user setting messages */ @@ -456,53 +178,6 @@ export const zEnterpriseSystemUserSettingReply = z.object({ enableEmailPasswordLogin: z.boolean().optional(), }) -export const zEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - mode: z.int().optional(), - namespace: z.string().optional(), - apiServer: z.string().optional(), - status: z.int().optional(), - statusMessage: z.string().optional(), - bootstrapProgress: zBootstrapProgress.optional(), - managedBy: z.string().optional(), - createdAt: z.iso.datetime().optional(), - updatedAt: z.iso.datetime().optional(), - backend: z.int().optional(), - host: z.string().optional(), -}) - -export const zCreateEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zEnvironmentAccessRow = z.object({ - environment: zConsoleEnvironment.optional(), - currentRelease: zConsoleRelease.optional(), - accessMode: z.string().optional(), - accessModeLabel: z.string().optional(), - hint: z.string().optional(), -}) - -export const zEnvironmentFilter = z.object({ - id: z.string().optional(), - name: z.string().optional(), - kind: z.string().optional(), -}) - -export const zGetAppInstanceOverviewReply = z.object({ - instance: zAppInstanceBasicInfo.optional(), - deployments: z.array(zDeploymentStatusRow).optional(), - access: zAccessStatus.optional(), -}) - -export const zGetAppInstanceSettingsReply = z.object({ - name: z.string().optional(), - description: z.string().optional(), - deleteGuard: zDeleteGuard.optional(), -}) - export const zGetBearerTokenResponse = z.object({ maskedToken: z.string().optional(), }) @@ -513,23 +188,6 @@ export const zGetClusterInfoReply = z.object({ verifyMode: z.string().optional(), }) -export const zGetEnvironmentAccessPolicyReply = z.object({ - policy: zAccessPolicyDetail.optional(), -}) - -export const zGetEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zGetInstanceReply = z.object({ - instanceId: z.string().optional(), - status: z.string().optional(), - desiredReleaseId: z.string().optional(), - observedReleaseId: z.string().optional(), - currentDeploymentId: z.string().optional(), - version: z.string().optional(), -}) - export const zGetLicenseStatusReply = z.object({ status: z.string().optional(), }) @@ -565,14 +223,25 @@ export const zGetWebAppWhitelistSubjectsResMember = z.object({ avatar: z.string().optional(), }) +export const zGroupAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + token_usage: z.string().optional(), + rpm: z.string().optional(), + concurrency: z.string().optional(), +}) + export const zHealthzReply = z.object({ message: z.string().optional(), status: z.string().optional(), }) -export const zHostEnvironmentConfig = z.object({ - machineId: z.string().optional(), - joinTokenHash: z.string().optional(), +export const zInnerAdmission = z.object({ + marker: z.string().optional(), + concurrencyGroupIds: z.array(z.string()).optional(), }) export const zInnerBatchGetWebAppAccessModesByIdReq = z.object({ @@ -592,50 +261,10 @@ export const zInnerBatchIsUserAllowedToAccessWebAppRes = z.object({ permissions: z.record(z.string(), z.boolean()).optional(), }) -export const zInnerCheckAppDeployAccessReply = z.object({ - allowed: z.boolean().optional(), - matchedPolicyId: z.string().optional(), - matchedScopeType: z.string().optional(), - reason: z.string().optional(), - cacheTtlSeconds: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zInnerCheckAppDeployAccessReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - principalType: z.string().optional(), - principalId: z.string().optional(), -}) - export const zInnerCleanAppRes = z.object({ message: z.string().optional(), }) -export const zInnerGetTokenRouteReply = z.object({ - environmentId: z.string().optional(), - namespace: z.string().optional(), - serviceName: z.string().optional(), - servicePort: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - environmentStatus: z.string().optional(), - appId: z.string().optional(), - tenantId: z.string().optional(), - instanceId: z.string().optional(), - observedReleaseId: z.string().optional(), - instanceStatus: z.string().optional(), -}) - -export const zInnerGetTokenRouteReq = z.object({ - token: z.string().optional(), -}) - export const zInnerGetWebAppAccessModeByCodeRes = z.object({ accessMode: z.string().optional(), }) @@ -648,6 +277,12 @@ export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zInnerReleaseAdmissionRequest = z.object({ + admission: zInnerAdmission.optional(), +}) + +export const zInnerReleaseAdmissionResponse = z.record(z.string(), z.unknown()) + export const zInnerTryAddAccountToDefaultWorkspaceReply = z.object({ workspaceId: z.string().optional(), joined: z.boolean().optional(), @@ -678,48 +313,32 @@ export const zJoinWorkspaceReq = z.object({ role: z.string().optional(), }) -export const zK8sEnvironmentConfig = z.object({ - namespace: z.string().optional(), - apiServer: z.string().optional(), - caBundle: z.string().optional(), - bearerToken: z.string().optional(), +export const zLimitConfig = z.object({ + type: z.int().optional(), + threshold: z.string().optional(), + action: z.int().optional(), + reached: z.boolean().optional(), }) -/** - * Field-level validation only; target (api_server) and RBAC validation happen - * in the bootstrap reconciler. - */ -export const zCreateEnvironmentReq = z.object({ - name: z.string().optional(), - description: z.string().optional(), - mode: z.int().optional(), - backend: z.int().optional(), - k8s: zK8sEnvironmentConfig.optional(), - host: zHostEnvironmentConfig.optional(), +export const zInnerGroupConfig = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), + membershipId: z.string().optional(), + limits: z.array(zLimitConfig).optional(), }) -export const zLastError = z.object({ - phase: z.string().optional(), - code: z.string().optional(), - message: z.string().optional(), - releaseId: z.string().optional(), +export const zInnerResolveResponse = z.object({ + appId: z.string().optional(), + groups: z.array(zInnerGroupConfig).optional(), + blocked: z.boolean().optional(), + blockGroupId: z.string().optional(), + blockReason: z.string().optional(), + admission: zInnerAdmission.optional(), }) -export const zAckDeploymentReq = z.object({ - deploymentId: z.string().optional(), - instanceId: z.string().optional(), - expectedVersion: z.string().optional(), - status: z.string().optional(), - observedReleaseId: z.string().optional(), - lastError: zLastError.optional(), -}) - -export const zListDeploymentBindingOptionsReply = z.object({ - slots: z.array(zDeploymentBindingOptionSlot).optional(), -}) - -export const zListDeploymentEnvironmentOptionsReply = z.object({ - environments: z.array(zDeploymentEnvironmentOption).optional(), +export const zListGroupAppsResponse = z.object({ + items: z.array(zGroupAppItem).optional(), + total: z.string().optional(), }) export const zLoginTypesReply = z.object({ @@ -871,31 +490,6 @@ export const zPluginInstallationSettingsReply = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) -export const zPreviewReleaseReq = z.object({ - appInstanceId: z.string().optional(), - releaseId: z.string().optional(), -}) - -export const zReleaseRow = z.object({ - id: z.string().optional(), - name: z.string().optional(), - createdAt: z.iso.datetime().optional(), - createdBy: zConsoleUser.optional(), - deployedTo: z.array(zDeployedEnvironment).optional(), -}) - -export const zReleaseRuntimeBinding = z.object({ - kind: z.string().optional(), - label: z.string().optional(), - displayValue: z.string().optional(), - valueType: z.string().optional(), -}) - -export const zPreviewReleaseReply = z.object({ - release: zConsoleRelease.optional(), - bindings: z.array(zReleaseRuntimeBinding).optional(), -}) - export const zResetMemberPasswordReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -930,26 +524,56 @@ export const zResetUserPasswordReq = z.object({ id: z.string().optional(), }) -export const zResolveCredentialsReq = z.object({ - instanceId: z.string().optional(), - deploymentId: z.string().optional(), - slots: z.array(z.string()).optional(), +export const zResourceGroupDetail = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), }) -/** - * Exactly one of credential_id / env_var_id is populated; model/plugin slots - * carry credential_id (pool A), env_var slots carry env_var_id (pool B). - * See design §4.1. - */ -export const zResolvedCredential = z.object({ - slot: z.string().optional(), - credentialId: z.string().optional(), - envVarId: z.string().optional(), - value: z.string().optional(), +export const zResourceGroupItem = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + token_quota: z.string().optional(), + token_usage: z.string().optional(), + app_count: z.string().optional(), + rpm_status: z.int().optional(), + conc_status: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), }) -export const zResolveCredentialsReply = z.object({ - resolved: z.array(zResolvedCredential).optional(), +export const zListResourceGroupsResponse = z.object({ + items: z.array(zResourceGroupItem).optional(), + total: z.string().optional(), }) /** @@ -1002,44 +626,6 @@ export const zGetLicenseReply = z.object({ license: zLicenseInfo.optional(), }) -export const zRetryEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zRetryEnvironmentReq = z.object({ - id: z.string().optional(), -}) - -export const zRuntimeEndpoints = z.object({ - run: z.string().optional(), - health: z.string().optional(), -}) - -export const zRuntimeInstanceDetail = z.object({ - deploymentName: z.string().optional(), - replicas: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - runtimeMode: z.string().optional(), - runtimeNote: z.string().optional(), - endpoints: zRuntimeEndpoints.optional(), - bindings: z.array(zReleaseRuntimeBinding).optional(), -}) - -export const zRuntimeInstanceRow = z.object({ - id: z.string().optional(), - environment: zConsoleEnvironment.optional(), - status: z.string().optional(), - currentRelease: zConsoleRelease.optional(), - detail: zRuntimeInstanceDetail.optional(), -}) - -export const zListRuntimeInstancesReply = z.object({ - data: z.array(zRuntimeInstanceRow).optional(), -}) - /** * SSO Configuration messages */ @@ -1102,8 +688,21 @@ export const zScimSettings = z.object({ lastSyncTime: z.iso.datetime().optional(), }) -export const zSearchAccessSubjectsReply = z.object({ - data: z.array(zAccessSubjectDisplay).optional(), +export const zSearchAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + icon: z.string().optional(), + icon_type: z.string().optional(), + icon_background: z.string().optional(), + created_by_name: z.string().optional(), +}) + +export const zSearchAppsResponse = z.object({ + items: z.array(zSearchAppItem).optional(), + total: z.string().optional(), }) export const zSecretKey = z.object({ @@ -1122,25 +721,6 @@ export const zSetDefaultWorkspaceReq = z.object({ id: z.string().optional(), }) -export const zStatusCount = z.object({ - status: z.string().optional(), - count: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zAppInstanceCard = z.object({ - id: z.string().optional(), - name: z.string().optional(), - icon: z.string().optional(), - mode: z.string().optional(), - sourceAppName: z.string().optional(), - statuses: z.array(zStatusCount).optional(), - lastDeployedAt: z.iso.datetime().optional(), -}) - export const zSubjectAccountData = z.object({ id: z.string().optional(), name: z.string().optional(), @@ -1214,38 +794,10 @@ export const zTestConnectionReply = z.object({ error: z.string().optional(), }) -export const zTestEnvironmentConnectionReply = z.object({ - ok: z.boolean().optional(), - reachableServerVersion: z.string().optional(), - namespaceExists: z.boolean().optional(), - missingPermissions: z.array(z.string()).optional(), - error: z.string().optional(), - probedAt: z.iso.datetime().optional(), -}) - -export const zTestEnvironmentConnectionReq = z.object({ - id: z.string().optional(), -}) - export const zToggleEndpointRequest = z.object({ enabled: z.boolean().optional(), }) -export const zUndeployRuntimeInstanceReply = z.object({ - deploymentId: z.string().optional(), - status: z.string().optional(), -}) - -export const zUndeployRuntimeInstanceReq = z.object({ - appInstanceId: z.string().optional(), - runtimeInstanceId: z.string().optional(), -}) - -export const zUpdateAccessChannelsReq = z.object({ - appInstanceId: z.string().optional(), - enabled: z.boolean().optional(), -}) - export const zUpdateAccessModeReq = z.object({ appId: z.string().optional(), accessMode: z.string().optional(), @@ -1255,16 +807,6 @@ export const zUpdateAccessModeRes = z.object({ message: z.string().optional(), }) -export const zUpdateAppInstanceReply = z.object({ - appInstanceId: z.string().optional(), -}) - -export const zUpdateAppInstanceReq = z.object({ - appInstanceId: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zUpdateBrandingInfoReq = z.object({ enabled: z.boolean().optional(), applicationTitle: z.string().optional(), @@ -1273,36 +815,6 @@ export const zUpdateBrandingInfoReq = z.object({ favicon: z.string().optional(), }) -export const zUpdateDeveloperApiReply = z.object({ - developerApi: zDeveloperApiAccess.optional(), -}) - -export const zUpdateDeveloperApiReq = z.object({ - appInstanceId: z.string().optional(), - enabled: z.boolean().optional(), -}) - -export const zUpdateEnvironmentAccessPolicyReply = z.object({ - permission: zEnvironmentAccessRow.optional(), -}) - -export const zUpdateEnvironmentAccessPolicyReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - accessMode: z.string().optional(), - subjects: z.array(zAccessSubject).optional(), -}) - -export const zUpdateEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zUpdateEnvironmentReq = z.object({ - id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zUpdateGroupSubjectsReq = z.object({ groupId: z.string().optional(), subjects: z.array(zSubject).optional(), @@ -1386,6 +898,27 @@ export const zUpdatePluginInstallationSettingsRequest = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) +export const zUpdateResourceGroupRequest = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), +}) + export const zUpdateUserReply = z.object({ account: zAccountDetail.optional(), }) @@ -1430,27 +963,6 @@ export const zUpdateWorkspaceReq = z.object({ status: z.string().optional(), }) -export const zWebAppAccessRow = z.object({ - environment: zConsoleEnvironment.optional(), - url: z.string().optional(), -}) - -export const zAccessChannels = z.object({ - enabled: z.boolean().optional(), - webappRows: z.array(zWebAppAccessRow).optional(), - cli: zCliAccess.optional(), -}) - -export const zGetAppInstanceAccessReply = z.object({ - permissions: z.array(zEnvironmentAccessRow).optional(), - accessChannels: zAccessChannels.optional(), - developerApi: zDeveloperApiAccess.optional(), -}) - -export const zUpdateAccessChannelsReply = z.object({ - accessChannels: zAccessChannels.optional(), -}) - export const zWebAppAuthInfo = z.object({ allowSso: z.boolean().optional(), allowEmailCodeLogin: z.boolean().optional(), @@ -1572,27 +1084,11 @@ export const zPagination = z.object({ .optional(), }) -export const zListAppInstancesReply = z.object({ - filters: z.array(zEnvironmentFilter).optional(), - data: z.array(zAppInstanceCard).optional(), - pagination: zPagination.optional(), -}) - -export const zListEnvironmentsReply = z.object({ - data: z.array(zEnvironment).optional(), - pagination: zPagination.optional(), -}) - export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), }) -export const zListReleasesReply = z.object({ - data: z.array(zReleaseRow).optional(), - pagination: zPagination.optional(), -}) - export const zListSecretKeysReply = z.object({ data: z.array(zSecretKey).optional(), pagination: zPagination.optional(), @@ -1608,271 +1104,6 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) -export const zEnterpriseAppDeployConsoleListAppInstancesQuery = z.object({ - environmentId: z.string().optional(), - notDeployed: z.boolean().optional(), - query: z.string().optional(), - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListAppInstancesResponse = zListAppInstancesReply - -export const zEnterpriseAppDeployConsoleCreateAppInstanceBody = zCreateAppInstanceReq - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateAppInstanceResponse = zCreateAppInstanceReply - -export const zEnterpriseAppDeployConsoleDeleteAppInstancePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleDeleteAppInstanceResponse = zDeleteAppInstanceReply - -export const zEnterpriseAppDeployConsoleUpdateAppInstanceBody = zUpdateAppInstanceReq - -export const zEnterpriseAppDeployConsoleUpdateAppInstancePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateAppInstanceResponse = zUpdateAppInstanceReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceAccessPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse = zGetAppInstanceAccessReply - -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsBody = zUpdateAccessChannelsReq - -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse = zUpdateAccessChannelsReply - -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsPath = z.object({ - appInstanceId: z.string(), -}) - -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery = z.object({ - keyword: z.string().optional(), - subjectTypes: z.array(z.string()).optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse = zSearchAccessSubjectsReply - -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody = zCreateDeveloperApiKeyReq - -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse = zCreateDeveloperApiKeyReply - -export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath = z.object({ - appInstanceId: z.string(), - apiKeyId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse = zDeleteDeveloperApiKeyReply - -export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse - = zListDeploymentBindingOptionsReply - -export const zEnterpriseAppDeployConsoleCreateDeploymentBody = zCreateDeploymentReq - -export const zEnterpriseAppDeployConsoleCreateDeploymentPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateDeploymentResponse = zCreateDeploymentReply - -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiBody = zUpdateDeveloperApiReq - -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse = zUpdateDeveloperApiReply - -export const zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath = z.object({ - appInstanceId: z.string(), - environmentId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse - = zGetEnvironmentAccessPolicyReply - -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody - = zUpdateEnvironmentAccessPolicyReq - -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath = z.object({ - appInstanceId: z.string(), - environmentId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse - = zUpdateEnvironmentAccessPolicyReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse - = zGetAppInstanceOverviewReply - -export const zEnterpriseAppDeployConsoleListReleasesPath = z.object({ - appInstanceId: z.string(), -}) - -export const zEnterpriseAppDeployConsoleListReleasesQuery = z.object({ - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListReleasesResponse = zListReleasesReply - -export const zEnterpriseAppDeployConsoleCreateReleaseBody = zCreateReleaseReq - -export const zEnterpriseAppDeployConsoleCreateReleasePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateReleaseResponse = zCreateReleaseReply - -export const zEnterpriseAppDeployConsolePreviewReleaseBody = zPreviewReleaseReq - -export const zEnterpriseAppDeployConsolePreviewReleasePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsolePreviewReleaseResponse = zPreviewReleaseReply - -export const zEnterpriseAppDeployConsoleListRuntimeInstancesPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListRuntimeInstancesResponse = zListRuntimeInstancesReply - -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody = zCancelRuntimeDeploymentReq - -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath = z.object({ - appInstanceId: z.string(), - runtimeInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse - = zCancelRuntimeDeploymentReply - -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody = zUndeployRuntimeInstanceReq - -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath = z.object({ - appInstanceId: z.string(), - runtimeInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse - = zUndeployRuntimeInstanceReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse - = zGetAppInstanceSettingsReply - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse - = zListDeploymentEnvironmentOptionsReply - /** * OK */ diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 79c8ec8322..d17b5dcbc3 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -11,6 +11,7 @@ type SwaggerSchema = JsonObject & { '$ref'?: string 'x-nullable'?: boolean 'additionalProperties'?: unknown + 'allOf'?: SwaggerSchema[] 'anyOf'?: SwaggerSchema[] 'const'?: unknown 'default'?: unknown @@ -19,6 +20,7 @@ type SwaggerSchema = JsonObject & { 'enum'?: unknown[] 'format'?: string 'items'?: SwaggerSchema + 'oneOf'?: SwaggerSchema[] 'properties'?: Record 'required'?: string[] 'type'?: string @@ -38,6 +40,8 @@ type SwaggerResponse = JsonObject & { } type SwaggerOperation = JsonObject & { + deprecated?: boolean + description?: string operationId?: string parameters?: SwaggerParameter[] responses?: Record @@ -54,8 +58,16 @@ type ApiSpec = { } type ApiJob = { + clean?: boolean document: SwaggerDocument outputPath: string + plugins?: UserConfig['plugins'] + source?: { + callback: () => void + enabled: true + path: null + serialize: () => string + } } type ApiContractOperation = { @@ -63,10 +75,17 @@ type ApiContractOperation = { path: string } +type ApiReadinessSurfaceStats = { + notReady: number + total: number +} + const currentDir = path.dirname(fileURLToPath(import.meta.url)) const apiOpenApiDir = path.resolve(currentDir, 'openapi') +const apiReadinessStatsPath = path.resolve(currentDir, 'generated/api/readiness.json') const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) +const noBodyResponseStatuses = new Set(['204', '205', '304']) const apiSpecs: ApiSpec[] = [ { filename: 'console-swagger.json', name: 'console' }, @@ -74,6 +93,9 @@ const apiSpecs: ApiSpec[] = [ { filename: 'service-swagger.json', name: 'service' }, ] +const inaccurateGeneratedContractDescription = 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.' +const apiReadinessStats: Record = {} + const isObject = (value: unknown): value is JsonObject => { return !!value && typeof value === 'object' && !Array.isArray(value) } @@ -83,6 +105,13 @@ const unknownObjectSchema = (): SwaggerSchema => ({ type: 'object', }) +const noContentSchema = (): SwaggerSchema => ({ + // Hey API's Swagger 2.0 pipeline currently needs a response schema symbol even for no-content responses. + additionalProperties: false, + properties: {}, + type: 'object', +}) + const toWords = (value: string) => { return value .replace(/[{}]/g, '') @@ -425,7 +454,12 @@ const normalizeGetBodyParameters = ( const normalizeResponses = (operation: SwaggerOperation) => { const responses = operation.responses ??= {} - for (const response of Object.values(responses)) { + for (const [status, response] of Object.entries(responses)) { + if (noBodyResponseStatuses.has(status)) { + response.schema = noContentSchema() + continue + } + if (!response.schema) response.schema = unknownObjectSchema() } @@ -438,7 +472,103 @@ const normalizeResponses = (operation: SwaggerOperation) => { } } -const normalizeOperations = (document: SwaggerDocument) => { +const hasProperties = (schema: SwaggerSchema) => { + return isObject(schema.properties) && Object.keys(schema.properties).length > 0 +} + +const isEmptySchemaObject = (value: unknown) => { + return isObject(value) && Object.keys(value).length === 0 +} + +const isLooseObjectSchema = (schema: SwaggerSchema) => { + if (hasProperties(schema)) + return false + + if (schema.additionalProperties === true || isEmptySchemaObject(schema.additionalProperties)) + return true + + return schema.type === 'object' && schema.additionalProperties === undefined +} + +const hasLooseSchema = ( + schema: SwaggerSchema | undefined, + definitions: Record, + visitedRefs = new Set(), +): boolean => { + if (!schema) + return true + + const ref = schema?.$ref + if (ref?.startsWith('#/definitions/')) { + const refName = ref.slice('#/definitions/'.length) + if (visitedRefs.has(refName)) + return false + + return hasLooseSchema(definitions[refName], definitions, new Set([...visitedRefs, refName])) + } + + const normalizedSchema = withoutNullableWrapper(schema) + + for (const variants of [normalizedSchema.allOf, normalizedSchema.anyOf, normalizedSchema.oneOf]) { + if (Array.isArray(variants) && variants.some(item => !isNullSchema(item) && hasLooseSchema(item, definitions, visitedRefs))) + return true + } + + if (normalizedSchema.type === 'array') + return hasLooseSchema(normalizedSchema.items, definitions, visitedRefs) + + if (isLooseObjectSchema(normalizedSchema)) + return true + + if (isObject(normalizedSchema.additionalProperties) && hasLooseSchema(normalizedSchema.additionalProperties, definitions, visitedRefs)) + return true + + return Object.values(normalizedSchema.properties ?? {}) + .some(property => hasLooseSchema(property, definitions, visitedRefs)) +} + +const hasPossiblyInaccurateGeneratedContractTypes = ( + operation: SwaggerOperation, + definitions: Record, +) => { + const successResponses = Object.entries(operation.responses ?? {}) + .filter(([status]) => /^2\d\d$/.test(status)) + + if (successResponses.length === 0) + return true + + const successResponsesWithBody = successResponses.filter(([status]) => !noBodyResponseStatuses.has(status)) + if (successResponsesWithBody.some(([, response]) => hasLooseSchema(response.schema, definitions))) + return true + + return operation.parameters?.some((parameter) => { + return parameter.in === 'body' && hasLooseSchema(parameter.schema, definitions) + }) ?? false +} + +const appendOperationDescription = (operation: SwaggerOperation, description: string) => { + const currentDescription = operation.description?.trim() + operation.description = currentDescription ? `${currentDescription}\n\n${description}` : description +} + +const markPossiblyInaccurateGeneratedContract = (operation: SwaggerOperation) => { + operation.deprecated = true + appendOperationDescription(operation, inaccurateGeneratedContractDescription) +} + +const recordApiReadiness = (surface: string, isReady: boolean) => { + const stats = apiReadinessStats[surface] ??= { + notReady: 0, + total: 0, + } + + stats.total += 1 + + if (!isReady) + stats.notReady += 1 +} + +const normalizeOperations = (document: SwaggerDocument, surface: string) => { const definitions = document.definitions ??= {} for (const [routePath, pathItem] of Object.entries(document.paths ?? {})) { @@ -450,14 +580,19 @@ const normalizeOperations = (document: SwaggerDocument) => { swaggerOperation.operationId = operationId(method, routePath) normalizeResponses(swaggerOperation) + const hasPossiblyInaccurateTypes = hasPossiblyInaccurateGeneratedContractTypes(swaggerOperation, definitions) + recordApiReadiness(surface, !hasPossiblyInaccurateTypes) if (method === 'get') normalizeGetBodyParameters(swaggerOperation, definitions) + + if (hasPossiblyInaccurateTypes) + markPossiblyInaccurateGeneratedContract(swaggerOperation) } } } -const normalizeApiSwagger = (document: SwaggerDocument) => { +const normalizeApiSwagger = (document: SwaggerDocument, surface: string) => { document.definitions ??= {} // Flask-RESTX emits Pydantic nested $defs inside individual schemas while @@ -466,11 +601,25 @@ const normalizeApiSwagger = (document: SwaggerDocument) => { ensureReferencedDefinitions(document) normalizeNullableAnyOf(document) removeNullDefaults(document) - normalizeOperations(document) + normalizeOperations(document, surface) return document } +const writeApiReadinessStats = () => { + const sortedSurfaces = Object.entries(apiReadinessStats) + .sort(([left], [right]) => left.localeCompare(right)) + + fs.mkdirSync(path.dirname(apiReadinessStatsPath), { recursive: true }) + fs.writeFileSync( + apiReadinessStatsPath, + `${JSON.stringify({ + surfaces: Object.fromEntries(sortedSurfaces), + warning: inaccurateGeneratedContractDescription, + }, null, 2)}\n`, + ) +} + const topLevelPathSegment = (routePath: string) => { return routePath.split('/').filter(Boolean)[0] ?? 'root' } @@ -520,6 +669,50 @@ const cloneDocumentWithPaths = ( } satisfies SwaggerDocument } +const consoleContractEntryContent = (segments: string[]) => { + const contracts = segments.map((segment) => { + return { + importPath: toKebabCase(segment), + name: toCamelCase(segmentWords(segment)), + } + }) + + const imports = contracts + .map(contract => `import { ${contract.name} } from './${contract.importPath}/orpc.gen'`) + .join('\n') + const contractEntries = contracts.map(contract => ` ${contract.name},`).join('\n') + + return `// This file is auto-generated by @hey-api/openapi-ts + +${imports} + +export const contract = { +${contractEntries} +} +` +} + +const writeConsoleContractEntry = (segments: string[]) => { + const entryPath = path.resolve(currentDir, 'generated/api/console/orpc.gen.ts') + fs.mkdirSync(path.dirname(entryPath), { recursive: true }) + fs.writeFileSync(entryPath, consoleContractEntryContent(segments)) +} + +const createConsoleContractEntryJob = (document: SwaggerDocument, segments: string[]): ApiJob => { + return { + clean: false, + document, + outputPath: 'generated/api/console', + plugins: [], + source: { + callback: () => writeConsoleContractEntry(segments), + enabled: true, + path: null, + serialize: () => '', + }, + } +} + const splitConsoleDocument = (document: SwaggerDocument) => { const pathsBySegment = new Map>>() @@ -530,16 +723,17 @@ const splitConsoleDocument = (document: SwaggerDocument) => { pathsBySegment.set(segment, paths) } - return [...pathsBySegment.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([segment, paths]): ApiJob => ({ - document: cloneDocumentWithPaths(document, paths), - outputPath: `generated/api/console/${toKebabCase(segment)}`, - })) + const segments = [...pathsBySegment.keys()].sort((left, right) => left.localeCompare(right)) + const jobs = segments.map((segment): ApiJob => ({ + document: cloneDocumentWithPaths(document, pathsBySegment.get(segment) ?? {}), + outputPath: `generated/api/console/${toKebabCase(segment)}`, + })) + + return [...jobs, createConsoleContractEntryJob(document, segments)] } const createApiJobs = (spec: ApiSpec): ApiJob[] => { - const document = normalizeApiSwagger(readApiSwagger(spec.filename)) + const document = normalizeApiSwagger(readApiSwagger(spec.filename), spec.name) if (spec.name === 'console') return splitConsoleDocument(document) @@ -552,29 +746,24 @@ const createApiJobs = (spec: ApiSpec): ApiJob[] => { ] } +const apiJobs = apiSpecs.flatMap(createApiJobs) +writeApiReadinessStats() + const createApiConfig = (job: ApiJob): UserConfig => ({ input: job.document, logs: { file: false, }, output: { + ...(job.clean === undefined ? {} : { clean: job.clean }), entryFile: false, fileName: { suffix: '.gen', }, path: job.outputPath, - postProcess: [ - { - args: ['fmt', '{{path}}'], - command: 'vp', - }, - { - args: ['--fix', '{{path}}/*.ts'], - command: 'eslint', - }, - ], + ...(job.source ? { source: job.source } : {}), }, - plugins: [ + plugins: job.plugins ?? [ { 'comments': false, 'name': '@hey-api/typescript', @@ -607,4 +796,4 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ ], }) -export default defineConfig(apiSpecs.flatMap(createApiJobs).map(createApiConfig)) +export default defineConfig(apiJobs.map(createApiConfig)) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 5e9af5e0f1..507a95a779 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -14,8 +14,10 @@ } }, "scripts": { - "gen-api-contract": "pnpm gen-api-openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts", + "gen-api-contract": "pnpm gen-api-openapi && pnpm gen-api-contract-from-openapi", + "gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api && pnpm gen-api-readiness-readme", "gen-api-openapi": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi", + "gen-api-readiness-readme": "node scripts/generate-api-readiness-readme.mjs && eslint --fix README.md", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", "type-check": "tsgo" }, diff --git a/packages/contracts/scripts/generate-api-readiness-readme.mjs b/packages/contracts/scripts/generate-api-readiness-readme.mjs new file mode 100644 index 0000000000..c75b944e2a --- /dev/null +++ b/packages/contracts/scripts/generate-api-readiness-readme.mjs @@ -0,0 +1,94 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const packageDir = path.resolve(currentDir, '..') +const readinessStatsPath = path.resolve(packageDir, 'generated/api/readiness.json') +const readmePath = path.resolve(packageDir, 'README.md') + +const readinessStartMarker = '' +const readinessEndMarker = '' + +const formatPercent = (ready, total) => { + return total === 0 ? '0.0%' : `${((ready / total) * 100).toFixed(1)}%` +} + +const collectStats = () => { + if (!fs.existsSync(readinessStatsPath)) { + throw new Error( + `Missing API readiness stats: ${readinessStatsPath}. Run "pnpm -C packages/contracts gen-api-contract-from-openapi" first.`, + ) + } + + return JSON.parse(fs.readFileSync(readinessStatsPath, 'utf8')) +} + +const tableRow = (surface, ready, notReady, total) => { + return `| ${surface} | ${ready} | ${notReady} | ${total} | ${formatPercent(ready, total)} |` +} + +const renderReadinessSection = (stats) => { + const rows = Object.entries(stats.surfaces) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([surface, stat]) => tableRow(surface, stat.total - stat.notReady, stat.notReady, stat.total)) + + const totals = Object.values(stats.surfaces).reduce( + (summary, stat) => { + summary.notReady += stat.notReady + summary.total += stat.total + return summary + }, + { notReady: 0, total: 0 }, + ) + const totalReady = totals.total - totals.notReady + + if (totals.total === 0) + throw new Error(`No API readiness stats found in ${readinessStatsPath}`) + + return `${readinessStartMarker} + + + +Snapshot generated from \`packages/contracts/generated/api/readiness.json\` after running \`pnpm -C packages/contracts gen-api-contract-from-openapi\`. + +Are we OpenAPI ready? **No.** Current generated API contracts are **${formatPercent(totalReady, totals.total)} ready**. + +| Surface | Ready | Not ready | Total | Ready % | +| --- | ---: | ---: | ---: | ---: | +${rows.join('\n')} +| **total** | **${totalReady}** | **${totals.notReady}** | **${totals.total}** | **${formatPercent(totalReady, totals.total)}** | + +Readiness here means the generated contract operation is not marked with: + +> ${stats.warning} + +Operations marked with that warning should not be migrated to blindly. Prefer fixing backend OpenAPI annotations first so the generated contract has accurate request and response types, then migrate callers endpoint by endpoint. + +The current heuristic marks an operation as not ready when a request body or success response that should have a body contains a loose object type, or when an operation has no documented 2xx response. 204, 205, and 304 responses are treated as bodyless when the request type is otherwise accurate. + +${readinessEndMarker} +` +} + +const updateReadme = (readinessSection) => { + const readme = fs.readFileSync(readmePath, 'utf8') + const startIndex = readme.indexOf(readinessStartMarker) + const endIndex = readme.indexOf(readinessEndMarker) + + if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { + throw new Error( + `Missing readiness markers in ${readmePath}. Expected ${readinessStartMarker} and ${readinessEndMarker}.`, + ) + } + + const nextReadme = [ + readme.slice(0, startIndex), + readinessSection, + readme.slice(endIndex + readinessEndMarker.length), + ].join('') + + fs.writeFileSync(readmePath, nextReadme) +} + +updateReadme(renderReadinessSection(collectStats())) diff --git a/packages/dev-proxy/README.md b/packages/dev-proxy/README.md new file mode 100644 index 0000000000..6b9d7298c4 --- /dev/null +++ b/packages/dev-proxy/README.md @@ -0,0 +1,196 @@ +# @langgenius/dev-proxy + +Generic Hono-based development proxy for frontend projects. The package does not ship any product-specific routes, cookie names, or environment variable conventions. Every proxied path and upstream target is declared in a local config file. + +## Installation + +```bash +pnpm add -D @langgenius/dev-proxy +``` + +Add a script in your frontend project: + +```json +{ + "scripts": { + "dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env" + } +} +``` + +Run it with: + +```bash +pnpm dev:proxy +``` + +## CLI + +```bash +dev-proxy --config ./dev-proxy.config.ts +``` + +Supported options: + +- `--config`, `-c`: config file path. Defaults to `dev-proxy.config.ts`. +- `--env-file`: load environment variables before evaluating the config file. +- `--host`: override `server.host` from config. +- `--port`: override `server.port` from config. +- `--help`, `-h`: print help. + +`--target` is not supported. Put targets in the config file so routes and upstreams stay explicit. + +## Config Shape + +```ts +import { defineDevProxyConfig } from '@langgenius/dev-proxy' + +export default defineDevProxyConfig({ + server: { + host: '127.0.0.1', + port: 5001, + }, + routes: [ + { + paths: '/api', + target: 'https://example.com', + }, + ], + cors: { + allowedOrigins: 'local', + }, +}) +``` + +Config files can be `.ts`, `.mts`, `.js`, or `.mjs`. + +`routes` are matched in declaration order. The first matching route wins. Each configured path matches both the exact path and all child paths, so `paths: '/api'` matches `/api`, `/api/apps`, and `/api/apps/123`. + +By default, credentialed CORS is allowed for local development origins such as `localhost`, `127.0.0.1`, and `::1`. To restrict it to specific origins: + +``` +cors: { + allowedOrigins: ['http://localhost:3000'], +} +``` + +## Scenario 1: Proxy One Local Route Group To An Online Backend + +Use this when a local frontend should call an online backend through one proxy server. For example, the frontend calls `http://127.0.0.1:5001/api/apps`, and the proxy forwards it to `https://cloud.example.com/api/apps`. + +```ts +import { defineDevProxyConfig } from '@langgenius/dev-proxy' + +const target = process.env.DEV_PROXY_TARGET || 'https://cloud.example.com' + +export default defineDevProxyConfig({ + server: { + host: process.env.DEV_PROXY_HOST || '127.0.0.1', + port: Number(process.env.DEV_PROXY_PORT || 5001), + }, + routes: [ + { + paths: '/api', + target, + }, + ], +}) +``` + +Optional `.env`: + +```env +DEV_PROXY_TARGET=https://cloud.example.com +DEV_PROXY_HOST=127.0.0.1 +DEV_PROXY_PORT=5001 +``` + +Command: + +```bash +dev-proxy --config ./dev-proxy.config.ts --env-file ./.env +``` + +## Scenario 2: Proxy Two Route Groups To Two Local Backends + +Use this when one frontend needs to talk to two different local services. For example: + +- `/console/api/*` goes to a local console backend at `http://127.0.0.1:5001` +- `/api/*` goes to a local public API backend at `http://127.0.0.1:5002` + +```ts +import { defineDevProxyConfig } from '@langgenius/dev-proxy' + +const consoleApiTarget = process.env.DEV_PROXY_CONSOLE_API_TARGET || 'http://127.0.0.1:5001' +const publicApiTarget = process.env.DEV_PROXY_PUBLIC_API_TARGET || 'http://127.0.0.1:5002' + +export default defineDevProxyConfig({ + server: { + host: process.env.DEV_PROXY_HOST || '127.0.0.1', + port: Number(process.env.DEV_PROXY_PORT || 8082), + }, + routes: [ + { + paths: '/console/api', + target: consoleApiTarget, + }, + { + paths: '/api', + target: publicApiTarget, + }, + ], +}) +``` + +Optional `.env`: + +```env +DEV_PROXY_CONSOLE_API_TARGET=http://127.0.0.1:5001 +DEV_PROXY_PUBLIC_API_TARGET=http://127.0.0.1:5002 +DEV_PROXY_HOST=127.0.0.1 +DEV_PROXY_PORT=8082 +``` + +When two route groups overlap, put the more specific one first: + +```ts +routes: [ + { paths: '/api/enterprise', target: 'http://127.0.0.1:5003' }, + { paths: '/api', target: 'http://127.0.0.1:5002' }, +] +``` + +## Cookie Rewrite + +Cookie rewriting is opt-in and config-driven. The package does not know any application cookie names. + +Use `cookieRewrite` when an upstream uses secure cookie prefixes such as `__Host-` or `__Secure-`, but local development needs cookies to work over `http://localhost`. + +```ts +import type { CookieRewriteOptions } from '@langgenius/dev-proxy' +import { defineDevProxyConfig } from '@langgenius/dev-proxy' + +const cookieRewrite: CookieRewriteOptions = { + hostPrefixCookies: ['access_token', 'refresh_token', /^passport-/], +} + +export default defineDevProxyConfig({ + routes: [ + { + paths: '/api', + target: 'https://cloud.example.com', + cookieRewrite, + }, + ], +}) +``` + +Set `cookieRewrite: false` to disable cookie rewriting for a route. + +## Behavior + +- The proxy preserves the matched path prefix when forwarding requests. +- Request bodies are forwarded as streams. +- Hop-by-hop headers are removed before forwarding. +- Local credentialed CORS and preflight requests are handled by the proxy. +- Route matching is explicit and order-sensitive. diff --git a/packages/dev-proxy/bin/dev-proxy.js b/packages/dev-proxy/bin/dev-proxy.js new file mode 100755 index 0000000000..02e37f3525 --- /dev/null +++ b/packages/dev-proxy/bin/dev-proxy.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import '../dist/cli.mjs' diff --git a/packages/dev-proxy/package.json b/packages/dev-proxy/package.json new file mode 100644 index 0000000000..d5524290eb --- /dev/null +++ b/packages/dev-proxy/package.json @@ -0,0 +1,43 @@ +{ + "name": "@langgenius/dev-proxy", + "type": "module", + "version": "0.0.5", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "types": "./dist/index.d.mts", + "bin": { + "dev-proxy": "./bin/dev-proxy.js" + }, + "files": [ + "bin", + "dist", + "src" + ], + "engines": { + "node": "^22.22.1" + }, + "scripts": { + "build": "vp pack", + "prepare": "pnpm run build", + "test": "vp test", + "type-check": "tsgo", + "prepublish": "pnpm run build" + }, + "dependencies": { + "@hono/node-server": "catalog:", + "c12": "catalog:", + "hono": "catalog:" + }, + "devDependencies": { + "@dify/tsconfig": "workspace:*", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/dev-proxy/src/cli.spec.ts b/packages/dev-proxy/src/cli.spec.ts new file mode 100644 index 0000000000..e8a87a0588 --- /dev/null +++ b/packages/dev-proxy/src/cli.spec.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import type { ChildProcessByStdio } from 'node:child_process' +import type { Readable } from 'node:stream' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const tempDirs: string[] = [] +type DevProxyCliProcess = ChildProcessByStdio + +const childProcesses: DevProxyCliProcess[] = [] +const binPath = fileURLToPath(new URL('../bin/dev-proxy.js', import.meta.url)) + +const createTempDir = async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-cli-test-')) + tempDirs.push(tempDir) + return tempDir +} + +const getFreePort = async () => { + const server = net.createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Failed to allocate a test port.') + + const { port } = address + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) + reject(error) + else + resolve() + }) + }) + + return port +} + +const waitForOutput = ( + child: DevProxyCliProcess, + output: () => string, + expectedOutput: string, +) => new Promise((resolve, reject) => { + let timeout: ReturnType + + function cleanup() { + clearTimeout(timeout) + child.stdout.off('data', onData) + child.stderr.off('data', onData) + child.off('exit', onExit) + } + + function onData() { + if (!output().includes(expectedOutput)) + return + + cleanup() + resolve() + } + + function onExit(code: number | null, signal: NodeJS.Signals | null) { + cleanup() + reject(new Error(`dev-proxy exited before writing "${expectedOutput}" with code ${code} and signal ${signal}. Output:\n${output()}`)) + } + + timeout = setTimeout(() => { + cleanup() + reject(new Error(`Timed out waiting for "${expectedOutput}". Output:\n${output()}`)) + }, 3000) + + child.stdout.on('data', onData) + child.stderr.on('data', onData) + child.once('exit', onExit) + onData() +}) + +const spawnCli = (args: readonly string[], cwd: string) => { + const child = spawn(process.execPath, [binPath, ...args], { + cwd, + env: { + ...process.env, + FORCE_COLOR: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + childProcesses.push(child) + return child +} + +const stopChildProcess = async (child: DevProxyCliProcess) => { + if (child.exitCode !== null || child.signalCode !== null) + return + + child.kill('SIGTERM') + await once(child, 'exit') +} + +describe('dev proxy CLI', () => { + afterEach(async () => { + await Promise.all(childProcesses.splice(0).map(stopChildProcess)) + await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, { + force: true, + recursive: true, + }))) + }) + + // Scenario: help output should still be a normal short-lived command. + it('should print help and exit', async () => { + // Arrange + const tempDir = await createTempDir() + const child = spawnCli(['--help'], tempDir) + + // Act + const [code] = await once(child, 'exit') + + // Assert + expect(code).toBe(0) + }) + + // Scenario: successful server startup should keep the CLI process alive. + it('should keep running after starting the proxy server', async () => { + // Arrange + const tempDir = await createTempDir() + const port = await getFreePort() + await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + export default { + routes: [{ paths: '/api', target: 'https://api.example.com' }], + } + `) + + let output = '' + const child = spawnCli(['--config', './dev-proxy.config.ts', '--host', '127.0.0.1', '--port', String(port)], tempDir) + child.stdout.on('data', chunk => output += chunk.toString()) + child.stderr.on('data', chunk => output += chunk.toString()) + + // Act + await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`) + await new Promise(resolve => setTimeout(resolve, 100)) + const response = await fetch(`http://127.0.0.1:${port}/not-proxied`) + + // Assert + expect(child.exitCode).toBeNull() + expect(child.signalCode).toBeNull() + expect(response.status).toBe(404) + }) +}) diff --git a/packages/dev-proxy/src/cli.ts b/packages/dev-proxy/src/cli.ts new file mode 100644 index 0000000000..05234cb359 --- /dev/null +++ b/packages/dev-proxy/src/cli.ts @@ -0,0 +1,56 @@ +import process from 'node:process' +import { serve } from '@hono/node-server' +import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config' +import { createDevProxyApp } from './server' + +function printUsage() { + console.log(`Usage: + dev-proxy --config [options] + +Options: + --config, -c Path to a dev proxy config file. Defaults to dev-proxy.config.ts. + --env-file Load environment variables before evaluating the config file. + --host Override the configured host. + --port Override the configured port. + --help, -h Show this help message.`) +} + +async function flushStandardStreams() { + await Promise.all([ + new Promise(resolve => process.stdout.write('', () => resolve())), + new Promise(resolve => process.stderr.write('', () => resolve())), + ]) +} + +async function main() { + const cliOptions = parseDevProxyCliArgs(process.argv.slice(2)) + + if (cliOptions.help) { + printUsage() + return + } + + const config = await loadDevProxyConfig(cliOptions.config, process.cwd(), { + envFile: cliOptions.envFile, + }) + const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions) + const app = createDevProxyApp(config) + + serve({ + fetch: app.fetch, + hostname: host, + port, + }) + + console.log(`[dev-proxy] listening on http://${host}:${port}`) +} + +try { + await main() + await flushStandardStreams() +} +catch (error) { + console.error(error instanceof Error ? error.message : error) + await flushStandardStreams() + process.exit(1) +} diff --git a/packages/dev-proxy/src/config.spec.ts b/packages/dev-proxy/src/config.spec.ts new file mode 100644 index 0000000000..6f681bcbae --- /dev/null +++ b/packages/dev-proxy/src/config.spec.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config' + +const tempDirs: string[] = [] + +const createTempDir = async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-test-')) + tempDirs.push(tempDir) + return tempDir +} + +describe('dev proxy config', () => { + afterEach(async () => { + delete process.env.DEV_PROXY_TEST_PORT + delete process.env.DEV_PROXY_TEST_TARGET + + await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, { + force: true, + recursive: true, + }))) + }) + + // Scenario: CLI options should support both inline and separated values. + it('should parse proxy CLI options', () => { + // Act + const options = parseDevProxyCliArgs([ + '--config=./dev-proxy.config.ts', + '--env-file', + './.env.proxy', + '--host', + '0.0.0.0', + '--port', + '8083', + ]) + + // Assert + expect(options).toEqual({ + config: './dev-proxy.config.ts', + envFile: './.env.proxy', + host: '0.0.0.0', + port: '8083', + }) + }) + + // Scenario: removed target shortcuts should fail instead of silently doing the wrong thing. + it('should reject unsupported target shortcuts', () => { + // Assert + expect(() => parseDevProxyCliArgs(['--target', 'enterprise'])).toThrow('Unsupported dev proxy option') + }) + + // Scenario: package manager argument separators should not be treated as proxy options. + it('should ignore package manager argument separators', () => { + // Act + const options = parseDevProxyCliArgs(['--config', './dev-proxy.config.ts', '--', '--help']) + + // Assert + expect(options).toEqual({ + config: './dev-proxy.config.ts', + help: true, + }) + }) + + // Scenario: CLI host and port should override config defaults. + it('should resolve server options with CLI overrides', () => { + // Act + const options = resolveDevProxyServerOptions({ + host: '127.0.0.1', + port: 5001, + }, { + host: '0.0.0.0', + port: '9002', + }) + + // Assert + expect(options).toEqual({ + host: '0.0.0.0', + port: 9002, + }) + }) + + // Scenario: TS config files should load through c12. + it('should load a TypeScript config file', async () => { + // Arrange + const tempDir = await createTempDir() + await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + export default { + server: { host: '127.0.0.1', port: 7777 }, + routes: [{ paths: ['/api', '/files'], target: 'https://api.example.com' }], + } + `) + + // Act + const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir) + + // Assert + expect(config.server).toEqual({ + host: '127.0.0.1', + port: 7777, + }) + expect(config.routes).toEqual([ + { + paths: ['/api', '/files'], + target: 'https://api.example.com', + }, + ]) + }) + + // Scenario: env files should be loaded before the TypeScript config is evaluated. + it('should load a TypeScript config file with env file values', async () => { + // Arrange + const tempDir = await createTempDir() + await fs.writeFile(path.join(tempDir, '.env.proxy'), [ + 'DEV_PROXY_TEST_PORT=7788', + 'DEV_PROXY_TEST_TARGET=https://env.example.com', + ].join('\n')) + await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), ` + export default { + server: { port: Number(process.env.DEV_PROXY_TEST_PORT) }, + routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }], + } + `) + + // Act + const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir, { + envFile: '.env.proxy', + }) + + // Assert + expect(config.server).toEqual({ + port: 7788, + }) + expect(config.routes).toEqual([ + { + paths: '/api', + target: 'https://env.example.com', + }, + ]) + }) +}) diff --git a/packages/dev-proxy/src/config.ts b/packages/dev-proxy/src/config.ts new file mode 100644 index 0000000000..b23cb0a152 --- /dev/null +++ b/packages/dev-proxy/src/config.ts @@ -0,0 +1,129 @@ +import type { DotenvOptions } from 'c12' +import type { DevProxyCliOptions, DevProxyConfig, DevProxyConfigLoadOptions, DevProxyServerConfig, ResolvedDevProxyServerOptions } from './types' +import path from 'node:path' +import { loadConfig } from 'c12' + +const DEFAULT_CONFIG_FILE = 'dev-proxy.config.ts' +const DEFAULT_PROXY_HOST = '127.0.0.1' +const DEFAULT_PROXY_PORT = 5001 + +const OPTION_NAME_TO_KEY = { + '--config': 'config', + '-c': 'config', + '--env-file': 'envFile', + '--host': 'host', + '--port': 'port', +} as const + +type OptionName = keyof typeof OPTION_NAME_TO_KEY + +const isOptionName = (value: string): value is OptionName => value in OPTION_NAME_TO_KEY + +const requireOptionValue = (name: string, value?: string) => { + if (!value || value.startsWith('-')) + throw new Error(`Missing value for ${name}.`) + + return value +} + +export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOptions => { + const options: DevProxyCliOptions = {} + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]! + + if (arg === '--') + continue + + if (arg === '--help' || arg === '-h') { + options.help = true + continue + } + + const [rawName, inlineValue] = arg.split('=', 2) + const name = rawName ?? '' + + if (!name.startsWith('-')) + continue + + if (!isOptionName(name)) + throw new Error(`Unsupported dev proxy option "${name}".`) + + const key = OPTION_NAME_TO_KEY[name] + options[key] = inlineValue ?? requireOptionValue(name, argv[index + 1]) + + if (inlineValue === undefined) + index += 1 + } + + return options +} + +const resolvePort = (rawPort: string | number) => { + const port = Number(rawPort) + if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error(`Invalid proxy port "${rawPort}". Expected an integer between 1 and 65535.`) + + return port +} + +export const resolveDevProxyServerOptions = ( + serverConfig: DevProxyServerConfig = {}, + cliOptions: DevProxyCliOptions = {}, +): ResolvedDevProxyServerOptions => { + const configuredPort = cliOptions.port ?? serverConfig.port ?? DEFAULT_PROXY_PORT + + return { + host: cliOptions.host || serverConfig.host || DEFAULT_PROXY_HOST, + port: resolvePort(configuredPort), + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +export function assertDevProxyConfig(config: unknown): asserts config is DevProxyConfig { + if (!isRecord(config)) + throw new Error('Dev proxy config must export an object.') + + if (!Array.isArray(config.routes)) + throw new Error('Dev proxy config must include a routes array.') +} + +const resolveDotenvOptions = ( + envFile: DevProxyConfigLoadOptions['envFile'], + cwd: string, +): DotenvOptions | false => { + if (!envFile) + return false + + const resolvedEnvFilePath = path.resolve(cwd, envFile) + return { + cwd: path.dirname(resolvedEnvFilePath), + fileName: path.basename(resolvedEnvFilePath), + interpolate: true, + } +} + +export const loadDevProxyConfig = async ( + configPath = DEFAULT_CONFIG_FILE, + cwd = process.cwd(), + options: DevProxyConfigLoadOptions = {}, +): Promise => { + const resolvedConfigPath = path.resolve(cwd, configPath) + const parsedPath = path.parse(resolvedConfigPath) + const { config: loadedConfig } = await loadConfig({ + configFile: parsedPath.name, + cwd: parsedPath.dir, + dotenv: resolveDotenvOptions(options.envFile, cwd), + envName: false, + globalRc: false, + packageJson: false, + rcFile: false, + }) + + assertDevProxyConfig(loadedConfig) + return loadedConfig +} + +export const defineDevProxyConfig = (config: DevProxyConfig) => config diff --git a/packages/dev-proxy/src/cookies.spec.ts b/packages/dev-proxy/src/cookies.spec.ts new file mode 100644 index 0000000000..4a1b614eeb --- /dev/null +++ b/packages/dev-proxy/src/cookies.spec.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies' + +describe('dev proxy cookies', () => { + // Scenario: cookie names should only receive secure host prefixes when configured. + it('should rewrite configured cookie names for HTTPS upstream requests', () => { + // Act + const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; theme=dark; passport-app=def', { + hostPrefixCookies: ['access_token', /^passport-/], + useHostPrefix: true, + }) + + // Assert + expect(cookieHeader).toBe('__Host-access_token=abc; theme=dark; __Host-passport-app=def') + }) + + // Scenario: HTTP upstreams should keep local cookie names even when rewrite config exists. + it('should keep local cookie names for HTTP upstream requests', () => { + // Act + const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; refresh_token=def', { + hostPrefixCookies: ['access_token', 'refresh_token'], + useHostPrefix: false, + }) + + // Assert + expect(cookieHeader).toBe('access_token=abc; refresh_token=def') + }) + + // Scenario: upstream set-cookie headers should be converted into localhost-safe cookies. + it('should rewrite upstream set-cookie headers for local development', () => { + // Act + const cookies = rewriteSetCookieHeadersForLocal([ + '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None; Partitioned', + ]) + + // Assert + expect(cookies).toEqual([ + 'access_token=abc; Path=/; SameSite=Lax', + ]) + }) +}) diff --git a/web/plugins/dev-proxy/cookies.ts b/packages/dev-proxy/src/cookies.ts similarity index 58% rename from web/plugins/dev-proxy/cookies.ts rename to packages/dev-proxy/src/cookies.ts index c606322e96..61fdb6abd4 100644 --- a/web/plugins/dev-proxy/cookies.ts +++ b/packages/dev-proxy/src/cookies.ts @@ -1,4 +1,4 @@ -const DEFAULT_PROXY_TARGET = 'https://cloud.dify.ai' +import type { CookieRewriteOptions } from './types' const SECURE_COOKIE_PREFIX_PATTERN = /^__(Host|Secure)-/ const SAME_SITE_NONE_PATTERN = /^samesite=none$/i @@ -7,39 +7,43 @@ const COOKIE_DOMAIN_PATTERN = /^domain=/i const COOKIE_SECURE_PATTERN = /^secure$/i const COOKIE_PARTITIONED_PATTERN = /^partitioned$/i -const HOST_PREFIX_COOKIE_NAMES = new Set([ - 'access_token', - 'csrf_token', - 'refresh_token', - 'webapp_access_token', -]) +const stripSecureCookiePrefix = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '') -const isPassportCookie = (cookieName: string) => cookieName.startsWith('passport-') +const matchesCookieName = (cookieName: string, matcher: string | RegExp) => + typeof matcher === 'string' + ? matcher === cookieName + : matcher.test(cookieName) -const shouldUseHostPrefix = (cookieName: string) => { - const normalizedCookieName = cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '') - return HOST_PREFIX_COOKIE_NAMES.has(normalizedCookieName) || isPassportCookie(normalizedCookieName) +const shouldUseHostPrefix = (cookieName: string, options: CookieRewriteOptions) => { + const normalizedCookieName = stripSecureCookiePrefix(cookieName) + + return options.hostPrefixCookies?.some(matcher => matchesCookieName(normalizedCookieName, matcher)) || false } -const toUpstreamCookieName = (cookieName: string) => { +const toUpstreamCookieName = (cookieName: string, options: CookieRewriteOptions) => { if (cookieName.startsWith('__Host-')) return cookieName if (cookieName.startsWith('__Secure-')) - return `__Host-${cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')}` + return `__Host-${stripSecureCookiePrefix(cookieName)}` - if (!shouldUseHostPrefix(cookieName)) + if (!shouldUseHostPrefix(cookieName, options)) return cookieName return `__Host-${cookieName}` } -const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '') +export const toLocalCookieName = (cookieName: string) => stripSecureCookiePrefix(cookieName) -export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => { +export const rewriteCookieHeaderForUpstream = ( + cookieHeader: string | undefined, + options: CookieRewriteOptions & { useHostPrefix?: boolean }, +) => { if (!cookieHeader) return cookieHeader + const { useHostPrefix = true } = options + return cookieHeader .split(/;\s*/) .filter(Boolean) @@ -50,7 +54,11 @@ export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => { const cookieName = cookie.slice(0, separatorIndex).trim() const cookieValue = cookie.slice(separatorIndex + 1) - return `${toUpstreamCookieName(cookieName)}=${cookieValue}` + const upstreamCookieName = useHostPrefix + ? toUpstreamCookieName(cookieName, options) + : cookieName + + return `${upstreamCookieName}=${cookieValue}` }) .join('; ') } @@ -84,15 +92,5 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => { return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ') } -export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | string[]): string[] | undefined => { - if (!setCookieHeaders) - return undefined - - const normalizedHeaders = Array.isArray(setCookieHeaders) - ? setCookieHeaders - : [setCookieHeaders] - - return normalizedHeaders.map(rewriteSetCookieValueForLocal) -} - -export { DEFAULT_PROXY_TARGET } +export const rewriteSetCookieHeadersForLocal = (setCookieHeaders: readonly string[]) => + setCookieHeaders.map(rewriteSetCookieValueForLocal) diff --git a/packages/dev-proxy/src/index.ts b/packages/dev-proxy/src/index.ts new file mode 100644 index 0000000000..e35893b98f --- /dev/null +++ b/packages/dev-proxy/src/index.ts @@ -0,0 +1,22 @@ +export { + assertDevProxyConfig, + defineDevProxyConfig, + loadDevProxyConfig, + parseDevProxyCliArgs, + resolveDevProxyServerOptions, +} from './config' +export { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal, toLocalCookieName } from './cookies' +export { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, isAllowedLocalDevOrigin } from './server' +export type { + CookieNameMatcher, + CookieRewriteOptions, + CreateDevProxyAppOptions, + DevProxyCliOptions, + DevProxyConfig, + DevProxyConfigLoadOptions, + DevProxyCorsAllowedOrigins, + DevProxyCorsConfig, + DevProxyRoute, + DevProxyServerConfig, + ResolvedDevProxyServerOptions, +} from './types' diff --git a/packages/dev-proxy/src/server.spec.ts b/packages/dev-proxy/src/server.spec.ts new file mode 100644 index 0000000000..32c16a1807 --- /dev/null +++ b/packages/dev-proxy/src/server.spec.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin } from './server' + +describe('dev proxy server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Scenario: target paths should not be duplicated when the incoming route already includes them. + it('should preserve prefixed targets when building upstream URLs', () => { + // Act + const url = buildUpstreamUrl('https://api.example.com/console/api', '/console/api/apps', '?page=1') + + // Assert + expect(url.href).toBe('https://api.example.com/console/api/apps?page=1') + }) + + // Scenario: only localhost dev origins should be reflected for credentialed CORS by default. + it('should only allow local development origins by default', () => { + // Assert + expect(isAllowedDevOrigin('http://localhost:3000')).toBe(true) + expect(isAllowedDevOrigin('http://127.0.0.1:3000')).toBe(true) + expect(isAllowedDevOrigin('https://example.com')).toBe(false) + }) + + // Scenario: explicit CORS origins should support non-local development hosts. + it('should allow explicitly configured origins', () => { + // Assert + expect(isAllowedDevOrigin('https://app.example.com', ['https://app.example.com'])).toBe(true) + expect(isAllowedDevOrigin('https://other.example.com', ['https://app.example.com'])).toBe(false) + }) + + // Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers when configured. + it('should proxy api requests with configured local cookie rewriting', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok', { + status: 200, + headers: [ + ['content-encoding', 'br'], + ['content-length', '123'], + ['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None'], + ['transfer-encoding', 'chunked'], + ], + })) + const app = createDevProxyApp({ + routes: [ + { + paths: '/console/api', + target: 'https://cloud.example.com', + cookieRewrite: { + hostPrefixCookies: ['access_token'], + }, + }, + ], + fetchImpl, + }) + + // Act + const response = await app.request('http://127.0.0.1:5001/console/api/apps?page=1', { + headers: { + 'Origin': 'http://localhost:3000', + 'Cookie': 'access_token=abc; theme=dark', + 'Accept-Encoding': 'zstd, br, gzip', + }, + }) + + // Assert + expect(fetchImpl).toHaveBeenCalledTimes(1) + expect(fetchImpl).toHaveBeenCalledWith( + new URL('https://cloud.example.com/console/api/apps?page=1'), + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ) + + const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers + if (!(requestHeaders instanceof Headers)) + throw new Error('Expected proxy request headers to be Headers') + + expect(requestHeaders.get('cookie')).toBe('__Host-access_token=abc; theme=dark') + expect(requestHeaders.get('origin')).toBe('https://cloud.example.com') + expect(requestHeaders.get('accept-encoding')).toBe('identity') + expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000') + expect(response.headers.get('access-control-allow-credentials')).toBe('true') + expect(response.headers.get('content-encoding')).toBeNull() + expect(response.headers.get('content-length')).toBeNull() + expect(response.headers.get('transfer-encoding')).toBeNull() + expect(response.headers.getSetCookie()).toEqual([ + 'access_token=abc; Path=/; SameSite=Lax', + ]) + }) + + // Scenario: generic proxy routes should not know Dify cookie names by default. + it('should not rewrite cookie names when cookie rewriting is not configured', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')) + const app = createDevProxyApp({ + routes: [ + { + paths: '/api', + target: 'https://api.example.com', + }, + ], + fetchImpl, + }) + + // Act + await app.request('http://127.0.0.1:5001/api/messages', { + headers: { + Cookie: 'access_token=abc; refresh_token=def', + }, + }) + + // Assert + const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers + if (!(requestHeaders instanceof Headers)) + throw new Error('Expected proxy request headers to be Headers') + + expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def') + }) + + // Scenario: local HTTP upstreams expect local cookie names even when cookie rewriting is configured. + it('should keep local cookie names for HTTP upstream targets', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')) + const app = createDevProxyApp({ + routes: [ + { + paths: '/console/api', + target: 'http://127.0.0.1:5001', + cookieRewrite: { + hostPrefixCookies: ['access_token', 'refresh_token'], + }, + }, + ], + fetchImpl, + }) + + // Act + await app.request('http://127.0.0.1:5010/console/api/account/profile', { + headers: { + Cookie: 'access_token=abc; refresh_token=def', + }, + }) + + // Assert + const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers + if (!(requestHeaders instanceof Headers)) + throw new Error('Expected proxy request headers to be Headers') + + expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def') + }) + + // Scenario: custom route paths should support independent upstream targets. + it('should proxy custom route paths to their configured targets', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')) + const app = createDevProxyApp({ + routes: [ + { + paths: '/api', + target: 'https://api.example.com', + }, + { + paths: '/files', + target: 'https://files.example.com/assets', + }, + ], + fetchImpl, + }) + + // Act + await app.request('http://127.0.0.1:5001/api/messages') + await app.request('http://127.0.0.1:5001/files/logo.png?size=small') + + // Assert + expect(fetchImpl.mock.calls.map(([url]) => url.toString())).toEqual([ + 'https://api.example.com/api/messages', + 'https://files.example.com/assets/files/logo.png?size=small', + ]) + }) + + // Scenario: routes are matched in config order so callers can put specific routes first. + it('should prefer earlier route entries', async () => { + // Arrange + const fetchImpl = vi.fn().mockResolvedValue(new Response('ok')) + const app = createDevProxyApp({ + routes: [ + { + paths: '/api/enterprise', + target: 'https://enterprise.example.com', + }, + { + paths: '/api', + target: 'https://api.example.com', + }, + ], + fetchImpl, + }) + + // Act + await app.request('http://127.0.0.1:5001/api/enterprise/sso/login') + + // Assert + expect(fetchImpl.mock.calls.map(([url]) => url.toString())).toEqual([ + 'https://enterprise.example.com/api/enterprise/sso/login', + ]) + }) + + // Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls. + it('should answer CORS preflight requests', async () => { + // Arrange + const app = createDevProxyApp({ + routes: [ + { + paths: '/api', + target: 'https://api.example.com', + }, + ], + fetchImpl: vi.fn(), + }) + + // Act + const response = await app.request('http://127.0.0.1:5001/api/messages', { + method: 'OPTIONS', + headers: { + 'Origin': 'http://localhost:3000', + 'Access-Control-Request-Headers': 'authorization,content-type,x-csrf-token', + }, + }) + + // Assert + expect(response.status).toBe(204) + expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000') + expect(response.headers.get('access-control-allow-credentials')).toBe('true') + expect(response.headers.get('access-control-allow-headers')).toBe('authorization,content-type,x-csrf-token') + }) +}) diff --git a/packages/dev-proxy/src/server.ts b/packages/dev-proxy/src/server.ts new file mode 100644 index 0000000000..79654750da --- /dev/null +++ b/packages/dev-proxy/src/server.ts @@ -0,0 +1,254 @@ +import type { Context, Hono } from 'hono' +import type { CookieRewriteOptions, CreateDevProxyAppOptions, DevProxyCorsAllowedOrigins, DevProxyRoute } from './types' +import { Hono as HonoApp } from 'hono' +import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies' + +const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) +const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS' +const DEFAULT_ALLOW_HEADERS = 'Authorization, Content-Type, X-CSRF-Token' +const UPSTREAM_ACCEPT_ENCODING = 'identity' +const RESPONSE_HEADERS_TO_DROP = [ + 'connection', + 'content-encoding', + 'content-length', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +] as const + +const appendHeaderValue = (headers: Headers, name: string, value: string) => { + const currentValue = headers.get(name) + if (!currentValue) { + headers.set(name, value) + return + } + + if (currentValue.split(',').map(item => item.trim()).includes(value)) + return + + headers.set(name, `${currentValue}, ${value}`) +} + +export const isAllowedLocalDevOrigin = (origin?: string | null) => { + if (!origin) + return false + + try { + const url = new URL(origin) + return LOCAL_DEV_HOSTS.has(url.hostname) + } + catch { + return false + } +} + +export const isAllowedDevOrigin = ( + origin?: string | null, + allowedOrigins: DevProxyCorsAllowedOrigins = 'local', +) => { + if (!origin) + return false + + if (allowedOrigins === 'local') + return isAllowedLocalDevOrigin(origin) + + return allowedOrigins.includes(origin) +} + +const applyCorsHeaders = ( + headers: Headers, + origin: string | undefined | null, + allowedOrigins: DevProxyCorsAllowedOrigins = 'local', +) => { + if (!isAllowedDevOrigin(origin, allowedOrigins)) + return + + headers.set('Access-Control-Allow-Origin', origin!) + headers.set('Access-Control-Allow-Credentials', 'true') + appendHeaderValue(headers, 'Vary', 'Origin') +} + +export const buildUpstreamUrl = (target: string, requestPath: string, search = '') => { + const targetUrl = new URL(target) + const normalizedTargetPath = targetUrl.pathname === '/' ? '' : targetUrl.pathname.replace(/\/$/, '') + const normalizedRequestPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}` + const hasTargetPrefix = normalizedTargetPath + && (normalizedRequestPath === normalizedTargetPath || normalizedRequestPath.startsWith(`${normalizedTargetPath}/`)) + + targetUrl.pathname = hasTargetPrefix + ? normalizedRequestPath + : `${normalizedTargetPath}${normalizedRequestPath}` + targetUrl.search = search + + return targetUrl +} + +const createProxyRequestHeaders = ( + request: Request, + targetUrl: URL, + cookieRewrite: CookieRewriteOptions | false | undefined, +) => { + const headers = new Headers(request.headers) + headers.delete('host') + headers.set('accept-encoding', UPSTREAM_ACCEPT_ENCODING) + + if (headers.has('origin')) + headers.set('origin', targetUrl.origin) + + if (cookieRewrite) { + const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, { + ...cookieRewrite, + useHostPrefix: targetUrl.protocol === 'https:', + }) + if (rewrittenCookieHeader) + headers.set('cookie', rewrittenCookieHeader) + } + + return headers +} + +const getSetCookieHeaders = (headers: Headers) => { + const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] } + const setCookieHeaders = headersWithGetSetCookie.getSetCookie?.() + if (setCookieHeaders?.length) + return setCookieHeaders + + const setCookie = headers.get('set-cookie') + return setCookie ? [setCookie] : [] +} + +const createUpstreamResponseHeaders = ( + response: Response, + requestOrigin: string | undefined | null, + allowedOrigins: DevProxyCorsAllowedOrigins, + cookieRewrite: CookieRewriteOptions | false | undefined, +) => { + const headers = new Headers(response.headers) + RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header)) + headers.delete('set-cookie') + + const setCookieHeaders = getSetCookieHeaders(response.headers) + const responseSetCookieHeaders = cookieRewrite + ? rewriteSetCookieHeadersForLocal(setCookieHeaders) + : setCookieHeaders + + responseSetCookieHeaders.forEach((cookie) => { + headers.append('set-cookie', cookie) + }) + + applyCorsHeaders(headers, requestOrigin, allowedOrigins) + return headers +} + +const proxyRequest = async ( + context: Context, + route: DevProxyRoute, + fetchImpl: typeof globalThis.fetch, + allowedOrigins: DevProxyCorsAllowedOrigins, +) => { + const requestUrl = new URL(context.req.url) + const targetUrl = buildUpstreamUrl(route.target, requestUrl.pathname, requestUrl.search) + const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl, route.cookieRewrite) + const requestInit: RequestInit & { duplex?: 'half' } = { + method: context.req.method, + headers: requestHeaders, + redirect: 'manual', + } + + if (context.req.method !== 'GET' && context.req.method !== 'HEAD') { + requestInit.body = context.req.raw.body + requestInit.duplex = 'half' + } + + const upstreamResponse = await fetchImpl(targetUrl, requestInit) + const responseHeaders = createUpstreamResponseHeaders( + upstreamResponse, + context.req.header('origin'), + allowedOrigins, + route.cookieRewrite, + ) + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: responseHeaders, + }) +} + +const normalizeRoutePaths = (paths: DevProxyRoute['paths']) => Array.isArray(paths) ? paths : [paths] + +const registerProxyRoute = ( + app: Hono, + route: DevProxyRoute, + path: string, + fetchImpl: typeof globalThis.fetch, + allowedOrigins: DevProxyCorsAllowedOrigins, +) => { + if (!path.startsWith('/')) + throw new Error(`Invalid dev proxy route path "${path}". Paths must start with "/".`) + + app.all(path, context => proxyRequest(context, route, fetchImpl, allowedOrigins)) + app.all(`${path}/*`, context => proxyRequest(context, route, fetchImpl, allowedOrigins)) +} + +const registerProxyRoutes = ( + app: Hono, + routes: readonly DevProxyRoute[], + fetchImpl: typeof globalThis.fetch, + allowedOrigins: DevProxyCorsAllowedOrigins, +) => { + routes.forEach((route) => { + normalizeRoutePaths(route.paths).forEach((path) => { + registerProxyRoute(app, route, path, fetchImpl, allowedOrigins) + }) + }) +} + +export const createDevProxyApp = (options: CreateDevProxyAppOptions) => { + const app = new HonoApp() + const fetchImpl = options.fetchImpl || globalThis.fetch + const logger = options.logger || console + const allowedOrigins = options.cors?.allowedOrigins || 'local' + + app.onError((error, context) => { + logger.error('[dev-proxy]', error) + + const headers = new Headers() + applyCorsHeaders(headers, context.req.header('origin'), allowedOrigins) + + return new Response('Upstream proxy request failed.', { + status: 502, + headers, + }) + }) + + app.use('*', async (context, next) => { + if (context.req.method === 'OPTIONS') { + const headers = new Headers() + applyCorsHeaders(headers, context.req.header('origin'), allowedOrigins) + headers.set('Access-Control-Allow-Methods', ALLOW_METHODS) + headers.set( + 'Access-Control-Allow-Headers', + context.req.header('Access-Control-Request-Headers') || DEFAULT_ALLOW_HEADERS, + ) + if (context.req.header('Access-Control-Request-Private-Network') === 'true') + headers.set('Access-Control-Allow-Private-Network', 'true') + + return new Response(null, { + status: 204, + headers, + }) + } + + await next() + applyCorsHeaders(context.res.headers, context.req.header('origin'), allowedOrigins) + }) + + registerProxyRoutes(app, options.routes, fetchImpl, allowedOrigins) + + return app +} diff --git a/packages/dev-proxy/src/types.ts b/packages/dev-proxy/src/types.ts new file mode 100644 index 0000000000..2c42b2f7fb --- /dev/null +++ b/packages/dev-proxy/src/types.ts @@ -0,0 +1,50 @@ +export type DevProxyServerConfig = { + host?: string + port?: number +} + +export type DevProxyCorsAllowedOrigins = 'local' | readonly string[] + +export type DevProxyCorsConfig = { + allowedOrigins?: DevProxyCorsAllowedOrigins +} + +export type CookieNameMatcher = string | RegExp + +export type CookieRewriteOptions = { + hostPrefixCookies?: readonly CookieNameMatcher[] +} + +export type DevProxyRoute = { + paths: string | readonly string[] + target: string + cookieRewrite?: CookieRewriteOptions | false +} + +export type DevProxyConfig = { + server?: DevProxyServerConfig + routes: readonly DevProxyRoute[] + cors?: DevProxyCorsConfig +} + +export type DevProxyCliOptions = { + config?: string + envFile?: string + host?: string + port?: string + help?: boolean +} + +export type DevProxyConfigLoadOptions = { + envFile?: string | false +} + +export type ResolvedDevProxyServerOptions = { + host: string + port: number +} + +export type CreateDevProxyAppOptions = Pick & { + fetchImpl?: typeof globalThis.fetch + logger?: Pick +} diff --git a/packages/dev-proxy/tsconfig.json b/packages/dev-proxy/tsconfig.json new file mode 100644 index 0000000000..813a9bd8a3 --- /dev/null +++ b/packages/dev-proxy/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@dify/tsconfig/node.json", + "compilerOptions": { + "types": [ + "node", + "vitest/globals" + ] + }, + "include": [ + "src/**/*.ts", + "vite.config.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/dev-proxy/vite.config.ts b/packages/dev-proxy/vite.config.ts new file mode 100644 index 0000000000..d060ae036e --- /dev/null +++ b/packages/dev-proxy/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite-plus' + +export default defineConfig({ + pack: { + clean: true, + deps: { + neverBundle: [ + '@hono/node-server', + 'c12', + 'hono', + ], + }, + entry: [ + 'src/index.ts', + 'src/cli.ts', + ], + format: ['esm'], + outDir: 'dist', + platform: 'node', + sourcemap: true, + target: 'node22', + treeshake: true, + }, + test: { + environment: 'node', + }, +}) diff --git a/packages/dify-ui/.storybook/storybook.css b/packages/dify-ui/.storybook/storybook.css index e9796fd046..ca76cd2968 100644 --- a/packages/dify-ui/.storybook/storybook.css +++ b/packages/dify-ui/.storybook/storybook.css @@ -1,6 +1,9 @@ @import 'tailwindcss'; -@config '../tailwind.config.ts'; +@plugin '../src/plugins/icons.ts'; + +@source '../src'; +@source '../.storybook'; @import '../src/styles/styles.css'; diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 4a7fe2f22a..6eadd200f0 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -1,6 +1,6 @@ # @langgenius/dify-ui -Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless primitive components consumed by `web/`. +Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headless primitive components consumed by `web/`. ## Component Authoring Rules @@ -9,6 +9,7 @@ Shared design tokens, the `cn()` utility, a Tailwind CSS preset, and headless pr - No imports from `web/`. No dependencies on next / i18next / ky / jotai / zustand. - One component per folder: `src//index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./` subpath to `package.json#exports`. - Props pattern: `Omit & VariantProps & { /* custom */ }`. +- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props. - When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath. ## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover @@ -51,9 +52,33 @@ The Figma design system uses `--radius/*` tokens whose scale is **offset by one ### Rules -- **Do not** add custom `borderRadius` values to `tailwind-preset.ts`. We use Tailwind v4 defaults and arbitrary values (`rounded-[Npx]`) for sizes without a standard equivalent. +- **Do not** add custom `borderRadius` theme values. We use Tailwind v4 defaults and arbitrary values (`rounded-[Npx]`) for sizes without a standard equivalent. - **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed. - When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`). - For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`. +## Search / Picker Primitive Selection: Autocomplete vs Combobox vs Select + +Pick by whether the user is entering free-form text, choosing a remembered value, or selecting from a closed list. + +Base UI decision rules: + +- [Autocomplete docs]: use `Combobox` instead of `Autocomplete` if the selection should be remembered and the input value cannot be custom. +- [Combobox docs]: do not use `Combobox` for simple search widgets that require unrestricted text entry; use `Autocomplete` instead. + +Apply this split in Dify UI: + +- `Autocomplete` — free-form text input with optional suggestions or completions. The input value may be custom and does not necessarily become a selected option. Use for search boxes, command-style suggestions, tag suggestions, and async text completion. +- `Combobox` — searchable picker whose value is one or more selected items from a collection. The chosen value is remembered by the root, and free-form text is not the final value. Use for model pickers, user pickers, dataset/document pickers, and multi-select chips. +- `Select` — closed-list picker without text entry. Use when the option set is small or already scannable and filtering is unnecessary. + +Composition rules: + +- Keep Base UI primitive semantics visible in the public API. Export compound parts such as `ComboboxInputGroup`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, and `ComboboxItemIndicator` instead of wrapping them into one business component. +- For `Combobox` multiple selection, follow the official chips pattern: `ComboboxInputGroup` contains `ComboboxChips`, `ComboboxValue` renders `ComboboxChip` items, and `ComboboxInput` remains inside the chips row. Chips should wrap and let the input group grow vertically instead of forcing horizontal overflow. +- Content primitives must own their Base UI `Portal` and use `z-50` on `Positioner`, matching the overlay contract in `README.md`. Toast owns `z-60`. +- Use `w-(--anchor-width)` with viewport-aware max-width for `Autocomplete` and `Combobox` popups. Do not add `min-w-(--anchor-width)` when it would defeat available-width clamping. + +[Autocomplete docs]: https://base-ui.com/react/components/autocomplete.md#usage-guidelines +[Combobox docs]: https://base-ui.com/react/components/combobox.md#usage-guidelines [docs]: https://base-ui.com/react/components/tooltip#infotips diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index cd9485c400..010fb3e56d 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -1,6 +1,6 @@ # @langgenius/dify-ui -Shared UI primitives, design tokens, Tailwind preset, and the `cn()` utility consumed by Dify's `web/` app. +Shared UI primitives, design tokens, CSS-first Tailwind styles, and the `cn()` utility consumed by Dify's `web/` app. The primitives are thin, opinionated wrappers around [Base UI] headless components, styled with `cva` + `cn` and Dify design tokens. @@ -28,6 +28,7 @@ Always import from a **subpath export** — there is no barrel: import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import '@langgenius/dify-ui/styles.css' // once, in the app root ``` @@ -36,22 +37,36 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported ## Primitives -| Category | Subpath | Notes | -| -------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | -| Overlay | `./alert-dialog`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | -| Form | `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | -| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | -| Media | `./avatar`, `./button` | Button exposes `cva` variants. | +| Category | Subpath | Notes | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. | +| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. | +| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | +| Media | `./avatar`, `./button` | Button exposes `cva` variants. | Utilities: - `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition. -- `./tailwind-preset` — Tailwind v4 preset with Dify tokens. Apps extend it from their own `tailwind.config.ts`. -- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and base reset. Import it once from the app root. +- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root. + +## Tailwind CSS v4 integration + +This package uses Tailwind CSS v4's CSS-first configuration model. Consumers should import Tailwind from their own root stylesheet, then import this package's CSS entry: + +```css +@import 'tailwindcss'; +@import '@langgenius/dify-ui/styles.css'; +``` + +If a consumer uses Dify UI source files through the workspace, add an explicit source so Tailwind can detect utility classes: + +```css +@source '../packages/dify-ui/src'; +``` ## Overlay & portal contract -All overlay primitives (`dialog`, `alert-dialog`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually. +Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure. ### Root isolation requirement @@ -69,21 +84,28 @@ Equivalent: any root element with `isolation: isolate` in CSS. Without it, overl Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites. -| Layer | z-index | Where | -| ----------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- | -| Overlays (Dialog, AlertDialog, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop | -| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. | +| Layer | z-index | Where | +| ------------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------- | +| Overlays (Dialog, AlertDialog, Autocomplete, Combobox, Drawer, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-50` | Positioner / Backdrop | +| Toast viewport | `z-60` | One layer above overlays so notifications are never hidden under a dialog. | -Rationale: during Dify's migration from legacy `portal-to-follow-elem` / `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins. +Rationale: Dify UI owns the normal application overlay layer. Overlay primitives share `z-50` and **rely on DOM order** for stacking — the portal mounted later wins. Toast owns `z-60` so notifications remain visible above dialogs, popovers, and other portalled surfaces without falling back to `z-9999`. -See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history and the remaining legacy allowlist. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`. +See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay best practices. ### Rules -- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated. -- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal. +- Never add ad hoc `z-*` overrides on primitives from this package. If something is getting clipped, fix the parent overlay structure instead of raising the child primitive. +- Never create an extra manual portal on top of our primitives — use the exported content / portal parts such as `DialogContent`, `PopoverContent`, and `DrawerPortal`. Base UI handles focus management, scroll-locking, and dismissal. - When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites. +### Tooltip, infotip, and popover semantics + +- Use `Tooltip` only for short, non-interactive visual labels. The trigger must already have visible text or an `aria-label`; the tooltip is not the accessible name and must not contain links, buttons, forms, or structured prose. +- Use `Popover` for explanatory content, long text, rich layout, or anything users may need to reach on touch or with assistive technology. In `web/`, the `Infotip` wrapper is the preferred pattern for a `?` help glyph backed by `Popover`. +- Pick a `placement` and let the primitive own spacing. Avoid per-call-site offsets unless the component API explicitly needs a measured layout exception. +- When passing a Base UI trigger `render` prop, render a real `