diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md deleted file mode 100644 index a7cae67e8f9..00000000000 --- a/.agents/skills/component-refactoring/SKILL.md +++ /dev/null @@ -1,440 +0,0 @@ ---- -name: component-refactoring -description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring. ---- - -# Dify Component Refactoring Skill - -Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below. - -> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing. - -## Quick Reference - -### Commands (run from `web/`) - -Use paths relative to `web/` (e.g., `app/components/...`). -Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics. - -```bash -cd web - -# Generate refactoring prompt -pnpm refactor-component - -# Output refactoring analysis as JSON -pnpm refactor-component --json - -# Generate testing prompt (after refactoring) -pnpm analyze-component - -# Output testing analysis as JSON -pnpm analyze-component --json -``` - -### Complexity Analysis - -```bash -# Analyze component complexity -pnpm analyze-component --json - -# Key metrics to check: -# - complexity: normalized score 0-100 (target < 50) -# - maxComplexity: highest single function complexity -# - lineCount: total lines (target < 300) -``` - -### Complexity Score Interpretation - -| Score | Level | Action | -|-------|-------|--------| -| 0-25 | 🟢 Simple | Ready for testing | -| 26-50 | 🟡 Medium | Consider minor refactoring | -| 51-75 | 🟠 Complex | **Refactor before testing** | -| 76-100 | 🔴 Very Complex | **Must refactor** | - -## Core Refactoring Patterns - -### Pattern 1: Extract Custom Hooks - -**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI. - -**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-.ts`. - -```typescript -// ❌ Before: Complex state logic in component -function Configuration() { - const [modelConfig, setModelConfig] = useState(...) - const [datasetConfigs, setDatasetConfigs] = useState(...) - const [completionParams, setCompletionParams] = useState({}) - - // 50+ lines of state management logic... - - return
...
-} - -// ✅ After: Extract to custom hook -// hooks/use-model-config.ts -export const useModelConfig = (appId: string) => { - const [modelConfig, setModelConfig] = useState(...) - const [completionParams, setCompletionParams] = useState({}) - - // Related state management logic here - - return { modelConfig, setModelConfig, completionParams, setCompletionParams } -} - -// Component becomes cleaner -function Configuration() { - const { modelConfig, setModelConfig } = useModelConfig(appId) - return
...
-} -``` - -**Dify Examples**: -- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts` -- `web/app/components/app/configuration/debug/hooks.tsx` -- `web/app/components/workflow/hooks/use-workflow.ts` - -### Pattern 2: Extract Sub-Components - -**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns. - -**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory. - -```typescript -// ❌ Before: Monolithic JSX with multiple sections -const AppInfo = () => { - return ( -
- {/* 100 lines of header UI */} - {/* 100 lines of operations UI */} - {/* 100 lines of modals */} -
- ) -} - -// ✅ After: Split into focused components -// app-info/ -// ├── index.tsx (orchestration only) -// ├── app-header.tsx (header UI) -// ├── app-operations.tsx (operations UI) -// └── app-modals.tsx (modal management) - -const AppInfo = () => { - const { showModal, setShowModal } = useAppInfoModals() - - return ( -
- - - setShowModal(null)} /> -
- ) -} -``` - -**Dify Examples**: -- `web/app/components/app/configuration/` directory structure -- `web/app/components/workflow/nodes/` per-node organization - -### Pattern 3: Simplify Conditional Logic - -**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains. - -```typescript -// ❌ Before: Deeply nested conditionals -const Template = useMemo(() => { - if (appDetail?.mode === AppModeEnum.CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - // Another 15 lines... - } - // More conditions... -}, [appDetail, locale]) - -// ✅ After: Use lookup tables + early returns -const TEMPLATE_MAP = { - [AppModeEnum.CHAT]: { - [LanguagesSupported[1]]: TemplateChatZh, - [LanguagesSupported[7]]: TemplateChatJa, - default: TemplateChatEn, - }, - [AppModeEnum.ADVANCED_CHAT]: { - [LanguagesSupported[1]]: TemplateAdvancedChatZh, - // ... - }, -} - -const Template = useMemo(() => { - const modeTemplates = TEMPLATE_MAP[appDetail?.mode] - if (!modeTemplates) return null - - const TemplateComponent = modeTemplates[locale] || modeTemplates.default - return -}, [appDetail, locale]) -``` - -### Pattern 4: Extract API/Data Logic - -**When**: Component directly handles API calls, data transformation, or complex async operations. - -**Dify Convention**: -- This skill is for component decomposition, not query/mutation design. -- 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. - -**Dify Examples**: -- `web/service/use-workflow.ts` -- `web/service/use-common.ts` -- `web/service/knowledge/use-dataset.ts` -- `web/service/knowledge/use-document.ts` - -### Pattern 5: Extract Modal/Dialog Management - -**When**: Component manages multiple modals with complex open/close states. - -**Dify Convention**: Modals should be extracted with their state management. - -```typescript -// ❌ Before: Multiple modal states in component -const AppInfo = () => { - const [showEditModal, setShowEditModal] = useState(false) - const [showDuplicateModal, setShowDuplicateModal] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [showSwitchModal, setShowSwitchModal] = useState(false) - const [showImportDSLModal, setShowImportDSLModal] = useState(false) - // 5+ more modal states... -} - -// ✅ After: Extract to modal management hook -type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null - -const useAppInfoModals = () => { - const [activeModal, setActiveModal] = useState(null) - - const openModal = useCallback((type: ModalType) => setActiveModal(type), []) - const closeModal = useCallback(() => setActiveModal(null), []) - - return { - activeModal, - openModal, - closeModal, - isOpen: (type: ModalType) => activeModal === type, - } -} -``` - -### Pattern 6: Extract Form Logic - -**When**: Complex form validation, submission handling, or field transformation. - -**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`. - -```typescript -// ✅ Use existing form infrastructure -import { useAppForm } from '@/app/components/base/form' - -const ConfigForm = () => { - const form = useAppForm({ - defaultValues: { name: '', description: '' }, - onSubmit: handleSubmit, - }) - - return ... -} -``` - -## Dify-Specific Refactoring Guidelines - -### 1. Context Provider Extraction - -**When**: Component provides complex context values with multiple states. - -```typescript -// ❌ Before: Large context value object -const value = { - appId, isAPIKeySet, isTrailFinished, mode, modelModeType, - promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall, - // 50+ more properties... -} -return ... - -// ✅ After: Split into domain-specific contexts - - - - {children} - - - -``` - -**Dify Reference**: `web/context/` directory structure - -### 2. Workflow Node Components - -**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`). - -**Conventions**: -- Keep node logic in `use-interactions.ts` -- Extract panel UI to separate files -- Use `_base` components for common patterns - -``` -nodes// - ├── index.tsx # Node registration - ├── node.tsx # Node visual component - ├── panel.tsx # Configuration panel - ├── use-interactions.ts # Node-specific hooks - └── types.ts # Type definitions -``` - -### 3. Configuration Components - -**When**: Refactoring app configuration components. - -**Conventions**: -- Separate config sections into subdirectories -- Use existing patterns from `web/app/components/app/configuration/` -- Keep feature toggles in dedicated components - -### 4. Tool/Plugin Components - -**When**: Refactoring tool-related components (`web/app/components/tools/`). - -**Conventions**: -- Follow existing modal patterns -- Use service hooks from `web/service/use-tools.ts` -- Keep provider-specific logic isolated - -## Refactoring Workflow - -### Step 1: Generate Refactoring Prompt - -```bash -pnpm refactor-component -``` - -This command will: -- Analyze component complexity and features -- Identify specific refactoring actions needed -- Generate a prompt for AI assistant (auto-copied to clipboard on macOS) -- Provide detailed requirements based on detected patterns - -### Step 2: Analyze Details - -```bash -pnpm analyze-component --json -``` - -Identify: -- Total complexity score -- Max function complexity -- Line count -- Features detected (state, effects, API, etc.) - -### Step 3: Plan - -Create a refactoring plan based on detected features: - -| Detected Feature | Refactoring Action | -|------------------|-------------------| -| `hasState: true` + `hasEffects: true` | Extract custom hook | -| `hasAPI: true` | Extract data/service hook | -| `hasEvents: true` (many) | Extract event handlers | -| `lineCount > 300` | Split into sub-components | -| `maxComplexity > 50` | Simplify conditional logic | - -### Step 4: Execute Incrementally - -1. **Extract one piece at a time** -2. **Run lint, type-check, and tests after each extraction** -3. **Verify functionality before next step** - -``` -For each extraction: - ┌────────────────────────────────────────┐ - │ 1. Extract code │ - │ 2. Run: pnpm lint:fix │ - │ 3. Run: pnpm type-check │ - │ 4. Run: pnpm test │ - │ 5. Test functionality manually │ - │ 6. PASS? → Next extraction │ - │ FAIL? → Fix before continuing │ - └────────────────────────────────────────┘ -``` - -### Step 5: Verify - -After refactoring: - -```bash -# Re-run refactor command to verify improvements -pnpm refactor-component - -# If complexity < 25 and lines < 200, you'll see: -# ✅ COMPONENT IS WELL-STRUCTURED - -# For detailed metrics: -pnpm analyze-component --json - -# Target metrics: -# - complexity < 50 -# - lineCount < 300 -# - maxComplexity < 30 -``` - -## Common Mistakes to Avoid - -### ❌ Over-Engineering - -```typescript -// ❌ Too many tiny hooks -const useButtonText = () => useState('Click') -const useButtonDisabled = () => useState(false) -const useButtonLoading = () => useState(false) - -// ✅ Cohesive hook with related state -const useButtonState = () => { - const [text, setText] = useState('Click') - const [disabled, setDisabled] = useState(false) - const [loading, setLoading] = useState(false) - return { text, setText, disabled, setDisabled, loading, setLoading } -} -``` - -### ❌ Breaking Existing Patterns - -- Follow existing directory structures -- Maintain naming conventions -- Preserve export patterns for compatibility - -### ❌ Premature Abstraction - -- Only extract when there's clear complexity benefit -- Don't create abstractions for single-use code -- Keep refactored code in the same domain area - -## References - -### Dify Codebase Examples - -- **Hook extraction**: `web/app/components/app/configuration/hooks/` -- **Component splitting**: `web/app/components/app/configuration/` -- **Service hooks**: `web/service/use-*.ts` -- **Workflow patterns**: `web/app/components/workflow/hooks/` -- **Form patterns**: `web/app/components/base/form/` - -### Related Skills - -- `frontend-testing` - For testing refactored components -- `web/docs/test.md` - Testing specification diff --git a/.agents/skills/component-refactoring/references/complexity-patterns.md b/.agents/skills/component-refactoring/references/complexity-patterns.md deleted file mode 100644 index 2873630d4ba..00000000000 --- a/.agents/skills/component-refactoring/references/complexity-patterns.md +++ /dev/null @@ -1,495 +0,0 @@ -# Complexity Reduction Patterns - -This document provides patterns for reducing cognitive complexity in Dify React components. - -## Understanding Complexity - -### SonarJS Cognitive Complexity - -The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics: - -- **Total Complexity**: Sum of all functions' complexity in the file -- **Max Complexity**: Highest single function complexity - -### What Increases Complexity - -| Pattern | Complexity Impact | -|---------|-------------------| -| `if/else` | +1 per branch | -| Nested conditions | +1 per nesting level | -| `switch/case` | +1 per case | -| `for/while/do` | +1 per loop | -| `&&`/`||` chains | +1 per operator | -| Nested callbacks | +1 per nesting level | -| `try/catch` | +1 per catch | -| Ternary expressions | +1 per nesting | - -## Pattern 1: Replace Conditionals with Lookup Tables - -**Before** (complexity: ~15): - -```typescript -const Template = useMemo(() => { - if (appDetail?.mode === AppModeEnum.CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) { - switch (locale) { - case LanguagesSupported[1]: - return - case LanguagesSupported[7]: - return - default: - return - } - } - if (appDetail?.mode === AppModeEnum.WORKFLOW) { - // Similar pattern... - } - return null -}, [appDetail, locale]) -``` - -**After** (complexity: ~3): - -```typescript -import type { ComponentType } from 'react' - -// Define lookup table outside component -const TEMPLATE_MAP: Record>> = { - [AppModeEnum.CHAT]: { - [LanguagesSupported[1]]: TemplateChatZh, - [LanguagesSupported[7]]: TemplateChatJa, - default: TemplateChatEn, - }, - [AppModeEnum.ADVANCED_CHAT]: { - [LanguagesSupported[1]]: TemplateAdvancedChatZh, - [LanguagesSupported[7]]: TemplateAdvancedChatJa, - default: TemplateAdvancedChatEn, - }, - [AppModeEnum.WORKFLOW]: { - [LanguagesSupported[1]]: TemplateWorkflowZh, - [LanguagesSupported[7]]: TemplateWorkflowJa, - default: TemplateWorkflowEn, - }, - // ... -} - -// Clean component logic -const Template = useMemo(() => { - if (!appDetail?.mode) return null - - const templates = TEMPLATE_MAP[appDetail.mode] - if (!templates) return null - - const TemplateComponent = templates[locale] ?? templates.default - return -}, [appDetail, locale]) -``` - -## Pattern 2: Use Early Returns - -**Before** (complexity: ~10): - -```typescript -const handleSubmit = () => { - if (isValid) { - if (hasChanges) { - if (isConnected) { - submitData() - } else { - showConnectionError() - } - } else { - showNoChangesMessage() - } - } else { - showValidationError() - } -} -``` - -**After** (complexity: ~4): - -```typescript -const handleSubmit = () => { - if (!isValid) { - showValidationError() - return - } - - if (!hasChanges) { - showNoChangesMessage() - return - } - - if (!isConnected) { - showConnectionError() - return - } - - submitData() -} -``` - -## Pattern 3: Extract Complex Conditions - -**Before** (complexity: high): - -```typescript -const canPublish = (() => { - if (mode !== AppModeEnum.COMPLETION) { - if (!isAdvancedMode) - return true - - if (modelModeType === ModelModeType.completion) { - if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) - return false - return true - } - return true - } - return !promptEmpty -})() -``` - -**After** (complexity: lower): - -```typescript -// Extract to named functions -const canPublishInCompletionMode = () => !promptEmpty - -const canPublishInChatMode = () => { - if (!isAdvancedMode) return true - if (modelModeType !== ModelModeType.completion) return true - return hasSetBlockStatus.history && hasSetBlockStatus.query -} - -// Clean main logic -const canPublish = mode === AppModeEnum.COMPLETION - ? canPublishInCompletionMode() - : canPublishInChatMode() -``` - -## Pattern 4: Replace Chained Ternaries - -**Before** (complexity: ~5): - -```typescript -const statusText = serverActivated - ? t('status.running') - : serverPublished - ? t('status.inactive') - : appUnpublished - ? t('status.unpublished') - : t('status.notConfigured') -``` - -**After** (complexity: ~2): - -```typescript -const getStatusText = () => { - if (serverActivated) return t('status.running') - if (serverPublished) return t('status.inactive') - if (appUnpublished) return t('status.unpublished') - return t('status.notConfigured') -} - -const statusText = getStatusText() -``` - -Or use lookup: - -```typescript -const STATUS_TEXT_MAP = { - running: 'status.running', - inactive: 'status.inactive', - unpublished: 'status.unpublished', - notConfigured: 'status.notConfigured', -} as const - -const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => { - if (serverActivated) return 'running' - if (serverPublished) return 'inactive' - if (appUnpublished) return 'unpublished' - return 'notConfigured' -} - -const statusText = t(STATUS_TEXT_MAP[getStatusKey()]) -``` - -## Pattern 5: Flatten Nested Loops - -**Before** (complexity: high): - -```typescript -const processData = (items: Item[]) => { - const results: ProcessedItem[] = [] - - for (const item of items) { - if (item.isValid) { - for (const child of item.children) { - if (child.isActive) { - for (const prop of child.properties) { - if (prop.value !== null) { - results.push({ - itemId: item.id, - childId: child.id, - propValue: prop.value, - }) - } - } - } - } - } - } - - return results -} -``` - -**After** (complexity: lower): - -```typescript -// Use functional approach -const processData = (items: Item[]) => { - return items - .filter(item => item.isValid) - .flatMap(item => - item.children - .filter(child => child.isActive) - .flatMap(child => - child.properties - .filter(prop => prop.value !== null) - .map(prop => ({ - itemId: item.id, - childId: child.id, - propValue: prop.value, - })) - ) - ) -} -``` - -## Pattern 6: Extract Event Handler Logic - -**Before** (complexity: high in component): - -```typescript -const Component = () => { - const handleSelect = (data: DataSet[]) => { - if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - let newDatasets = data - if (data.find(item => !item.name)) { - const newSelected = produce(data, (draft) => { - data.forEach((item, index) => { - if (!item.name) { - const newItem = dataSets.find(i => i.id === item.id) - if (newItem) - draft[index] = newItem - } - }) - }) - setDataSets(newSelected) - newDatasets = newSelected - } - else { - setDataSets(data) - } - hideSelectDataSet() - - // 40 more lines of logic... - } - - return
...
-} -``` - -**After** (complexity: lower): - -```typescript -// Extract to hook or utility -const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState) => { - const normalizeSelection = (data: DataSet[]) => { - const hasUnloadedItem = data.some(item => !item.name) - if (!hasUnloadedItem) return data - - return produce(data, (draft) => { - data.forEach((item, index) => { - if (!item.name) { - const existing = dataSets.find(i => i.id === item.id) - if (existing) draft[index] = existing - } - }) - }) - } - - const hasSelectionChanged = (newData: DataSet[]) => { - return !isEqual( - newData.map(item => item.id), - dataSets.map(item => item.id) - ) - } - - return { normalizeSelection, hasSelectionChanged } -} - -// Component becomes cleaner -const Component = () => { - const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets) - - const handleSelect = (data: DataSet[]) => { - if (!hasSelectionChanged(data)) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - const normalized = normalizeSelection(data) - setDataSets(normalized) - hideSelectDataSet() - } - - return
...
-} -``` - -## Pattern 7: Reduce Boolean Logic Complexity - -**Before** (complexity: ~8): - -```typescript -const toggleDisabled = hasInsufficientPermissions - || appUnpublished - || missingStartNode - || triggerModeDisabled - || (isAdvancedApp && !currentWorkflow?.graph) - || (isBasicApp && !basicAppConfig.updated_at) -``` - -**After** (complexity: ~3): - -```typescript -// Extract meaningful boolean functions -const isAppReady = () => { - if (isAdvancedApp) return !!currentWorkflow?.graph - return !!basicAppConfig.updated_at -} - -const hasRequiredPermissions = () => { - return isCurrentWorkspaceEditor && !hasInsufficientPermissions -} - -const canToggle = () => { - if (!hasRequiredPermissions()) return false - if (!isAppReady()) return false - if (missingStartNode) return false - if (triggerModeDisabled) return false - return true -} - -const toggleDisabled = !canToggle() -``` - -## Pattern 8: Simplify useMemo/useCallback Dependencies - -**Before** (complexity: multiple recalculations): - -```typescript -const payload = useMemo(() => { - let parameters: Parameter[] = [] - let outputParameters: OutputParameter[] = [] - - if (!published) { - parameters = (inputs || []).map((item) => ({ - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - })) - outputParameters = (outputs || []).map((item) => ({ - name: item.variable, - description: '', - type: item.value_type, - })) - } - else if (detail && detail.tool) { - parameters = (inputs || []).map((item) => ({ - // Complex transformation... - })) - outputParameters = (outputs || []).map((item) => ({ - // Complex transformation... - })) - } - - return { - icon: detail?.icon || icon, - label: detail?.label || name, - // ...more fields - } -}, [detail, published, workflowAppId, icon, name, description, inputs, outputs]) -``` - -**After** (complexity: separated concerns): - -```typescript -// Separate transformations -const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => { - return useMemo(() => { - if (!published) { - return inputs.map(item => ({ - name: item.variable, - description: '', - form: 'llm', - required: item.required, - type: item.type, - })) - } - - if (!detail?.tool) return [] - - return inputs.map(item => ({ - name: item.variable, - required: item.required, - type: item.type === 'paragraph' ? 'string' : item.type, - description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '', - form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm', - })) - }, [inputs, detail, published]) -} - -// Component uses hook -const parameters = useParameterTransform(inputs, detail, published) -const outputParameters = useOutputTransform(outputs, detail, published) - -const payload = useMemo(() => ({ - icon: detail?.icon || icon, - label: detail?.label || name, - parameters, - outputParameters, - // ... -}), [detail, icon, name, parameters, outputParameters]) -``` - -## Target Metrics After Refactoring - -| Metric | Target | -|--------|--------| -| Total Complexity | < 50 | -| Max Function Complexity | < 30 | -| Function Length | < 30 lines | -| Nesting Depth | ≤ 3 levels | -| Conditional Chains | ≤ 3 conditions | diff --git a/.agents/skills/component-refactoring/references/component-splitting.md b/.agents/skills/component-refactoring/references/component-splitting.md deleted file mode 100644 index 81c007e0050..00000000000 --- a/.agents/skills/component-refactoring/references/component-splitting.md +++ /dev/null @@ -1,477 +0,0 @@ -# Component Splitting Patterns - -This document provides detailed guidance on splitting large components into smaller, focused components in Dify. - -## When to Split Components - -Split a component when you identify: - -1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently -1. **Conditional rendering blocks** - Large `{condition && }` blocks -1. **Repeated patterns** - Similar UI structures used multiple times -1. **300+ lines** - Component exceeds manageable size -1. **Modal clusters** - Multiple modals rendered in one component - -## Splitting Strategies - -### Strategy 1: Section-Based Splitting - -Identify visual sections and extract each as a component. - -```typescript -// ❌ Before: Monolithic component (500+ lines) -const ConfigurationPage = () => { - return ( -
- {/* Header Section - 50 lines */} -
-

{t('configuration.title')}

-
- {isAdvancedMode && Advanced} - - -
-
- - {/* Config Section - 200 lines */} -
- -
- - {/* Debug Section - 150 lines */} -
- -
- - {/* Modals Section - 100 lines */} - {showSelectDataSet && } - {showHistoryModal && } - {showUseGPT4Confirm && } -
- ) -} - -// ✅ After: Split into focused components -// configuration/ -// ├── index.tsx (orchestration) -// ├── configuration-header.tsx -// ├── configuration-content.tsx -// ├── configuration-debug.tsx -// └── configuration-modals.tsx - -// configuration-header.tsx -interface ConfigurationHeaderProps { - isAdvancedMode: boolean - onPublish: () => void -} - -function ConfigurationHeader({ - isAdvancedMode, - onPublish, -}: ConfigurationHeaderProps) { - const { t } = useTranslation() - - return ( -
-

{t('configuration.title')}

-
- {isAdvancedMode && Advanced} - - -
-
- ) -} - -// index.tsx (orchestration only) -const ConfigurationPage = () => { - const { modelConfig, setModelConfig } = useModelConfig() - const { activeModal, openModal, closeModal } = useModalState() - - return ( -
- - - {!isMobile && ( - - )} - -
- ) -} -``` - -### Strategy 2: Conditional Block Extraction - -Extract large conditional rendering blocks. - -```typescript -// ❌ Before: Large conditional blocks -const AppInfo = () => { - return ( -
- {expand ? ( -
- {/* 100 lines of expanded view */} -
- ) : ( -
- {/* 50 lines of collapsed view */} -
- )} -
- ) -} - -// ✅ After: Separate view components -function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) { - return ( -
- {/* Clean, focused expanded view */} -
- ) -} - -function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) { - return ( -
- {/* Clean, focused collapsed view */} -
- ) -} - -const AppInfo = () => { - return ( -
- {expand - ? - : - } -
- ) -} -``` - -### Strategy 3: Modal Extraction - -Extract modals with their trigger logic. - -```typescript -// ❌ Before: Multiple modals in one component -const AppInfo = () => { - const [showEdit, setShowEdit] = useState(false) - const [showDuplicate, setShowDuplicate] = useState(false) - const [showDelete, setShowDelete] = useState(false) - const [showSwitch, setShowSwitch] = useState(false) - - const onEdit = async (data) => { /* 20 lines */ } - const onDuplicate = async (data) => { /* 20 lines */ } - const onDelete = async () => { /* 15 lines */ } - - return ( -
- {/* Main content */} - - {showEdit && setShowEdit(false)} />} - {showDuplicate && setShowDuplicate(false)} />} - {showDelete && setShowDelete(false)} />} - {showSwitch && } -
- ) -} - -// ✅ After: Modal manager component -// app-info-modals.tsx -type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null - -interface AppInfoModalsProps { - appDetail: AppDetail - activeModal: ModalType - onClose: () => void - onSuccess: () => void -} - -function AppInfoModals({ - appDetail, - activeModal, - onClose, - onSuccess, -}: AppInfoModalsProps) { - const handleEdit = async (data) => { /* logic */ } - const handleDuplicate = async (data) => { /* logic */ } - const handleDelete = async () => { /* logic */ } - - return ( - <> - {activeModal === 'edit' && ( - - )} - {activeModal === 'duplicate' && ( - - )} - {activeModal === 'delete' && ( - - )} - {activeModal === 'switch' && ( - - )} - - ) -} - -// Parent component -const AppInfo = () => { - const { activeModal, openModal, closeModal } = useModalState() - - return ( -
- {/* Main content with openModal triggers */} - - - -
- ) -} -``` - -### Strategy 4: List Item Extraction - -Extract repeated item rendering. - -```typescript -// ❌ Before: Inline item rendering -const OperationsList = () => { - return ( -
- {operations.map(op => ( -
- {op.icon} - {op.title} - {op.description} - - {op.badge && {op.badge}} - {/* More complex rendering... */} -
- ))} -
- ) -} - -// ✅ After: Extracted item component -interface OperationItemProps { - operation: Operation - onAction: (id: string) => void -} - -function OperationItem({ operation, onAction }: OperationItemProps) { - return ( -
- {operation.icon} - {operation.title} - {operation.description} - - {operation.badge && {operation.badge}} -
- ) -} - -const OperationsList = () => { - const handleAction = useCallback((id: string) => { - const op = operations.find(o => o.id === id) - op?.onClick() - }, [operations]) - - return ( -
- {operations.map(op => ( - - ))} -
- ) -} -``` - -## Directory Structure Patterns - -### Pattern A: Flat Structure (Simple Components) - -For components with 2-3 sub-components: - -``` -component-name/ - ├── index.tsx # Main component - ├── sub-component-a.tsx - ├── sub-component-b.tsx - └── types.ts # Shared types -``` - -### Pattern B: Nested Structure (Complex Components) - -For components with many sub-components: - -``` -component-name/ - ├── index.tsx # Main orchestration - ├── types.ts # Shared types - ├── hooks/ - │ ├── use-feature-a.ts - │ └── use-feature-b.ts - ├── components/ - │ ├── header/ - │ │ └── index.tsx - │ ├── content/ - │ │ └── index.tsx - │ └── modals/ - │ └── index.tsx - └── utils/ - └── helpers.ts -``` - -### Pattern C: Feature-Based Structure (Dify Standard) - -Following Dify's existing patterns: - -``` -configuration/ - ├── index.tsx # Main page component - ├── base/ # Base/shared components - │ ├── feature-panel/ - │ ├── group-name/ - │ └── operation-btn/ - ├── config/ # Config section - │ ├── index.tsx - │ ├── agent/ - │ └── automatic/ - ├── dataset-config/ # Dataset section - │ ├── index.tsx - │ ├── card-item/ - │ └── params-config/ - ├── debug/ # Debug section - │ ├── index.tsx - │ └── hooks.tsx - └── hooks/ # Shared hooks - └── use-advanced-prompt-config.ts -``` - -## Props Design - -### Minimal Props Principle - -Pass only what's needed: - -```typescript -// ❌ Bad: Passing entire objects when only some fields needed - - -// ✅ Good: Destructure to minimum required - -``` - -### Callback Props Pattern - -Use callbacks for child-to-parent communication: - -```typescript -// Parent -const Parent = () => { - const [value, setValue] = useState('') - - return ( - - ) -} - -// Child -interface ChildProps { - value: string - onChange: (value: string) => void - onSubmit: () => void -} - -function Child({ value, onChange, onSubmit }: ChildProps) { - return ( -
- onChange(e.target.value)} /> - -
- ) -} -``` - -### Render Props for Flexibility - -When sub-components need parent context: - -```typescript -interface ListProps { - items: T[] - renderItem: (item: T, index: number) => React.ReactNode - renderEmpty?: () => React.ReactNode -} - -function List({ items, renderItem, renderEmpty }: ListProps) { - if (items.length === 0 && renderEmpty) { - return <>{renderEmpty()} - } - - return ( -
- {items.map((item, index) => renderItem(item, index))} -
- ) -} - -// Usage - } - renderEmpty={() => } -/> -``` diff --git a/.agents/skills/component-refactoring/references/hook-extraction.md b/.agents/skills/component-refactoring/references/hook-extraction.md deleted file mode 100644 index 6fad2c8885e..00000000000 --- a/.agents/skills/component-refactoring/references/hook-extraction.md +++ /dev/null @@ -1,281 +0,0 @@ -# Hook Extraction Patterns - -This document provides detailed guidance on extracting custom hooks from complex components in Dify. - -## When to Extract Hooks - -Extract a custom hook when you identify: - -1. **Coupled state groups** - Multiple `useState` hooks that are always used together -1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic -1. **Business logic** - Data transformations, validations, or calculations -1. **Reusable patterns** - Logic that appears in multiple components - -## Extraction Process - -### Step 1: Identify State Groups - -Look for state variables that are logically related: - -```typescript -// ❌ These belong together - extract to hook -const [modelConfig, setModelConfig] = useState(...) -const [completionParams, setCompletionParams] = useState({}) -const [modelModeType, setModelModeType] = useState(...) - -// These are model-related state that should be in useModelConfig() -``` - -### Step 2: Identify Related Effects - -Find effects that modify the grouped state: - -```typescript -// ❌ These effects belong with the state above -useEffect(() => { - if (hasFetchedDetail && !modelModeType) { - const mode = currModel?.model_properties.mode - if (mode) { - const newModelConfig = produce(modelConfig, (draft) => { - draft.mode = mode - }) - setModelConfig(newModelConfig) - } - } -}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel]) -``` - -### Step 3: Create the Hook - -```typescript -// hooks/use-model-config.ts -import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { ModelConfig } from '@/models/debug' -import { produce } from 'immer' -import { useEffect, useState } from 'react' -import { ModelModeType } from '@/types/app' - -interface UseModelConfigParams { - initialConfig?: Partial - currModel?: { model_properties?: { mode?: ModelModeType } } - hasFetchedDetail: boolean -} - -interface UseModelConfigReturn { - modelConfig: ModelConfig - setModelConfig: (config: ModelConfig) => void - completionParams: FormValue - setCompletionParams: (params: FormValue) => void - modelModeType: ModelModeType -} - -export const useModelConfig = ({ - initialConfig, - currModel, - hasFetchedDetail, -}: UseModelConfigParams): UseModelConfigReturn => { - const [modelConfig, setModelConfig] = useState({ - provider: 'langgenius/openai/openai', - model_id: 'gpt-3.5-turbo', - mode: ModelModeType.unset, - // ... default values - ...initialConfig, - }) - - const [completionParams, setCompletionParams] = useState({}) - - const modelModeType = modelConfig.mode - - // Fill old app data missing model mode - useEffect(() => { - if (hasFetchedDetail && !modelModeType) { - const mode = currModel?.model_properties?.mode - if (mode) { - setModelConfig(produce(modelConfig, (draft) => { - draft.mode = mode - })) - } - } - }, [hasFetchedDetail, modelModeType, currModel]) - - return { - modelConfig, - setModelConfig, - completionParams, - setCompletionParams, - modelModeType, - } -} -``` - -### Step 4: Update Component - -```typescript -// Before: 50+ lines of state management -function Configuration() { - const [modelConfig, setModelConfig] = useState(...) - // ... lots of related state and effects -} - -// After: Clean component -function Configuration() { - const { - modelConfig, - setModelConfig, - completionParams, - setCompletionParams, - modelModeType, - } = useModelConfig({ - currModel, - hasFetchedDetail, - }) - - // Component now focuses on UI -} -``` - -## Naming Conventions - -### Hook Names - -- Use `use` prefix: `useModelConfig`, `useDatasetConfig` -- Be specific: `useAdvancedPromptConfig` not `usePrompt` -- Include domain: `useWorkflowVariables`, `useMCPServer` - -### File Names - -- Kebab-case: `use-model-config.ts` -- Place in `hooks/` subdirectory when multiple hooks exist -- Place alongside component for single-use hooks - -### Return Type Names - -- Suffix with `Return`: `UseModelConfigReturn` -- Suffix params with `Params`: `UseModelConfigParams` - -## Common Hook Patterns in Dify - -### 1. Data Fetching / Mutation Hooks - -When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. - -- Do not introduce deprecated `useInvalid` / `useReset`. -- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. - -### 2. Form State Hook - -```typescript -// Pattern: Form state + validation + submission -export const useConfigForm = (initialValues: ConfigFormValues) => { - const [values, setValues] = useState(initialValues) - const [errors, setErrors] = useState>({}) - const [isSubmitting, setIsSubmitting] = useState(false) - - const validate = useCallback(() => { - const newErrors: Record = {} - if (!values.name) newErrors.name = 'Name is required' - setErrors(newErrors) - return Object.keys(newErrors).length === 0 - }, [values]) - - const handleChange = useCallback((field: string, value: any) => { - setValues(prev => ({ ...prev, [field]: value })) - }, []) - - const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise) => { - if (!validate()) return - setIsSubmitting(true) - try { - await onSubmit(values) - } finally { - setIsSubmitting(false) - } - }, [values, validate]) - - return { values, errors, isSubmitting, handleChange, handleSubmit } -} -``` - -### 3. Modal State Hook - -```typescript -// Pattern: Multiple modal management -type ModalType = 'edit' | 'delete' | 'duplicate' | null - -export const useModalState = () => { - const [activeModal, setActiveModal] = useState(null) - const [modalData, setModalData] = useState(null) - - const openModal = useCallback((type: ModalType, data?: any) => { - setActiveModal(type) - setModalData(data) - }, []) - - const closeModal = useCallback(() => { - setActiveModal(null) - setModalData(null) - }, []) - - return { - activeModal, - modalData, - openModal, - closeModal, - isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]), - } -} -``` - -### 4. Toggle/Boolean Hook - -```typescript -// Pattern: Boolean state with convenience methods -export const useToggle = (initialValue = false) => { - const [value, setValue] = useState(initialValue) - - const toggle = useCallback(() => setValue(v => !v), []) - const setTrue = useCallback(() => setValue(true), []) - const setFalse = useCallback(() => setValue(false), []) - - return [value, { toggle, setTrue, setFalse, set: setValue }] as const -} - -// Usage -const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle() -``` - -## Testing Extracted Hooks - -After extraction, test hooks in isolation: - -```typescript -// use-model-config.spec.ts -import { renderHook, act } from '@testing-library/react' -import { useModelConfig } from './use-model-config' - -describe('useModelConfig', () => { - it('should initialize with default values', () => { - const { result } = renderHook(() => useModelConfig({ - hasFetchedDetail: false, - })) - - expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai') - expect(result.current.modelModeType).toBe(ModelModeType.unset) - }) - - it('should update model config', () => { - const { result } = renderHook(() => useModelConfig({ - hasFetchedDetail: true, - })) - - act(() => { - result.current.setModelConfig({ - ...result.current.modelConfig, - model_id: 'gpt-4', - }) - }) - - expect(result.current.modelConfig.model_id).toBe('gpt-4') - }) -}) -``` diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 55ad08941c4..738ec9de95a 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, 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 @@ -12,26 +12,79 @@ Use this as the decision guide for React/TypeScript component structure. Existin - 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. +- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO. +- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ. +- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior. +- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together. - Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance. +## Feature Workflow Layout + +- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI. +- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions. +- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region. +- Folders represent groups of related files with a shared owner and a stable reason to change together. +- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching. + ## 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. +- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state. +- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon. - 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. +- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth. +- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need. - 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. +## Feature-Scoped Jotai State + +- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration. +- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports. +- Derived atom names read as business facts. Write atom names read as user or workflow commands. +- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms. +- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract. +- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow. +- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface. +- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient. + ## 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. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them. +- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract. +- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary. +- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states. +- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state. + +## Generated API Contracts + +- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`. +- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type. +- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type. +- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values. +- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum. +- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful. +- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string. +- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors. +- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data. +- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers. + +## Nullable API Data + +- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing. +- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch. +- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action. +- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary. +- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible. +- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter. +- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters. +- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant. ## Queries And Mutations @@ -39,7 +92,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin - 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. +- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`. +- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly. - 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`. @@ -48,12 +102,13 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## 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. +- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader 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. +- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface. ## You Might Not Need An Effect @@ -68,4 +123,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin ## 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. +- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched. +- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree. - Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index bd57d973528..54226a3a839 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -29,13 +29,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -91,13 +91,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -142,13 +142,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.12" @@ -195,7 +195,7 @@ jobs: - name: Report coverage if: ${{ env.CODECOV_TOKEN != '' }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./coverage.xml disable_search: true diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 4aa39e86966..00dae047e04 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -20,7 +20,7 @@ jobs: run: echo "autofix.ci updates pull request branches, not merge group refs." - if: github.event_name != 'merge_group' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Check Docker Compose inputs if: github.event_name != 'merge_group' @@ -66,7 +66,7 @@ jobs: python-version: "3.11" - if: github.event_name != 'merge_group' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Generate Docker Compose if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index d35a5ae178c..2ff4e8c2123 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -9,6 +9,7 @@ on: - "release/e-*" - "hotfix/**" - "feat/hitl-backend" + - "feat/rbac" tags: - "*" @@ -21,6 +22,7 @@ env: DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }} DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }} + DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }} jobs: build: @@ -60,6 +62,20 @@ jobs: file: "web/Dockerfile" platform: linux/arm64 runs_on: depot-ubuntu-24.04-4 + - service_name: "build-agent-amd64" + image_name_env: "DIFY_AGENT_IMAGE_NAME" + artifact_context: "agent" + build_context: "{{defaultContext}}" + file: "dify-agent/Dockerfile" + platform: linux/amd64 + runs_on: depot-ubuntu-24.04-4 + - service_name: "build-agent-arm64" + image_name_env: "DIFY_AGENT_IMAGE_NAME" + artifact_context: "agent" + build_context: "{{defaultContext}}" + file: "dify-agent/Dockerfile" + platform: linux/arm64 + runs_on: depot-ubuntu-24.04-4 steps: - name: Prepare @@ -68,7 +84,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Login to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ env.DOCKERHUB_USER }} password: ${{ env.DOCKERHUB_TOKEN }} @@ -78,13 +94,13 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env[matrix.image_name_env] }} - name: Build Docker image id: build - uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 with: project: ${{ vars.DEPOT_PROJECT_ID }} context: ${{ matrix.build_context }} @@ -122,12 +138,15 @@ jobs: - service_name: "validate-web-amd64" build_context: "{{defaultContext}}" file: "web/Dockerfile" + - service_name: "validate-agent-amd64" + build_context: "{{defaultContext}}" + file: "dify-agent/Dockerfile" steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Validate Docker image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: push: false context: ${{ matrix.build_context }} @@ -147,6 +166,9 @@ jobs: - service_name: "merge-web-images" image_name_env: "DIFY_WEB_IMAGE_NAME" context: "web" + - service_name: "merge-agent-images" + image_name_env: "DIFY_AGENT_IMAGE_NAME" + context: "agent" steps: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -156,14 +178,14 @@ jobs: merge-multiple: true - name: Login to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ env.DOCKERHUB_USER }} password: ${{ env.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env[matrix.image_name_env] }} tags: | diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index d7787c57334..636cc80c547 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -79,7 +79,7 @@ jobs: ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -88,7 +88,7 @@ jobs: with: bun-version: latest - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: package_json_field: packageManager run_install: false @@ -123,7 +123,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -131,7 +131,7 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 with: { bun-version: latest } - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: { package_json_field: packageManager, run_install: false } - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -170,7 +170,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -178,7 +178,7 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 with: { bun-version: latest } - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: { package_json_field: packageManager, run_install: false } - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -233,7 +233,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -241,7 +241,7 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 with: { bun-version: latest } - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: { package_json_field: packageManager, run_install: false } - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -274,7 +274,7 @@ jobs: - name: Upload results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: e2e-run-${{ matrix.name }}-${{ github.run_id }} path: cli/test-results/ @@ -295,7 +295,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -303,7 +303,7 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 with: { bun-version: latest } - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: { package_json_field: packageManager, run_install: false } - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -351,7 +351,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -359,7 +359,7 @@ jobs: - uses: ./.github/actions/setup-web - uses: oven-sh/setup-bun@v2 with: { bun-version: latest } - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: { package_json_field: packageManager, run_install: false } - run: pnpm install --frozen-lockfile - run: pnpm tree:gen @@ -408,7 +408,7 @@ jobs: - name: Upload results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: e2e-last-${{ github.run_id }} path: cli/test-results/ diff --git a/.github/workflows/cli-edge.yml b/.github/workflows/cli-edge.yml index 159f3545e13..792c0328c54 100644 --- a/.github/workflows/cli-edge.yml +++ b/.github/workflows/cli-edge.yml @@ -23,7 +23,7 @@ jobs: working-directory: ./cli steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 4d7de2dd2c8..a76cb234a5b 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -35,7 +35,7 @@ jobs: dify_tag: ${{ steps.resolve.outputs.dify_tag }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -98,7 +98,7 @@ jobs: DIFY_TAG: ${{ needs.validate.outputs.dify_tag }} steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false fetch-depth: 1 diff --git a/.github/workflows/cli-smoke.yml b/.github/workflows/cli-smoke.yml index 045ff8e71a7..796b1b536cd 100644 --- a/.github/workflows/cli-smoke.yml +++ b/.github/workflows/cli-smoke.yml @@ -24,7 +24,7 @@ jobs: shell: bash steps: - name: Checkout cli ref - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index d27ab7db544..f46b662919f 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -51,7 +51,7 @@ jobs: - name: Report coverage if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: directory: cli/coverage flags: cli diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 9d3ccb34b2c..8a71903d99b 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -13,13 +13,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.12" @@ -40,7 +40,7 @@ jobs: cp envs/middleware.env.example middleware.env - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 + uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0 with: compose-file: | docker/docker-compose.middleware.yaml @@ -63,13 +63,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.12" @@ -94,7 +94,7 @@ jobs: sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 + uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0 with: compose-file: | docker/docker-compose.middleware.yaml diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 987edc37ef4..db38cdde716 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -53,7 +53,7 @@ jobs: uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 - name: Build Docker Image - uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 with: project: ${{ vars.DEPOT_PROJECT_ID }} push: false @@ -77,10 +77,10 @@ jobs: file: "web/Dockerfile" steps: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Build Docker Image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: push: false context: ${{ matrix.context }} diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml index 594b10c7435..a1ff0c3bf91 100644 --- a/.github/workflows/hotfix-cherry-pick.yml +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -24,7 +24,7 @@ jobs: name: Require cherry-pick provenance runs-on: depot-ubuntu-24.04 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 34dda7fb149..69c540c54b4 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -48,7 +48,7 @@ jobs: vdb-changed: ${{ steps.changes.outputs.vdb }} migration-changed: ${{ steps.changes.outputs.migration }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: changes with: diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index 386bd257514..cc40c1839c6 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -17,12 +17,12 @@ jobs: pull-requests: write steps: - name: Checkout PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage-comment.yml b/.github/workflows/pyrefly-type-coverage-comment.yml index 2fe9aa591de..abc14c693f5 100644 --- a/.github/workflows/pyrefly-type-coverage-comment.yml +++ b/.github/workflows/pyrefly-type-coverage-comment.yml @@ -21,10 +21,10 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }} steps: - name: Checkout default branch (trusted code) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Python & UV - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage.yml b/.github/workflows/pyrefly-type-coverage.yml index 915e406b571..be001a8d5e8 100644 --- a/.github/workflows/pyrefly-type-coverage.yml +++ b/.github/workflows/pyrefly-type-coverage.yml @@ -17,12 +17,12 @@ jobs: pull-requests: write steps: - name: Checkout PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b23648c7c61..e761feaefc1 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: days-before-issue-stale: 15 days-before-issue-close: 3 diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index e18607a60ea..e4c686adb31 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -33,7 +33,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: false python-version: "3.12" @@ -71,7 +71,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -114,7 +114,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -171,7 +171,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index adaf99f33a5..d61f940bdbc 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -24,7 +24,7 @@ jobs: working-directory: sdks/nodejs-client steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index be602cf87e5..de0725650a1 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127 + uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml index 87c88e2023f..5b2264e8733 100644 --- a/.github/workflows/trigger-i18n-sync.yml +++ b/.github/workflows/trigger-i18n-sync.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index fbd073b6725..bef53100358 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -36,7 +36,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 972ab881727..bee195c2206 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -33,7 +33,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index bdc24887dbe..2447e9033a3 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -28,7 +28,7 @@ jobs: uses: ./.github/actions/setup-web - name: Setup UV and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 56cf8d5fca4..5e95e231118 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -64,7 +64,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -83,7 +83,7 @@ jobs: - name: Report coverage if: ${{ env.CODECOV_TOKEN != '' }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: directory: web/coverage flags: web @@ -102,7 +102,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false @@ -117,7 +117,7 @@ jobs: - name: Report coverage if: ${{ env.CODECOV_TOKEN != '' }} - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: directory: packages/dify-ui/coverage flags: dify-ui @@ -134,7 +134,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index 238c48a9de3..b9032c521eb 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -33,6 +33,7 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge from clients.agent_backend.request_builder import ( AGENT_SOUL_PROMPT_LAYER_ID, DIFY_EXECUTION_CONTEXT_LAYER_ID, + DIFY_KNOWLEDGE_BASE_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, @@ -47,6 +48,7 @@ from clients.agent_backend.request_builder import ( __all__ = [ "AGENT_SOUL_PROMPT_LAYER_ID", "DIFY_EXECUTION_CONTEXT_LAYER_ID", + "DIFY_KNOWLEDGE_BASE_LAYER_ID", "DIFY_PLUGIN_TOOLS_LAYER_ID", "WORKFLOW_NODE_JOB_PROMPT_LAYER_ID", "WORKFLOW_USER_PROMPT_LAYER_ID", diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 55944929ddc..c245a09e970 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -32,6 +32,7 @@ from dify_agent.layers.execution_context import ( DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig, ) +from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.protocol import ( @@ -55,6 +56,7 @@ AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt" DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context" DIFY_DRIVE_LAYER_ID = "drive" DIFY_PLUGIN_TOOLS_LAYER_ID = "tools" +DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge" DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" @@ -139,6 +141,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + knowledge: DifyKnowledgeBaseLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None @@ -185,6 +188,7 @@ class AgentBackendAgentAppRunInput(BaseModel): idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None + knowledge: DifyKnowledgeBaseLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. drive_config: DifyDriveLayerConfig | None = None @@ -221,7 +225,7 @@ class AgentBackendRunRequestBuilder: Layer graph: optional Agent Soul system prompt → user prompt → execution context → optional history (multi-turn) → LLM → optional - plugin tools → optional structured output. Mirrors the workflow-node + plugin tools / knowledge search → optional structured output. Mirrors the workflow-node layer ordering minus the workflow-job / previous-node prompt. """ layers: list[RunLayerSpec] = [] @@ -300,6 +304,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + layers.append( + RunLayerSpec( + name=DIFY_KNOWLEDGE_BASE_LAYER_ID, + type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=run_input.knowledge, + ) + ) + if run_input.ask_human_config is not None: # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends # the run with a deferred_tool_call; the caller pauses (workflow HITL) and @@ -398,7 +413,12 @@ class AgentBackendRunRequestBuilder: ) def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest: - """Build a workflow Agent Node run request without defining another wire schema.""" + """Build a workflow Agent Node run request without defining another wire schema. + + Layer graph mirrors the workflow surface: prompts → execution context → + optional drive/history → LLM → optional plugin tools / knowledge search + → optional auxiliary layers such as ask_human, shell, and structured output. + """ layers: list[RunLayerSpec] = [] if run_input.agent_soul_prompt: layers.append( @@ -483,6 +503,17 @@ class AgentBackendRunRequestBuilder: ) ) + if run_input.knowledge is not None and run_input.knowledge.dataset_ids: + layers.append( + RunLayerSpec( + name=DIFY_KNOWLEDGE_BASE_LAYER_ID, + type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + metadata=run_input.metadata, + config=run_input.knowledge, + ) + ) + if run_input.ask_human_config is not None: # Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends # the run with a deferred_tool_call; the caller pauses (workflow HITL) and diff --git a/api/commands/__init__.py b/api/commands/__init__.py index ea4c5aaa2a8..94321ed1e49 100644 --- a/api/commands/__init__.py +++ b/api/commands/__init__.py @@ -22,6 +22,7 @@ from .plugin import ( setup_system_trigger_oauth_client, transform_datasource_credentials, ) +from .rbac import migrate_member_roles_to_rbac from .retention import ( archive_workflow_runs, clean_expired_messages, @@ -74,6 +75,7 @@ __all__ = [ "migrate_annotation_vector_database", "migrate_data_for_plugin", "migrate_knowledge_vector_database", + "migrate_member_roles_to_rbac", "migrate_oss", "migration_data_wizard", "old_metadata_migration", diff --git a/api/commands/rbac.py b/api/commands/rbac.py new file mode 100644 index 00000000000..33eb5858da4 --- /dev/null +++ b/api/commands/rbac.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import click +from sqlalchemy import select + +from core.db.session_factory import session_factory +from models import TenantAccountJoin, TenantAccountRole +from services.enterprise.rbac_service import ListOption, RBACService + + +def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str: + """Resolve a legacy workspace role to the current tenant's builtin RBAC role id. + + The migration replays the old `TenantAccountJoin.role` values onto the + RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and + identified by runtime ids, so the command must look them up per tenant. + """ + expected_builtin_tag = { + TenantAccountRole.OWNER.value: "owner", + TenantAccountRole.ADMIN.value: "admin", + TenantAccountRole.EDITOR.value: "editor", + TenantAccountRole.NORMAL.value: "normal", + TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator", + }.get(legacy_role) + if not expected_builtin_tag: + raise ValueError(f"Unsupported legacy workspace role: {legacy_role}") + + roles = RBACService.Roles.list( + tenant_id=tenant_id, + account_id=operator_account_id, + options=ListOption(page_number=1, results_per_page=100), + ).data + for role in roles: + if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag: + return str(role.id) + + raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}") + + +@click.command( + "rbac-migrate-member-roles", help="Migrate legacy workspace member roles into RBAC member-role bindings." +) +@click.option("--tenant-id", help="Only migrate a single workspace.") +@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.") +def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None: + """Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data. + + This is an offline migration command for workspaces that already have + members in the legacy role model but need matching records in the RBAC + member-role binding store. + """ + click.echo(click.style("Starting RBAC member-role migration.", fg="green")) + + with session_factory.create_session() as session: + stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc()) + if tenant_id: + stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id) + + joins = list(session.scalars(stmt).all()) + + if not joins: + click.echo(click.style("No workspace members found for migration.", fg="yellow")) + return + + owner_account_by_tenant: dict[str, str] = {} + resolved_role_ids: dict[tuple[str, str], str] = {} + migrated_count = 0 + + for join in joins: + workspace_id = str(join.tenant_id) + member_account_id = str(join.account_id) + legacy_role = str(join.role) + + if workspace_id not in owner_account_by_tenant: + owner_join = next( + ( + item + for item in joins + if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value + ), + None, + ) + if not owner_join: + raise ValueError(f"Workspace owner not found for tenant={workspace_id}") + owner_account_by_tenant[workspace_id] = str(owner_join.account_id) + + operator_account_id = owner_account_by_tenant[workspace_id] + cache_key = (workspace_id, legacy_role) + if cache_key not in resolved_role_ids: + resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role) + + resolved_role_id = resolved_role_ids[cache_key] + click.echo( + f"tenant={workspace_id} member={member_account_id} " + f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}" + ) + + if dry_run: + continue + + RBACService.MemberRoles.replace( + tenant_id=workspace_id, + account_id=operator_account_id, + member_account_id=member_account_id, + role_ids=[resolved_role_id], + ) + migrated_count += 1 + + if dry_run: + click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow")) + else: + click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green")) diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index 705ea67bcbc..2c0eaeeb81b 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -29,6 +29,11 @@ class EnterpriseFeatureConfig(BaseSettings): "This helps gain runtime performance by trading off consistency.", ) + RBAC_ENABLED: bool = Field( + description="Enable enterprise RBAC APIs. When disabled, compatibility responses fall back to legacy roles.", + default=False, + ) + class EnterpriseTelemetryConfig(BaseSettings): """ diff --git a/api/controllers/common/controller_schemas.py b/api/controllers/common/controller_schemas.py index 8eeed8f0a0a..d35fd9fe1b3 100644 --- a/api/controllers/common/controller_schemas.py +++ b/api/controllers/common/controller_schemas.py @@ -1,7 +1,8 @@ -from typing import Any, Literal +from copy import deepcopy +from typing import Annotated, Any, Literal, override from uuid import UUID -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, model_validator from libs.helper import UUIDStrOrEmpty @@ -9,8 +10,53 @@ from libs.helper import UUIDStrOrEmpty class ConversationRenamePayload(BaseModel): - name: str | None = None - auto_generate: bool = False + name: str | None = Field( + default=None, + description="Conversation name. Required when `auto_generate` is `false`.", + ) + auto_generate: bool = Field( + default=False, + description="Automatically generate the conversation name. When `true`, the `name` field is ignored.", + ) + + @classmethod + @override + def __get_pydantic_json_schema__(cls, core_schema: Any, handler: GetJsonSchemaHandler) -> dict[str, Any]: + schema = handler.resolve_ref_schema(handler(core_schema)) + properties = schema.get("properties") + if not isinstance(properties, dict): + return schema + + auto_generate_schema = deepcopy(properties.get("auto_generate", {"type": "boolean"})) + name_schema = deepcopy(properties.get("name", {"type": "string"})) + non_blank_name_schema: dict[str, Any] = {"pattern": r".*\S.*", "type": "string"} + if isinstance(name_schema, dict) and isinstance(name_schema.get("title"), str): + non_blank_name_schema["title"] = name_schema["title"] + + auto_generate_true_schema = {**auto_generate_schema, "enum": [True]} + auto_generate_true_schema.pop("default", None) + + return { + **schema, + "anyOf": [ + { + "properties": { + "auto_generate": auto_generate_true_schema, + "name": name_schema, + }, + "required": ["auto_generate"], + "type": "object", + }, + { + "properties": { + "auto_generate": {**auto_generate_schema, "enum": [False]}, + "name": non_blank_name_schema, + }, + "required": ["name"], + "type": "object", + }, + ], + } @model_validator(mode="after") def validate_name_requirement(self): @@ -24,14 +70,28 @@ class ConversationRenamePayload(BaseModel): class MessageListQuery(BaseModel): - conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID") - first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination") - limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)") + conversation_id: UUIDStrOrEmpty = Field(description="Conversation ID.") + first_id: UUIDStrOrEmpty | None = Field( + default=None, + description=( + "The ID of the first chat record on the current page. Omit this value to fetch the latest messages; " + "for subsequent pages, use the first message ID from the current list to fetch older messages." + ), + ) + limit: int = Field( + default=20, + ge=1, + le=100, + description="Number of chat history messages to return per request.", + ) class MessageFeedbackPayload(BaseModel): - rating: Literal["like", "dislike"] | None = None - content: str | None = None + rating: Literal["like", "dislike"] | None = Field( + default=None, + description="Feedback rating. Set to `null` to revoke previously submitted feedback.", + ) + content: str | None = Field(default=None, description="Optional text feedback providing additional detail.") # --- Saved message schemas --- @@ -48,6 +108,39 @@ class SavedMessageCreatePayload(BaseModel): # --- Workflow schemas --- +WORKFLOW_INPUT_FILE_ITEM_SCHEMA: dict[str, object] = { + "type": "object", + "required": ["type", "transfer_method"], + "properties": { + "type": { + "description": "File type.", + "enum": ["document", "image", "audio", "video", "custom"], + "type": "string", + }, + "transfer_method": { + "description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.", + "enum": ["remote_url", "local_file"], + "type": "string", + }, + "url": { + "description": "File URL when `transfer_method` is `remote_url`.", + "format": "url", + "type": "string", + }, + "upload_file_id": { + "description": ( + "Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when " + "`transfer_method` is `local_file`." + ), + "type": "string", + }, + }, +} +WORKFLOW_INPUT_FILE_LIST_SCHEMA: dict[str, object] = { + "anyOf": [{"items": WORKFLOW_INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}] +} +WorkflowInputFileList = Annotated[list[dict[str, Any]] | None, WithJsonSchema(WORKFLOW_INPUT_FILE_LIST_SCHEMA)] + class DefaultBlockConfigQuery(BaseModel): q: str | None = None @@ -61,8 +154,22 @@ class WorkflowListQuery(BaseModel): class WorkflowRunPayload(BaseModel): - inputs: dict[str, Any] - files: list[dict[str, Any]] | None = Field(default=None) + inputs: dict[str, Any] = Field( + description=( + "Key-value pairs for workflow input variables. Values for file-type variables should be arrays of " + "file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the " + "`user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) " + "response to discover the variable names and types expected by your app." + ) + ) + files: WorkflowInputFileList = Field( + default=None, + description=( + "File list for workflow system file inputs. Available when file upload is enabled for the workflow. " + "To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use " + "the returned `id` as `upload_file_id` with `transfer_method: local_file`." + ), + ) class WorkflowUpdatePayload(BaseModel): @@ -77,28 +184,49 @@ DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100 class ChildChunkCreatePayload(BaseModel): - content: str + content: str = Field(description="Child chunk text content.") class ChildChunkUpdatePayload(BaseModel): - content: str + content: str = Field(description="Child chunk text content.") class DocumentBatchDownloadZipPayload(BaseModel): """Request payload for bulk downloading documents as a zip archive.""" - document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS) + document_ids: list[UUID] = Field( + ..., + min_length=1, + max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS, + description="List of document IDs to include in the ZIP download.", + ) class MetadataUpdatePayload(BaseModel): - name: str + name: str = Field(description="New metadata field name.") # --- Audio schemas --- +UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})] + + class TextToAudioPayload(BaseModel): - message_id: str | None = Field(default=None, description="Message ID") - voice: str | None = Field(default=None, description="Voice to use for TTS") - text: str | None = Field(default=None, description="Text to convert to audio") - streaming: bool | None = Field(default=None, description="Enable streaming response") + message_id: UUIDString | None = Field( + default=None, + description="Message ID. Takes priority over `text` when both are provided.", + ) + voice: str | None = Field( + default=None, + description=( + "Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. " + "Omit to use the app's configured voice when available; that value is exposed by " + "[Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`." + ), + ) + text: str | None = Field(default=None, description="Speech content to convert.") + streaming: bool | None = Field( + default=None, + description="Reserved for compatibility; TTS response streaming is determined by the provider output.", + ) diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index 06d68a5df16..6a0b35aa633 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -153,6 +153,7 @@ class ApiBaseUrlResponse(ResponseModel): class NewAppResponse(ResponseModel): new_app_id: str + permission_keys: list[str] = Field(default_factory=list) class Parameters(BaseModel): diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py index d9b8f8f9a37..3bb2684df43 100644 --- a/api/controllers/common/human_input.py +++ b/api/controllers/common/human_input.py @@ -35,17 +35,23 @@ class HumanInputFormSubmitPayload(BaseModel): ), examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE], ) - action: str + action: str = Field( + description=( + "ID of the action button the recipient selected. Must match one of the `id` values from the form's " + "`user_actions` list." + ) + ) 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) + match value: + case None: + result[key] = "" + case dict() | list(): + result[key] = json.dumps(value, ensure_ascii=False) + case _: + result[key] = str(value) return result diff --git a/api/controllers/common/wraps.py b/api/controllers/common/wraps.py index c481f6eca94..7e39b4f37cd 100644 --- a/api/controllers/common/wraps.py +++ b/api/controllers/common/wraps.py @@ -1,7 +1,36 @@ +"""Shared decorator utilities for Dify controller layers. + +This module provides decorators that are not tied to any single API group (e.g. +console, inner, service). Currently it exposes the RBAC permission gate, which +can be applied to any blueprint. + +Key exports +----------- +``rbac_permission_required`` – decorator that enforces enterprise RBAC access + control. When ``RBAC_ENABLED`` is ``False`` it is a no-op. + +``RBACPermission``, ``RBACResourceScope`` – re-exported from ``core.rbac`` so + callers only need a single import site. + +Private helpers +--------------- +``_extract_resource_id``, ``_is_resource_owned_by_current_user`` – kept module- + private but accessible via the module namespace for unit-test patching. +""" + from collections.abc import Callable from functools import wraps +from sqlalchemy import select +from werkzeug.exceptions import Forbidden, NotFound + +from configs import dify_config from core.rbac import RBACPermission, RBACResourceScope +from extensions.ext_database import db +from libs.login import current_account_with_tenant +from models.dataset import Dataset +from models.model import App +from services.enterprise.rbac_service import RBACService __all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"] @@ -14,17 +43,105 @@ def rbac_permission_required[**P, R]( ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Check enterprise RBAC permissions for the current user. + When ``RBAC_ENABLED`` is ``False`` the decorator is a no-op and the + request passes through unchanged. When enabled it extracts the resource ID + from ``request.view_args`` for resource-scoped checks, calls the RBAC + service ``check-access`` endpoint, and raises ``Forbidden`` if the access + is denied. For workspace-level checks, set ``resource_required=False`` so + the RBAC request omits ``resource_id``. + Args: resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace). - scene: The :class:`RBACPermission` permission point. + scene: The :class:`RBACPermission` permission point, e.g. ``RBACPermission.APP_DELETE``. resource_required: Whether a concrete resource ID is required. """ def decorator(view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + if not dify_config.RBAC_ENABLED: + return view(*args, **kwargs) + + current_user, current_tenant_id = current_account_with_tenant() + check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type + resource_id = None + if resource_required and check_resource_type: + resource_id = _extract_resource_id(resource_type, kwargs) + if _is_resource_owned_by_current_user(current_tenant_id, current_user.id, resource_type, resource_id): + return view(*args, **kwargs) + allowed = RBACService.CheckAccess.check( + current_tenant_id, + current_user.id, + scene=scene, + resource_type=check_resource_type, + resource_id=resource_id, + ) + + if not allowed: + raise Forbidden() + return view(*args, **kwargs) return decorated return decorator + + +def _is_resource_owned_by_current_user( + tenant_id: str, account_id: str, resource_type: RBACResourceScope, resource_id: str +) -> bool: + if resource_type == RBACResourceScope.APP: + maintainer = db.session.scalar( + select(App.maintainer).where( + App.id == resource_id, + App.tenant_id == tenant_id, + App.status == "normal", + ) + ) + return maintainer == account_id + + if resource_type == RBACResourceScope.DATASET: + maintainer = db.session.scalar( + select(Dataset.maintainer).where( + Dataset.id == resource_id, + Dataset.tenant_id == tenant_id, + ) + ) + return maintainer == account_id + + return False + + +def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str: + """Extract the resource ID from matched path arguments. + + Some legacy route classes use neutral names such as ``resource_id`` for + app/dataset resources, and Agent App routes use ``agent_id`` as the app id. + Dataset endpoints behind a rag-pipeline route contain ``pipeline_id`` + instead of ``dataset_id``. In that case we look up the associated + ``Dataset`` row via ``Dataset.pipeline_id``. + """ + from flask import request + + view_args = request.view_args or {} + matched_args = {**view_args, **(path_args or {})} + + if resource_type == RBACResourceScope.APP: + app_id = matched_args.get("app_id") or matched_args.get("agent_id") or matched_args.get("resource_id") + if not app_id: + raise ValueError("Missing app_id in request path") + return str(app_id) + + if resource_type == RBACResourceScope.DATASET: + dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id") + if dataset_id: + return str(dataset_id) + + pipeline_id = matched_args.get("pipeline_id") + if pipeline_id: + dataset = db.session.scalar(select(Dataset).where(Dataset.pipeline_id == str(pipeline_id))) + if not dataset: + raise NotFound("Dataset not found for pipeline") + return str(dataset.id) + raise ValueError("Missing dataset_id or pipeline_id in request path") + raise ValueError(f"Unknown resource_type: {resource_type}") diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index e2bf0bd22ce..cd8d6e0ab46 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -139,6 +139,7 @@ from .workspace import ( model_providers, models, plugin, + rbac, snippets, tool_providers, trigger_providers, @@ -212,6 +213,7 @@ __all__ = [ "rag_pipeline_draft_variable", "rag_pipeline_import", "rag_pipeline_workflow", + "rbac", "recommended_app", "saved_message", "setup", diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 6915a54db93..2cd01e427f7 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -7,8 +7,11 @@ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user_id, @@ -70,6 +73,7 @@ class WorkflowAgentComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_user_id @with_current_tenant_id @@ -166,6 +170,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_user_id @with_current_tenant_id @@ -203,6 +208,7 @@ class AgentComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user_id @with_current_tenant_id def put(self, tenant_id: str, account_id: str, agent_id: UUID): diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index fc37845d63c..d4546ac88bf 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -1,21 +1,31 @@ from uuid import UUID -from flask import request +from flask import abort, request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field, field_validator 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.agent.app_helpers import resolve_agent_app_model +from controllers.console.app.app import ( + AppDetailWithSite as GenericAppDetailWithSite, +) from controllers.console.app.app import ( - AppDetailWithSite, AppListQuery, - AppPagination, - UpdateAppPayload, + CopyAppPayload, _normalize_app_list_query_args, ) +from controllers.console.app.app import ( + AppPagination as GenericAppPagination, +) +from controllers.console.app.app import ( + AppPartial as GenericAppPartial, +) +from controllers.console.app.app import ( + UpdateAppPayload as GenericUpdateAppPayload, +) from controllers.console.wraps import ( account_initialization_required, - cloud_edition_billing_resource_check, edit_permission_required, enterprise_license_required, setup_required, @@ -27,14 +37,24 @@ from fields.agent_fields import ( AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, + AgentLogMessageListResponse, + AgentLogSourceListResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, + AgentStatisticSummaryEnvelopeResponse, ) +from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account from models.model import IconType from services.agent.errors import AgentNotFoundError +from services.agent.observability_service import ( + AgentLogQueryParams, + AgentObservabilityService, + AgentStatisticsQueryParams, +) from services.agent.roster_service import AgentRosterService from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService @@ -53,35 +73,163 @@ class AgentIdPath(BaseModel): class AgentAppCreatePayload(BaseModel): name: str = Field(..., min_length=1, description="Agent name") description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400) - role: str = Field(default="", description="Agent role", max_length=255) + role: str = Field(..., min_length=1, description="Agent role", max_length=255) icon_type: IconType | None = Field(default=None, description="Icon type") icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") + @field_validator("role") + @classmethod + def validate_role(cls, value: str) -> str: + role = value.strip() + if not role: + raise ValueError("Agent role is required.") + return role -class AgentAppUpdatePayload(UpdateAppPayload): - role: str | None = Field(default=None, description="Agent role", max_length=255) + +# Keep agent-app roster DTOs agent-specific instead of reusing the shared +# /apps response/request models. The roster surface needs Agent-only fields such +# as `role`, while the generic console/apps contracts must stay unchanged. +class AgentAppUpdatePayload(GenericUpdateAppPayload): + role: str = Field(..., min_length=1, description="Agent role", max_length=255) + + @field_validator("role") + @classmethod + def validate_role(cls, value: str) -> str: + role = value.strip() + if not role: + raise ValueError("Agent role is required.") + return role + + +class AgentAppPublishedReferenceResponse(BaseModel): + app_id: str + app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None + + +class AgentLogsQuery(BaseModel): + page: int = Field(default=1, ge=1, description="Page number") + limit: int = Field(default=20, ge=1, le=100, description="Page size") + keyword: str | None = Field(default=None, description="Search query, answer, or conversation name") + status: str | None = Field(default=None, description="Deprecated single status filter") + statuses: list[str] = Field(default_factory=list, description="Filter by one or more of success, failed, paused") + source: str | None = Field( + default=None, + description="Deprecated single source filter", + ) + sources: list[str] = Field( + default_factory=list, + description=( + "Filter by one or more source IDs, e.g. webapp: " + "or workflow::::" + ), + ) + sort_by: str = Field(default="updated_at", description="Sort by created_at or updated_at") + sort_order: str = Field(default="desc", description="Sort order: asc or desc") + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("keyword", "status", "source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + @field_validator("statuses", "sources", mode="before") + @classmethod + def empty_list_values_to_list(cls, value: object) -> list[str]: + if value in (None, ""): + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [item for item in value if item] + return [] + + @field_validator("sort_by") + @classmethod + def validate_sort_by(cls, value: str) -> str: + normalized = value.strip().lower() + if normalized not in {"created_at", "updated_at"}: + raise ValueError("sort_by must be created_at or updated_at") + return normalized + + @field_validator("sort_order") + @classmethod + def validate_sort_order(cls, value: str) -> str: + normalized = value.strip().lower() + if normalized not in {"asc", "desc"}: + raise ValueError("sort_order must be asc or desc") + return normalized + + +class AgentStatisticsQuery(BaseModel): + source: str | None = Field( + default=None, + description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + ) + start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") + end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") + + @field_validator("source", "start", "end", mode="before") + @classmethod + def empty_string_to_none(cls, value: str | None) -> str | None: + if value == "": + return None + return value + + +class AgentAppPartial(GenericAppPartial): + app_id: str | None = None + role: str | None = None + active_config_is_published: bool = False + published_reference_count: int = 0 + published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list) + + +class AgentAppDetailWithSite(GenericAppDetailWithSite): + app_id: str | None = None + role: str | None = None + active_config_is_published: bool = False + + +class AgentAppPagination(GenericAppPagination): + data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute] + validation_alias=AliasChoices("items", "data") + ) register_schema_models( console_ns, AgentAppCreatePayload, AgentAppUpdatePayload, + CopyAppPayload, AgentInviteOptionsQuery, + AgentLogsQuery, + AgentStatisticsQuery, AgentIdPath, AppListQuery, - UpdateAppPayload, RosterListQuery, ) register_response_schema_models( console_ns, - AppDetailWithSite, - AppPagination, + AgentAppPagination, + AgentAppPublishedReferenceResponse, + AgentAppDetailWithSite, + AgentAppPartial, AgentConfigSnapshotDetailResponse, AgentConfigSnapshotListResponse, AgentInviteOptionsResponse, + AgentLogListResponse, + AgentLogMessageListResponse, + AgentLogSourceListResponse, AgentPublishedReferenceResponse, AgentRosterListResponse, + AgentStatisticSummaryEnvelopeResponse, ) @@ -90,29 +238,60 @@ def _agent_roster_service() -> AgentRosterService: def _serialize_agent_app_detail(app_model) -> dict: + """Serialize an Agent App detail using roster-only DTOs. + + `/agent` responses are roster-shaped rather than raw app-shaped: `id` + becomes the backing roster Agent id, `app_id` carries the underlying App + id, and `role` is injected from the backing roster Agent. Keeping that + remap in this serializer lets generated console/agent contracts expose the + roster persona fields without widening the shared /apps detail schema. + """ + app_model = AppService().get_app(app_model) if FeatureService.get_system_features().webapp_auth.enabled: app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined] - agent = _agent_roster_service().get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id) + roster_service = _agent_roster_service() + payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json") + agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id)) if not agent: raise AgentNotFoundError() - payload = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json") payload.pop("bound_agent_id", None) payload["app_id"] = str(app_model.id) payload["id"] = agent.id payload["role"] = agent.role or "" + payload["active_config_is_published"] = roster_service.active_config_is_published( + tenant_id=app_model.tenant_id, + agent=agent, + ) return payload def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: + """Serialize Agent App lists with roster-shaped items. + + Each item starts from the shared App list shape, then drops + `bound_agent_id`, rewrites `id` to the backing roster Agent id, stores the + original App id in `app_id`, and injects roster-only `role` when a backing + Agent is present. + """ + app_ids = [str(app.id) for app in app_pagination.items] - agents_by_app_id = _agent_roster_service().load_app_backing_agents_by_app_id( + roster_service = _agent_roster_service() + agents_by_app_id = roster_service.load_app_backing_agents_by_app_id( tenant_id=tenant_id, app_ids=app_ids, ) - payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") + active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=list(agents_by_app_id.values()), + ) + published_references_by_agent_id = roster_service.load_published_references_by_agent_id( + tenant_id=tenant_id, + agent_ids=[agent.id for agent in agents_by_app_id.values()], + ) + payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") for item in payload["data"]: app_id = item["id"] item.pop("bound_agent_id", None) @@ -121,17 +300,57 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: item["app_id"] = app_id item["id"] = agent.id item["role"] = agent.role or "" - return payload + item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) + published_references = published_references_by_agent_id.get(agent.id, []) + item["published_reference_count"] = len(published_references) + item["published_references"] = [ + { + "app_id": reference["app_id"], + "app_name": reference["app_name"], + "app_icon_type": reference["app_icon_type"], + "app_icon": reference["app_icon"], + "app_icon_background": reference["app_icon_background"], + } + for reference in published_references + ] + return AgentAppPagination.model_validate(payload).model_dump( + mode="json", + exclude={"data": {"__all__": {"bound_agent_id"}}}, + ) def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID): - return _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id)) + return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + + +def _agent_observability_service() -> AgentObservabilityService: + return AgentObservabilityService(db.session) + + +def _parse_observability_time_range(start: str | None, end: str | None, account: Account): + timezone = account.timezone or "UTC" + try: + return parse_time_range(start, end, timezone) + except ValueError as exc: + abort(400, description=str(exc)) + + +def _multi_query_values(name: str, legacy_name: str | None = None) -> list[str]: + values: list[str] = [] + for query_name in (name, f"{name}[]"): + values.extend(request.args.getlist(query_name)) + if legacy_name: + values.extend(request.args.getlist(legacy_name)) + parsed: list[str] = [] + for value in values: + parsed.extend(item.strip() for item in value.split(",") if item.strip()) + return parsed @console_ns.route("/agent") class AgentAppListApi(Resource): @console_ns.doc(params=query_params_from_model(AppListQuery)) - @console_ns.response(200, "Agent app list", console_ns.models[AppPagination.__name__]) + @console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__]) @setup_required @login_required @account_initialization_required @@ -150,21 +369,20 @@ class AgentAppListApi(Resource): status="normal", ) - app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params) + app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, db.session) if app_pagination is None: - empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) + empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json") return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id) @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) - @console_ns.response(201, "Agent app created successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(400, "Invalid request parameters") @setup_required @login_required @account_initialization_required - @cloud_edition_billing_resource_check("apps") @edit_permission_required @with_current_user @with_current_tenant_id @@ -186,7 +404,7 @@ class AgentAppListApi(Resource): @console_ns.route("/agent/") class AgentAppApi(Resource): - @console_ns.response(200, "Agent app detail", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(200, "Agent app detail", console_ns.models[AgentAppDetailWithSite.__name__]) @setup_required @login_required @account_initialization_required @@ -197,7 +415,7 @@ class AgentAppApi(Resource): return _serialize_agent_app_detail(app_model) @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) - @console_ns.response(200, "Agent app updated successfully", console_ns.models[AppDetailWithSite.__name__]) + @console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @console_ns.response(403, "Insufficient permissions") @console_ns.response(400, "Invalid request parameters") @setup_required @@ -234,6 +452,33 @@ class AgentAppApi(Resource): return "", 204 +@console_ns.route("/agent//copy") +class AgentAppCopyApi(Resource): + @console_ns.expect(console_ns.models[CopyAppPayload.__name__]) + @console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__]) + @console_ns.response(403, "Insufficient permissions") + @console_ns.response(400, "Invalid request parameters") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @with_current_user + @with_current_tenant_id + def post(self, tenant_id: str, current_user: Account, agent_id: UUID): + args = CopyAppPayload.model_validate(console_ns.payload or {}) + copied_app = _agent_roster_service().duplicate_agent_app( + tenant_id=tenant_id, + agent_id=str(agent_id), + account=current_user, + name=args.name, + description=args.description, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) + return _serialize_agent_app_detail(copied_app), 201 + + @console_ns.route("/agent/invite-options") class AgentInviteOptionsApi(Resource): @console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery)) @@ -256,6 +501,124 @@ class AgentInviteOptionsApi(Resource): ) +@console_ns.route("/agent//logs") +class AgentLogsApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentLogsQuery)) + @console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) + query_data["sources"] = _multi_query_values("sources", "source") + query_data["statuses"] = _multi_query_values("statuses", "status") + query = AgentLogsQuery.model_validate(query_data) + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().list_logs( + app=app_model, + agent_id=str(agent_id), + params=AgentLogQueryParams( + page=query.page, + limit=query.limit, + keyword=query.keyword, + statuses=tuple(query.statuses), + sources=tuple(query.sources), + sort_by=query.sort_by, + sort_order=query.sort_order, + start=start, + end=end, + ), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentLogListResponse, payload) + + +@console_ns.route("/agent//logs//messages") +class AgentLogMessagesApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentLogsQuery)) + @console_ns.response(200, "Agent log messages", console_ns.models[AgentLogMessageListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) + query_data["sources"] = _multi_query_values("sources", "source") + query_data["statuses"] = _multi_query_values("statuses", "status") + query = AgentLogsQuery.model_validate(query_data) + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().list_log_messages( + app=app_model, + agent_id=str(agent_id), + conversation_id=str(conversation_id), + params=AgentLogQueryParams( + page=query.page, + limit=query.limit, + keyword=query.keyword, + statuses=tuple(query.statuses), + sources=tuple(query.sources), + sort_by=query.sort_by, + sort_order=query.sort_order, + start=start, + end=end, + ), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentLogMessageListResponse, payload) + + +@console_ns.route("/agent//log-sources") +class AgentLogSourcesApi(Resource): + @console_ns.response(200, "Agent log sources", console_ns.models[AgentLogSourceListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id)) + return dump_response(AgentLogSourceListResponse, payload) + + +@console_ns.route("/agent//statistics/summary") +class AgentStatisticsSummaryApi(Resource): + @console_ns.doc(params=query_params_from_model(AgentStatisticsQuery)) + @console_ns.response( + 200, + "Agent monitoring summary and chart data", + console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @with_current_user + @with_current_tenant_id + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): + app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True)) + timezone = current_user.timezone or "UTC" + start, end = _parse_observability_time_range(query.start, query.end, current_user) + try: + payload = _agent_observability_service().get_statistics_summary( + app=app_model, + agent_id=str(agent_id), + params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone), + ) + except ValueError as exc: + abort(400, description=str(exc)) + return dump_response(AgentStatisticSummaryEnvelopeResponse, payload) + + @console_ns.route("/agent//versions") class AgentRosterVersionsApi(Resource): @console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__]) diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 57470dc9770..dcea303d7cc 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden +from configs import dify_config from controllers.common.schema import register_response_schema_models from extensions.ext_database import db from fields.base import ResponseModel @@ -22,8 +23,11 @@ from services.api_token_service import ApiTokenCache from . import console_ns from .wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -143,7 +147,7 @@ class BaseApiKeyResource(Resource): assert self.resource_id_field is not None, "resource_id_field must be set" _get_resource(resource_id, current_tenant_id, self.resource_model) - if not current_user.is_admin_or_owner: + if not dify_config.RBAC_ENABLED and not current_user.is_admin_or_owner: raise Forbidden() key = db.session.scalar( @@ -186,6 +190,7 @@ class AppApiKeyListResource(BaseApiKeyListResource): @console_ns.response(400, "Maximum keys exceeded") @with_current_tenant_id @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for an app""" return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201 @@ -204,6 +209,7 @@ class AppApiKeyResource(BaseApiKeyResource): @console_ns.response(204, "API key deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) def delete( self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID ) -> tuple[str, int]: @@ -234,6 +240,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource): @console_ns.response(400, "Maximum keys exceeded") @with_current_tenant_id @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE) def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for a dataset""" return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201 @@ -252,6 +259,7 @@ class DatasetApiKeyResource(BaseApiKeyResource): @console_ns.response(204, "API key deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE) def delete( self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID ) -> tuple[str, int]: diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 23ccd28ad6f..a53a174da42 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -17,7 +17,10 @@ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -30,7 +33,7 @@ from models import Account from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig from models.model import App, AppMode, UploadFile from services.agent.composer_service import AgentComposerService -from services.agent.skill_package_service import SkillManifest, SkillPackageError, SkillPackageService +from services.agent.skill_package_service import SkillManifest, SkillPackageError from services.agent.skill_standardize_service import SkillStandardizeService from services.agent.skill_tool_inference_service import ( SkillToolInferenceError, @@ -45,11 +48,18 @@ from services.agent_drive_service import ( normalize_drive_key, ) from services.agent_service import AgentService -from services.file_service import FileService logger = logging.getLogger(__name__) _WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT] +_AGENT_SKILL_UPLOAD_PARAMS = { + "file": { + "in": "formData", + "type": "file", + "required": True, + "description": "Skill package (.zip or .skill).", + } +} class AgentLogQuery(BaseModel): @@ -125,11 +135,6 @@ class AgentSkillUploadResponse(ResponseModel): manifest: SkillManifest -class AgentSkillStandardizeResponse(ResponseModel): - skill: AgentSkillRefConfig - manifest: SkillManifest - - class AgentDriveFileResponse(ResponseModel): name: str drive_key: str @@ -156,7 +161,6 @@ register_response_schema_models( AgentDriveFileCommitResponse, AgentDriveFileResponse, AgentLogResponse, - AgentSkillStandardizeResponse, AgentSkillUploadResponse, SkillToolInferenceResult, ) @@ -174,30 +178,9 @@ def _agent_not_bound() -> tuple[dict[str, str], int]: return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400 -def _upload_skill_for_app(*, current_user: Account): - if "file" not in request.files: - return {"code": "no_file", "message": "no skill file uploaded"}, 400 - if len(request.files) > 1: - return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400 +def _upload_skill_for_app(*, current_user: Account, app_model: App): + """Upload one skill package and commit its normalized files into the agent drive.""" - upload = request.files["file"] - content = upload.stream.read() - try: - manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "") - except SkillPackageError as exc: - return {"code": exc.code, "message": exc.message}, exc.status_code - - upload_file = FileService(db.engine).upload_file( - filename=upload.filename or "skill.zip", - content=content, - mimetype=upload.mimetype or "application/zip", - user=current_user, - ) - skill_ref = manifest.to_skill_ref(file_id=upload_file.id) - return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201 - - -def _standardize_skill_for_app(*, current_user: Account, app_model: App): query = query_params_from_request(AgentDriveMutationQuery) agent_id = _resolve_agent_id(app_model, query.node_id) if not agent_id: @@ -371,6 +354,7 @@ class AgentLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.AGENT_CHAT]) def get(self, app_model: App): """Get agent logs""" @@ -382,51 +366,9 @@ class AgentLogApi(Resource): @console_ns.route("/agent//skills/upload") class AgentSkillUploadByAgentApi(Resource): @console_ns.doc("upload_agent_skill_by_agent") - @console_ns.doc(description="Upload + validate a Skill package for an Agent App") - @console_ns.doc(params={"agent_id": "Agent ID"}) - @console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__]) - @console_ns.response(400, "Invalid skill package") - @setup_required - @login_required - @account_initialization_required - @with_current_user - @with_current_tenant_id - def post(self, tenant_id: str, current_user: Account, agent_id: UUID): - resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - return _upload_skill_for_app(current_user=current_user) - - -@console_ns.route("/apps//agent/skills/upload") -class AgentSkillUploadApi(Resource): - @console_ns.doc("upload_agent_skill") - @console_ns.doc(description="Upload + validate a Skill package (.zip/.skill) and extract its manifest") - @console_ns.doc(params={"app_id": "Application ID"}) - @console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__]) - @console_ns.response(400, "Invalid skill package") - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) - @with_current_user - def post(self, current_user: Account, app_model: App): - """Validate an uploaded Skill package and persist the archive. - - Returns a validated skill ref (to bind into the Agent soul config on save) - plus its manifest. Standardizing into the agent drive is ENG-594. - """ - return _upload_skill_for_app(current_user=current_user) - - -@console_ns.route("/agent//skills/standardize") -class AgentSkillStandardizeByAgentApi(Resource): - @console_ns.doc("standardize_agent_skill_by_agent") - @console_ns.doc(description="Validate + standardize a Skill into an Agent App drive") - @console_ns.doc(params={"agent_id": "Agent ID"}) - @console_ns.response( - 201, - "Skill standardized into drive", - console_ns.models[AgentSkillStandardizeResponse.__name__], - ) + @console_ns.doc(description="Upload + standardize a Skill into an Agent App drive") + @console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS}) + @console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__]) @console_ns.response(400, "Invalid skill package or no bound agent") @setup_required @login_required @@ -435,19 +377,22 @@ class AgentSkillStandardizeByAgentApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, current_user: Account, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - return _standardize_skill_for_app(current_user=current_user, app_model=app_model) + return _upload_skill_for_app(current_user=current_user, app_model=app_model) -@console_ns.route("/apps//agent/skills/standardize") -class AgentSkillStandardizeApi(Resource): - @console_ns.doc("standardize_agent_skill") - @console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)") - @console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)}) - @console_ns.response( - 201, - "Skill standardized into drive", - console_ns.models[AgentSkillStandardizeResponse.__name__], +@console_ns.route("/apps//agent/skills/upload") +class AgentSkillUploadApi(Resource): + @console_ns.doc("upload_agent_skill") + @console_ns.doc(description="Upload + standardize a Skill into the agent drive") + @console_ns.doc( + consumes=["multipart/form-data"], + params={ + "app_id": "Application ID", + **query_params_from_model(AgentDriveMutationQuery), + **_AGENT_SKILL_UPLOAD_PARAMS, + }, ) + @console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__]) @console_ns.response(400, "Invalid skill package or no bound agent") @setup_required @login_required @@ -455,8 +400,8 @@ class AgentSkillStandardizeApi(Resource): @get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES) @with_current_user def post(self, current_user: Account, app_model: App): - """Upload a Skill, validate it, and standardize it into the app agent's drive.""" - return _standardize_skill_for_app(current_user=current_user, app_model=app_model) + """Upload a Skill, validate it, and commit drive-backed skill files.""" + return _upload_skill_for_app(current_user=current_user, app_model=app_model) @console_ns.route("/agent//files") diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 79d7589873c..d155dae6ac3 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -19,13 +19,17 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, ) from events.app_event import app_model_config_was_updated +from extensions.ext_database import db from libs.helper import dump_response from libs.login import login_required from models import Account @@ -78,6 +82,7 @@ class AgentAppFeatureConfigResource(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @account_initialization_required @with_current_user @with_current_tenant_id @@ -86,9 +91,7 @@ class AgentAppFeatureConfigResource(Resource): args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {}) new_app_model_config = AgentAppFeatureConfigService.update_features( - app_model=app_model, - account=current_user, - config=args.model_dump(exclude_none=True), + app_model=app_model, account=current_user, config=args.model_dump(exclude_none=True), session=db.session ) app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config) diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index dd0b7e9ef5f..edf3a98af8c 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -9,11 +9,14 @@ from controllers.common.errors import NoFileUploadedError, TooManyFilesError 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.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, annotation_import_concurrency_limit, annotation_import_rate_limit, cloud_edition_billing_resource_check, edit_permission_required, + rbac_permission_required, setup_required, ) from extensions.ext_redis import redis_client @@ -155,6 +158,7 @@ class AnnotationReplyActionApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID, action: Literal["enable", "disable"]): args = AnnotationReplyPayload.model_validate(console_ns.payload) match action: @@ -185,6 +189,7 @@ class AppAnnotationSettingDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID): result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id)) return result, 200 @@ -202,6 +207,7 @@ class AppAnnotationSettingUpdateApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID, annotation_setting_id: UUID): annotation_setting_id_str = str(annotation_setting_id) @@ -230,6 +236,7 @@ class AnnotationReplyActionStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID, job_id: UUID, action: str): job_id_str = str(job_id) app_annotation_job_key = f"{action}_app_annotation_job_{job_id_str}" @@ -258,6 +265,7 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID): args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page @@ -286,6 +294,7 @@ class AnnotationApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID): args = CreateAnnotationPayload.model_validate(console_ns.payload) upsert_args: UpsertAnnotationArgs = {} @@ -304,6 +313,7 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @console_ns.response(204, "Annotations deleted successfully") def delete(self, app_id: UUID): @@ -342,6 +352,7 @@ class AnnotationExportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) 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) @@ -369,6 +380,7 @@ class AnnotationUpdateDeleteApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID, annotation_id: UUID): args = UpdateAnnotationPayload.model_validate(console_ns.payload) update_args: UpdateAnnotationArgs = {} @@ -383,6 +395,7 @@ class AnnotationUpdateDeleteApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @console_ns.response(204, "Annotation deleted successfully") def delete(self, app_id: UUID, annotation_id: UUID): AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id)) @@ -410,6 +423,7 @@ class AnnotationBatchImportApi(Resource): @annotation_import_rate_limit @annotation_import_concurrency_limit @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID): from configs import dify_config @@ -462,6 +476,7 @@ class AnnotationBatchImportStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) 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) @@ -492,6 +507,7 @@ class AnnotationHitHistoryListApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) 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) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 5cd674e4eab..cd8d9ff3785 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -3,7 +3,7 @@ import re import uuid from collections.abc import Sequence from datetime import datetime -from typing import Any, Literal, cast +from typing import Any, Literal from flask import request from flask_restx import Resource @@ -11,8 +11,9 @@ from pydantic import AliasChoices, BaseModel, Field, computed_field, field_valid from sqlalchemy import select from sqlalchemy.orm import Session from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound +from configs import dify_config from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse from controllers.common.helpers import FileInfo from controllers.common.schema import ( @@ -25,11 +26,14 @@ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model, with_session from controllers.console.workspace.models import LoadBalancingPayload from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_resource_check, edit_permission_required, enterprise_license_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -48,6 +52,7 @@ from models import Account, App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams +from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -72,12 +77,14 @@ _logger = logging.getLogger(__name__) _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] +DEFAULT_APP_LIST_MODE: AppListMode = "all" +APP_LIST_PERMISSION_KEYS = frozenset({"app.preview", "app.acl.preview", "app.full_access"}) class AppListBaseQuery(BaseModel): page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)") limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)") - mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter") + mode: AppListMode = Field(default=DEFAULT_APP_LIST_MODE, description="App mode filter") sort_by: AppListSortBy = Field( default="last_modified", description="Sort apps by last modified, recently created, or earliest created", @@ -160,6 +167,10 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, return normalized +def _has_app_list_permission(permission_keys: Sequence[str]) -> bool: + return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys) + + 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) @@ -398,12 +409,13 @@ class AppPartial(ResponseModel): create_user_name: str | None = None author_name: str | None = None has_draft_trigger: bool | None = None + permission_keys: list[str] = Field(default_factory=list) # For Agent App type: the roster Agent backing this app (None otherwise). bound_agent_id: str | None = None # For Agent App responses exposed through /agent. app_id: str | None = None - role: str | None = None is_starred: bool = False + maintainer: str | None = None @computed_field(return_type=str | None) # type: ignore @property @@ -439,6 +451,8 @@ class AppDetail(ResponseModel): updated_at: int | None = None access_mode: str | None = None tags: list[Tag] = Field(default_factory=list) + permission_keys: list[str] = Field(default_factory=list) + maintainer: str | None = None @field_validator("created_at", "updated_at", mode="before") @classmethod @@ -456,7 +470,6 @@ class AppDetailWithSite(AppDetail): bound_agent_id: str | None = None # For Agent App responses exposed through /agent. app_id: str | None = None - role: str | None = None @computed_field(return_type=str | None) # type: ignore @property @@ -539,10 +552,7 @@ register_schema_models( ModelConfig, Site, DeletedTool, - AppPartial, AppDetail, - AppDetailWithSite, - AppPagination, AppExportResponse, Segmentation, PreProcessingRule, @@ -562,6 +572,13 @@ register_schema_models( LoadBalancingPayload, ) +register_response_schema_models( + console_ns, + AppPartial, + AppDetailWithSite, + AppPagination, +) + @console_ns.route("/apps") class AppListApi(Resource): @@ -590,16 +607,65 @@ class AppListApi(Resource): is_created_by_me=args.is_created_by_me, ) + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user_id, + ) + if dify_config.RBAC_ENABLED: + whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources( + str(current_tenant_id), + current_user_id, + ) + can_manage_own_apps = "app.create_and_management" in permissions.workspace.permission_keys + has_default_preview = _has_app_list_permission( + permissions.app.default_permission_keys + ) or _has_app_list_permission(permissions.workspace.permission_keys) + permission_app_ids: set[str] | None = None + if not has_default_preview: + permission_app_ids = { + override.resource_id + for override in permissions.app.overrides + if _has_app_list_permission(override.permission_keys) + } + + if getattr(whitelist_scope, "unrestricted", False): + accessible_app_ids = permission_app_ids + else: + accessible_app_ids = set(whitelist_scope.resource_ids) + if permission_app_ids is not None: + accessible_app_ids |= permission_app_ids + elif has_default_preview: + accessible_app_ids = None + + if accessible_app_ids: + params.accessible_app_ids = sorted(accessible_app_ids) + params.include_own_apps = can_manage_own_apps + elif accessible_app_ids is not None and can_manage_own_apps: + params.is_created_by_me = True + elif accessible_app_ids is not None: + params.accessible_app_ids = [] + # get app list app_service = AppService() - app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params) + app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session) 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 + app_ids = [str(app.id) for app in app_pagination.items] + permission_keys_map = permissions.app.permission_keys_by_resource_ids(app_ids) _enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id) pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True) + if app_pagination.items: + pagination_model = pagination_model.model_copy( + update={ + "data": [ + item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])}) + for item in pagination_model.data + ] + } + ) return pagination_model.model_dump(mode="json"), 200 @console_ns.doc("create_app") @@ -611,6 +677,7 @@ class AppListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT, resource_required=False) @cloud_edition_billing_resource_check("apps") @edit_permission_required @with_current_user @@ -629,7 +696,14 @@ class AppListApi(Resource): app_service = AppService() app = app_service.create_app(current_tenant_id, params, current_user) - app_detail = AppDetailWithSite.model_validate(app, from_attributes=True) + permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [str(app.id)], + ) + app_detail = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(str(app.id), [])} + ) return app_detail.model_dump(mode="json"), 201 @@ -659,7 +733,7 @@ class StarredAppListApi(Resource): is_created_by_me=args.is_created_by_me, ) - app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params) + app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params, db.session) 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 @@ -715,8 +789,11 @@ class AppApi(Resource): @login_required @account_initialization_required @enterprise_license_required + @with_current_user + @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=None) - def get(self, app_model: App): + def get(self, current_tenant_id: str, current_user: Account, app_model: App): """Get app detail""" app_service = AppService() @@ -726,7 +803,16 @@ class AppApi(Resource): app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) app_model.access_mode = app_setting.access_mode - response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True) + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user.id, + app_id=str(app_model.id), + ) + permission_keys_map = permissions.app.permission_keys_by_resource_ids([str(app_model.id)]) + + response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(str(app_model.id), [])} + ) return response_model.model_dump(mode="json") @console_ns.doc("update_app") @@ -739,8 +825,9 @@ class AppApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=None) def put(self, app_model: App): """Update app""" args = UpdateAppPayload.model_validate(console_ns.payload) @@ -765,11 +852,12 @@ class AppApi(Resource): @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response(204, "App deleted successfully") @console_ns.response(403, "Insufficient permissions") - @get_app_model @setup_required @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + @get_app_model def delete(self, app_model: App): """Delete app""" app_service = AppService() @@ -789,10 +877,12 @@ class AppCopyApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @with_current_user - def post(self, current_user: Account, app_model: App): + @with_current_tenant_id + @get_app_model(mode=None) + def post(self, current_tenant_id: str, current_user: Account, app_model: App): """Copy app""" # The role of the current user in the ta table must be admin, owner, or editor args = CopyAppPayload.model_validate(console_ns.payload or {}) @@ -834,7 +924,17 @@ class AppCopyApi(Resource): stmt = select(App).where(App.id == result.app_id) app = session.scalar(stmt) - response_model = AppDetailWithSite.model_validate(app, from_attributes=True) + if not app: + raise NotFound("App not found") + + permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [str(app.id)], + ) + response_model = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(str(app.id), [])} + ) return response_model.model_dump(mode="json"), 201 @@ -846,11 +946,12 @@ class AppExportApi(Resource): @console_ns.doc(params=query_params_from_model(AppExportQuery)) @console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__]) @console_ns.response(403, "Insufficient permissions") - @get_app_model @setup_required @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL) + @get_app_model def get(self, app_model: App): """Export app""" args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) @@ -871,12 +972,12 @@ class AppPublishToCreatorsPlatformApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL) @with_current_user_id + @get_app_model(mode=None) def post(self, current_user_id: str, app_model: App): """Publish app to Creators Platform""" - from configs import dify_config from core.helper.creators import get_redirect_url, upload_dsl if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: @@ -901,8 +1002,9 @@ class AppNameApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=None) def post(self, app_model: App): args = AppNamePayload.model_validate(console_ns.payload) @@ -923,8 +1025,9 @@ class AppIconApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=None) def post(self, app_model: App): args = AppIconPayload.model_validate(console_ns.payload or {}) @@ -950,8 +1053,9 @@ class AppSiteStatus(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @get_app_model(mode=None) def post(self, app_model: App): args = AppSiteStatusPayload.model_validate(console_ns.payload) @@ -973,6 +1077,7 @@ class AppApiStatus(Resource): @login_required @is_admin_or_owner_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @get_app_model(mode=None) def post(self, app_model: App): args = AppApiStatusPayload.model_validate(console_ns.payload) @@ -997,6 +1102,7 @@ class AppTraceApi(Resource): @login_required @account_initialization_required @with_session + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model def get(self, session: Session, app_model: App): """Get app trace""" @@ -1018,6 +1124,7 @@ class AppTraceApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model def post(self, app_model: App): # add app trace diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index cbdcdc8f10b..d58c5df1e96 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -2,25 +2,36 @@ from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from configs import dify_config 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 ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_resource_check, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) from extensions.ext_database import db -from libs.login import login_required +from extensions.ext_redis import redis_client +from libs.login import current_account_with_tenant, login_required from models.account import Account from models.model import App -from services.app_dsl_service import AppDslService, Import +from services.app_dsl_service import ( + IMPORT_INFO_REDIS_KEY_PREFIX, + AppDslService, + Import, + PendingData, +) from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus from services.feature_service import FeatureService from .. import console_ns +from .permission_keys import get_app_permission_keys class AppImportPayload(BaseModel): @@ -39,6 +50,24 @@ register_enum_models(console_ns, ImportStatus) register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) +def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]: + if current_user is None: + account, tenant_id = current_account_with_tenant() + return account, str(tenant_id) if tenant_id else None + + current_tenant_id = getattr(current_user, "current_tenant_id", None) + if current_tenant_id: + return current_user, str(current_tenant_id) + + current_tenant = getattr(current_user, "current_tenant", None) + current_tenant_object_id = getattr(current_tenant, "id", None) + if current_tenant_object_id: + return current_user, str(current_tenant_object_id) + + account, fallback_tenant_id = current_account_with_tenant() + return account, str(fallback_tenant_id) if fallback_tenant_id else None + + @console_ns.route("/apps/imports") class AppImportApi(Resource): @console_ns.expect(console_ns.models[AppImportPayload.__name__]) @@ -50,10 +79,11 @@ class AppImportApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("apps") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False) @with_current_user - def post(self, current_user: Account): - # Check user role first + def post(self, current_user: Account | None = None): args = AppImportPayload.model_validate(console_ns.payload) + current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0] # AppDslService performs internal commits for some creation paths, so use a plain # Session here instead of nesting it inside sessionmaker(...).begin(). @@ -77,6 +107,20 @@ class AppImportApi(Resource): session.rollback() else: session.commit() + + is_created_app = args.app_id is None and result.status in { + ImportStatus.COMPLETED, + ImportStatus.COMPLETED_WITH_WARNINGS, + } + if dify_config.RBAC_ENABLED and is_created_app and result.app_id: + current_user, current_tenant_id = _current_user_and_tenant_id(current_user) + if current_tenant_id: + result.permission_keys = get_app_permission_keys( + current_tenant_id, + current_user.id, + result.app_id, + ) + if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: # update web app setting as private EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") @@ -99,9 +143,16 @@ class AppImportConfirmApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False) @with_current_user - def post(self, current_user: Account, import_id: str): - # Check user role first + def post(self, current_user: Account | None = None, import_id: str = ""): + current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0] + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + pending_data_raw = redis_client.get(redis_key) + pending_data: PendingData | None = None + if pending_data_raw: + pending_data = PendingData.model_validate_json(pending_data_raw) + with Session(db.engine, expire_on_commit=False) as session: import_service = AppDslService(session) # Confirm import @@ -112,6 +163,24 @@ class AppImportConfirmApi(Resource): else: session.commit() + is_created_app = bool( + pending_data + and pending_data.app_id is None + and result.status + in { + ImportStatus.COMPLETED, + ImportStatus.COMPLETED_WITH_WARNINGS, + } + ) + if dify_config.RBAC_ENABLED and is_created_app and result.app_id: + current_user, current_tenant_id = _current_user_and_tenant_id(current_user) + if current_tenant_id: + result.permission_keys = get_app_permission_keys( + current_tenant_id, + current_user.id, + result.app_id, + ) + # Return appropriate status code based on result if result.status == ImportStatus.FAILED: return result.model_dump(mode="json"), 400 @@ -120,12 +189,17 @@ class AppImportConfirmApi(Resource): @console_ns.route("/apps/imports//check-dependencies") class AppImportCheckDependenciesApi(Resource): - @console_ns.response(200, "Dependencies checked", console_ns.models[CheckDependenciesResult.__name__]) + @console_ns.response( + 200, + "Dependencies checked", + console_ns.models[CheckDependenciesResult.__name__], + ) @setup_required @login_required @get_app_model @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_model: App): with Session(db.engine, expire_on_commit=False) as session: import_service = AppDslService(session) diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 7ef43570c2f..43b41903f60 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -22,7 +22,13 @@ from controllers.console.app.error import ( UnsupportedAudioTypeError, ) from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError from libs.login import login_required @@ -126,10 +132,10 @@ class ChatMessageTextApi(Resource): console_ns.models[AudioBinaryResponse.__name__], ) @console_ns.response(400, "Bad request - Invalid parameters") - @get_app_model @setup_required @login_required @account_initialization_required + @get_app_model def post(self, app_model: App): try: payload = TextToSpeechPayload.model_validate(console_ns.payload) @@ -180,10 +186,11 @@ class TextModesApi(Resource): console_ns.models[TextToSpeechVoiceListResponse.__name__], ) @console_ns.response(400, "Invalid language parameter") - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App): try: args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 853f7b023ff..62b95ad22e4 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -22,8 +22,11 @@ from controllers.console.app.error import ( ) from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -113,8 +116,9 @@ class CompletionMessageApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @get_app_model(mode=AppMode.COMPLETION) def post(self, current_user: Account, app_model: App): args_model = CompletionMessagePayload.model_validate(console_ns.payload) args = args_model.model_dump(exclude_none=True, by_alias=True) @@ -159,8 +163,8 @@ class CompletionMessageStopApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @with_current_user_id + @get_app_model(mode=AppMode.COMPLETION) def post(self, current_user_id: str, app_model: App, task_id: str): AppTaskService.stop_task( @@ -185,9 +189,10 @@ class ChatMessageApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT]) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT]) def post(self, current_user: Account, app_model: App): return _create_chat_message(current_user=current_user, app_model=app_model) @@ -205,6 +210,7 @@ class AgentChatMessageApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): @@ -221,8 +227,8 @@ class ChatMessageStopApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user_id + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def post(self, current_user_id: str, app_model: App, task_id: str): return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index b9fcf2073d7..ec34c26fedd 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -13,8 +13,11 @@ from controllers.common.schema import query_params_from_model, register_schema_m from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -97,9 +100,10 @@ class CompletionConversationApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.COMPLETION) def get(self, current_user: Account, app_model: App): args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) @@ -169,9 +173,10 @@ class CompletionConversationDetailApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.COMPLETION) def get(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) return ConversationMessageDetailResponse.model_validate( @@ -187,9 +192,10 @@ class CompletionConversationDetailApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=AppMode.COMPLETION) def delete(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) @@ -212,9 +218,10 @@ class ChatConversationApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App): args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) @@ -323,9 +330,10 @@ class ChatConversationDetailApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) return ConversationDetailResponse.model_validate( @@ -340,10 +348,11 @@ class ChatConversationDetailApi(Resource): @console_ns.response(404, "Conversation not found") @setup_required @login_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def delete(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 9cf3f278eac..3069dd3011c 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -12,7 +12,13 @@ from sqlalchemy.orm import sessionmaker from controllers.common.schema import query_params_from_model, 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.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from extensions.ext_database import db from fields._value_type_serializer import serialize_value_type from fields.base import ResponseModel @@ -93,6 +99,7 @@ class ConversationVariablesApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=AppMode.ADVANCED_CHAT) def get(self, app_model: App): args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 8d1eb700739..6d6a56b5e1d 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -12,8 +12,11 @@ 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 ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, ) @@ -83,6 +86,7 @@ class AppMCPServerController(Resource): @login_required @account_initialization_required @setup_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model def get(self, app_model: App): server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1)) @@ -99,11 +103,12 @@ class AppMCPServerController(Resource): ) @console_ns.response(403, "Insufficient permissions") @account_initialization_required - @get_app_model @login_required @setup_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_tenant_id + @get_app_model def post(self, current_tenant_id: str, app_model: App): payload = MCPServerCreatePayload.model_validate(console_ns.payload or {}) @@ -133,11 +138,12 @@ class AppMCPServerController(Resource): ) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") - @get_app_model @login_required @setup_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model def put(self, app_model: App): payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {}) server = db.session.get(AppMCPServer, payload.id) @@ -174,6 +180,7 @@ class AppMCPServerRefreshController(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id def get(self, current_tenant_id: str, server_id: UUID): server = db.session.scalar( diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index ef112b1b1e4..9944f02207f 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -23,8 +23,11 @@ from controllers.console.app.error import ( from controllers.console.app.wraps import get_app_model from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -182,8 +185,9 @@ class ChatMessageListApi(Resource): @login_required @account_initialization_required @setup_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, app_model: App): return _list_chat_messages(app_model=app_model) @@ -200,6 +204,7 @@ class AgentChatMessageListApi(Resource): @account_initialization_required @setup_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id def get(self, current_tenant_id: str, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) @@ -215,11 +220,11 @@ class MessageFeedbackApi(Resource): @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(404, "Message not found") @console_ns.response(403, "Insufficient permissions") - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @get_app_model def post(self, current_user: Account, app_model: App): return _update_message_feedback(current_user=current_user, app_model=app_model) @@ -252,10 +257,11 @@ class MessageAnnotationCountApi(Resource): "Annotation count retrieved successfully", console_ns.models[AnnotationCountResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App): count = db.session.scalar( select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id) @@ -278,8 +284,9 @@ class MessageSuggestedQuestionApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App, message_id: UUID): return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) @@ -318,10 +325,11 @@ class MessageFeedbackExportApi(Resource): ) @console_ns.response(400, "Invalid parameters") @console_ns.response(500, "Internal server error") - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App): args = FeedbackExportQuery.model_validate(request.args.to_dict()) @@ -330,6 +338,7 @@ class MessageFeedbackExportApi(Resource): try: export_data = FeedbackService.export_feedbacks( + db.session(), app_id=app_model.id, from_source=args.from_source, rating=args.rating, @@ -356,10 +365,11 @@ class MessageApi(Resource): @console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"}) @console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__]) @console_ns.response(404, "Message not found") - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App, message_id: UUID): return _get_message_detail(app_model=app_model, message_id=message_id) diff --git a/api/controllers/console/app/model_config.py b/api/controllers/console/app/model_config.py index 6e2e20c0a35..3a016e3b9b2 100644 --- a/api/controllers/console/app/model_config.py +++ b/api/controllers/console/app/model_config.py @@ -10,8 +10,11 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user_id, @@ -86,10 +89,11 @@ class ModelConfigResource(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @account_initialization_required - @get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION]) @with_current_user_id @with_current_tenant_id + @get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION]) def post(self, current_tenant_id: str, current_user_id: str, app_model: App): """Modify app model config""" # validate config diff --git a/api/controllers/console/app/ops_trace.py b/api/controllers/console/app/ops_trace.py index c9f9308c2ea..d350ff52770 100644 --- a/api/controllers/console/app/ops_trace.py +++ b/api/controllers/console/app/ops_trace.py @@ -9,7 +9,13 @@ from controllers.common.schema import query_params_from_model, register_response 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 controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from fields.base import ResponseModel from libs.login import login_required from models import App @@ -64,6 +70,7 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model def get(self, app_model: App): args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore diff --git a/api/controllers/console/app/permission_keys.py b/api/controllers/console/app/permission_keys.py new file mode 100644 index 00000000000..810ea04e377 --- /dev/null +++ b/api/controllers/console/app/permission_keys.py @@ -0,0 +1,6 @@ +from services.enterprise import rbac_service as enterprise_rbac_service + + +def get_app_permission_keys(tenant_id: str, account_id: str | None, app_id: str) -> list[str]: + permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(tenant_id, account_id, [app_id]) + return permission_keys_map.get(app_id, []) diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index df398fa7b9c..edc79f8fbc6 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -10,9 +10,12 @@ 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 ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -85,9 +88,10 @@ class AppSite(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @account_initialization_required - @get_app_model @with_current_user + @get_app_model def post(self, current_user: Account, app_model: App): args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) @@ -134,9 +138,10 @@ class AppSiteAccessTokenReset(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @account_initialization_required - @get_app_model @with_current_user + @get_app_model def post(self, current_user: Account, app_model: App): site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index bc0120fe4f8..fbb6d3e987f 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -8,7 +8,14 @@ from pydantic import BaseModel, Field, field_validator 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, with_current_user +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, + with_current_user, +) from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from fields.base import ResponseModel @@ -131,11 +138,12 @@ class DailyMessageStatistic(Resource): "Daily message statistics retrieved successfully", console_ns.models[DailyMessageStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -191,11 +199,12 @@ class DailyConversationStatistic(Resource): "Daily conversation statistics retrieved successfully", console_ns.models[DailyConversationStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -250,11 +259,12 @@ class DailyTerminalsStatistic(Resource): "Daily terminal statistics retrieved successfully", console_ns.models[DailyTerminalStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -310,11 +320,12 @@ class DailyTokenCostStatistic(Resource): "Daily token cost statistics retrieved successfully", console_ns.models[DailyTokenCostStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -376,8 +387,9 @@ class AverageSessionInteractionStatistic(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -452,11 +464,12 @@ class UserSatisfactionRateStatistic(Resource): "User satisfaction rate statistics retrieved successfully", console_ns.models[UserSatisfactionRateStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -524,8 +537,9 @@ class AverageResponseTimeStatistic(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model(mode=AppMode.COMPLETION) def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -581,11 +595,12 @@ class TokensPerSecondStatistic(Resource): "Tokens per second statistics retrieved successfully", console_ns.models[TokensPerSecondStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index a8969f4d5ec..aff32035233 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -26,10 +26,14 @@ from controllers.console.app.error import ( DraftWorkflowNotExist, DraftWorkflowNotSync, ) +from controllers.console.app.permission_keys import get_app_permission_keys from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -436,8 +440,9 @@ class DraftWorkflowApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ Get draft workflow @@ -483,6 +488,7 @@ class DraftWorkflowApi(Resource): @console_ns.response(403, "Permission denied") @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App): """ Sync draft workflow @@ -548,6 +554,7 @@ class AdvancedChatDraftWorkflowRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -597,6 +604,7 @@ class AdvancedChatDraftRunIterationNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -639,6 +647,7 @@ class WorkflowDraftRunIterationNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -677,6 +686,7 @@ class AdvancedChatDraftRunLoopNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -719,6 +729,7 @@ class WorkflowDraftRunLoopNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -793,6 +804,7 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App, node_id: str): """ Preview human input form content and placeholders @@ -824,6 +836,7 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -857,6 +870,7 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App, node_id: str): """ Preview human input form content and placeholders @@ -888,6 +902,7 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -921,6 +936,7 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) def post(self, current_user: Account, app_model: App, node_id: str): """ Test human input delivery @@ -952,6 +968,7 @@ class DraftWorkflowRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -990,8 +1007,9 @@ class WorkflowTaskStopApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def post(self, app_model: App, task_id: str): """ Stop workflow task @@ -1022,6 +1040,7 @@ class DraftWorkflowNodeRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1072,8 +1091,9 @@ class PublishedWorkflowApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ Get published workflow @@ -1093,6 +1113,7 @@ class PublishedWorkflowApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1141,8 +1162,9 @@ class DefaultBlockConfigsApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ Get default block config @@ -1167,8 +1189,9 @@ class DefaultBlockConfigApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, block_type: str): """ Get default block config @@ -1205,14 +1228,15 @@ class ConvertToWorkflowApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.COMPLETION]) @with_current_user + @with_current_tenant_id @edit_permission_required - def post(self, current_user: Account, app_model: App): + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + def post(self, current_tenant_id: str, current_user: Account, app_model: App): """ Convert basic mode of chatbot app to workflow mode Convert expert mode of chatbot app to workflow mode Convert Completion App to Workflow App """ - payload = console_ns.payload or {} args = ConvertToWorkflowPayload.model_validate(payload).model_dump(exclude_none=True) @@ -1223,6 +1247,7 @@ class ConvertToWorkflowApi(Resource): # return app id return { "new_app_id": new_app_model.id, + "permission_keys": get_app_permission_keys(str(current_tenant_id), current_user.id, str(new_app_model.id)), } @@ -1245,6 +1270,7 @@ class WorkflowFeaturesApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App): args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {}) @@ -1270,6 +1296,7 @@ class PublishedAllWorkflowApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1322,6 +1349,7 @@ class DraftWorkflowRestoreApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) def post(self, current_user: Account, app_model: App, workflow_id: str): workflow_service = WorkflowService() @@ -1360,6 +1388,7 @@ class WorkflowByIdApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def patch(self, current_user: Account, app_model: App, workflow_id: str): """ Update workflow attributes @@ -1398,6 +1427,7 @@ class WorkflowByIdApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @console_ns.response(204, "Workflow deleted successfully") def delete(self, app_model: App, workflow_id: str): """ @@ -1436,6 +1466,7 @@ class DraftWorkflowNodeLastRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, node_id: str): srv = WorkflowService() @@ -1480,6 +1511,7 @@ class DraftWorkflowTriggerRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1548,6 +1580,7 @@ class DraftWorkflowTriggerNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1631,6 +1664,7 @@ class DraftWorkflowTriggerRunAllApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) def post(self, current_user: Account, app_model: App): """ Full workflow debug when the start node is a trigger diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index 72bececd999..cf94ceff853 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -10,7 +10,13 @@ from sqlalchemy.orm import sessionmaker from controllers.common.schema import query_params_from_model, 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.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from extensions.ext_database import db from fields.base import ResponseModel from fields.end_user_fields import SimpleEndUser @@ -175,6 +181,7 @@ class WorkflowAppLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model(mode=[AppMode.WORKFLOW]) def get(self, app_model: App): """ @@ -218,6 +225,7 @@ class WorkflowArchivedLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model(mode=[AppMode.WORKFLOW]) def get(self, app_model: App): """ diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index 3cecd450545..a9bf85ed36c 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -8,8 +8,11 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -218,8 +221,9 @@ class WorkflowCommentListApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model() def get(self, current_tenant_id: str, app_model: App): """Get all comments for a workflow.""" comments = WorkflowCommentService.get_comments(tenant_id=current_tenant_id, app_id=app_model.id) @@ -234,10 +238,11 @@ class WorkflowCommentListApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def post(self, current_tenant_id: str, current_user: Account, app_model: App): """Create a new workflow comment.""" payload = WorkflowCommentCreatePayload.model_validate(console_ns.payload or {}) @@ -266,8 +271,9 @@ class WorkflowCommentDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model() def get(self, current_tenant_id: str, app_model: App, comment_id: str): """Get a specific workflow comment.""" comment = WorkflowCommentService.get_comment( @@ -284,10 +290,11 @@ class WorkflowCommentDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def put(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Update a workflow comment.""" payload = WorkflowCommentUpdatePayload.model_validate(console_ns.payload or {}) @@ -312,10 +319,11 @@ class WorkflowCommentDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def delete(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Delete a workflow comment.""" WorkflowCommentService.delete_comment( @@ -339,10 +347,11 @@ class WorkflowCommentResolveApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def post(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Resolve a workflow comment.""" comment = WorkflowCommentService.resolve_comment( @@ -367,10 +376,11 @@ class WorkflowCommentReplyApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def post(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Add a reply to a workflow comment.""" # Validate comment access first @@ -402,10 +412,11 @@ class WorkflowCommentReplyDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def put(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str, reply_id: str): """Update a comment reply.""" # Validate comment access first @@ -434,10 +445,11 @@ class WorkflowCommentReplyDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def delete(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str, reply_id: str): """Delete a comment reply.""" # Validate comment access first @@ -469,8 +481,9 @@ class WorkflowCommentMentionUsersApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model() def get(self, current_user: Account, app_model: App): """Get all users in current tenant for mentions.""" current_tenant = current_user.current_tenant # need the tenant object here diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 11411115c1b..ff82572b87e 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -18,8 +18,11 @@ from controllers.console.app.error import ( ) from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -283,6 +286,7 @@ def _api_prerequisite[T, **P, R]( @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @wraps(f) @@ -304,6 +308,7 @@ class WorkflowVariableCollectionApi(Resource): ) @_api_prerequisite @marshal_with(workflow_draft_variable_list_without_value_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): """ Get draft workflow @@ -368,6 +373,7 @@ class NodeVariableCollectionApi(Resource): @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App, node_id: str): validate_node_id(node_id) with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: @@ -402,6 +408,7 @@ class VariableApi(Resource): @console_ns.response(404, "Variable not found") @_api_prerequisite @marshal_with(workflow_draft_variable_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App, variable_id: UUID): draft_var_srv = WorkflowDraftVariableService( session=db.session(), @@ -574,6 +581,7 @@ class ConversationVariableCollectionApi(Resource): @console_ns.response(404, "Draft workflow not found") @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): # NOTE(QuantumGhost): Prefill conversation variables into the draft variables table # so their IDs can be returned to the caller. @@ -599,8 +607,9 @@ class ConversationVariableCollectionApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=AppMode.ADVANCED_CHAT) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=AppMode.ADVANCED_CHAT) def post(self, current_user: Account, app_model: App): payload = ConversationVariableUpdatePayload.model_validate(console_ns.payload or {}) @@ -628,6 +637,7 @@ class SystemVariableCollectionApi(Resource): @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID, current_user.id) @@ -644,6 +654,7 @@ class EnvironmentVariableCollectionApi(Resource): ) @console_ns.response(404, "Draft workflow not found") @_api_prerequisite + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, _current_user: Account, app_model: App): """ Get draft workflow @@ -688,8 +699,9 @@ class EnvironmentVariableCollectionApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def post(self, current_user: Account, app_model: App): payload = EnvironmentVariableUpdatePayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/app/workflow_node_output_inspector.py b/api/controllers/console/app/workflow_node_output_inspector.py index 98f86ad7cb6..6ed59d6c566 100644 --- a/api/controllers/console/app/workflow_node_output_inspector.py +++ b/api/controllers/console/app/workflow_node_output_inspector.py @@ -34,7 +34,13 @@ from controllers.common.fields import EventStreamResponse from controllers.common.schema import register_response_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.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from libs.exception import BaseHTTPException from libs.login import login_required from models import App, AppMode @@ -146,6 +152,7 @@ class WorkflowDraftRunNodeOutputsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return _serve_snapshot(app_model, run_id) @@ -169,6 +176,7 @@ class WorkflowDraftRunNodeOutputDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str): return _serve_node_detail(app_model, run_id, node_id) @@ -195,6 +203,7 @@ class WorkflowDraftRunNodeOutputPreviewApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str, output_name: str): return _serve_output_preview(app_model, run_id, node_id, output_name) @@ -338,6 +347,7 @@ class WorkflowDraftRunNodeOutputEventsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return Response( @@ -368,6 +378,7 @@ class WorkflowPublishedRunNodeOutputsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return _serve_snapshot(app_model, run_id) @@ -391,6 +402,7 @@ class WorkflowPublishedRunNodeOutputDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str): return _serve_node_detail(app_model, run_id, node_id) @@ -418,6 +430,7 @@ class WorkflowPublishedRunNodeOutputPreviewApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str, output_name: str): return _serve_output_preview(app_model, run_id, node_id, output_name) @@ -439,6 +452,7 @@ class WorkflowPublishedRunNodeOutputEventsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return Response( diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 359daa12c20..374537229ca 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -14,7 +14,10 @@ from controllers.common.schema import query_params_from_model, register_response from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -155,6 +158,7 @@ class AdvancedChatAppWorkflowRunListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) def get(self, app_model: App): """ @@ -193,6 +197,7 @@ class WorkflowRunExportApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model() def get(self, app_model: App, run_id: UUID): tenant_id = app_model.tenant_id @@ -251,6 +256,7 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) def get(self, app_model: App): """ @@ -291,6 +297,7 @@ class WorkflowRunListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ @@ -332,6 +339,7 @@ class WorkflowRunCountApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ @@ -372,6 +380,7 @@ class WorkflowRunDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): """ @@ -401,8 +410,9 @@ class WorkflowRunNodeExecutionListApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, current_user: Account, app_model: App, run_id: UUID): """ Get workflow run node execution list diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index ec2a5ffce11..0346d510fbc 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -6,7 +6,14 @@ from sqlalchemy.orm import sessionmaker 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, with_current_user +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, + with_current_user, +) from extensions.ext_database import db from fields.base import ResponseModel from libs.datetime_utils import parse_time_range @@ -91,11 +98,12 @@ class WorkflowDailyRunsStatistic(Resource): "Daily runs statistics retrieved successfully", console_ns.models[WorkflowDailyRunsStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -134,11 +142,12 @@ class WorkflowDailyTerminalsStatistic(Resource): "Daily terminals statistics retrieved successfully", console_ns.models[WorkflowDailyTerminalsStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -177,11 +186,12 @@ class WorkflowDailyTokenCostStatistic(Resource): "Daily token cost statistics retrieved successfully", console_ns.models[WorkflowDailyTokenCostStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -223,8 +233,9 @@ class WorkflowAverageAppInteractionStatistic(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model(mode=[AppMode.WORKFLOW]) def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index 6a1bd843ee2..8bd41b3e429 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -19,7 +19,15 @@ from models.trigger import AppTrigger, WorkflowWebhookTrigger from .. import console_ns from ..app.wraps import get_app_model -from ..wraps import account_initialization_required, edit_permission_required, setup_required, with_current_tenant_id +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + edit_permission_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, +) logger = logging.getLogger(__name__) @@ -90,8 +98,9 @@ class WebhookTriggerApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.WORKFLOW) @console_ns.response(200, "Success", console_ns.models[WebhookTriggerResponse.__name__]) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.WORKFLOW) def get(self, app_model: App): """Get webhook trigger for a node""" args = Parser.model_validate(request.args.to_dict(flat=True)) @@ -122,9 +131,10 @@ class AppTriggersApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.WORKFLOW) @console_ns.response(200, "Success", console_ns.models[WorkflowTriggerListResponse.__name__]) @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.WORKFLOW) def get(self, current_tenant_id: str, app_model: App): """Get app triggers list""" with sessionmaker(db.engine, expire_on_commit=False).begin() as session: @@ -162,9 +172,10 @@ class AppTriggerEnableApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=AppMode.WORKFLOW) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @console_ns.response(200, "Success", console_ns.models[WorkflowTriggerResponse.__name__]) @with_current_tenant_id + @get_app_model(mode=AppMode.WORKFLOW) def post(self, current_tenant_id: str, app_model: App): """Update app trigger (enable/disable)""" args = ParserEnable.model_validate(console_ns.payload) diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index 7e7810d86da..c9142d85ede 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -1,6 +1,7 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select from configs import dify_config from constants.languages import supported_language @@ -11,7 +12,8 @@ from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.helper import EmailStr, timezone from models import AccountStatus -from services.account_service import RegisterService +from models.account import TenantAccountJoin, TenantAccountRole +from services.account_service import RegisterService, TenantService from services.billing_service import BillingService @@ -25,18 +27,22 @@ class ActivatePayload(BaseModel): workspace_id: str | None = Field(default=None) email: EmailStr | None = Field(default=None) token: str - name: str = Field(..., max_length=30) - interface_language: str = Field(...) - timezone: str = Field(...) + name: str | None = Field(default=None, max_length=30) + interface_language: str | None = Field(default=None) + timezone: str | None = Field(default=None) @field_validator("interface_language") @classmethod - def validate_lang(cls, value: str) -> str: + def validate_lang(cls, value: str | None) -> str | None: + if value is None: + return None return supported_language(value) @field_validator("timezone") @classmethod - def validate_tz(cls, value: str) -> str: + def validate_tz(cls, value: str | None) -> str | None: + if value is None: + return None return timezone(value) @@ -48,6 +54,8 @@ class ActivationCheckData(BaseModel): workspace_name: str | None workspace_id: str | None email: str | None + account_status: str | None = None + requires_setup: bool | None = None class ActivationCheckResponse(BaseModel): @@ -95,9 +103,20 @@ class ActivateCheckApi(Resource): workspace_name = tenant.name if tenant else None workspace_id = tenant.id if tenant else None invitee_email = data.get("email") if data else None + account = invitation.get("account") + account_status = account.status if account else None + requires_setup = data.get("requires_setup") + if requires_setup is None: + requires_setup = account_status == AccountStatus.PENDING return { "is_valid": invitation is not None, - "data": {"workspace_name": workspace_name, "workspace_id": workspace_id, "email": invitee_email}, + "data": { + "workspace_name": workspace_name, + "workspace_id": workspace_id, + "email": invitee_email, + "account_status": account_status, + "requires_setup": requires_setup, + }, } else: return {"is_valid": False} @@ -126,15 +145,45 @@ class ActivateApi(Resource): if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email): raise AccountInFreezeError() + tenant = invitation["tenant"] + raw_role = invitation["data"].get("role") + try: + role = TenantAccountRole(raw_role) if raw_role else TenantAccountRole.NORMAL + except ValueError: + role = TenantAccountRole.NORMAL + if not TenantAccountRole.is_non_owner_role(role): + role = TenantAccountRole.NORMAL + + membership_id = db.session.scalar( + select(TenantAccountJoin.id).where( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.account_id == account.id, + ) + ) + + requires_setup = invitation["data"].get("requires_setup") + if requires_setup is None: + requires_setup = account.status == AccountStatus.PENDING + + setup_fields: tuple[str, str, str] | None = None + if requires_setup: + if not args.name or not args.interface_language or not args.timezone: + raise AlreadyActivateError() + setup_fields = (args.name, args.interface_language, args.timezone) + RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token) - account.name = args.name + if membership_id is None: + TenantService.create_tenant_member(tenant, account, db.session, role=role) - account.interface_language = args.interface_language - account.timezone = args.timezone - account.interface_theme = "light" - account.status = AccountStatus.ACTIVE - account.initialized_at = naive_utc_now() - db.session.commit() + if setup_fields: + account.name = setup_fields[0] + account.interface_language = setup_fields[1] + account.timezone = setup_fields[2] + account.interface_theme = "light" + account.status = AccountStatus.ACTIVE + account.initialized_at = naive_utc_now() + + TenantService.switch_tenant(account, tenant.id) return {"result": "success"} diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index f06db799498..a9c97401105 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -11,7 +11,15 @@ from services.auth.api_key_auth_service import ApiKeyAuthService from .. import console_ns from ..auth.error import ApiKeyAuthFailedError -from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required, with_current_tenant_id +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + is_admin_or_owner_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, +) class ApiKeyAuthBindingPayload(BaseModel): @@ -75,6 +83,7 @@ class ApiKeyAuthDataSourceBinding(Resource): @login_required @account_initialization_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__]) @with_current_tenant_id def post(self, current_tenant_id: str): @@ -95,6 +104,7 @@ class ApiKeyAuthDataSourceBindingDelete(Resource): @login_required @account_initialization_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @console_ns.response(204, "Binding deleted successfully") @with_current_tenant_id def delete(self, current_tenant_id: str, binding_id: UUID): diff --git a/api/controllers/console/auth/data_source_oauth.py b/api/controllers/console/auth/data_source_oauth.py index f1493b5e6f4..6ad2efa360a 100644 --- a/api/controllers/console/auth/data_source_oauth.py +++ b/api/controllers/console/auth/data_source_oauth.py @@ -13,7 +13,14 @@ from libs.login import login_required from libs.oauth_data_source import NotionOAuth from .. import console_ns -from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + is_admin_or_owner_required, + rbac_permission_required, + setup_required, +) logger = logging.getLogger(__name__) @@ -75,6 +82,7 @@ class OAuthDataSource(Resource): @console_ns.response(400, "Invalid provider") @console_ns.response(403, "Admin privileges required") @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) def get(self, provider: str): # The role of the current user in the table must be admin or owner OAUTH_DATASOURCE_PROVIDERS = get_oauth_providers() diff --git a/api/controllers/console/auth/forgot_password.py b/api/controllers/console/auth/forgot_password.py index c34dd1ac859..d82f63c11db 100644 --- a/api/controllers/console/auth/forgot_password.py +++ b/api/controllers/console/auth/forgot_password.py @@ -202,6 +202,6 @@ class ForgotPasswordResetApi(Resource): and FeatureService.get_system_features().is_allow_create_workspace ): tenant = TenantService.create_tenant(f"{account.name}'s Workspace") - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db.session, role="owner") account.current_tenant = tenant tenant_was_created.send(tenant) diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 6a1b4c6769e..053f313ba53 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -35,6 +35,7 @@ from controllers.console.wraps import ( with_current_user, ) from events.tenant_event import tenant_was_created +from extensions.ext_database import db from libs.helper import EmailStr, extract_remote_ip from libs.helper import timezone as validate_timezone_string from libs.token import ( @@ -299,7 +300,7 @@ class EmailCodeLoginApi(Resource): raise NotAllowedCreateWorkspace() else: new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace") - TenantService.create_tenant_member(new_tenant, account, role="owner") + TenantService.create_tenant_member(new_tenant, account, db.session, role="owner") account.current_tenant = new_tenant tenant_was_created.send(new_tenant) diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 31649812fe8..78d1583fde9 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -246,7 +246,7 @@ def _generate_account( raise WorkSpaceNotAllowedCreateError() else: new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace") - TenantService.create_tenant_member(new_tenant, account, role="owner") + TenantService.create_tenant_member(new_tenant, account, db.session, role="owner") account.current_tenant = new_tenant tenant_was_created.send(new_tenant) diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index 07db712fba1..6dd13b485bc 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -31,7 +31,15 @@ from services.datasource_provider_service import DatasourceProviderService from tasks.document_indexing_sync_task import document_indexing_sync_task from .. import console_ns -from ..wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, + with_current_user, +) class NotionEstimatePayload(BaseModel): @@ -390,6 +398,7 @@ class DataSourceNotionDatasetSyncApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID) -> tuple[dict[str, str], int]: dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -408,6 +417,7 @@ class DataSourceNotionDocumentSyncApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID, document_id: UUID) -> tuple[dict[str, str], int]: dataset_id_str = str(dataset_id) document_id_str = str(document_id) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index ca2ef5d6e2d..55bc85483d5 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -17,10 +17,13 @@ from controllers.console.apikey import ApiKeyItem, ApiKeyList from controllers.console.app.error import ProviderNotInitializeError from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_rate_limit_check, enterprise_license_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -46,9 +49,16 @@ from models.enums import ApiTokenType, SegmentStatus from models.provider_ids import ModelProviderID from services.api_token_service import ApiTokenCache from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService +from services.enterprise import rbac_service as enterprise_rbac_service register_response_schema_models(console_ns, ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse) +DATASET_LIST_PERMISSION_KEYS = frozenset({"dataset.preview", "dataset.acl.preview", "dataset.full_access"}) + + +def _has_dataset_list_permission(permission_keys: list[str]) -> bool: + return any(permission_key in DATASET_LIST_PERMISSION_KEYS for permission_key in permission_keys) + def _validate_indexing_technique(value: str | None) -> str | None: if value is None: @@ -402,20 +412,68 @@ class DatasetListApi(Resource): if "tag_ids" in request.args: query_params["tag_ids"] = request.args.getlist("tag_ids") query = ConsoleDatasetListQuery.model_validate(query_params) - # provider = request.args.get("provider", default="vendor") + + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user.id, + ) + + accessible_dataset_ids: list[str] | None = None + include_own_datasets = False + if dify_config.RBAC_ENABLED: + whitelist_scope = enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources( + str(current_tenant_id), + current_user.id, + ) + has_default_readonly = _has_dataset_list_permission( + permissions.dataset.default_permission_keys + ) or _has_dataset_list_permission(permissions.workspace.permission_keys) + permission_dataset_ids: set[str] | None = None + if not has_default_readonly: + permission_dataset_ids = { + override.resource_id + for override in permissions.dataset.overrides + if _has_dataset_list_permission(override.permission_keys) + } + if getattr(whitelist_scope, "unrestricted", False): + filtered_dataset_ids = permission_dataset_ids + else: + filtered_dataset_ids = set(whitelist_scope.resource_ids) + if permission_dataset_ids is not None: + filtered_dataset_ids |= permission_dataset_ids + elif has_default_readonly: + filtered_dataset_ids = None + if filtered_dataset_ids is not None: + accessible_dataset_ids = sorted(filtered_dataset_ids) + include_own_datasets = "dataset.create_and_management" in permissions.workspace.permission_keys + if query.ids: - datasets, total = DatasetService.get_datasets_by_ids(query.ids, current_tenant_id) + datasets, total = DatasetService.get_datasets_by_ids( + query.ids, + current_tenant_id, + user=current_user, + accessible_dataset_ids=accessible_dataset_ids, + include_own_datasets=include_own_datasets, + ) else: datasets, total = DatasetService.get_datasets( query.page, query.limit, + db.session, current_tenant_id, current_user, query.keyword, query.tag_ids, query.include_all, + accessible_dataset_ids=accessible_dataset_ids, + include_own_datasets=include_own_datasets, ) + permission_keys_map = {} + if datasets: + dataset_ids = [str(dataset.id) for dataset in datasets] + permission_keys_map = permissions.dataset.permission_keys_by_resource_ids(dataset_ids) + # check embedding setting provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id) configurations = provider_manager.get_configurations(tenant_id=current_tenant_id) @@ -430,13 +488,13 @@ class DatasetListApi(Resource): dataset_ids = [item["id"] for item in data if item.get("permission") == "partial_members"] partial_members_map: dict[str, list[str]] = {} if dataset_ids: - permissions = db.session.execute( + partial_member_rows = db.session.execute( select(DatasetPermission.dataset_id, DatasetPermission.account_id).where( DatasetPermission.dataset_id.in_(dataset_ids) ) ).all() - for dataset_id, account_id in permissions: + for dataset_id, account_id in partial_member_rows: partial_members_map.setdefault(dataset_id, []).append(account_id) for item in data: @@ -455,6 +513,7 @@ class DatasetListApi(Resource): item.update({"partial_member_list": partial_members_map.get(item["id"], [])}) else: item.update({"partial_member_list": []}) + item["permission_keys"] = permission_keys_map.get(str(item["id"]), []) response = { "data": data, @@ -473,6 +532,9 @@ class DatasetListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id @@ -483,6 +545,11 @@ class DatasetListApi(Resource): if not current_user.is_dataset_editor: raise Forbidden() + if dify_config.RBAC_ENABLED: + permission = DatasetPermissionEnum.ALL_TEAM + else: + permission = payload.permission or DatasetPermissionEnum.ONLY_ME + try: dataset = DatasetService.create_empty_dataset( tenant_id=current_tenant_id, @@ -490,7 +557,7 @@ class DatasetListApi(Resource): description=payload.description, indexing_technique=payload.indexing_technique, account=current_user, - permission=payload.permission or DatasetPermissionEnum.ONLY_ME, + permission=permission, provider=payload.provider, external_knowledge_api_id=payload.external_knowledge_api_id, external_knowledge_id=payload.external_knowledge_id, @@ -498,7 +565,17 @@ class DatasetListApi(Resource): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return dump_response(DatasetDetailResponse, dataset), 201 + permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [str(dataset.id)], + ) + + item = DatasetDetailWithPartialMembersResponse.model_validate(dataset, from_attributes=True).model_dump( + mode="json" + ) + item["permission_keys"] = permission_keys_map.get(str(dataset.id), []) + return item, 201 @console_ns.route("/datasets/") @@ -516,6 +593,7 @@ class DatasetApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): @@ -527,7 +605,14 @@ class DatasetApi(Resource): DatasetService.check_dataset_permission(dataset, current_user) except services.errors.account.NoPermissionError as e: raise Forbidden(str(e)) + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user.id, + dataset_id=dataset_id_str, + ) + permission_keys_map = permissions.dataset.permission_keys_by_resource_ids([dataset_id_str]) data = dump_response(DatasetDetailResponse, dataset) + data["permission_keys"] = permission_keys_map.get(dataset_id_str, []) if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY: if dataset.embedding_model_provider: provider_id = ModelProviderID(dataset.embedding_model_provider) @@ -573,6 +658,7 @@ class DatasetApi(Resource): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -592,16 +678,23 @@ class DatasetApi(Resource): payload.is_multimodal = is_multimodal payload_data = payload.model_dump(exclude_unset=True) # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator - DatasetPermissionService.check_permission( - current_user, dataset, payload.permission, payload.partial_member_list - ) + if not dify_config.RBAC_ENABLED: + DatasetPermissionService.check_permission( + current_user, dataset, payload.permission, payload.partial_member_list + ) dataset = DatasetService.update_dataset(dataset_id_str, payload_data, current_user) if dataset is None: raise NotFound("Dataset not found.") + permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [dataset_id_str], + ) result_data = dump_response(DatasetDetailResponse, dataset) + result_data["permission_keys"] = permission_keys_map.get(dataset_id_str, []) tenant_id = current_tenant_id if payload.partial_member_list is not None and payload.permission == DatasetPermissionEnum.PARTIAL_TEAM: @@ -621,6 +714,7 @@ class DatasetApi(Resource): @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Dataset deleted successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) @@ -650,6 +744,7 @@ class DatasetUseCheckApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) @@ -671,6 +766,7 @@ class DatasetQueryApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -811,6 +907,7 @@ class DatasetRelatedAppListApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -847,6 +944,7 @@ class DatasetIndexingStatusApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_tenant_id: str, dataset_id: UUID): dataset_id_str = str(dataset_id) documents = db.session.scalars( @@ -916,6 +1014,7 @@ class DatasetApiKeyApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str): @@ -956,6 +1055,7 @@ class DatasetApiDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, current_tenant_id: str, api_key_id: UUID): @@ -990,6 +1090,7 @@ class DatasetEnableApiApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, dataset_id: UUID, status: str): dataset_id_str = str(dataset_id) @@ -1059,6 +1160,7 @@ class DatasetErrorDocs(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -1085,6 +1187,7 @@ class DatasetPermissionUserListApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -1114,6 +1217,7 @@ class DatasetAutoDisableLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index d4d50600a09..07e150617bf 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -19,6 +19,7 @@ from controllers.common.controller_schemas import DocumentBatchDownloadZipPayloa from controllers.common.fields import BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required from core.errors.error import ( LLMBadRequestError, ModelCurrentlyNotSupportError, @@ -289,6 +290,7 @@ class DatasetDocumentListApi(Resource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) raw_args = request.args.to_dict() @@ -415,6 +417,7 @@ class DatasetDocumentListApi(Resource): @console_ns.expect(console_ns.models[KnowledgeConfig.__name__]) @console_ns.response(200, "Documents created successfully", console_ns.models[DatasetAndDocumentResponse.__name__]) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) @@ -458,6 +461,7 @@ class DatasetDocumentListApi(Resource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Documents deleted successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -555,6 +559,7 @@ class DocumentIndexingEstimateApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -626,6 +631,7 @@ class DocumentBatchIndexingEstimateApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, batch: str): dataset_id_str = str(dataset_id) documents = self.get_batch_documents(dataset_id_str, batch, current_user) @@ -726,6 +732,7 @@ class DocumentBatchIndexingStatusApi(DocumentResource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_user: Account, dataset_id: UUID, batch: str): dataset_id_str = str(dataset_id) documents = self.get_batch_documents(dataset_id_str, batch, current_user) @@ -783,6 +790,7 @@ class DocumentIndexingStatusApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -846,6 +854,7 @@ class DocumentApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -937,6 +946,7 @@ class DocumentApi(DocumentResource): @console_ns.response(204, "Document deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -969,6 +979,7 @@ class DocumentDownloadApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_DOCUMENT_DOWNLOAD) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID) -> dict[str, Any]: # Reuse the shared permission/tenant checks implemented in DocumentResource. document = self.get_document(str(dataset_id), str(document_id), current_user, current_tenant_id) @@ -989,6 +1000,7 @@ class DocumentBatchDownloadZipApi(DocumentResource): @console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): """Stream a ZIP archive containing the requested uploaded documents.""" # Parse and validate request payload. @@ -1037,6 +1049,7 @@ class DocumentProcessingApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, @@ -1093,6 +1106,7 @@ class DocumentMetadataApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def put(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -1142,6 +1156,7 @@ class DocumentStatusApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable", "archive", "un_archive"] ): @@ -1181,6 +1196,7 @@ class DocumentPauseApi(DocumentResource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Document paused successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, dataset_id: UUID, document_id: UUID): """pause document.""" dataset_id_str = str(dataset_id) @@ -1216,6 +1232,7 @@ class DocumentRecoverApi(DocumentResource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Document resumed successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, dataset_id: UUID, document_id: UUID): """recover document.""" dataset_id_str = str(dataset_id) @@ -1249,6 +1266,7 @@ class DocumentRetryApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.expect(console_ns.models[DocumentRetryPayload.__name__]) @console_ns.response(204, "Documents retry started successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, dataset_id: UUID): """retry document.""" payload = DocumentRetryPayload.model_validate(console_ns.payload or {}) @@ -1290,6 +1308,7 @@ class DocumentRenameApi(DocumentResource): @console_ns.response(200, "Document renamed successfully", console_ns.models[DocumentResponse.__name__]) @console_ns.expect(console_ns.models[DocumentRenamePayload.__name__]) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID, document_id: UUID): # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator if not current_user.is_dataset_editor: @@ -1315,6 +1334,7 @@ class WebsiteDocumentSyncApi(DocumentResource): @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID): """sync website document.""" dataset_id_str = str(dataset_id) @@ -1348,6 +1368,7 @@ class DocumentPipelineExecutionLogApi(DocumentResource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -1398,6 +1419,7 @@ class DocumentGenerateSummaryApi(Resource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID): """ Generate summary index for specified documents. @@ -1491,6 +1513,7 @@ class DocumentSummaryStatusApi(DocumentResource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_user: Account, dataset_id: UUID, document_id: UUID): """ Get summary index generation status for a document. diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py index 4e521100abe..4858b5ff6b0 100644 --- a/api/controllers/console/datasets/datasets_segments.py +++ b/api/controllers/console/datasets/datasets_segments.py @@ -28,10 +28,13 @@ from controllers.console.datasets.error import ( InvalidActionError, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_knowledge_limit_check, cloud_edition_billing_rate_limit_check, cloud_edition_billing_resource_check, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -169,6 +172,7 @@ class DatasetDocumentSegmentListApi(Resource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -278,6 +282,7 @@ class DatasetDocumentSegmentListApi(Resource): @console_ns.doc(params=query_params_from_model(SegmentIdListQuery)) @console_ns.response(204, "Segments deleted successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_user: Account, dataset_id: UUID, document_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -316,6 +321,7 @@ class DatasetDocumentSegmentApi(Resource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, @@ -384,6 +390,7 @@ class DatasetDocumentSegmentAddApi(Resource): @console_ns.response(200, "Segment created successfully", console_ns.models[SegmentDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -442,6 +449,7 @@ class DatasetDocumentSegmentUpdateApi(Resource): @console_ns.response(200, "Segment updated successfully", console_ns.models[SegmentDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -513,6 +521,7 @@ class DatasetDocumentSegmentUpdateApi(Resource): @console_ns.response(204, "Segment deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -563,6 +572,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource): @console_ns.expect(console_ns.models[BatchImportPayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -608,6 +618,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, job_id=None, dataset_id: UUID | None = None, document_id: UUID | None = None): if job_id is None: raise NotFound("The job does not exist.") @@ -634,6 +645,7 @@ class ChildChunkAddApi(Resource): @console_ns.response(200, "Child chunk created successfully", console_ns.models[ChildChunkDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -693,6 +705,7 @@ class ChildChunkAddApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -747,6 +760,7 @@ class ChildChunkAddApi(Resource): @console_ns.expect(console_ns.models[ChildChunkBatchUpdatePayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -799,6 +813,7 @@ class ChildChunkUpdateApi(Resource): @console_ns.response(204, "Child chunk deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete( self, current_tenant_id: str, @@ -866,6 +881,7 @@ class ChildChunkUpdateApi(Resource): @console_ns.response(200, "Child chunk updated successfully", console_ns.models[ChildChunkDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index fb19ad81c67..033c9a69af6 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -17,8 +17,11 @@ from controllers.common.schema import ( from controllers.console import console_ns from controllers.console.datasets.error import DatasetNameDuplicateError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -40,6 +43,7 @@ from fields.dataset_fields import ( from libs.login import login_required from models import Account from services.dataset_service import DatasetService +from services.enterprise import rbac_service as enterprise_rbac_service from services.external_knowledge_service import ExternalDatasetService from services.hit_testing_service import HitTestingService from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService @@ -319,6 +323,7 @@ class ExternalDatasetCreateApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EXTERNAL_CONNECT) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): @@ -339,7 +344,16 @@ class ExternalDatasetCreateApi(Resource): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return marshal(dataset, dataset_detail_fields), 201 + item = marshal(dataset, dataset_detail_fields) + dataset_id_str = item["id"] + permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [dataset_id_str], + ) + item["permission_keys"] = permission_keys_map.get(dataset_id_str, []) + + return item, 201 @console_ns.route("/datasets//external-hit-testing") @@ -359,6 +373,7 @@ class ExternalKnowledgeHitTestingApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_PIPELINE_TEST) def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) diff --git a/api/controllers/console/datasets/hit_testing.py b/api/controllers/console/datasets/hit_testing.py index c08ed2fe9f0..739f0250333 100644 --- a/api/controllers/console/datasets/hit_testing.py +++ b/api/controllers/console/datasets/hit_testing.py @@ -5,6 +5,7 @@ from uuid import UUID from flask_restx import Resource from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required from fields.hit_testing_fields import HitTestingResponse from libs.helper import dump_response from libs.login import login_required @@ -43,6 +44,7 @@ class HitTestingApi(Resource, DatasetsHitTestingBase): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_tenant_id @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_PIPELINE_TEST) def post(self, current_user: Account, current_tenant_id: str, dataset_id: UUID) -> dict[str, object]: dataset_id_str = str(dataset_id) diff --git a/api/controllers/console/datasets/hit_testing_base.py b/api/controllers/console/datasets/hit_testing_base.py index e3efe804872..4e90e66eb25 100644 --- a/api/controllers/console/datasets/hit_testing_base.py +++ b/api/controllers/console/datasets/hit_testing_base.py @@ -23,17 +23,26 @@ from libs.login import resolve_account_fallback from models.account import Account from models.dataset import Dataset from services.dataset_service import DatasetService -from services.entities.knowledge_entities.knowledge_entities import RetrievalModel +from services.entities.knowledge_entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel from services.hit_testing_service import HitTestingService logger = logging.getLogger(__name__) class HitTestingPayload(BaseModel): - query: str = Field(max_length=250) - retrieval_model: RetrievalModel | None = None - external_retrieval_model: dict[str, Any] | None = Field(default=None) - attachment_ids: list[str] | None = None + query: str = Field(description="Search query text.", max_length=250) + retrieval_model: RetrievalModel | None = Field( + default=None, + description="Retrieval model configuration. Controls how chunks are searched and ranked.", + ) + external_retrieval_model: ExternalRetrievalModel = Field( + default=None, + description="Retrieval settings for external knowledge bases.", + ) + attachment_ids: list[str] | None = Field( + default=None, + description="List of attachment IDs to include in the retrieval context.", + ) class DatasetsHitTestingBase: diff --git a/api/controllers/console/datasets/metadata.py b/api/controllers/console/datasets/metadata.py index ec4c5bedb61..7195fe066fd 100644 --- a/api/controllers/console/datasets/metadata.py +++ b/api/controllers/console/datasets/metadata.py @@ -8,8 +8,11 @@ from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, enterprise_license_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -52,6 +55,7 @@ class DatasetMetadataCreateApi(Resource): @console_ns.expect(console_ns.models[MetadataArgs.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): metadata_args = MetadataArgs.model_validate(console_ns.payload or {}) @@ -71,6 +75,7 @@ class DatasetMetadataCreateApi(Resource): @console_ns.response( 200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__] ) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -90,6 +95,7 @@ class DatasetMetadataApi(Resource): @console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, metadata_id: UUID): payload = MetadataUpdatePayload.model_validate(console_ns.payload or {}) name = payload.name @@ -112,6 +118,7 @@ class DatasetMetadataApi(Resource): @enterprise_license_required @console_ns.response(204, "Metadata deleted successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_user: Account, dataset_id: UUID, metadata_id: UUID): dataset_id_str = str(dataset_id) metadata_id_str = str(metadata_id) @@ -149,6 +156,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource): @enterprise_license_required @console_ns.response(204, "Action completed successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable"]): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -177,6 +185,7 @@ class DocumentMetadataEditApi(Resource): "Documents metadata updated successfully", ) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index 980f116e216..c5ca1d155de 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -10,8 +10,11 @@ from controllers.common.fields import RedirectResponse, SimpleResultResponse 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.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -104,6 +107,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, provider_id: str): @@ -218,6 +222,7 @@ class DatasourceAuth(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceCredentialPayload.model_validate(console_ns.payload or {}) @@ -262,6 +267,7 @@ class DatasourceAuthDeleteApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): datasource_provider_id = DatasourceProviderID(provider_id) @@ -287,6 +293,7 @@ class DatasourceAuthUpdateApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): datasource_provider_id = DatasourceProviderID(provider_id) @@ -338,6 +345,7 @@ class DatasourceAuthOauthCustomClient(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceCustomClientPayload.model_validate(console_ns.payload or {}) @@ -374,6 +382,7 @@ class DatasourceAuthDefaultApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceDefaultPayload.model_validate(console_ns.payload or {}) @@ -395,6 +404,7 @@ class DatasourceUpdateProviderNameApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceUpdateNamePayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py index e0c8d95b766..0b83dc9312d 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py @@ -13,8 +13,11 @@ from controllers.common.schema import ( from controllers.console import console_ns from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -78,6 +81,9 @@ class RagPipelineImportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) @with_current_user def post(self, current_user: Account) -> JsonResponseWithStatus: # Check user role first @@ -122,6 +128,9 @@ class RagPipelineImportConfirmApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) @with_current_user def post(self, current_user: Account, import_id: str) -> JsonResponseWithStatus: with Session(db.engine, expire_on_commit=False) as session: @@ -151,6 +160,7 @@ class RagPipelineImportCheckDependenciesApi(Resource): @get_rag_pipeline @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, pipeline: Pipeline) -> JsonResponseWithStatus: with Session(db.engine, expire_on_commit=False) as session: import_service = RagPipelineDslService(session) @@ -168,6 +178,7 @@ class RagPipelineExportApi(Resource): @get_rag_pipeline @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_IMPORT_EXPORT_DSL) def get(self, pipeline: Pipeline) -> JsonResponseWithStatus: # Add include_secret params query = IncludeSecretQuery.model_validate(request.args.to_dict()) 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 7c941e14368..fdc55ea9737 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -28,8 +28,11 @@ from controllers.console.app.workflow import ( ) from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -186,6 +189,7 @@ class DraftRagPipelineApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get draft rag pipeline's workflow @@ -206,6 +210,7 @@ class DraftRagPipelineApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @console_ns.expect(console_ns.models[DraftWorkflowSyncPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowSyncResponse.__name__]) def post(self, current_user: Account, pipeline: Pipeline): @@ -266,6 +271,7 @@ class RagPipelineDraftRunIterationNodeApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, pipeline: Pipeline, node_id: str): """ Run draft workflow iteration node @@ -298,6 +304,7 @@ class RagPipelineDraftRunLoopNodeApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @@ -332,6 +339,7 @@ class DraftRagPipelineRunApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline): @@ -363,6 +371,7 @@ class PublishedRagPipelineRunApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline): @@ -395,6 +404,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @@ -426,6 +436,7 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @account_initialization_required @with_current_user @get_rag_pipeline @@ -462,6 +473,7 @@ class RagPipelineDraftNodeRunApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @account_initialization_required @with_current_user @get_rag_pipeline @@ -491,6 +503,7 @@ class RagPipelineTaskStopApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @account_initialization_required @with_current_user @get_rag_pipeline @@ -514,6 +527,7 @@ class PublishedRagPipelineApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def get(self, pipeline: Pipeline): """ @@ -537,6 +551,7 @@ class PublishedRagPipelineApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline): @@ -571,6 +586,7 @@ class DefaultRagPipelineBlockConfigsApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def get(self, pipeline: Pipeline): """ @@ -593,6 +609,7 @@ class DefaultRagPipelineBlockConfigApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def get(self, pipeline: Pipeline, block_type: str): """ @@ -625,6 +642,7 @@ class PublishedAllRagPipelineApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def get(self, current_user: Account, pipeline: Pipeline): @@ -670,6 +688,7 @@ class RagPipelineDraftWorkflowRestoreApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline, workflow_id: str): @@ -704,6 +723,7 @@ class RagPipelineByIdApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline @console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__]) @@ -739,6 +759,7 @@ class RagPipelineByIdApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def delete(self, pipeline: Pipeline, workflow_id: str): """ @@ -775,6 +796,7 @@ class PublishedRagPipelineSecondStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get second step parameters of rag pipeline @@ -797,6 +819,7 @@ class PublishedRagPipelineFirstStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get first step parameters of rag pipeline @@ -819,6 +842,7 @@ class DraftRagPipelineFirstStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get first step parameters of rag pipeline @@ -841,6 +865,7 @@ class DraftRagPipelineSecondStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get second step parameters of rag pipeline @@ -1012,6 +1037,7 @@ class RagPipelineDatasourceVariableApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, pipeline: Pipeline): """ Set datasource variables diff --git a/api/controllers/console/explore/installed_app.py b/api/controllers/console/explore/installed_app.py index 14837897502..c1fa1378ffa 100644 --- a/api/controllers/console/explore/installed_app.py +++ b/api/controllers/console/explore/installed_app.py @@ -73,9 +73,12 @@ def _published_app_filter(): has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id)) has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id)) - return or_( - and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow), - and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config), + return and_( + App.mode != AppMode.AGENT, + or_( + and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow), + and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config), + ), ) diff --git a/api/controllers/console/explore/saved_message.py b/api/controllers/console/explore/saved_message.py index 3e8f1ce9083..ce43ff18c93 100644 --- a/api/controllers/console/explore/saved_message.py +++ b/api/controllers/console/explore/saved_message.py @@ -11,6 +11,7 @@ from controllers.console.app.error import AppUnavailableError from controllers.console.explore.error import NotCompletionAppError from controllers.console.explore.wraps import InstalledAppResource from controllers.console.wraps import with_current_user +from extensions.ext_database import db from fields.conversation_fields import ResultResponse from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem from models import Account @@ -37,6 +38,7 @@ class SavedMessageListApi(InstalledAppResource): args = SavedMessageListQuery.model_validate(request.args.to_dict()) pagination = SavedMessageService.pagination_by_last_id( + db.session(), app_model, current_user, str(args.last_id) if args.last_id else None, @@ -63,7 +65,7 @@ class SavedMessageListApi(InstalledAppResource): payload = SavedMessageCreatePayload.model_validate(console_ns.payload or {}) try: - SavedMessageService.save(app_model, current_user, str(payload.message_id)) + SavedMessageService.save(db.session(), app_model, current_user, str(payload.message_id)) except MessageNotExistsError: raise NotFound("Message Not Exists.") @@ -86,6 +88,6 @@ class SavedMessageApi(InstalledAppResource): if app_model.mode != "completion": raise NotCompletionAppError() - SavedMessageService.delete(app_model, current_user, message_id_str) + SavedMessageService.delete(db.session(), app_model, current_user, message_id_str) return "", 204 diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 6d9362ae0b1..ec1e01dc460 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -7,6 +7,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field, TypeAdapter, field_validator from constants import HIDDEN_VALUE +from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import to_timestamp from libs.login import login_required @@ -126,7 +127,7 @@ class APIBasedExtensionAPI(Resource): def get(self, current_tenant_id: str): return [ _serialize_api_based_extension(extension) - for extension in APIBasedExtensionService.get_all_by_tenant_id(current_tenant_id) + for extension in APIBasedExtensionService.get_all_by_tenant_id(db.session(), current_tenant_id) ] @console_ns.doc("create_api_based_extension") @@ -147,7 +148,12 @@ class APIBasedExtensionAPI(Resource): api_key=payload.api_key, ) - return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201 + return ( + _serialize_saved_api_based_extension( + APIBasedExtensionService.save(db.session(), extension_data), payload.api_key + ), + 201, + ) @console_ns.route("/api-based-extension/") @@ -164,7 +170,7 @@ class APIBasedExtensionDetailAPI(Resource): api_based_extension_id = str(id) return _serialize_api_based_extension( - APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id) + APIBasedExtensionService.get_with_tenant_id(db.session(), current_tenant_id, api_based_extension_id) ) @console_ns.doc("update_api_based_extension") @@ -179,7 +185,9 @@ class APIBasedExtensionDetailAPI(Resource): def post(self, current_tenant_id: str, id: UUID): api_based_extension_id = str(id) - extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id) + extension_data_from_db = APIBasedExtensionService.get_with_tenant_id( + db.session(), current_tenant_id, api_based_extension_id + ) payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {}) api_key_for_response = extension_data_from_db.api_key @@ -192,7 +200,7 @@ class APIBasedExtensionDetailAPI(Resource): api_key_for_response = payload.api_key return _serialize_saved_api_based_extension( - APIBasedExtensionService.save(extension_data_from_db), + APIBasedExtensionService.save(db.session(), extension_data_from_db), api_key_for_response, ) @@ -207,8 +215,10 @@ class APIBasedExtensionDetailAPI(Resource): def delete(self, current_tenant_id: str, id: UUID): api_based_extension_id = str(id) - extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id) + extension_data_from_db = APIBasedExtensionService.get_with_tenant_id( + db.session(), current_tenant_id, api_based_extension_id + ) - APIBasedExtensionService.delete(extension_data_from_db) + APIBasedExtensionService.delete(db.session(), extension_data_from_db) return "", 204 diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 243c29e6719..5af885ab91b 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -31,8 +31,11 @@ from controllers.console.snippets.payloads import ( WorkflowRunQuery, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -154,6 +157,7 @@ class SnippetDraftWorkflowApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get draft workflow for snippet.""" snippet_service = _snippet_service() @@ -181,6 +185,9 @@ class SnippetDraftWorkflowApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet): """Sync draft workflow for snippet.""" payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {}) @@ -219,6 +226,7 @@ class SnippetDraftConfigApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get snippet draft workflow configuration limits.""" return { @@ -240,6 +248,7 @@ class SnippetPublishedWorkflowApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get published workflow for snippet.""" if not snippet.is_published: @@ -265,6 +274,9 @@ class SnippetPublishedWorkflowApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet): """Publish snippet workflow.""" snippet_service = _snippet_service() @@ -301,6 +313,7 @@ class SnippetDefaultBlockConfigsApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get default block configurations for snippet workflow.""" snippet_service = _snippet_service() @@ -323,6 +336,7 @@ class SnippetPublishedAllWorkflowApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get all published workflow versions for snippet.""" args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True)) @@ -361,6 +375,9 @@ class SnippetDraftWorkflowRestoreApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str): """Restore a published snippet workflow version into the draft workflow.""" snippet_service = _snippet_service() @@ -486,6 +503,9 @@ class SnippetDraftNodeRunApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): """ Run a single node in snippet draft workflow. @@ -574,6 +594,9 @@ class SnippetDraftRunIterationNodeApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): """ Run a draft workflow iteration node for snippet. @@ -619,6 +642,9 @@ class SnippetDraftRunLoopNodeApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): """ Run a draft workflow loop node for snippet. @@ -662,6 +688,9 @@ class SnippetDraftWorkflowRunApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet): """ Run draft workflow for snippet. @@ -700,6 +729,9 @@ class SnippetWorkflowTaskStopApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, snippet: CustomizedSnippet, task_id: str): """ Stop a running snippet workflow task. diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index a28ba07b5dd..4befd259666 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -34,8 +34,11 @@ from controllers.console.app.workflow_draft_variable import ( ) from controllers.console.snippets.snippet_workflow import get_snippet from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -102,6 +105,7 @@ class SnippetWorkflowVariableCollectionApi(Resource): ) @_snippet_draft_var_prerequisite @marshal_with(workflow_draft_variable_list_without_value_model) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore @@ -125,6 +129,9 @@ class SnippetWorkflowVariableCollectionApi(Resource): @console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)") @console_ns.response(204, "Workflow variables deleted successfully") @_snippet_draft_var_prerequisite + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response: draft_var_srv = WorkflowDraftVariableService(session=db.session()) draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id) diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 82a713f1c6f..38e7395ccf8 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -122,7 +122,7 @@ class TagListApi(Resource): raise Forbidden() payload = TagBasePayload.model_validate(console_ns.payload or {}) - tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type)) + tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session) response = TagResponse.model_validate( {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0} @@ -146,9 +146,9 @@ class TagUpdateDeleteApi(Resource): raise Forbidden() payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {}) - tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str) + tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session) - binding_count = TagService.get_tag_binding_count(tag_id_str) + binding_count = TagService.get_tag_binding_count(tag_id_str, db.session) response = TagResponse.model_validate( {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count} @@ -164,7 +164,7 @@ class TagUpdateDeleteApi(Resource): def delete(self, tag_id: UUID): tag_id_str = str(tag_id) - TagService.delete_tag(tag_id_str) + TagService.delete_tag(tag_id_str, db.session) return "", 204 @@ -189,7 +189,8 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: tag_ids=payload.tag_ids, target_id=payload.target_id, type=payload.type, - ) + ), + db.session, ) return {"result": "success"}, 200 @@ -203,7 +204,8 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]: tag_ids=payload.tag_ids, target_id=payload.target_id, type=payload.type, - ) + ), + db.session, ) return {"result": "success"}, 200 diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index 9c5aa166bc0..821e30ee7e3 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -15,8 +15,11 @@ from pydantic import BaseModel, Field from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user_id, @@ -166,6 +169,7 @@ class EndpointCollectionApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -194,6 +198,7 @@ class DeprecatedEndpointCreateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -285,6 +290,7 @@ class EndpointItemApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -304,6 +310,7 @@ class EndpointItemApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -333,6 +340,7 @@ class DeprecatedEndpointDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -363,6 +371,7 @@ class DeprecatedEndpointUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -385,6 +394,7 @@ class EndpointEnableApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -412,6 +422,7 @@ class EndpointDisableApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 6fea5417152..b3230d77e69 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -32,16 +32,17 @@ from extensions.ext_redis import redis_client from fields.base import ResponseModel from fields.member_fields import AccountWithRole, AccountWithRoleList from libs.helper import extract_remote_ip -from libs.login import login_required +from libs.login import current_account_with_tenant, login_required from models.account import Account, TenantAccountJoin, TenantAccountRole from services.account_service import AccountService, RegisterService, TenantService +from services.enterprise import rbac_service as enterprise_rbac_service from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService class MemberInvitePayload(BaseModel): emails: list[str] = Field(default_factory=list) - role: TenantAccountRole + role: str language: str | None = None @@ -107,6 +108,22 @@ def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: return FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True).dataset_operator_enabled +def _serialize_member_roles( + current_role: str | None, member_roles: list[enterprise_rbac_service.RBACRole] +) -> list[dict[str, str]]: + if dify_config.RBAC_ENABLED: + return [{"id": role.id, "name": role.name} for role in member_roles] + else: + if current_role: + return [{"id": current_role, "name": current_role}] + return [] + + +def _normalize_enum_value(value: object) -> str: + normalized = getattr(value, "value", value) + return str(normalized) if normalized is not None else "" + + def _normalize_invitee_emails(emails: list[str]) -> list[str]: return list(dict.fromkeys(email.lower() for email in emails)) @@ -164,11 +181,42 @@ class MemberListApi(Resource): @account_initialization_required @console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__]) @with_current_user - def get(self, current_user: Account): + def get(self, current_user: Account | None = None): + if current_user is None: + current_user, _ = current_account_with_tenant() if not current_user.current_tenant: raise ValueError("No current tenant") members = TenantService.get_tenant_members(current_user.current_tenant) - member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True) + if dify_config.RBAC_ENABLED: + member_ids = [member.id for member in members] + member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get( + str(current_user.current_tenant.id), + current_user.id, + member_ids, + ) + roles_map = {item.account_id: item.roles for item in member_roles} + else: + roles_map = {} + + serialized_members = [] + for member in members: + current_role = _normalize_enum_value(member.current_role) + serialized_members.append( + { + "id": member.id, + "name": member.name, + "email": member.email, + "avatar": member.avatar, + "last_login_at": member.last_login_at, + "last_active_at": member.last_active_at, + "created_at": member.created_at, + "role": current_role, + "roles": _serialize_member_roles(current_role, roles_map.get(member.id, [])), + "status": _normalize_enum_value(member.status), + } + ) + + member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members) response = AccountWithRoleList(accounts=member_models) return response.model_dump(mode="json"), 200 @@ -190,8 +238,11 @@ class MemberInviteEmailApi(Resource): invitee_emails = _normalize_invitee_emails(args.emails) invitee_role = args.role interface_language = args.language - if not TenantAccountRole.is_non_owner_role(invitee_role): - return {"code": "invalid-role", "message": "Invalid role"}, 400 + if not dify_config.RBAC_ENABLED: + if not TenantAccountRole.is_valid_role(invitee_role): + return {"code": "invalid-role", "message": "Invalid role"}, 400 + if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)): + return {"code": "invalid-role", "message": "Invalid role"}, 400 inviter = current_user if not inviter.current_tenant: raise ValueError("No current tenant") @@ -208,8 +259,9 @@ class MemberInviteEmailApi(Resource): tenant_id = inviter.current_tenant.id with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60): - new_member_count = _count_new_member_invites(tenant_id, invitee_emails) - _check_member_invite_limits(tenant_id, new_member_count) + if dify_config.ENTERPRISE_ENABLED is True or dify_config.BILLING_ENABLED is True: + new_member_count = _count_new_member_invites(tenant_id, invitee_emails) + _check_member_invite_limits(tenant_id, new_member_count) for invitee_email in invitee_emails: try: @@ -232,7 +284,11 @@ class MemberInviteEmailApi(Resource): ) except AccountAlreadyInTenantError: invitation_results.append( - {"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"} + { + "status": "already_member", + "email": invitee_email, + "message": "Account already in workspace.", + } ) except Exception as e: invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)}) diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 07c124890cf..8fda67f4ef8 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -9,8 +9,11 @@ from controllers.common.fields import BinaryFileResponse, SimpleResultResponse 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.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -166,6 +169,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -191,6 +195,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def put(self, current_tenant_id: str, provider: str): @@ -217,6 +222,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, current_tenant_id: str, provider: str): @@ -238,6 +244,7 @@ class ModelProviderCredentialSwitchApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -319,6 +326,7 @@ class PreferredProviderTypeUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index 2139d6f18e0..e82c0fbc2db 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -14,8 +14,11 @@ from controllers.common.schema import ( ) from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -217,6 +220,7 @@ class DefaultModelApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str): @@ -263,6 +267,7 @@ class ModelProviderModelApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -310,6 +315,7 @@ class ModelProviderModelApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, tenant_id: str, provider: str): @@ -389,6 +395,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -421,6 +428,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def put(self, current_tenant_id: str, provider: str): @@ -448,6 +456,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, current_tenant_id: str, provider: str): @@ -472,6 +481,7 @@ class ModelProviderModelCredentialSwitchApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -498,6 +508,7 @@ class ModelProviderModelEnableApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) def patch(self, tenant_id: str, provider: str): args = ParserDeleteModels.model_validate(console_ns.payload) @@ -519,6 +530,7 @@ class ModelProviderModelDisableApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) def patch(self, tenant_id: str, provider: str): args = ParserDeleteModels.model_validate(console_ns.payload) diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index 94979e25b36..d599466002d 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -20,8 +20,11 @@ from controllers.common.schema import ( from controllers.console import console_ns from controllers.console.workspace import plugin_permission_required from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -975,6 +978,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -1005,6 +1009,7 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py new file mode 100644 index 00000000000..1b213a4f741 --- /dev/null +++ b/api/controllers/console/workspace/rbac.py @@ -0,0 +1,939 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from flask import request +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator +from sqlalchemy import select +from werkzeug.exceptions import NotFound + +from configs import dify_config +from controllers.common.schema import register_response_schema_models +from controllers.console import console_ns +from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required +from core.db.session_factory import session_factory +from libs.login import current_account_with_tenant, login_required +from models import Account +from services.enterprise import rbac_service as svc + + +class _RBACRoleList(svc.Paginated[svc.RBACRole]): + pass + + +class _RBACRoleAccountList(svc.Paginated[svc.RBACRoleAccount]): + pass + + +class _AccessPolicyList(svc.Paginated[svc.AccessPolicy]): + pass + + +class _MembersInRoleList(svc.Paginated[svc.MembersInRole]): + pass + + +register_response_schema_models( + console_ns, + svc.PermissionCatalogResponse, + svc.RBACRole, + _RBACRoleList, + _RBACRoleAccountList, + _MembersInRoleList, + svc.AccessPolicy, + _AccessPolicyList, + svc.AccessPolicyBindingState, + svc.MyPermissionsResponse, + svc.AppAccessMatrix, + svc.DatasetAccessMatrix, + svc.WorkspaceAccessMatrix, + svc.ResourceWhitelist, + svc.ResourceUserAccessPoliciesResponse, + svc.ReplaceUserAccessPoliciesResponse, + svc.RoleBindingsResponse, + svc.MemberBindingsResponse, + svc.MemberRolesResponse, + svc.AccessMatrixItem, +) + +_LEGACY_ROLE_PERMISSION_KEYS: dict[str, list[str]] = { + # This is a compatibility projection from the pre-RBAC workspace roles into + # the 2.0 permission matrix documented in "权限整理2.0". It intentionally + # models the product-facing role surface for the new RBAC UI instead of the + # legacy backend's exact hard-authorization checks. + "owner": [ + *svc._LEGACY_WORKSPACE_OWNER_KEYS, + *svc._LEGACY_APP_OWNER_KEYS, + *svc._LEGACY_DATASET_OWNER_KEYS, + ], + "admin": [ + *svc._LEGACY_WORKSPACE_ADMIN_KEYS, + *svc._LEGACY_APP_ADMIN_KEYS, + *svc._LEGACY_DATASET_ADMIN_KEYS, + ], + "editor": [ + *svc._LEGACY_WORKSPACE_EDITOR_KEYS, + *svc._LEGACY_APP_EDITOR_KEYS, + *svc._LEGACY_DATASET_EDITOR_KEYS, + ], + "normal": [ + *svc._LEGACY_WORKSPACE_NORMAL_KEYS, + *svc._LEGACY_APP_NORMAL_KEYS, + ], + "dataset_operator": [ + *svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + *svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + ], +} + + +def _current_ids() -> tuple[str, str]: + """Return ``(tenant_id, account_id)`` for the authenticated user, or + raise a 404 when no tenant is associated with the session. + """ + + user, tenant_id = current_account_with_tenant() + if not tenant_id: + raise NotFound("Current workspace not found") + return tenant_id, user.id + + +def _payload(model: type[BaseModel]) -> Any: + """Validate the JSON body against ``model`` or raise ``ValidationError``. + + ``ValidationError`` bubbles up as HTTP 400 thanks to + ``controllers/common/helpers.py`` error handling. + """ + try: + return model.model_validate(console_ns.payload or {}) + except ValidationError as exc: + # Re-raise as-is so the upstream error handler renders a 400. + raise exc + + +def _dump(model: BaseModel) -> dict[str, Any]: + return model.model_dump(mode="json") + + +def _account_names_by_ids(account_ids: list[str]) -> dict[str, dict[str, str]]: + ids = sorted({account_id.strip() for account_id in account_ids if account_id and account_id.strip()}) + if not ids: + return {} + + with session_factory.create_session() as session: + rows = session.execute( + select(Account.id, Account.name, Account.avatar, Account.email).where(Account.id.in_(ids)) + ).all() + + return { + account_id: { + "name": name or "", + "avatar": avatar or "", + "email": email or "", + } + for account_id, name, avatar, email in rows + } + + +def _hydrate_access_matrix_account_names(items: list[svc.AccessMatrixItem]) -> None: + account_ids: list[str] = [] + for item in items: + for account in item.accounts: + account_id = account.account_id + if account_id and not account.account_name: + account_ids.append(account_id) + + account_names = _account_names_by_ids(account_ids) + if not account_names: + return + + for item in items: + for account in item.accounts: + account_id = str(account.account_id or "").strip() + if account_id and not account.account_name: + account.account_name = account_names.get(account_id, {}).get("name", "") + account.avatar = account_names.get(account_id, {}).get("avatar", "") + account.email = account_names.get(account_id, {}).get("email", "") + + +def _hydrate_resource_user_account_names(items: list[svc.ResourceUserAccessPolicies]) -> None: + account_names = _account_names_by_ids([item.account.account_id for item in items]) + for item in items: + account_id = item.account.account_id + if account_id and not item.account.account_name: + item.account.account_name = account_names.get(account_id, {}).get("name", "") + item.account.avatar = account_names.get(account_id, {}).get("avatar", "") + item.account.email = account_names.get(account_id, {}).get("email", "") + + +class _PaginationQuery(BaseModel): + model_config = ConfigDict(extra="ignore") + + page_number: int | None = Field(default=None, ge=1, validation_alias=AliasChoices("page", "page_number")) + results_per_page: int | None = Field( + default=None, ge=1, le=99999, validation_alias=AliasChoices("limit", "results_per_page") + ) + reverse: bool | None = None + + def to_inner_options(self) -> svc.ListOption: + return svc.ListOption.model_validate(self.model_dump()) + + +class _RolesListQuery(_PaginationQuery): + include_owner: int = Field(default=0, ge=0, le=1) + + +class CopyRoleParam(BaseModel): + copy_member: bool = True + + +def _pagination_options() -> svc.ListOption: + return _PaginationQuery.model_validate(request.args.to_dict(flat=True)).to_inner_options() + + +def _legacy_workspace_roles( + options: svc.ListOption | None = None, *, include_owner: int = 0 +) -> svc.Paginated[svc.RBACRole]: + """Return the built-in legacy workspace roles in the RBAC list shape. + + This keeps the new `/rbac/roles` endpoint compatible with the original + Dify role model when enterprise RBAC is disabled. + """ + + legacy_roles = [ + svc.RBACRole( + id=role_name, + tenant_id="", + type=svc.RBACRoleType.WORKSPACE.value, + category="global_system_default", + name=role_name, + description="", + is_builtin=True, + permission_keys=list(_LEGACY_ROLE_PERMISSION_KEYS[role_name]), + role_tag="owner" if role_name == "owner" else "", + ) + for role_name in ("owner", "admin", "editor", "normal", "dataset_operator") + ] + + if not include_owner: + legacy_roles = [r for r in legacy_roles if r.name != "owner"] + + page_number = options.page_number if options and options.page_number is not None else 1 + results_per_page = ( + options.results_per_page if options and options.results_per_page is not None else len(legacy_roles) + ) + reverse = options.reverse if options and options.reverse is not None else False + + ordered_roles = list(reversed(legacy_roles)) if reverse else legacy_roles + start = max(page_number - 1, 0) * results_per_page + end = start + results_per_page + paged_roles = ordered_roles[start:end] + total_count = len(legacy_roles) + total_pages = (total_count + results_per_page - 1) // results_per_page if results_per_page > 0 else 0 + + return svc.Paginated[svc.RBACRole]( + data=paged_roles, + pagination=svc.Pagination( + total_count=total_count, + per_page=results_per_page, + current_page=page_number, + total_pages=total_pages, + ), + ) + + +# --------------------------------------------------------------------------- +# Permission catalogs. +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog") +class RBACWorkspaceCatalogApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.PermissionCatalogResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Catalog.workspace(tenant_id, account_id)) + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/app") +class RBACAppCatalogApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.PermissionCatalogResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Catalog.app(tenant_id, account_id)) + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/dataset") +class RBACDatasetCatalogApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.PermissionCatalogResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Catalog.dataset(tenant_id, account_id)) + + +# --------------------------------------------------------------------------- +# Roles. +# --------------------------------------------------------------------------- + + +class _RoleUpsertRequest(BaseModel): + """Accepts the payload sent by the Create/Edit Role dialog.""" + + name: str + description: str = "" + permission_keys: list[str] = [] + + def to_mutation(self) -> svc.RoleMutation: + return svc.RoleMutation( + name=self.name, + description=self.description, + permission_keys=list(self.permission_keys), + ) + + +@console_ns.route("/workspaces/current/rbac/roles") +class RBACRolesApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[_RBACRoleList.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + query = _RolesListQuery.model_validate(request.args.to_dict(flat=True)) + options = query.to_inner_options() + if not dify_config.RBAC_ENABLED: + result = _legacy_workspace_roles(options, include_owner=query.include_owner) + else: + result = svc.RBACService.Roles.list( + tenant_id, account_id, include_owner=query.include_owner, options=options + ) + + return _dump(result) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Role created", console_ns.models[svc.RBACRole.__name__]) + def post(self): + tenant_id, account_id = _current_ids() + request = _payload(_RoleUpsertRequest) + role = svc.RBACService.Roles.create(tenant_id, account_id, request.to_mutation()) + return _dump(role), 201 + + +@console_ns.route("/workspaces/current/rbac/roles/") +class RBACRoleItemApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__]) + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Roles.get(tenant_id, account_id, str(role_id))) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__]) + def put(self, role_id): + tenant_id, account_id = _current_ids() + request = _payload(_RoleUpsertRequest) + role = svc.RBACService.Roles.update(tenant_id, account_id, str(role_id), request.to_mutation()) + return _dump(role) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__]) + def delete(self, role_id): + tenant_id, account_id = _current_ids() + svc.RBACService.Roles.delete(tenant_id, account_id, str(role_id)) + return {"result": "success"} + + +@console_ns.route("/workspaces/current/rbac/roles//copy") +class RBACRoleCopyApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Role copied", console_ns.models[svc.RBACRole.__name__]) + def post(self, role_id): + tenant_id, account_id = _current_ids() + request = _payload(CopyRoleParam) + role = svc.RBACService.Roles.copy(tenant_id, account_id, str(role_id), copy_member=request.copy_member) + return _dump(role), 201 + + +@console_ns.route("/workspaces/current/rbac/roles//members") +class RBACRoleMembersApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[_RBACRoleAccountList.__name__]) + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.Roles.members( + tenant_id, + account_id, + str(role_id), + options=_pagination_options(), + ) + ) + + +# --------------------------------------------------------------------------- +# Access policies (tenant-level permission sets). +# --------------------------------------------------------------------------- + + +class _AccessPolicyCreateRequest(BaseModel): + name: str + resource_type: svc.RBACResourceType + description: str = "" + permission_keys: list[str] = [] + + +class _AccessPolicyUpdateRequest(BaseModel): + name: str + description: str = "" + permission_keys: list[str] = [] + + +@console_ns.route("/workspaces/current/rbac/access-policies") +class RBACAccessPoliciesApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[_AccessPolicyList.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + # `resource_type` is exposed as a query argument so the UI can show + # only app-scoped or only dataset-scoped permission sets. + resource_type = request.args.get("resource_type") or None + return _dump( + svc.RBACService.AccessPolicies.list( + tenant_id, + account_id, + resource_type=resource_type, + options=_pagination_options(), + ) + ) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Policy created", console_ns.models[svc.AccessPolicy.__name__]) + def post(self): + tenant_id, account_id = _current_ids() + request = _payload(_AccessPolicyCreateRequest) + policy = svc.RBACService.AccessPolicies.create( + tenant_id, + account_id, + svc.AccessPolicyCreate( + name=request.name, + resource_type=request.resource_type, + description=request.description, + permission_keys=list(request.permission_keys), + ), + ) + return _dump(policy), 201 + + +@console_ns.route("/workspaces/current/rbac/access-policies/") +class RBACAccessPolicyItemApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicy.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicies.get(tenant_id, account_id, str(policy_id))) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicy.__name__]) + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_AccessPolicyUpdateRequest) + policy = svc.RBACService.AccessPolicies.update( + tenant_id, + account_id, + str(policy_id), + svc.AccessPolicyUpdate( + name=request.name, + description=request.description, + permission_keys=list(request.permission_keys), + ), + ) + return _dump(policy) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicy.__name__]) + def delete(self, policy_id): + tenant_id, account_id = _current_ids() + svc.RBACService.AccessPolicies.delete(tenant_id, account_id, str(policy_id)) + return {"result": "success"} + + +@console_ns.route("/workspaces/current/rbac/access-policies//copy") +class RBACAccessPolicyCopyApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Policy copied", console_ns.models[svc.AccessPolicy.__name__]) + def post(self, policy_id): + tenant_id, account_id = _current_ids() + policy = svc.RBACService.AccessPolicies.copy(tenant_id, account_id, str(policy_id)) + return _dump(policy), 201 + + +@console_ns.route("/workspaces/current/rbac/access-policy-bindings//lock") +class RBACAccessPolicyBindingLockApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicyBindingState.__name__]) + def put(self, binding_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicyBindings.lock(tenant_id, account_id, str(binding_id))) + + +@console_ns.route("/workspaces/current/rbac/access-policy-bindings//unlock") +class RBACAccessPolicyBindingUnlockApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicyBindingState.__name__]) + def put(self, binding_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicyBindings.unlock(tenant_id, account_id, str(binding_id))) + + +# --------------------------------------------------------------------------- +# Per-app access (App Access Config). +# --------------------------------------------------------------------------- + + +class _AccessScope(StrEnum): + ALL = "all" + SPECIFIC = "specific" + ONLY_ME = "only_me" + + +class _ResourceAccessScopeRequest(BaseModel): + scope: _AccessScope + + +class _ReplaceBindingsRequest(BaseModel): + role_ids: list[str] = Field(default_factory=list) + account_ids: list[str] = Field(default_factory=list) + + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_bindings(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class _DeleteMemberBindingsRequest(BaseModel): + account_ids: list[str] = Field(default_factory=list) + + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +@console_ns.route("/workspaces/current/rbac/my-permissions") +class RBACMyPermissionsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MyPermissionsResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.MyPermissions.get( + tenant_id, + account_id, + app_id=request.args.get("app_id") or None, + dataset_id=request.args.get("dataset_id") or None, + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policy") +class RBACAppMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.AppAccessMatrix.__name__]) + def get(self, app_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.AppAccess.matrix(tenant_id, account_id, str(app_id)) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/apps//whitelist") +class RBACAppWhitelistApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def get(self, app_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.whitelist(tenant_id, account_id, str(app_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def put(self, app_id): + tenant_id, account_id = _current_ids() + request = _payload(_ResourceAccessScopeRequest) + return _dump( + svc.RBACService.AppAccess.replace_whitelist( + tenant_id, + account_id, + str(app_id), + svc.ReplaceMemberBindings(scope=request.scope.value), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//user-access-policies") +class RBACAppUserAccessPoliciesApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceUserAccessPoliciesResponse.__name__]) + def get(self, app_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.AppAccess.user_access_policies(tenant_id, account_id, str(app_id)) + _hydrate_resource_user_account_names(result.data) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/apps//users//access-policies") +class RBACAppUserAccessPolicyAssignmentApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ReplaceUserAccessPoliciesResponse.__name__]) + def put(self, app_id, target_account_id): + tenant_id, account_id = _current_ids() + payload = _payload(svc.ReplaceUserAccessPolicies) + return _dump( + svc.RBACService.AppAccess.replace_user_access_policies( + tenant_id, + account_id, + str(app_id), + str(target_account_id), + payload, + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policies//role-bindings") +class RBACAppRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.list_role_bindings(tenant_id, account_id, str(app_id), str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policies//member-bindings") +class RBACAppMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.list_member_bindings(tenant_id, account_id, str(app_id), str(policy_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def delete(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + request_body = _payload(_DeleteMemberBindingsRequest) + svc.RBACService.AppAccess.delete_member_bindings( + tenant_id, + account_id, + str(app_id), + str(policy_id), + svc.DeleteMemberBindings(account_ids=request_body.account_ids), + ) + return {"result": "success"} + + +# --------------------------------------------------------------------------- +# Per-dataset access (Knowledge Base Access Config). +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/datasets//access-policy") +class RBACDatasetMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.DatasetAccessMatrix.__name__]) + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.DatasetAccess.matrix(tenant_id, account_id, str(dataset_id)) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/datasets//whitelist") +class RBACDatasetWhitelistApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.DatasetAccess.whitelist(tenant_id, account_id, str(dataset_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def put(self, dataset_id): + tenant_id, account_id = _current_ids() + request = _payload(_ResourceAccessScopeRequest) + return _dump( + svc.RBACService.DatasetAccess.replace_whitelist( + tenant_id, + account_id, + str(dataset_id), + svc.ReplaceMemberBindings(scope=request.scope.value), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/datasets//user-access-policies") +class RBACDatasetUserAccessPoliciesApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceUserAccessPoliciesResponse.__name__]) + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.DatasetAccess.user_access_policies(tenant_id, account_id, str(dataset_id)) + _hydrate_resource_user_account_names(result.data) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/datasets//users//access-policies") +class RBACDatasetUserAccessPolicyAssignmentApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ReplaceUserAccessPoliciesResponse.__name__]) + def put(self, dataset_id, target_account_id): + tenant_id, account_id = _current_ids() + payload = _payload(svc.ReplaceUserAccessPolicies) + return _dump( + svc.RBACService.DatasetAccess.replace_user_access_policies( + tenant_id, + account_id, + str(dataset_id), + str(target_account_id), + payload, + ) + ) + + +@console_ns.route("/workspaces/current/rbac/datasets//access-policies//role-bindings") +class RBACDatasetRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.DatasetAccess.list_role_bindings(tenant_id, account_id, str(dataset_id), str(policy_id)) + ) + + +@console_ns.route( + "/workspaces/current/rbac/datasets//access-policies//member-bindings" +) +class RBACDatasetMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.DatasetAccess.list_member_bindings(tenant_id, account_id, str(dataset_id), str(policy_id)) + ) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def delete(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + request_body = _payload(_DeleteMemberBindingsRequest) + svc.RBACService.DatasetAccess.delete_member_bindings( + tenant_id, + account_id, + str(dataset_id), + str(policy_id), + svc.DeleteMemberBindings(account_ids=request_body.account_ids), + ) + return {"result": "success"} + + +# --------------------------------------------------------------------------- +# Workspace-level access (Settings > Access Rules). +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy") +class RBACWorkspaceAppMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.WorkspaceAccessMatrix.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + options = _pagination_options() + result = svc.RBACService.WorkspaceAccess.app_matrix(tenant_id, account_id, options=options) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//role-bindings") +class RBACWorkspaceAppRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.list_app_role_bindings(tenant_id, account_id, str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//bindings") +class RBACWorkspaceAppBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.AccessMatrixItem.__name__]) + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_app_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//member-bindings") +class RBACWorkspaceAppMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.list_app_member_bindings(tenant_id, account_id, str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policy") +class RBACWorkspaceDatasetMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.WorkspaceAccessMatrix.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + options = _pagination_options() + result = svc.RBACService.WorkspaceAccess.dataset_matrix(tenant_id, account_id, options=options) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//role-bindings") +class RBACWorkspaceDatasetRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.list_dataset_role_bindings(tenant_id, account_id, str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//bindings") +class RBACWorkspaceDatasetBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.AccessMatrixItem.__name__]) + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_dataset_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//member-bindings") +class RBACWorkspaceDatasetMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.WorkspaceAccess.list_dataset_member_bindings(tenant_id, account_id, str(policy_id)) + ) + + +# --------------------------------------------------------------------------- +# Member ↔ role bindings (Settings > Members > Assign roles). +# --------------------------------------------------------------------------- + + +class _ReplaceMemberRolesRequest(BaseModel): + role_ids: list[str] = [] + + @field_validator("role_ids", mode="before") + @classmethod + def _coerce_role_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +@console_ns.route("/workspaces/current/rbac/members//rbac-roles") +class RBACMemberRolesApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberRolesResponse.__name__]) + def get(self, member_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.MemberRoles.get(tenant_id, account_id, str(member_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberRolesResponse.__name__]) + def put(self, member_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceMemberRolesRequest) + return _dump( + svc.RBACService.MemberRoles.replace( + tenant_id, + account_id, + str(member_id), + role_ids=list(request.role_ids), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/roles//members") +class ListMembersByRole(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[_MembersInRoleList.__name__]) + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.Roles.list_members_by_role(tenant_id, role_id=role_id, options=_pagination_options()) + ) diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index 4bd493d25e9..e8f0b228c8b 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -21,8 +21,11 @@ from controllers.console.snippets.payloads import ( UpdateSnippetPayload, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -126,6 +129,7 @@ class CustomizedSnippetsApi(Resource): snippet_service = _snippet_service() snippets, total, has_more = snippet_service.get_snippets( tenant_id=current_tenant_id, + session=db.session, page=query.page, limit=query.limit, keyword=query.keyword, @@ -150,6 +154,9 @@ class CustomizedSnippetsApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): @@ -212,6 +219,9 @@ class CustomizedSnippetDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user @with_current_tenant_id def patch(self, current_tenant_id: str, current_user: Account, snippet_id: str): @@ -256,6 +266,7 @@ class CustomizedSnippetDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) @with_current_tenant_id def delete(self, current_tenant_id: str, snippet_id: str): """Delete customized snippet.""" @@ -291,6 +302,9 @@ class CustomizedSnippetExportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_tenant_id def get(self, current_tenant_id: str, snippet_id: str): """Export snippet as DSL.""" @@ -336,6 +350,9 @@ class CustomizedSnippetImportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user def post(self, current_user: Account): """Import snippet from DSL.""" @@ -374,6 +391,9 @@ class CustomizedSnippetImportConfirmApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user def post(self, current_user: Account, import_id: str): """Confirm a pending snippet import.""" @@ -402,6 +422,9 @@ class CustomizedSnippetCheckDependenciesApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_tenant_id def get(self, current_tenant_id: str, snippet_id: str): """Check dependencies for a snippet.""" @@ -432,6 +455,9 @@ class CustomizedSnippetUseCountIncrementApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_tenant_id def post(self, current_tenant_id: str, snippet_id: str): """Increment snippet use count when it is inserted into a workflow.""" diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 94600f38860..9a92571594c 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -14,9 +14,12 @@ from controllers.common.fields import BinaryFileResponse, RedirectResponse, Simp 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.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, enterprise_license_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -369,6 +372,7 @@ class ToolBuiltinProviderDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -411,6 +415,7 @@ class ToolBuiltinProviderUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -471,6 +476,7 @@ class ToolApiProviderAddApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -540,6 +546,7 @@ class ToolApiProviderUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -568,6 +575,7 @@ class ToolApiProviderDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -659,6 +667,7 @@ class ToolWorkflowProviderCreateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -686,6 +695,7 @@ class ToolWorkflowProviderUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -713,6 +723,7 @@ class ToolWorkflowProviderDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -858,6 +869,7 @@ class ToolPluginOAuthApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -959,6 +971,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -975,6 +988,7 @@ class ToolOAuthCustomClient(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index ff1a5c18bd9..b960a5eb9c3 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -27,9 +27,12 @@ from services.trigger.trigger_subscription_operator_service import TriggerSubscr from .. import console_ns from ..wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -143,6 +146,7 @@ class TriggerSubscriptionListApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -172,6 +176,7 @@ class TriggerSubscriptionBuilderCreateApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -201,6 +206,7 @@ class TriggerSubscriptionBuilderGetApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get a subscription instance for a trigger provider""" @@ -218,6 +224,7 @@ class TriggerSubscriptionBuilderVerifyApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -250,6 +257,7 @@ class TriggerSubscriptionBuilderUpdateApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str, subscription_builder_id: str): @@ -282,6 +290,7 @@ class TriggerSubscriptionBuilderLogsApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get the request logs for a subscription instance for a trigger provider""" @@ -302,6 +311,7 @@ class TriggerSubscriptionBuilderBuildApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -336,6 +346,7 @@ class TriggerSubscriptionUpdateApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, subscription_id: str): @@ -393,6 +404,7 @@ class TriggerSubscriptionDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, subscription_id: str): @@ -581,6 +593,7 @@ class TriggerOAuthClientManageApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, provider: str): @@ -626,6 +639,7 @@ class TriggerOAuthClientManageApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -650,6 +664,7 @@ class TriggerOAuthClientManageApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_tenant_id @@ -678,6 +693,7 @@ class TriggerSubscriptionVerifyApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 1c0f210123a..017793ffe0b 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -9,9 +9,14 @@ from typing import Any, Concatenate, overload from flask import abort, request from pydantic import BaseModel, ValidationError from sqlalchemy import select -from werkzeug.exceptions import UnprocessableEntity +from werkzeug.exceptions import Forbidden, UnprocessableEntity from configs import dify_config +from controllers.common.wraps import ( + RBACPermission, + RBACResourceScope, + rbac_permission_required, +) from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError from controllers.console.workspace.error import AccountNotInitializedError from enums.cloud_plan import CloudPlan @@ -28,6 +33,10 @@ from services.operation_service import OperationService, UtmInfo from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout +# Re-exported so controllers can import the RBAC enums and decorator alongside +# other console wraps from this module. +__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"] + # Field names for decryption FIELD_NAME_PASSWORD = "password" FIELD_NAME_CODE = "code" @@ -335,15 +344,15 @@ def knowledge_pipeline_publish_enabled[**P, R](view: Callable[P, R]) -> Callable def edit_permission_required[**P, R](f: Callable[P, R]) -> Callable[P, R]: @wraps(f) def decorated_function(*args: P.args, **kwargs: P.kwargs): - from werkzeug.exceptions import Forbidden from libs.login import current_user - user = current_user._get_current_object() # type: ignore - if not isinstance(user, Account): - raise Forbidden() - if not current_user.has_edit_permission: - raise Forbidden() + if not dify_config.RBAC_ENABLED: + user = current_user._get_current_object() # type: ignore + if not isinstance(user, Account): + raise Forbidden() + if not current_user.has_edit_permission: + raise Forbidden() return f(*args, **kwargs) return decorated_function @@ -352,13 +361,13 @@ def edit_permission_required[**P, R](f: Callable[P, R]) -> Callable[P, R]: def is_admin_or_owner_required[**P, R](f: Callable[P, R]) -> Callable[P, R]: @wraps(f) def decorated_function(*args: P.args, **kwargs: P.kwargs): - from werkzeug.exceptions import Forbidden from libs.login import current_user - user = current_user._get_current_object() - if not isinstance(user, Account) or not user.is_admin_or_owner: - raise Forbidden() + if not dify_config.RBAC_ENABLED: + user = current_user._get_current_object() + if not isinstance(user, Account) or not user.is_admin_or_owner: + raise Forbidden() return f(*args, **kwargs) return decorated_function diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index c782c93ffd4..20fd651b759 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -9,14 +9,16 @@ api = ExternalApi( bp, version="1.0", title="Inner API", - description="Internal APIs for enterprise features, billing, and plugin communication", + description="Internal APIs for enterprise features, billing, knowledge retrieval, and plugin communication", ) # Create namespace inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/") from . import mail as _mail +from . import runtime_credentials as _runtime_credentials from .app import dsl as _app_dsl +from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin from .workspace import workspace as _workspace @@ -26,8 +28,10 @@ api.add_namespace(inner_api_ns) __all__ = [ "_agent_drive", "_app_dsl", + "_knowledge_retrieval", "_mail", "_plugin", + "_runtime_credentials", "_workspace", "api", "bp", diff --git a/api/controllers/inner_api/knowledge/__init__.py b/api/controllers/inner_api/knowledge/__init__.py new file mode 100644 index 00000000000..20c447fa778 --- /dev/null +++ b/api/controllers/inner_api/knowledge/__init__.py @@ -0,0 +1 @@ +"""Inner knowledge retrieval endpoints.""" diff --git a/api/controllers/inner_api/knowledge/retrieval.py b/api/controllers/inner_api/knowledge/retrieval.py new file mode 100644 index 00000000000..ef33fbda518 --- /dev/null +++ b/api/controllers/inner_api/knowledge/retrieval.py @@ -0,0 +1,110 @@ +"""Inner API endpoint for tenant-scoped knowledge retrieval. + +This controller is a thin HTTP wrapper around +``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``. +It intentionally keeps authorization simple: shared inner API key plus +tenant-scoped app/dataset validation in the service layer. +""" + +from flask_restx import Resource +from pydantic import ValidationError + +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import inner_api_only +from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc +from libs.exception import BaseHTTPException +from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse +from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError +from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService + + +class InnerKnowledgeRetrievalHttpError(BaseHTTPException): + error_code = "knowledge_retrieve_failed" + description = "Knowledge retrieval failed." + code = 500 + + def __init__( + self, + *, + error_code: str | None = None, + description: str | None = None, + status_code: int | None = None, + ) -> None: + if error_code is not None: + self.error_code = error_code + if description is not None: + self.description = description + if status_code is not None: + self.code = status_code + super().__init__(self.description) + + +register_schema_models(inner_api_ns, InnerKnowledgeRetrieveRequest) +register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse) + + +@inner_api_ns.route("/knowledge/retrieve") +class InnerKnowledgeRetrieveApi(Resource): + """Retrieve knowledge from one or more datasets within the caller tenant.""" + + @inner_api_only + @inner_api_ns.doc("inner_knowledge_retrieve") + @inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers") + @inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__]) + @inner_api_ns.response( + 200, + "Knowledge retrieved successfully", + inner_api_ns.models[InnerKnowledgeRetrieveResponse.__name__], + ) + @inner_api_ns.doc( + responses={ + 400: "Invalid request body", + 401: "Unauthorized - invalid inner API key", + 403: "Caller tenant does not own the requested resource", + 404: "App or dataset not found", + 422: "Invalid retrieval configuration", + 429: "Knowledge retrieval rate limited", + 502: "External knowledge retrieval failed", + 500: "Unexpected knowledge retrieval failure", + } + ) + def post(self) -> dict[str, object]: + """Validate the payload, run retrieval, and return workflow-style sources.""" + try: + payload = InnerKnowledgeRetrieveRequest.model_validate(inner_api_ns.payload or {}) + except ValidationError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="invalid_request", + description=str(exc), + status_code=400, + ) from exc + + try: + response = InnerKnowledgeRetrievalService().retrieve(payload) + except InnerKnowledgeRetrievalServiceError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code=exc.error_code, + description=exc.description, + status_code=exc.status_code, + ) from exc + except retrieval_exc.RateLimitExceededError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="knowledge_rate_limited", + description=str(exc), + status_code=429, + ) from exc + except ExternalKnowledgeRetrievalError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="external_knowledge_failed", + description=str(exc), + status_code=502, + ) from exc + except ValueError as exc: + raise InnerKnowledgeRetrievalHttpError( + error_code="retrieval_config_invalid", + description=str(exc), + status_code=422, + ) from exc + + return response.model_dump(mode="json", by_alias=True) diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index ad369660d9a..cc68fab6c99 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from libs.login import current_user from models.account import Tenant +from models.enums import EndUserType from models.model import DefaultEndUserSessionID, EndUser @@ -75,7 +76,7 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: if not user_model: user_model = EndUser( tenant_id=tenant_id, - type="service_api", + type=EndUserType.SERVICE_API, is_anonymous=is_anonymous, session_id=user_id, ) diff --git a/api/controllers/inner_api/runtime_credentials.py b/api/controllers/inner_api/runtime_credentials.py new file mode 100644 index 00000000000..bea65230d73 --- /dev/null +++ b/api/controllers/inner_api/runtime_credentials.py @@ -0,0 +1,205 @@ +"""Inner API endpoints for runtime credential resolution. + +Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint +returns decrypted model and tool credentials for in-memory runtime use only. +""" + +import json +import logging +from json import JSONDecodeError +from typing import Any + +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from controllers.common.schema import register_schema_model +from controllers.console.wraps import setup_required +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import enterprise_inner_api_only +from core.helper import encrypter +from core.helper.provider_cache import ToolProviderCredentialsCache +from core.helper.provider_encryption import create_provider_encrypter +from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager +from core.tools.tool_manager import ToolManager +from extensions.ext_database import db +from models.provider import ProviderCredential +from models.tools import BuiltinToolProvider + +logger = logging.getLogger(__name__) + +_KIND_MODEL = "model" +_KIND_TOOL = "tool" + +# (body, status) pair returned by a resolver helper when resolution fails. +ResolveError = tuple[dict[str, str], int] + + +class InnerRuntimeCredentialResolveItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + credential_id: str = Field(description="Credential id") + provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai") + kind: str = Field(description="Credential kind, either 'model' or 'tool'") + + +class InnerRuntimeCredentialsResolvePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_id: str = Field(description="Workspace id") + credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list) + + +register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload) + + +@inner_api_ns.route("/enterprise/credentials/resolve") +class EnterpriseRuntimeCredentialsResolve(Resource): + @setup_required + @enterprise_inner_api_only + @inner_api_ns.doc( + "enterprise_runtime_credentials_resolve", + responses={ + 200: "Credentials resolved", + 400: "Invalid request or credential config", + 404: "Provider or credential not found", + }, + ) + @inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__]) + def post(self): + args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {}) + if not args.credentials: + return {"credentials": []}, 200 + + # Model resolution shares one provider configuration set; build it lazily + # so a tool-only request never pays for the plugin daemon round trip. + model_configurations = None + + resolved: list[dict[str, Any]] = [] + for item in args.credentials: + if item.kind == _KIND_MODEL: + if model_configurations is None: + provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id) + model_configurations = provider_manager.get_configurations(args.tenant_id) + values, error = _resolve_model(args.tenant_id, model_configurations, item) + elif item.kind == _KIND_TOOL: + values, error = _resolve_tool(args.tenant_id, item) + else: + return {"message": f"unsupported credential kind '{item.kind}'"}, 400 + + if error is not None: + return error + resolved.append( + { + "credential_id": item.credential_id, + "kind": item.kind, + "provider": item.provider, + "values": values, + } + ) + + return {"credentials": resolved}, 200 + + +def _resolve_model( + tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem +) -> tuple[dict[str, Any] | None, ResolveError | None]: + provider_configuration = provider_configurations.get(item.provider) + if provider_configuration is None: + return None, ({"message": f"provider '{item.provider}' not found"}, 404) + + provider_schema = provider_configuration.provider.provider_credential_schema + secret_variables = provider_configuration.extract_secret_variables( + provider_schema.credential_form_schemas if provider_schema else [] + ) + + with Session(db.engine) as session: + stmt = select(ProviderCredential).where( + ProviderCredential.id == item.credential_id, + ProviderCredential.tenant_id == tenant_id, + ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()), + ) + credential = session.execute(stmt).scalar_one_or_none() + + if credential is None or not credential.encrypted_config: + return None, ({"message": f"credential '{item.credential_id}' not found"}, 404) + + try: + values = json.loads(credential.encrypted_config) + except JSONDecodeError: + return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400) + if not isinstance(values, dict): + return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400) + + for key in secret_variables: + value = values.get(key) + if value is None: + continue + try: + values[key] = encrypter.decrypt_token(tenant_id=tenant_id, token=value) + except Exception as exc: + logger.warning( + "failed to resolve runtime model credential", + extra={ + "credential_id": item.credential_id, + "provider": item.provider, + "tenant_id": tenant_id, + "error": type(exc).__name__, + }, + ) + return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400) + + return values, None + + +def _resolve_tool( + tenant_id: str, item: InnerRuntimeCredentialResolveItem +) -> tuple[dict[str, Any] | None, ResolveError | None]: + try: + provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id) + except Exception as exc: + logger.warning( + "failed to load runtime tool provider", + extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__}, + ) + return None, ({"message": f"tool provider '{item.provider}' not found"}, 404) + + with Session(db.engine) as session: + stmt = select(BuiltinToolProvider).where( + BuiltinToolProvider.id == item.credential_id, + BuiltinToolProvider.provider == item.provider, + BuiltinToolProvider.tenant_id == tenant_id, + ) + builtin_provider = session.execute(stmt).scalar_one_or_none() + + if builtin_provider is None: + return None, ({"message": f"credential '{item.credential_id}' not found"}, 404) + + try: + # Tool credentials are stored as a single encrypted dict; the secret + # fields are decided by the schema bound to this credential type. + provider_encrypter, _ = create_provider_encrypter( + tenant_id=tenant_id, + config=[ + schema.to_basic_provider_config() + for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type) + ], + cache=ToolProviderCredentialsCache( + tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id + ), + ) + values = dict(provider_encrypter.decrypt(builtin_provider.credentials)) + except Exception as exc: + logger.warning( + "failed to resolve runtime tool credential", + extra={ + "credential_id": item.credential_id, + "provider": item.provider, + "tenant_id": tenant_id, + "error": type(exc).__name__, + }, + ) + return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400) + + return values, None diff --git a/api/controllers/inner_api/workspace/workspace.py b/api/controllers/inner_api/workspace/workspace.py index ef0a46db63a..dd93616e6b1 100644 --- a/api/controllers/inner_api/workspace/workspace.py +++ b/api/controllers/inner_api/workspace/workspace.py @@ -48,7 +48,7 @@ class EnterpriseWorkspace(Resource): return {"message": "owner account not found."}, 404 tenant = TenantService.create_tenant(args.name, is_from_dashboard=True) - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db.session, role="owner") tenant_was_created.send(tenant) diff --git a/api/controllers/inner_api/wraps.py b/api/controllers/inner_api/wraps.py index 95181b93cfa..999932c98e1 100644 --- a/api/controllers/inner_api/wraps.py +++ b/api/controllers/inner_api/wraps.py @@ -8,39 +8,39 @@ from flask import abort, request from configs import dify_config from core.db.session_factory import session_factory +from libs.exception import BaseHTTPException from models.model import EndUser -def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: +class InnerApiUnauthorizedError(BaseHTTPException): + error_code = "inner_api_unauthorized" + description = "Unauthorized." + code = 401 + + +def inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """Restrict access to callers authenticated with the shared inner API key.""" + @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: if not dify_config.INNER_API: abort(404) - # get header 'X-Inner-Api-Key' inner_api_key = request.headers.get("X-Inner-Api-Key") if not inner_api_key or inner_api_key != dify_config.INNER_API_KEY: - abort(401) + raise InnerApiUnauthorizedError() return view(*args, **kwargs) return decorated +def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + return inner_api_only(view) + + def enterprise_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs) -> R: - if not dify_config.INNER_API: - abort(404) - - # get header 'X-Inner-Api-Key' - inner_api_key = request.headers.get("X-Inner-Api-Key") - if not inner_api_key or inner_api_key != dify_config.INNER_API_KEY: - abort(401) - - return view(*args, **kwargs) - - return decorated + return inner_api_only(view) def enterprise_inner_api_user_auth[**P, R](view: Callable[P, R]) -> Callable[P, R]: diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index f652bbc5814..cda6b915018 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -13,7 +13,7 @@ from core.mcp.server.streamable_http import handle_mcp_request from extensions.ext_database import db from graphon.variables.input_entities import VariableEntity, VariableEntityType from libs import helper -from models.enums import AppMCPServerStatus +from models.enums import AppMCPServerStatus, EndUserType from models.model import App, AppMCPServer, AppMode, EndUser @@ -201,7 +201,7 @@ class MCPAppApi(Resource): select(EndUser) .where(EndUser.tenant_id == tenant_id) .where(EndUser.session_id == mcp_server_id) - .where(EndUser.type == "mcp") + .where(EndUser.type == EndUserType.MCP) .limit(1) ) @@ -212,7 +212,7 @@ class MCPAppApi(Resource): end_user = EndUser( tenant_id=tenant_id, app_id=app_id, - type="mcp", + type=EndUserType.MCP, name=client_name, session_id=mcp_server_id, ) diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 84b1610d5f5..c4796313c0b 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -174,7 +174,7 @@ class AppListApi(Resource): tag_ids: list[str] | None = None if query.tag: - tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag) + tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session) if not tags: return empty tag_ids = [tag.id for tag in tags] @@ -191,7 +191,7 @@ class AppListApi(Resource): openapi_visible=True, ) - pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params) + pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, db.session) if pagination is None: return empty diff --git a/api/controllers/openapi/auth/prepare.py b/api/controllers/openapi/auth/prepare.py index 3826f2c33cd..afded64702d 100644 --- a/api/controllers/openapi/auth/prepare.py +++ b/api/controllers/openapi/auth/prepare.py @@ -6,9 +6,9 @@ from flask import request from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized from controllers.openapi.auth.data import AuthData -from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from models.account import TenantStatus +from models.enums import EndUserType from services.account_service import AccountService, TenantService from services.app_service import AppService from services.end_user_service import EndUserService @@ -85,7 +85,7 @@ def resolve_external_user(data: AuthData) -> None: if data.tenant is None or data.app is None or data.external_identity is None: raise Unauthorized("missing context for external user resolution") end_user = EndUserService.get_or_create_end_user_by_type( - InvokeFrom.OPENAPI, + EndUserType.OPENAPI, tenant_id=str(data.tenant.id), app_id=str(data.app.id), user_id=data.external_identity.email, diff --git a/api/controllers/service_api/app/annotation.py b/api/controllers/service_api/app/annotation.py index e99018a985d..627545d7168 100644 --- a/api/controllers/service_api/app/annotation.py +++ b/api/controllers/service_api/app/annotation.py @@ -23,20 +23,25 @@ from services.annotation_service import ( class AnnotationCreatePayload(BaseModel): - question: str = Field(description="Annotation question") - answer: str = Field(description="Annotation answer") + question: str = Field(description="Annotation question.") + answer: str = Field(description="Annotation answer.") class AnnotationReplyActionPayload(BaseModel): - score_threshold: float = Field(description="Score threshold for annotation matching") - embedding_provider_name: str = Field(description="Embedding provider name") - embedding_model_name: str = Field(description="Embedding model name") + score_threshold: float = Field( + description=( + "Minimum similarity score for an annotation to be considered a match. Higher values require closer matches." + ), + json_schema_extra={"format": "float"}, + ) + embedding_provider_name: str = Field(description="Name of the embedding model provider.") + embedding_model_name: str = Field(description="Name of the embedding model to use for annotation matching.") class AnnotationListQuery(BaseModel): - page: int = Field(default=1, ge=1, description="Page number") - limit: int = Field(default=20, ge=1, description="Number of annotations per page") - keyword: str = Field(default="", description="Keyword to search annotations") + page: int = Field(default=1, ge=1, description="Page number for pagination.") + limit: int = Field(default=20, ge=1, description="Number of items per page.") + keyword: str = Field(default="", description="Keyword to filter annotations by question or answer content.") class AnnotationJobStatusResponse(ResponseModel): @@ -45,6 +50,13 @@ class AnnotationJobStatusResponse(ResponseModel): error_msg: str | None = None +ANNOTATION_REPLY_ACTION_PARAM = { + "description": "Action to perform: `enable` or `disable`.", + "enum": ["enable", "disable"], + "type": "string", +} + + register_schema_models( service_api_ns, AnnotationCreatePayload, @@ -58,10 +70,22 @@ register_response_schema_models(service_api_ns, AnnotationJobStatusResponse) @service_api_ns.route("/apps/annotation-reply/") class AnnotationReplyActionApi(Resource): + @service_api_ns.doc( + summary="Configure Annotation Reply", + description=( + "Enables or disables the annotation reply feature. Requires embedding model configuration " + "when enabling. Executes asynchronously — use [Get Annotation Reply Job " + "Status](/api-reference/annotations/get-annotation-reply-job-status) to track progress." + ), + tags=["Annotations"], + responses={ + 200: "Annotation reply settings task initiated.", + }, + ) @service_api_ns.expect(service_api_ns.models[AnnotationReplyActionPayload.__name__]) @service_api_ns.doc("annotation_reply_action") @service_api_ns.doc(description="Enable or disable annotation reply feature") - @service_api_ns.doc(params={"action": "Action to perform: 'enable' or 'disable'"}) + @service_api_ns.doc(params={"action": ANNOTATION_REPLY_ACTION_PARAM}) @service_api_ns.doc( responses={ 200: "Action completed successfully", @@ -92,9 +116,29 @@ class AnnotationReplyActionApi(Resource): @service_api_ns.route("/apps/annotation-reply//status/") class AnnotationReplyActionStatusApi(Resource): + @service_api_ns.doc( + summary="Get Annotation Reply Job Status", + description=( + "Retrieves the status of an asynchronous annotation reply configuration job started by " + "[Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply)." + ), + tags=["Annotations"], + responses={ + 200: "Successfully retrieved task status.", + 400: "`invalid_param` : The specified job does not exist.", + }, + ) @service_api_ns.doc("get_annotation_reply_action_status") @service_api_ns.doc(description="Get the status of an annotation reply action job") - @service_api_ns.doc(params={"action": "Action type", "job_id": "Job ID"}) + @service_api_ns.doc( + params={ + "action": ANNOTATION_REPLY_ACTION_PARAM, + "job_id": ( + "Job ID returned by " + "[Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply)." + ), + } + ) @service_api_ns.doc( responses={ 200: "Job status retrieved successfully", @@ -127,6 +171,14 @@ class AnnotationReplyActionStatusApi(Resource): @service_api_ns.route("/apps/annotations") class AnnotationListApi(Resource): + @service_api_ns.doc( + summary="List Annotations", + description="Retrieves a paginated list of annotations for the application. Supports keyword search filtering.", + tags=["Annotations"], + responses={ + 200: "Successfully retrieved annotation list.", + }, + ) @service_api_ns.doc("list_annotations") @service_api_ns.doc(description="List annotations for the application") @service_api_ns.doc(params=query_params_from_model(AnnotationListQuery)) @@ -159,6 +211,17 @@ class AnnotationListApi(Resource): ) return response.model_dump(mode="json") + @service_api_ns.doc( + summary="Create Annotation", + description=( + "Creates a new annotation. Annotations provide predefined question-answer pairs that the app " + "can match and return directly instead of generating a response." + ), + tags=["Annotations"], + responses={ + 201: "Annotation created successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[AnnotationCreatePayload.__name__]) @service_api_ns.doc("create_annotation") @service_api_ns.doc(description="Create a new annotation") @@ -185,10 +248,20 @@ class AnnotationListApi(Resource): @service_api_ns.route("/apps/annotations/") class AnnotationUpdateDeleteApi(Resource): + @service_api_ns.doc( + summary="Update Annotation", + description="Updates the question and answer of an existing annotation.", + tags=["Annotations"], + responses={ + 200: "Annotation updated successfully.", + 403: "`forbidden` : Insufficient permissions to edit annotations.", + 404: "`not_found` : Annotation does not exist.", + }, + ) @service_api_ns.expect(service_api_ns.models[AnnotationCreatePayload.__name__]) @service_api_ns.doc("update_annotation") @service_api_ns.doc(description="Update an existing annotation") - @service_api_ns.doc(params={"annotation_id": "Annotation ID"}) + @service_api_ns.doc(params={"annotation_id": "The unique identifier of the annotation to update."}) @service_api_ns.doc( responses={ 200: "Annotation updated successfully", @@ -212,9 +285,19 @@ class AnnotationUpdateDeleteApi(Resource): response = Annotation.model_validate(annotation, from_attributes=True) return response.model_dump(mode="json") + @service_api_ns.doc( + summary="Delete Annotation", + description="Deletes an annotation and its associated hit history.", + tags=["Annotations"], + responses={ + 204: "Annotation deleted successfully.", + 403: "`forbidden` : Insufficient permissions to edit annotations.", + 404: "`not_found` : Annotation does not exist.", + }, + ) @service_api_ns.doc("delete_annotation") @service_api_ns.doc(description="Delete an annotation") - @service_api_ns.doc(params={"annotation_id": "Annotation ID"}) + @service_api_ns.doc(params={"annotation_id": "The unique identifier of the annotation to delete."}) @service_api_ns.doc( responses={ 204: "Annotation deleted successfully", diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py index cc55876bd5a..d670c7f5a6f 100644 --- a/api/controllers/service_api/app/app.py +++ b/api/controllers/service_api/app/app.py @@ -33,6 +33,18 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App class AppParameterApi(Resource): """Resource for app variables.""" + @service_api_ns.doc( + summary="Get App Parameters", + description=( + "Retrieve the application's input form configuration, including feature switches, input " + "parameter names, types, and default values." + ), + tags=["Applications"], + responses={ + 200: "Application parameters information.", + 400: "`app_unavailable` : App unavailable or misconfigured.", + }, + ) @service_api_ns.doc("get_app_parameters") @service_api_ns.doc(description="Retrieve application input parameters and configuration") @service_api_ns.doc( @@ -71,6 +83,14 @@ class AppParameterApi(Resource): @service_api_ns.route("/meta") class AppMetaApi(Resource): + @service_api_ns.doc( + summary="Get App Meta", + description="Retrieve metadata about this application, including tool icons and other configuration details.", + tags=["Applications"], + responses={ + 200: "Successfully retrieved application meta information.", + }, + ) @service_api_ns.doc("get_app_meta") @service_api_ns.doc(description="Get application metadata") @service_api_ns.doc( @@ -92,6 +112,14 @@ class AppMetaApi(Resource): @service_api_ns.route("/info") class AppInfoApi(Resource): + @service_api_ns.doc( + summary="Get App Info", + description="Retrieve basic information about this application, including name, description, tags, and mode.", + tags=["Applications"], + responses={ + 200: "Basic information of the application.", + }, + ) @service_api_ns.doc("get_app_info") @service_api_ns.doc(description="Get basic application information") @service_api_ns.doc( diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py index 0c2047a824e..2b5a9ba83a1 100644 --- a/api/controllers/service_api/app/audio.py +++ b/api/controllers/service_api/app/audio.py @@ -20,6 +20,7 @@ from controllers.service_api.app.error import ( ProviderQuotaExceededError, UnsupportedAudioTypeError, ) +from controllers.service_api.schema import binary_response, expect_with_user, multipart_file_params from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError @@ -39,8 +40,40 @@ register_response_schema_models(service_api_ns, AudioBinaryResponse, AudioTransc @service_api_ns.route("/audio-to-text") class AudioApi(Resource): + @service_api_ns.doc( + summary="Convert Audio to Text", + description=( + "Convert audio file to text. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, " + "`audio/wav`, and `audio/amr`. File size limit is `30 MB`." + ), + tags=["TTS"], + responses={ + 200: "Successfully converted audio to text.", + 400: ( + "- `app_unavailable` : App unavailable or misconfigured.\n" + "- `provider_not_support_speech_to_text` : Model provider does not support speech-to-text.\n" + "- `provider_not_initialize` : No valid model provider credentials found.\n" + "- `provider_quota_exceeded` : Model provider quota exhausted.\n" + "- `model_currently_not_support` : Current model does not support this operation.\n" + "- `completion_request_error` : Speech recognition request failed." + ), + 413: "`audio_too_large` : Audio file size exceeded the limit.", + 415: "`unsupported_audio_type` : Audio type is not allowed.", + 500: "`internal_server_error` : Internal server error.", + }, + ) @service_api_ns.doc("audio_to_text") @service_api_ns.doc(description="Convert audio to text using speech-to-text") + @service_api_ns.doc( + consumes=["multipart/form-data"], + params=multipart_file_params( + include_user=True, + file_description=( + "Audio file to transcribe. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, " + "`audio/wav`, and `audio/amr`. File size limit is `30 MB`." + ), + ), + ) @service_api_ns.doc( responses={ 200: "Audio successfully transcribed", @@ -99,7 +132,27 @@ register_schema_model(service_api_ns, TextToAudioPayload) @service_api_ns.route("/text-to-audio") class TextApi(Resource): - @service_api_ns.expect(service_api_ns.models[TextToAudioPayload.__name__]) + @service_api_ns.doc( + summary="Convert Text to Audio", + description="Convert text to speech.", + tags=["TTS"], + responses={ + 200: ( + "Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; " + "otherwise the provider output is returned directly." + ), + 400: ( + "- `app_unavailable` : App unavailable or misconfigured.\n" + "- `provider_not_initialize` : No valid model provider credentials found.\n" + "- `provider_quota_exceeded` : Model provider quota exhausted.\n" + "- `model_currently_not_support` : Current model does not support this operation.\n" + "- `completion_request_error` : Text-to-speech request failed." + ), + 500: "`internal_server_error` : Internal server error.", + }, + ) + @expect_with_user(service_api_ns, TextToAudioPayload) + @binary_response(service_api_ns, "audio/mpeg") @service_api_ns.doc("text_to_audio") @service_api_ns.doc(description="Convert text to audio using text-to-speech") @service_api_ns.doc( @@ -110,11 +163,7 @@ class TextApi(Resource): 500: "Internal server error", } ) - @service_api_ns.response( - 200, - "Text successfully converted to audio", - service_api_ns.models[AudioBinaryResponse.__name__], - ) + @service_api_ns.response(200, "Text successfully converted to audio") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON)) def post(self, app_model: App, end_user: EndUser): """Convert text to audio using text-to-speech. diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index 7009bbfeaf6..1468f3d776f 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -5,6 +5,7 @@ from uuid import UUID from flask import request from flask_restx import Resource from pydantic import BaseModel, Field, field_validator +from pydantic.json_schema import SkipJsonSchema from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services @@ -20,6 +21,12 @@ from controllers.service_api.app.error import ( ProviderNotInitializeError, ProviderQuotaExceededError, ) +from controllers.service_api.schema import ( + InputFileList, + expect_user_json, + expect_with_user, + json_or_event_stream_response, +) from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.entities.app_invoke_entities import InvokeFrom @@ -51,24 +58,84 @@ def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None class CompletionRequestPayload(BaseModel): - inputs: dict[str, Any] - query: str = Field(default="") - files: list[dict[str, Any]] | None = Field(default=None) - response_mode: Literal["blocking", "streaming"] | None = None - retriever_from: str = Field(default="dev") - trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping") + inputs: dict[str, Any] = Field( + description=( + "Values for app-defined variables. Refer to the `user_input_form` field in the " + "[Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected " + "variable names and types." + ) + ) + query: str = Field(default="", description="User input or prompt content.") + files: InputFileList = Field( + default=None, + description=( + "File list for multimodal understanding, including images, documents, audio, and video. To attach a " + "local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned " + "`id` as `upload_file_id` with `transfer_method: local_file`." + ), + ) + response_mode: Literal["blocking", "streaming"] | None = Field( + default=None, + description=( + "Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, " + "the request runs in blocking mode." + ), + ) + retriever_from: SkipJsonSchema[str] = Field(default="dev") + trace_session_id: SkipJsonSchema[str | None] = Field( + default=None, description="Trace session ID for observability grouping" + ) class ChatRequestPayload(BaseModel): - inputs: dict[str, Any] - query: str - files: list[dict[str, Any]] | None = Field(default=None) - response_mode: Literal["blocking", "streaming"] | None = None - conversation_id: UUIDStrOrEmpty | None = Field(default=None, description="Conversation UUID") - retriever_from: str = Field(default="dev") - auto_generate_name: bool = Field(default=True, description="Auto generate conversation name") - workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat") - trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping") + inputs: dict[str, Any] = Field( + description=( + "Values for app-defined variables. Refer to the `user_input_form` field in the " + "[Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected " + "variable names and types." + ) + ) + query: str = Field(description="User input or question content.") + files: InputFileList = Field( + default=None, + description=( + "File list for multimodal understanding, including images, documents, audio, and video. To attach a " + "local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned " + "`id` as `upload_file_id` with `transfer_method: local_file`." + ), + ) + response_mode: Literal["blocking", "streaming"] | None = Field( + default=None, + description=( + "Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app " + "mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream." + ), + ) + conversation_id: UUIDStrOrEmpty | None = Field( + default=None, + description=( + "Conversation ID to continue a conversation. Omit this field or pass an empty string to start a new " + "conversation, then pass the returned `conversation_id` in subsequent requests." + ), + ) + retriever_from: SkipJsonSchema[str] = Field(default="dev") + auto_generate_name: bool = Field( + default=True, + description=( + "Auto-generate the conversation title. If `false`, use the Rename Conversation API with " + "`auto_generate: true` to generate the title asynchronously." + ), + ) + workflow_id: str | None = Field( + default=None, + description=( + "Published workflow version ID to execute for advanced chat. If omitted, the app's current published " + "workflow is used." + ), + ) + trace_session_id: SkipJsonSchema[str | None] = Field( + default=None, description="Trace session ID for observability grouping" + ) @field_validator("conversation_id", mode="before") @classmethod @@ -92,7 +159,33 @@ register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResu @service_api_ns.route("/completion-messages") class CompletionApi(Resource): - @service_api_ns.expect(service_api_ns.models[CompletionRequestPayload.__name__]) + @service_api_ns.doc( + summary="Send Completion Message", + description="Send a request to the text generation application.", + tags=["Completions"], + responses={ + 200: ( + "Successful response. The content type and structure depend on the `response_mode` parameter " + "in the request.\n" + "\n" + "- If `response_mode` is `blocking`, returns `application/json` with a `CompletionResponse` " + "object.\n" + "- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of " + "`ChunkCompletionEvent` objects." + ), + 400: ( + "- `app_unavailable` : App unavailable or misconfigured.\n" + "- `provider_not_initialize` : No valid model provider credentials found.\n" + "- `provider_quota_exceeded` : Model provider quota exhausted.\n" + "- `model_currently_not_support` : Current model unavailable.\n" + "- `completion_request_error` : Text generation failed." + ), + 429: "`too_many_requests` : Too many concurrent requests for this app.", + 500: "`internal_server_error` : Internal server error.", + }, + ) + @expect_with_user(service_api_ns, CompletionRequestPayload) + @json_or_event_stream_response(service_api_ns) @service_api_ns.doc("create_completion") @service_api_ns.doc(description="Create a completion for the given prompt") @service_api_ns.doc( @@ -168,9 +261,20 @@ class CompletionApi(Resource): @service_api_ns.route("/completion-messages//stop") class CompletionStopApi(Resource): + @service_api_ns.doc( + summary="Stop Completion Message Generation", + description="Stops a completion message generation task. Only supported in `streaming` mode.", + tags=["Completions"], + responses={ + 400: "`app_unavailable` : App unavailable or misconfigured.", + }, + ) + @expect_user_json(service_api_ns) @service_api_ns.doc("stop_completion") @service_api_ns.doc(description="Stop a running completion task") - @service_api_ns.doc(params={"task_id": "The ID of the task to stop"}) + @service_api_ns.doc( + params={"task_id": ("Task ID, obtained from a streaming chunk returned by the Send Completion Message API.")} + ) @service_api_ns.doc( responses={ 200: "Task stopped successfully", @@ -197,7 +301,39 @@ class CompletionStopApi(Resource): @service_api_ns.route("/chat-messages") class ChatApi(Resource): - @service_api_ns.expect(service_api_ns.models[ChatRequestPayload.__name__]) + @service_api_ns.doc( + summary="Send Chat Message", + description="Send a request to the chat application.", + tags=["Chats", "Chatflows"], + responses={ + 200: ( + "Successful response. The content type and structure depend on the `response_mode` parameter " + "in the request.\n" + "\n" + "- If `response_mode` is `blocking`, returns `application/json` with a " + "`ChatCompletionResponse` object.\n" + "- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of " + "Server-Sent Events." + ), + 400: ( + "- `app_unavailable` : App unavailable or misconfigured.\n" + "- `not_chat_app` : App mode does not match the API route.\n" + "- `conversation_completed` : The conversation has ended.\n" + "- `provider_not_initialize` : No valid model provider credentials found.\n" + "- `provider_quota_exceeded` : Model provider quota exhausted.\n" + "- `model_currently_not_support` : Current model unavailable.\n" + "- `completion_request_error` : Text generation failed." + ), + 404: "`not_found` : Conversation does not exist.", + 429: ( + "- `too_many_requests` : Too many concurrent requests for this app.\n" + "- `rate_limit_error` : The upstream model provider rate limit was exceeded." + ), + 500: "`internal_server_error` : Internal server error.", + }, + ) + @expect_with_user(service_api_ns, ChatRequestPayload) + @json_or_event_stream_response(service_api_ns) @service_api_ns.doc("create_chat_message") @service_api_ns.doc(description="Send a message in a chat conversation") @service_api_ns.doc( @@ -276,9 +412,20 @@ class ChatApi(Resource): @service_api_ns.route("/chat-messages//stop") class ChatStopApi(Resource): + @service_api_ns.doc( + summary="Stop Chat Message Generation", + description="Stops a chat message generation task. Only supported in `streaming` mode.", + tags=["Chats", "Chatflows"], + responses={ + 400: "`not_chat_app` : App mode does not match the API route.", + }, + ) + @expect_user_json(service_api_ns) @service_api_ns.doc("stop_chat_message") @service_api_ns.doc(description="Stop a running chat message generation") - @service_api_ns.doc(params={"task_id": "The ID of the task to stop"}) + @service_api_ns.doc( + params={"task_id": "Task ID, obtained from a streaming chunk returned by the Send Chat Message API."} + ) @service_api_ns.doc( responses={ 200: "Task stopped successfully", diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py index f6be7f74cc7..9b5533ea07a 100644 --- a/api/controllers/service_api/app/conversation.py +++ b/api/controllers/service_api/app/conversation.py @@ -13,6 +13,7 @@ from controllers.common.controller_schemas import ConversationRenamePayload from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError +from controllers.service_api.schema import expect_user_json, expect_with_user from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db @@ -29,18 +30,28 @@ from services.conversation_service import ConversationService class ConversationListQuery(BaseModel): - last_id: UUIDStrOrEmpty | None = Field(default=None, description="Last conversation ID for pagination") - limit: int = Field(default=20, ge=1, le=100, description="Number of conversations to return") + last_id: UUIDStrOrEmpty | None = Field( + default=None, + description="The ID of the last record on the current page. Used to fetch the next page.", + ) + limit: int = Field(default=20, ge=1, le=100, description="Number of records to return.") sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field( - default="-updated_at", description="Sort order for conversations" + default="-updated_at", + description="Sorting field. Use the `-` prefix for descending order.", ) class ConversationVariablesQuery(BaseModel): - last_id: UUIDStrOrEmpty | None = Field(default=None, description="Last variable ID for pagination") - limit: int = Field(default=20, ge=1, le=100, description="Number of variables to return") + last_id: UUIDStrOrEmpty | None = Field( + default=None, + description="The ID of the last record on the current page. Used to fetch the next page.", + ) + limit: int = Field(default=20, ge=1, le=100, description="Number of records to return.") variable_name: str | None = Field( - default=None, description="Filter variables by name", min_length=1, max_length=255 + default=None, + description="Filter variables by a specific name.", + min_length=1, + max_length=255, ) @field_validator("variable_name", mode="before") @@ -68,7 +79,7 @@ class ConversationVariablesQuery(BaseModel): class ConversationVariableUpdatePayload(BaseModel): - value: Any + value: Any = Field(description="The new value for the variable. Must match the variable's expected type.") class ConversationVariableResponse(ResponseModel): @@ -145,6 +156,16 @@ register_response_schema_models( @service_api_ns.route("/conversations") class ConversationApi(Resource): + @service_api_ns.doc( + summary="List Conversations", + description="Retrieve the conversation list for the current user, ordered by most recently active.", + tags=["Conversations"], + responses={ + 200: "Successfully retrieved conversations list.", + 400: "`not_chat_app` : App mode does not match the API route.", + 404: "`not_found` : Last conversation does not exist (invalid `last_id`).", + }, + ) @service_api_ns.doc(params=query_params_from_model(ConversationListQuery)) @service_api_ns.doc("list_conversations") @service_api_ns.doc(description="List all conversations for the current user") @@ -197,9 +218,20 @@ class ConversationApi(Resource): @service_api_ns.route("/conversations/") class ConversationDetailApi(Resource): + @service_api_ns.doc( + summary="Delete Conversation", + description="Delete a conversation.", + tags=["Conversations"], + responses={ + 204: "Conversation deleted successfully.", + 400: "`not_chat_app` : App mode does not match the API route.", + 404: "`not_found` : Conversation does not exist.", + }, + ) + @expect_user_json(service_api_ns) @service_api_ns.doc("delete_conversation") @service_api_ns.doc(description="Delete a specific conversation") - @service_api_ns.doc(params={"c_id": "Conversation ID"}) + @service_api_ns.doc(params={"c_id": "Conversation ID."}) @service_api_ns.doc( responses={ 204: "Conversation deleted successfully", @@ -225,10 +257,23 @@ class ConversationDetailApi(Resource): @service_api_ns.route("/conversations//name") class ConversationRenameApi(Resource): - @service_api_ns.expect(service_api_ns.models[ConversationRenamePayload.__name__]) + @service_api_ns.doc( + summary="Rename Conversation", + description=( + "Rename a conversation or auto-generate a name. The conversation name is used for display on " + "clients that support multiple conversations." + ), + tags=["Conversations"], + responses={ + 200: "Conversation renamed successfully.", + 400: "`not_chat_app` : App mode does not match the API route.", + 404: "`not_found` : Conversation does not exist.", + }, + ) + @expect_with_user(service_api_ns, ConversationRenamePayload) @service_api_ns.doc("rename_conversation") @service_api_ns.doc(description="Rename a conversation or auto-generate a name") - @service_api_ns.doc(params={"c_id": "Conversation ID"}) + @service_api_ns.doc(params={"c_id": "Conversation ID."}) @service_api_ns.doc( responses={ 200: "Conversation renamed successfully", @@ -267,10 +312,20 @@ class ConversationRenameApi(Resource): @service_api_ns.route("/conversations//variables") class ConversationVariablesApi(Resource): + @service_api_ns.doc( + summary="List Conversation Variables", + description="Retrieve variables from a specific conversation.", + tags=["Conversations"], + responses={ + 200: "Successfully retrieved conversation variables.", + 400: "`not_chat_app` : App mode does not match the API route.", + 404: "`not_found` : Conversation does not exist.", + }, + ) @service_api_ns.doc(params=query_params_from_model(ConversationVariablesQuery)) @service_api_ns.doc("list_conversation_variables") @service_api_ns.doc(description="List all variables for a conversation") - @service_api_ns.doc(params={"c_id": "Conversation ID"}) + @service_api_ns.doc(params={"c_id": "Conversation ID."}) @service_api_ns.doc( responses={ 200: "Variables retrieved successfully", @@ -312,10 +367,25 @@ class ConversationVariablesApi(Resource): @service_api_ns.route("/conversations//variables/") class ConversationVariableDetailApi(Resource): - @service_api_ns.expect(service_api_ns.models[ConversationVariableUpdatePayload.__name__]) + @service_api_ns.doc( + summary="Update Conversation Variable", + description="Update the value of a specific conversation variable. The value must match the expected type.", + tags=["Conversations"], + responses={ + 200: "Variable updated successfully.", + 400: ( + "- `not_chat_app` : App mode does not match the API route.\n" + "- `bad_request` : Variable value type mismatch." + ), + 404: ( + "- `not_found` : Conversation does not exist.\n- `not_found` : Conversation variable does not exist." + ), + }, + ) + @expect_with_user(service_api_ns, ConversationVariableUpdatePayload) @service_api_ns.doc("update_conversation_variable") @service_api_ns.doc(description="Update a conversation variable's value") - @service_api_ns.doc(params={"c_id": "Conversation ID", "variable_id": "Variable ID"}) + @service_api_ns.doc(params={"c_id": "Conversation ID.", "variable_id": "Variable ID."}) @service_api_ns.doc( responses={ 200: "Variable updated successfully", diff --git a/api/controllers/service_api/app/file.py b/api/controllers/service_api/app/file.py index 687d34076df..9210c60adeb 100644 --- a/api/controllers/service_api/app/file.py +++ b/api/controllers/service_api/app/file.py @@ -12,6 +12,7 @@ from controllers.common.errors import ( ) from controllers.common.schema import register_schema_models from controllers.service_api import service_api_ns +from controllers.service_api.schema import multipart_file_params from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from extensions.ext_database import db from fields.file_fields import FileResponse @@ -23,8 +24,27 @@ register_schema_models(service_api_ns, FileResponse) @service_api_ns.route("/files/upload") class FileApi(Resource): + @service_api_ns.doc( + summary="Upload File", + description=( + "Upload a file for use when sending messages, enabling multimodal understanding of images, " + "documents, audio, and video. Uploaded files are for use by the current end-user only." + ), + tags=["Files"], + responses={ + 201: "File uploaded successfully.", + 400: ( + "- `no_file_uploaded` : No file was provided in the request.\n" + "- `too_many_files` : Only one file is allowed per request.\n" + "- `filename_not_exists_error` : The uploaded file has no filename." + ), + 413: "`file_too_large` : File size exceeded.", + 415: "`unsupported_file_type` : File type not allowed.", + }, + ) @service_api_ns.doc("upload_file") @service_api_ns.doc(description="Upload a file for use in conversations") + @service_api_ns.doc(consumes=["multipart/form-data"], params=multipart_file_params(include_user=True)) @service_api_ns.doc( responses={ 201: "File uploaded successfully", diff --git a/api/controllers/service_api/app/file_preview.py b/api/controllers/service_api/app/file_preview.py index 7e68399fb0b..3bd0db6eb54 100644 --- a/api/controllers/service_api/app/file_preview.py +++ b/api/controllers/service_api/app/file_preview.py @@ -15,6 +15,7 @@ from controllers.service_api.app.error import ( FileAccessDeniedError, FileNotFoundError, ) +from controllers.service_api.schema import binary_response from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from extensions.ext_database import db from extensions.ext_storage import storage @@ -24,12 +25,35 @@ logger = logging.getLogger(__name__) class FilePreviewQuery(BaseModel): - as_attachment: bool = Field(default=False, description="Download as attachment") + as_attachment: bool = Field( + default=False, + description="If `true`, forces the file to download as an attachment instead of previewing in browser.", + ) register_schema_model(service_api_ns, FilePreviewQuery) register_response_schema_model(service_api_ns, BinaryFileResponse) +FILE_PREVIEW_RESPONSE_MEDIA_TYPES = [ + "application/octet-stream", + "application/pdf", + "audio/aac", + "audio/flac", + "audio/mp4", + "audio/mpeg", + "audio/ogg", + "audio/wav", + "audio/x-m4a", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "text/plain", + "video/mp4", + "video/quicktime", + "video/webm", +] + @service_api_ns.route("/files//preview") class FilePreviewApi(Resource): @@ -40,10 +64,36 @@ class FilePreviewApi(Resource): Files can only be accessed if they belong to messages within the requesting app's context. """ + @service_api_ns.doc( + summary="Download File", + description=( + "Preview or download uploaded files previously uploaded via the [Upload " + "File](/api-reference/files/upload-file) API. Files can only be accessed if they belong to " + "messages within the requesting application." + ), + tags=["Files"], + responses={ + 200: ( + "Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If " + "`as_attachment` is `true`, the file is returned as a download with `Content-Disposition: " + "attachment`." + ), + 403: "`file_access_denied` : Access to the requested file is denied.", + 404: "`file_not_found` : The requested file was not found.", + }, + ) @service_api_ns.doc(params=query_params_from_model(FilePreviewQuery)) + @binary_response(service_api_ns, FILE_PREVIEW_RESPONSE_MEDIA_TYPES) @service_api_ns.doc("preview_file") @service_api_ns.doc(description="Preview or download a file uploaded via Service API") - @service_api_ns.doc(params={"file_id": "UUID of the file to preview"}) + @service_api_ns.doc( + params={ + "file_id": ( + "The unique identifier of the file to preview, obtained from the " + "[Upload File](/api-reference/files/upload-file) API response." + ) + } + ) @service_api_ns.doc( responses={ 200: "File retrieved successfully", @@ -52,11 +102,7 @@ class FilePreviewApi(Resource): 404: "File not found", } ) - @service_api_ns.response( - 200, - "File retrieved successfully", - service_api_ns.models[BinaryFileResponse.__name__], - ) + @service_api_ns.response(200, "File retrieved successfully") @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY)) def get(self, app_model: App, end_user: EndUser, file_id: UUID): """ diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py index 1dc247d7517..07cc1eeab6d 100644 --- a/api/controllers/service_api/app/human_input_form.py +++ b/api/controllers/service_api/app/human_input_form.py @@ -18,6 +18,7 @@ from werkzeug.exceptions import BadRequest, NotFound from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.service_api import service_api_ns +from controllers.service_api.schema import expect_with_user 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 @@ -72,6 +73,23 @@ def _ensure_form_is_allowed_for_service_api(form: Form) -> None: @service_api_ns.route("/form/human_input/") class WorkflowHumanInputFormApi(Resource): + @service_api_ns.doc( + summary="Get Human Input Form", + description=( + "Retrieve a paused Human Input form's contents using the `form_token` from a " + "`human_input_required` event. Requires **WebApp** delivery." + ), + tags=["Human Input"], + responses={ + 200: "Form contents retrieved successfully.", + 404: "`not_found` : Form not found.", + 412: ( + "- `human_input_form_submitted` : Form already submitted. Forms are one-shot; the first " + "response wins regardless of which user submits it.\n" + "- `human_input_form_expired` : The form's expiration time passed before submission arrived." + ), + }, + ) @service_api_ns.doc("get_human_input_form") @service_api_ns.doc(description="Get a paused human input form by token") @service_api_ns.doc(params={"form_token": "Human input form token"}) @@ -101,7 +119,29 @@ class WorkflowHumanInputFormApi(Resource): inputs = service.resolve_form_inputs(form) return _jsonify_form_definition(form, inputs=inputs) - @service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__]) + @service_api_ns.doc( + summary="Submit Human Input Form", + description=( + "Submit the recipient's response to a paused Human Input form. The workflow resumes on " + "acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) " + "to follow subsequent events. Requires **WebApp** delivery." + ), + tags=["Human Input"], + responses={ + 200: "Form submitted successfully. The response body is an empty object.", + 400: ( + "- `bad_request` : Form recipient type is invalid.\n" + "- `invalid_form_data` : Submission failed validation against the form definition." + ), + 404: "`not_found` : Form not found.", + 412: ( + "- `human_input_form_submitted` : Form already submitted. Forms are one-shot; the first " + "response wins regardless of which user submits it.\n" + "- `human_input_form_expired` : The form's expiration time passed before submission arrived." + ), + }, + ) + @expect_with_user(service_api_ns, HumanInputFormSubmitPayload) @service_api_ns.doc("submit_human_input_form") @service_api_ns.doc(description="Submit a paused human input form by token") @service_api_ns.doc(params={"form_token": "Human input form token"}) diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py index adbea6570dd..18d1c5d3254 100644 --- a/api/controllers/service_api/app/message.py +++ b/api/controllers/service_api/app/message.py @@ -12,6 +12,7 @@ from controllers.common.fields import SimpleResultStringListResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotChatAppError +from controllers.service_api.schema import expect_with_user from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.app.entities.app_invoke_entities import InvokeFrom from fields.base import ResponseModel @@ -30,8 +31,8 @@ logger = logging.getLogger(__name__) class FeedbackListQuery(BaseModel): - page: int = Field(default=1, ge=1, description="Page number") - limit: int = Field(default=20, ge=1, le=101, description="Number of feedbacks per page") + page: int = Field(default=1, ge=1, description="Page number for pagination.") + limit: int = Field(default=20, ge=1, le=101, description="Number of records per page.") class AppFeedbackResponse(ResponseModel): @@ -64,6 +65,19 @@ register_response_schema_models( @service_api_ns.route("/messages") class MessageListApi(Resource): + @service_api_ns.doc( + summary="List Conversation Messages", + description=( + "Returns historical chat records in a scrolling load format, with the first page returning " + "the latest `limit` messages, i.e., in reverse order." + ), + tags=["Conversations"], + responses={ + 200: "Successfully retrieved conversation history.", + 400: "`not_chat_app` : App mode does not match the API route.", + 404: ("- `not_found` : Conversation does not exist.\n- `not_found` : First message does not exist."), + }, + ) @service_api_ns.doc(params=query_params_from_model(MessageListQuery)) @service_api_ns.doc("list_messages") @service_api_ns.doc(description="List messages in a conversation") @@ -112,11 +126,23 @@ class MessageListApi(Resource): @service_api_ns.route("/messages//feedbacks") class MessageFeedbackApi(Resource): - @service_api_ns.expect(service_api_ns.models[MessageFeedbackPayload.__name__]) + @service_api_ns.doc( + summary="Submit Message Feedback", + description=( + "Submit feedback for a message. End users can rate messages as `like` or `dislike`, and " + "optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted " + "feedback." + ), + tags=["Feedback"], + responses={ + 404: "`not_found` : Message does not exist.", + }, + ) + @expect_with_user(service_api_ns, MessageFeedbackPayload) @service_api_ns.response(200, "Feedback submitted successfully", service_api_ns.models[ResultResponse.__name__]) @service_api_ns.doc("create_message_feedback") @service_api_ns.doc(description="Submit feedback for a message") - @service_api_ns.doc(params={"message_id": "Message ID"}) + @service_api_ns.doc(params={"message_id": "Message ID."}) @service_api_ns.doc( responses={ 200: "Feedback submitted successfully", @@ -150,6 +176,17 @@ class MessageFeedbackApi(Resource): @service_api_ns.route("/app/feedbacks") class AppGetFeedbacksApi(Resource): + @service_api_ns.doc( + summary="List App Feedbacks", + description=( + "Retrieve a paginated list of all feedback submitted for messages in this application, " + "including both end-user and admin feedback." + ), + tags=["Feedback"], + responses={ + 200: "A list of application feedbacks.", + }, + ) @service_api_ns.doc(params=query_params_from_model(FeedbackListQuery)) @service_api_ns.doc("get_app_feedbacks") @service_api_ns.doc(description="Get all feedbacks for the application") @@ -177,6 +214,20 @@ class AppGetFeedbacksApi(Resource): @service_api_ns.route("/messages//suggested") class MessageSuggestedApi(Resource): + @service_api_ns.doc( + summary="Get Next Suggested Questions", + description="Get next questions suggestions for the current message.", + tags=["Chats", "Chatflows"], + responses={ + 200: "Successfully retrieved suggested questions.", + 400: ( + "- `not_chat_app` : App mode does not match the API route.\n" + "- `bad_request` : Suggested questions feature is disabled." + ), + 404: "`not_found` : Message does not exist.", + 500: "`internal_server_error` : Internal server error.", + }, + ) @service_api_ns.response( 200, "Suggested questions retrieved successfully", diff --git a/api/controllers/service_api/app/site.py b/api/controllers/service_api/app/site.py index f5d1dcd283d..35098ca1367 100644 --- a/api/controllers/service_api/app/site.py +++ b/api/controllers/service_api/app/site.py @@ -17,6 +17,18 @@ register_response_schema_models(service_api_ns, SiteResponse) class AppSiteApi(Resource): """Resource for app sites.""" + @service_api_ns.doc( + summary="Get App WebApp Settings", + description=( + "Retrieve the WebApp settings of this application, including site configuration, theme, and " + "customization options." + ), + tags=["Applications"], + responses={ + 200: "WebApp settings of the application.", + 403: "`forbidden` : Site not found for this application or the workspace has been archived.", + }, + ) @service_api_ns.doc("get_app_site") @service_api_ns.doc(description="Get application site configuration") @service_api_ns.doc( diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index b655e0beb4a..091b79fefbd 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -7,6 +7,7 @@ from dateutil.parser import isoparse from flask import request from flask_restx import Resource, fields from pydantic import BaseModel, Field, field_validator +from pydantic.json_schema import SkipJsonSchema from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import BadRequest, InternalServerError, NotFound @@ -21,6 +22,11 @@ from controllers.service_api.app.error import ( ProviderNotInitializeError, ProviderQuotaExceededError, ) +from controllers.service_api.schema import ( + expect_user_json, + expect_with_user, + json_or_event_stream_response, +) from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.apps.base_app_queue_manager import AppQueueManager @@ -53,19 +59,41 @@ logger = logging.getLogger(__name__) class WorkflowRunPayload(WorkflowRunPayloadBase): - response_mode: Literal["blocking", "streaming"] | None = None - trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping") + response_mode: Literal["blocking", "streaming"] | None = Field( + default=None, + description=( + "Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. " + "When omitted, the request runs in blocking mode." + ), + ) + trace_session_id: SkipJsonSchema[str | None] = Field( + default=None, description="Trace session ID for observability grouping" + ) class WorkflowLogQuery(BaseModel): - keyword: str | None = None - status: Literal["succeeded", "failed", "stopped"] | None = None - created_at__before: str | None = None - created_at__after: str | None = None - created_by_end_user_session_id: str | None = None - created_by_account: str | None = None - page: int = Field(default=1, ge=1, le=99999) - limit: int = Field(default=20, ge=1, le=100) + keyword: str | None = Field(default=None, description="Keyword to search in logs.") + status: Literal["succeeded", "failed", "stopped"] | None = Field( + default=None, + description="Filter by execution status.", + ) + created_at__before: str | None = Field( + default=None, + description="Filter logs created before this ISO 8601 timestamp.", + json_schema_extra={"format": "date-time"}, + ) + created_at__after: str | None = Field( + default=None, + description="Filter logs created after this ISO 8601 timestamp.", + json_schema_extra={"format": "date-time"}, + ) + created_by_end_user_session_id: str | None = Field( + default=None, + description="Filter by end user session ID.", + ) + created_by_account: str | None = Field(default=None, description="Filter by account ID.") + page: int = Field(default=1, ge=1, le=99999, description="Page number for pagination.") + limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.") register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery) @@ -177,14 +205,15 @@ register_response_schema_models( def _serialize_workflow_run(workflow_run: WorkflowRun) -> dict: status = _enum_value(workflow_run.status) raw_outputs = workflow_run.outputs_dict - if status == WorkflowExecutionStatus.PAUSED.value or raw_outputs is None: - outputs: dict = {} - elif isinstance(raw_outputs, dict): - outputs = raw_outputs - elif isinstance(raw_outputs, Mapping): - outputs = dict(raw_outputs) - else: - outputs = {} + match raw_outputs: + case _ if status == WorkflowExecutionStatus.PAUSED.value or raw_outputs is None: + outputs: dict = {} + case dict(): + outputs = raw_outputs + case _ if isinstance(raw_outputs, Mapping): + outputs = dict(raw_outputs) + case _: + outputs = {} return WorkflowRunResponse.model_validate( { "id": workflow_run.id, @@ -208,9 +237,23 @@ def _serialize_workflow_log_pagination(pagination) -> dict: @service_api_ns.route("/workflows/run/") class WorkflowRunDetailApi(Resource): + @service_api_ns.doc( + summary="Get Workflow Run Detail", + description="Retrieve the current execution results of a workflow task based on the workflow execution ID.", + tags=["Chatflows", "Workflows"], + responses={ + 200: "Successfully retrieved workflow run details.", + 400: "`not_workflow_app` : App mode does not match the API route.", + 404: "`not_found` : Workflow run not found.", + }, + ) @service_api_ns.doc("get_workflow_run_detail") @service_api_ns.doc(description="Get workflow run details") - @service_api_ns.doc(params={"workflow_run_id": "Workflow run ID"}) + @service_api_ns.doc( + params={ + "workflow_run_id": "Workflow run ID, obtained from the workflow execution response or streaming events." + } + ) @service_api_ns.doc( responses={ 200: "Workflow run details retrieved successfully", @@ -249,7 +292,37 @@ class WorkflowRunDetailApi(Resource): @service_api_ns.route("/workflows/run") class WorkflowRunApi(Resource): - @service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__]) + @service_api_ns.doc( + summary="Run Workflow", + description="Execute a workflow. Cannot be executed without a published workflow.", + tags=["Workflows"], + responses={ + 200: ( + "Successful response. The content type and structure depend on the `response_mode` parameter " + "in the request.\n" + "\n" + "- If `response_mode` is `blocking`, returns `application/json` with a " + "`WorkflowBlockingResponse` object.\n" + "- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of " + "`ChunkWorkflowEvent` objects." + ), + 400: ( + "- `not_workflow_app` : App mode does not match the API route.\n" + "- `provider_not_initialize` : No valid model provider credentials found.\n" + "- `provider_quota_exceeded` : Model provider quota exhausted.\n" + "- `model_currently_not_support` : Current model unavailable.\n" + "- `completion_request_error` : Workflow execution request failed.\n" + "- `invalid_param` : Invalid parameter value." + ), + 429: ( + "- `too_many_requests` : Too many concurrent requests for this app.\n" + "- `rate_limit_error` : The upstream model provider rate limit was exceeded." + ), + 500: "`internal_server_error` : Internal server error.", + }, + ) + @expect_with_user(service_api_ns, WorkflowRunPayload) + @json_or_event_stream_response(service_api_ns) @service_api_ns.doc("run_workflow") @service_api_ns.doc(description="Execute a workflow") @service_api_ns.doc( @@ -313,10 +386,52 @@ class WorkflowRunApi(Resource): @service_api_ns.route("/workflows//run") class WorkflowRunByIdApi(Resource): - @service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__]) + @service_api_ns.doc( + summary="Run Workflow by ID", + description=( + "Execute a specific workflow version identified by its ID. Useful for running a particular " + "published version of the workflow." + ), + tags=["Workflows"], + responses={ + 200: ( + "Successful response. The content type and structure depend on the `response_mode` parameter " + "in the request.\n" + "\n" + "- If `response_mode` is `blocking`, returns `application/json` with a " + "`WorkflowBlockingResponse` object.\n" + "- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of " + "`ChunkWorkflowEvent` objects." + ), + 400: ( + "- `not_workflow_app` : App mode does not match the API route.\n" + "- `bad_request` : Workflow is a draft or has an invalid ID format.\n" + "- `provider_not_initialize` : No valid model provider credentials found.\n" + "- `provider_quota_exceeded` : Model provider quota exhausted.\n" + "- `model_currently_not_support` : Current model unavailable.\n" + "- `completion_request_error` : Workflow execution request failed.\n" + "- `invalid_param` : Required parameter missing or invalid." + ), + 404: "`not_found` : Workflow not found.", + 429: ( + "- `too_many_requests` : Too many concurrent requests for this app.\n" + "- `rate_limit_error` : The upstream model provider rate limit was exceeded." + ), + 500: "`internal_server_error` : Internal server error.", + }, + ) + @expect_with_user(service_api_ns, WorkflowRunPayload) + @json_or_event_stream_response(service_api_ns) @service_api_ns.doc("run_workflow_by_id") @service_api_ns.doc(description="Execute a specific workflow by ID") - @service_api_ns.doc(params={"workflow_id": "Workflow ID to execute"}) + @service_api_ns.doc( + params={ + "workflow_id": ( + "Workflow ID of the specific version to execute. This value is returned in the `workflow_id` field " + "of workflow run responses." + ) + } + ) @service_api_ns.doc( responses={ 200: "Workflow executed successfully", @@ -387,9 +502,23 @@ class WorkflowRunByIdApi(Resource): @service_api_ns.route("/workflows/tasks//stop") class WorkflowTaskStopApi(Resource): + @service_api_ns.doc( + summary="Stop Workflow Task", + description="Stop a running workflow task. Only supported in `streaming` mode.", + tags=["Workflows"], + responses={ + 400: ( + "- `not_workflow_app` : App mode does not match the API route.\n" + "- `invalid_param` : Required parameter missing or invalid." + ), + }, + ) + @expect_user_json(service_api_ns) @service_api_ns.doc("stop_workflow_task") @service_api_ns.doc(description="Stop a running workflow task") - @service_api_ns.doc(params={"task_id": "Task ID to stop"}) + @service_api_ns.doc( + params={"task_id": "Task ID, obtained from the streaming chunk returned by the Run Workflow API."} + ) @service_api_ns.doc( responses={ 200: "Task stopped successfully", @@ -417,6 +546,14 @@ class WorkflowTaskStopApi(Resource): @service_api_ns.route("/workflows/logs") class WorkflowAppLogApi(Resource): + @service_api_ns.doc( + summary="List Workflow Logs", + description="Retrieve paginated workflow execution logs with filtering options.", + tags=["Chatflows", "Workflows"], + responses={ + 200: "Successfully retrieved workflow logs.", + }, + ) @service_api_ns.doc(params=query_params_from_model(WorkflowLogQuery)) @service_api_ns.doc("get_workflow_logs") @service_api_ns.doc(description="Get workflow execution logs") diff --git a/api/controllers/service_api/app/workflow_events.py b/api/controllers/service_api/app/workflow_events.py index 6dc9ef6e8dd..83a7c8227f5 100644 --- a/api/controllers/service_api/app/workflow_events.py +++ b/api/controllers/service_api/app/workflow_events.py @@ -15,6 +15,7 @@ from controllers.common.fields import EventStreamResponse from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.schema import event_stream_response from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.base_app_generator import BaseAppGenerator @@ -31,9 +32,25 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream class WorkflowEventsQuery(BaseModel): - user: str = Field(..., description="End user identifier") - include_state_snapshot: bool = Field(default=False, description="Replay from persisted state snapshot") - continue_on_pause: bool = Field(default=False, description="Keep the stream open across workflow_paused events") + user: str = Field( + ..., + description="End-user identifier that originally triggered the run. Must match the creator of the run.", + ) + include_state_snapshot: bool = Field( + default=False, + description=( + "When `true`, replay from the persisted state snapshot to include a status summary of already-executed " + "nodes before streaming new events." + ), + ) + continue_on_pause: bool = Field( + default=False, + description=( + "Set to `true` to keep the stream open across multiple `workflow_paused` events, which is useful when " + "the workflow has more than one Human Input node in sequence. By default, the stream closes after the " + "first pause." + ), + ) register_schema_models(service_api_ns, WorkflowEventsQuery) @@ -44,9 +61,27 @@ register_response_schema_model(service_api_ns, EventStreamResponse) class WorkflowEventsApi(Resource): """Service API for getting workflow execution events after resume.""" + @service_api_ns.doc( + summary="Stream Workflow Events", + description=( + "Resume the Server-Sent Events stream for a workflow run after a pause or a dropped SSE " + "connection. For runs that have already finished, the stream emits a single " + "`workflow_finished` event and closes." + ), + tags=["Chatflows", "Workflows"], + responses={ + 200: ( + "Server-Sent Events stream. Each event is delivered as `data: {JSON}\\n\\n`. Event payloads " + "follow the same schemas as the original streaming response." + ), + 400: "`not_workflow_app` : Please check if your app mode matches the right API route.", + 404: "`not_found` : Workflow run not found.", + }, + ) + @event_stream_response(service_api_ns) @service_api_ns.doc("get_workflow_events") @service_api_ns.doc(description="Get workflow execution events stream after resume") - @service_api_ns.doc(params={"task_id": "Workflow run ID"}) + @service_api_ns.doc(params={"task_id": "Workflow run ID returned by the original workflow run request."}) @service_api_ns.doc(params=query_params_from_model(WorkflowEventsQuery)) @service_api_ns.doc( responses={ diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index c307063b3e5..292c39f69bc 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -1,11 +1,21 @@ -from typing import Any, Literal +from typing import Annotated, Any, Literal, override from uuid import UUID from flask import request -from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + GetJsonSchemaHandler, + RootModel, + WithJsonSchema, + field_validator, + model_validator, +) from werkzeug.exceptions import Forbidden, NotFound import services +from configs import dify_config from controllers.common.fields import SimpleResultResponse from controllers.common.schema import ( query_params_from_model, @@ -33,7 +43,12 @@ from models.dataset import DatasetPermissionEnum from models.enums import TagType from models.provider_ids import ModelProviderID from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService -from services.entities.knowledge_entities.knowledge_entities import RetrievalModel +from services.entities.knowledge_entities.knowledge_entities import ( + ExternalRetrievalModel, + KnowledgeProvider, + RetrievalModel, + SummaryIndexSetting, +) from services.tag_service import ( SaveTagPayload, TagBindingCreatePayload, @@ -46,41 +61,159 @@ from services.tag_service import ( register_enum_models(service_api_ns, DatasetPermissionEnum) +PartialMemberList = Annotated[ + list[dict[str, str]] | None, + WithJsonSchema( + { + "anyOf": [ + { + "items": { + "properties": { + "user_id": { + "description": "ID of the team member to grant access.", + "type": "string", + } + }, + "type": "object", + }, + "type": "array", + }, + {"type": "null"}, + ] + } + ), +] + + +_SERVICE_DATASET_DETAIL_EXCLUDE = {"permission_keys"} +_SERVICE_DATASET_LIST_EXCLUDE = {"data": {"__all__": _SERVICE_DATASET_DETAIL_EXCLUDE}} + + +def _dump_service_dataset_detail(dataset: Any) -> dict[str, Any]: + return DatasetDetailResponse.model_validate(dataset, from_attributes=True).model_dump( + mode="json", + exclude=_SERVICE_DATASET_DETAIL_EXCLUDE, + ) + + +def _dump_service_dataset_list(response: dict[str, Any]) -> dict[str, Any]: + return DatasetListResponse.model_validate(response).model_dump( + mode="json", + exclude=_SERVICE_DATASET_LIST_EXCLUDE, + ) + + +def _dump_service_dataset_with_partial_members(data: dict[str, Any]) -> dict[str, Any]: + exclude: set[str] = set(_SERVICE_DATASET_DETAIL_EXCLUDE) + if "partial_member_list" not in data: + exclude.add("partial_member_list") + + return DatasetDetailWithPartialMembersResponse.model_validate(data).model_dump(mode="json", exclude=exclude) + class DatasetCreatePayload(BaseModel): - name: str = Field(..., min_length=1, max_length=40) - description: str = Field(default="", description="Dataset description (max 400 chars)", max_length=400) - indexing_technique: Literal["high_quality", "economy"] | None = None - permission: DatasetPermissionEnum | None = DatasetPermissionEnum.ONLY_ME - external_knowledge_api_id: str | None = None - provider: str = "vendor" - external_knowledge_id: str | None = None - retrieval_model: RetrievalModel | None = None - embedding_model: str | None = None - embedding_model_provider: str | None = None - summary_index_setting: dict | None = Field(default=None) + name: str = Field(..., min_length=1, max_length=40, description="Name of the knowledge base.") + description: str = Field(default="", description="Description of the knowledge base.", max_length=400) + indexing_technique: Literal["high_quality", "economy"] | None = Field( + default=None, + description="`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing.", + ) + permission: DatasetPermissionEnum | None = Field( + default=DatasetPermissionEnum.ONLY_ME, + description=( + "Controls who can access this knowledge base. `only_me` restricts access to the creator, " + "`all_team_members` grants workspace-wide access, and `partial_members` grants access to specified " + "members." + ), + ) + external_knowledge_api_id: str | None = Field(default=None, description="ID of the external knowledge API.") + provider: KnowledgeProvider = Field( + default="vendor", + description="Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.", + ) + external_knowledge_id: str | None = Field(default=None, description="ID of the external knowledge base.") + retrieval_model: RetrievalModel | None = Field( + default=None, + description="Retrieval model configuration. Controls how chunks are searched and ranked.", + ) + embedding_model: str | None = Field( + default=None, + description=( + "Embedding model name. Use the `model` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + embedding_model_provider: str | None = Field( + default=None, + description=( + "Embedding model provider. Use the `provider` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + summary_index_setting: SummaryIndexSetting = Field( + default=None, + description="Summary index configuration.", + ) class DatasetUpdatePayload(BaseModel): - name: str | None = Field(default=None, min_length=1, max_length=40) - description: str | None = Field(default=None, description="Dataset description (max 400 chars)", max_length=400) - indexing_technique: Literal["high_quality", "economy"] | None = None - permission: DatasetPermissionEnum | None = None - embedding_model: str | None = None - embedding_model_provider: str | None = None - retrieval_model: RetrievalModel | None = None - partial_member_list: list[dict[str, str]] | None = None - external_retrieval_model: dict[str, Any] | None = Field(default=None) - external_knowledge_id: str | None = None - external_knowledge_api_id: str | None = None + name: str | None = Field(default=None, min_length=1, max_length=40, description="Name of the knowledge base.") + description: str | None = Field(default=None, description="Description of the knowledge base.", max_length=400) + indexing_technique: Literal["high_quality", "economy"] | None = Field( + default=None, + description="`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing.", + ) + permission: DatasetPermissionEnum | None = Field( + default=None, + description=( + "Controls who can access this knowledge base. `only_me` restricts access to the creator, " + "`all_team_members` grants workspace-wide access, and `partial_members` grants access to specified " + "members." + ), + ) + embedding_model: str | None = Field( + default=None, + description=( + "Embedding model name. Use the `model` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + embedding_model_provider: str | None = Field( + default=None, + description=( + "Embedding model provider. Use the `provider` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + retrieval_model: RetrievalModel | None = Field( + default=None, + description="Retrieval model configuration. Controls how chunks are searched and ranked.", + ) + partial_member_list: PartialMemberList = Field( + default=None, + description="List of team members with access when `permission` is `partial_members`.", + ) + external_retrieval_model: ExternalRetrievalModel = Field( + default=None, + description="Retrieval settings for external knowledge bases.", + ) + external_knowledge_id: str | None = Field(default=None, description="ID of the external knowledge base.") + external_knowledge_api_id: str | None = Field(default=None, description="ID of the external knowledge API.") class DocumentStatusPayload(BaseModel): - document_ids: list[str] = Field(default_factory=list, description="Document IDs to update") + document_ids: list[str] = Field(default_factory=list, description="List of document IDs to update.") + + +DOCUMENT_STATUS_ACTION_PARAM = { + "description": "Action to perform: 'enable', 'disable', 'archive', or 'un_archive'", + "enum": ["enable", "disable", "archive", "un_archive"], + "type": "string", +} class TagNamePayload(BaseModel): - name: str = Field(..., min_length=1, max_length=50) + name: str = Field(..., min_length=1, max_length=50, description="Tag name.") class TagCreatePayload(TagNamePayload): @@ -88,16 +221,16 @@ class TagCreatePayload(TagNamePayload): class TagUpdatePayload(TagNamePayload): - tag_id: str + tag_id: str = Field(description="Tag ID to update.") class TagDeletePayload(BaseModel): - tag_id: str + tag_id: str = Field(description="Tag ID to delete.") class TagBindingPayload(BaseModel): - tag_ids: list[str] - target_id: str + tag_ids: list[str] = Field(description="Tag IDs to bind.") + target_id: str = Field(description="Knowledge base ID to bind the tags to.") @field_validator("tag_ids") @classmethod @@ -112,7 +245,46 @@ class TagUnbindingPayload(BaseModel): tag_ids: list[str] = Field(default_factory=list) tag_id: str | None = None - target_id: str + target_id: str = Field(description="Knowledge base ID.") + + @classmethod + @override + def __get_pydantic_json_schema__(cls, _core_schema: object, _handler: GetJsonSchemaHandler) -> dict[str, object]: + tag_id_property = { + "description": "Legacy single tag ID accepted by the Service API.", + "type": "string", + } + tag_ids_property = { + "description": "Tag IDs to unbind. Use this for new integrations.", + "items": {"type": "string"}, + "minItems": 1, + "type": "array", + } + target_id_property = {"description": "Knowledge base ID.", "title": "Target Id", "type": "string"} + return { + "anyOf": [ + { + "properties": { + "tag_id": tag_id_property, + "tag_ids": tag_ids_property, + "target_id": target_id_property, + }, + "required": ["tag_id", "target_id"], + "type": "object", + }, + { + "properties": { + "tag_id": {**tag_id_property, "nullable": True}, + "tag_ids": tag_ids_property, + "target_id": target_id_property, + }, + "required": ["tag_ids", "target_id"], + "type": "object", + }, + ], + "description": "Accepts either the legacy tag_id payload or the normalized tag_ids payload.", + "title": cls.__name__, + } @model_validator(mode="before") @classmethod @@ -146,11 +318,14 @@ class KnowledgeTagListResponse(RootModel[list[KnowledgeTagResponse]]): class DatasetListQuery(BaseModel): - page: int = Field(default=1, description="Page number") - limit: int = Field(default=20, description="Number of items per page") - keyword: str | None = Field(default=None, description="Search keyword") - include_all: bool = Field(default=False, description="Include all datasets") - tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs") + page: int = Field(default=1, description="Page number to retrieve.") + limit: int = Field(default=20, description="Number of items per page. Server caps at `100`.") + keyword: str | None = Field(default=None, description="Search keyword to filter by name.") + include_all: bool = Field( + default=False, + description="Whether to include all knowledge bases regardless of permissions.", + ) + tag_ids: list[str] = Field(default_factory=list, description="Tag IDs to filter by.") class DatasetDetailWithPartialMembersResponse(DatasetDetailResponse): @@ -204,6 +379,14 @@ register_response_schema_models( class DatasetListApi(DatasetApiResource): """Resource for datasets.""" + @service_api_ns.doc( + summary="List Knowledge Bases", + description="Returns a paginated list of knowledge bases. Supports filtering by keyword and tags.", + tags=["Knowledge Bases"], + responses={ + 200: "List of knowledge bases.", + }, + ) @service_api_ns.doc("list_datasets") @service_api_ns.doc(description="List all datasets") @service_api_ns.doc( @@ -227,7 +410,14 @@ class DatasetListApi(DatasetApiResource): # provider = request.args.get("provider", default="vendor") datasets, total = DatasetService.get_datasets( - query.page, query.limit, tenant_id, current_user, query.keyword, query.tag_ids, query.include_all + query.page, + query.limit, + db.session, + tenant_id, + current_user, + query.keyword, + query.tag_ids, + query.include_all, ) # check embedding setting assert isinstance(current_user, Account) @@ -242,7 +432,7 @@ class DatasetListApi(DatasetApiResource): for embedding_model in embedding_models: model_names.append(f"{embedding_model.model}:{embedding_model.provider.provider}") - data = [dump_response(DatasetDetailResponse, dataset) for dataset in datasets] + data = [_dump_service_dataset_detail(dataset) for dataset in datasets] for item in data: if item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY and item["embedding_model_provider"]: item["embedding_model_provider"] = str(ModelProviderID(item["embedding_model_provider"])) @@ -260,8 +450,21 @@ class DatasetListApi(DatasetApiResource): "total": total, "page": query.page, } - return dump_response(DatasetListResponse, response), 200 + return _dump_service_dataset_list(response), 200 + @service_api_ns.doc( + summary="Create an Empty Knowledge Base", + description=( + "Create a new empty knowledge base. After creation, use [Create Document by " + "Text](/api-reference/documents/create-document-by-text) or [Create Document by " + "File](/api-reference/documents/create-document-by-file) to add documents." + ), + tags=["Knowledge Bases"], + responses={ + 200: "Knowledge base created successfully.", + 409: "`dataset_name_duplicate` : The dataset name already exists. Please modify your dataset name.", + }, + ) @service_api_ns.expect(service_api_ns.models[DatasetCreatePayload.__name__]) @service_api_ns.doc("create_dataset") @service_api_ns.doc(description="Create a new dataset") @@ -320,16 +523,29 @@ class DatasetListApi(DatasetApiResource): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return dump_response(DatasetDetailResponse, dataset), 200 + return _dump_service_dataset_detail(dataset), 200 @service_api_ns.route("/datasets/") class DatasetApi(DatasetApiResource): """Resource for dataset.""" + @service_api_ns.doc( + summary="Get Knowledge Base", + description=( + "Retrieve detailed information about a specific knowledge base, including its embedding " + "model, retrieval configuration, and document statistics." + ), + tags=["Knowledge Bases"], + responses={ + 200: "Knowledge base details.", + 403: "`forbidden` : Insufficient permissions to access this knowledge base.", + 404: "`not_found` : Dataset not found.", + }, + ) @service_api_ns.doc("get_dataset") @service_api_ns.doc(description="Get a specific dataset by ID") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Dataset retrieved successfully", @@ -352,7 +568,7 @@ class DatasetApi(DatasetApiResource): DatasetService.check_dataset_permission(dataset, current_user) except services.errors.account.NoPermissionError as e: raise Forbidden(str(e)) - data = dump_response(DatasetDetailResponse, dataset) + data = _dump_service_dataset_detail(dataset) # check embedding setting assert isinstance(current_user, Account) cid = current_user.current_tenant_id @@ -384,18 +600,25 @@ class DatasetApi(DatasetApiResource): part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str) data.update({"partial_member_list": part_users_list}) - return ( - DatasetDetailWithPartialMembersResponse.model_validate(data).model_dump( - mode="json", - exclude={"partial_member_list"} if "partial_member_list" not in data else set(), - ), - 200, - ) + return _dump_service_dataset_with_partial_members(data), 200 + @service_api_ns.doc( + summary="Update Knowledge Base", + description=( + "Update the name, description, permissions, or retrieval settings of an existing knowledge " + "base. Only the fields provided in the request body are updated." + ), + tags=["Knowledge Bases"], + responses={ + 200: "Knowledge base updated successfully.", + 403: "`forbidden` : Insufficient permissions to access this knowledge base.", + 404: "`not_found` : Dataset not found.", + }, + ) @service_api_ns.expect(service_api_ns.models[DatasetUpdatePayload.__name__]) @service_api_ns.doc("update_dataset") @service_api_ns.doc(description="Update an existing dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Dataset updated successfully", @@ -446,20 +669,21 @@ class DatasetApi(DatasetApiResource): retrieval_model.reranking_model.reranking_model_name, ) - # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator - DatasetPermissionService.check_permission( - current_user, - dataset, - str(payload.permission) if payload.permission else None, - payload.partial_member_list, - ) + if not dify_config.RBAC_ENABLED: + # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator + DatasetPermissionService.check_permission( + current_user, + dataset, + str(payload.permission) if payload.permission else None, + payload.partial_member_list, + ) dataset = DatasetService.update_dataset(dataset_id_str, update_data, current_user) if dataset is None: raise NotFound("Dataset not found.") - result_data = dump_response(DatasetDetailResponse, dataset) + result_data = _dump_service_dataset_detail(dataset) assert isinstance(current_user, Account) tenant_id = current_user.current_tenant_id @@ -472,11 +696,27 @@ class DatasetApi(DatasetApiResource): partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str) result_data.update({"partial_member_list": partial_member_list}) - return DatasetDetailWithPartialMembersResponse.model_validate(result_data).model_dump(mode="json"), 200 + return _dump_service_dataset_with_partial_members(result_data), 200 + @service_api_ns.doc( + summary="Delete Knowledge Base", + description=( + "Permanently delete a knowledge base and all its documents. The knowledge base must not be " + "in use by any application." + ), + tags=["Knowledge Bases"], + responses={ + 204: "Success.", + 404: "`not_found` : Dataset not found.", + 409: ( + "`dataset_in_use` : The knowledge base is being used by some apps. Please remove it from the " + "apps before deleting." + ), + }, + ) @service_api_ns.doc("delete_dataset") @service_api_ns.doc(description="Delete a dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 204: "Dataset deleted successfully", @@ -519,6 +759,17 @@ class DatasetApi(DatasetApiResource): class DocumentStatusApi(DatasetApiResource): """Resource for batch document status operations.""" + @service_api_ns.doc( + summary="Update Document Status in Batch", + description="Enable, disable, archive, or unarchive multiple documents at once.", + tags=["Documents"], + responses={ + 200: "Documents updated successfully.", + 400: "`invalid_action` : Invalid action.", + 403: "`forbidden` : Insufficient permissions.", + 404: "`not_found` : Knowledge base not found.", + }, + ) @service_api_ns.response( 200, "Document status updated successfully", @@ -528,8 +779,8 @@ class DocumentStatusApi(DatasetApiResource): @service_api_ns.doc(description="Batch update document status") @service_api_ns.doc( params={ - "dataset_id": "Dataset ID", - "action": "Action to perform: 'enable', 'disable', 'archive', or 'un_archive'", + "dataset_id": "Knowledge base ID.", + "action": DOCUMENT_STATUS_ACTION_PARAM, } ) @service_api_ns.doc( @@ -591,6 +842,14 @@ class DocumentStatusApi(DatasetApiResource): @service_api_ns.route("/datasets/tags") class DatasetTagsApi(DatasetApiResource): + @service_api_ns.doc( + summary="List Knowledge Tags", + description="Returns the list of all knowledge base tags in the workspace.", + tags=["Tags"], + responses={ + 200: "List of tags.", + }, + ) @service_api_ns.doc("list_dataset_tags") @service_api_ns.doc(description="Get all knowledge type tags") @service_api_ns.doc( @@ -612,6 +871,14 @@ class DatasetTagsApi(DatasetApiResource): tags = TagService.get_tags(db.session(), "knowledge", cid) return dump_response(KnowledgeTagListResponse, tags), 200 + @service_api_ns.doc( + summary="Create Knowledge Tag", + description="Create a new tag for organizing knowledge bases.", + tags=["Tags"], + responses={ + 200: "Tag created successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[TagCreatePayload.__name__]) @service_api_ns.doc("create_dataset_tag") @service_api_ns.doc(description="Add a knowledge type tag") @@ -634,7 +901,7 @@ class DatasetTagsApi(DatasetApiResource): raise Forbidden() payload = TagCreatePayload.model_validate(service_api_ns.payload or {}) - tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE)) + tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), db.session) response = dump_response( KnowledgeTagResponse, @@ -642,6 +909,14 @@ class DatasetTagsApi(DatasetApiResource): ) return response, 200 + @service_api_ns.doc( + summary="Update Knowledge Tag", + description="Rename an existing knowledge base tag.", + tags=["Tags"], + responses={ + 200: "Tag updated successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[TagUpdatePayload.__name__]) @service_api_ns.doc("update_dataset_tag") @service_api_ns.doc(description="Update a knowledge type tag") @@ -664,9 +939,9 @@ class DatasetTagsApi(DatasetApiResource): payload = TagUpdatePayload.model_validate(service_api_ns.payload or {}) tag_id = payload.tag_id - tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id) + tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id, db.session) - binding_count = TagService.get_tag_binding_count(tag_id) + binding_count = TagService.get_tag_binding_count(tag_id, db.session) response = dump_response( KnowledgeTagResponse, @@ -674,6 +949,14 @@ class DatasetTagsApi(DatasetApiResource): ) return response, 200 + @service_api_ns.doc( + summary="Delete Knowledge Tag", + description="Permanently delete a knowledge base tag. Does not delete the knowledge bases that were tagged.", + tags=["Tags"], + responses={ + 204: "Success.", + }, + ) @service_api_ns.expect(service_api_ns.models[TagDeletePayload.__name__]) @service_api_ns.doc("delete_dataset_tag") @service_api_ns.doc(description="Delete a knowledge type tag") @@ -688,13 +971,21 @@ class DatasetTagsApi(DatasetApiResource): def delete(self, _): """Delete a knowledge type tag.""" payload = TagDeletePayload.model_validate(service_api_ns.payload or {}) - TagService.delete_tag(payload.tag_id) + TagService.delete_tag(payload.tag_id, db.session) return "", 204 @service_api_ns.route("/datasets/tags/binding") class DatasetTagBindingApi(DatasetApiResource): + @service_api_ns.doc( + summary="Create Tag Binding", + description="Bind one or more tags to a knowledge base. A knowledge base can have multiple tags.", + tags=["Tags"], + responses={ + 204: "Success.", + }, + ) @service_api_ns.expect(service_api_ns.models[TagBindingPayload.__name__]) @service_api_ns.doc("bind_dataset_tags") @service_api_ns.doc(description="Bind tags to a dataset") @@ -713,7 +1004,8 @@ class DatasetTagBindingApi(DatasetApiResource): payload = TagBindingPayload.model_validate(service_api_ns.payload or {}) TagService.save_tag_binding( - TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE) + TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE), + db.session, ) return "", 204 @@ -721,6 +1013,14 @@ class DatasetTagBindingApi(DatasetApiResource): @service_api_ns.route("/datasets/tags/unbinding") class DatasetTagUnbindingApi(DatasetApiResource): + @service_api_ns.doc( + summary="Delete Tag Binding", + description="Remove one or more tags from a knowledge base.", + tags=["Tags"], + responses={ + 204: "Success.", + }, + ) @service_api_ns.expect(service_api_ns.models[TagUnbindingPayload.__name__]) @service_api_ns.doc("unbind_dataset_tags") @service_api_ns.doc(description="Unbind tags from a dataset") @@ -739,7 +1039,8 @@ class DatasetTagUnbindingApi(DatasetApiResource): payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {}) TagService.delete_tag_binding( - TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE) + TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE), + db.session, ) return "", 204 @@ -747,9 +1048,17 @@ class DatasetTagUnbindingApi(DatasetApiResource): @service_api_ns.route("/datasets//tags") class DatasetTagsBindingStatusApi(DatasetApiResource): + @service_api_ns.doc( + summary="Get Knowledge Base Tags", + description="Returns the list of tags bound to a specific knowledge base.", + tags=["Tags"], + responses={ + 200: "Tags bound to the knowledge base.", + }, + ) @service_api_ns.doc("get_dataset_tags_binding_status") @service_api_ns.doc(description="Get tags bound to a specific dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Tags retrieved successfully", @@ -766,6 +1075,8 @@ class DatasetTagsBindingStatusApi(DatasetApiResource): dataset_id = kwargs.get("dataset_id") assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - tags = TagService.get_tags_by_target_id("knowledge", current_user.current_tenant_id, str(dataset_id)) + tags = TagService.get_tags_by_target_id( + "knowledge", current_user.current_tenant_id, str(dataset_id), db.session + ) tags_list = [{"id": tag.id, "name": tag.name} for tag in tags] return dump_response(DatasetBoundTagListResponse, {"data": tags_list, "total": len(tags)}), 200 diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index c71feb1aa7b..9bae862814a 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -8,11 +8,12 @@ deprecated in generated API docs so clients migrate toward the canonical paths. import json from collections.abc import Mapping from contextlib import ExitStack -from typing import Any, Literal, Self +from copy import deepcopy +from typing import Annotated, Any, Literal, Self, override from uuid import UUID from flask import request, send_file -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, field_validator, model_validator from sqlalchemy import desc, func, select from werkzeug.exceptions import Forbidden, NotFound @@ -39,6 +40,7 @@ from controllers.service_api.dataset.error import ( DocumentIndexingError, InvalidMetadataError, ) +from controllers.service_api.schema import binary_response from controllers.service_api.wraps import ( DatasetApiResource, cloud_edition_billing_rate_limit_check, @@ -61,6 +63,8 @@ from models.dataset import Dataset, Document, DocumentSegment from models.enums import SegmentStatus from services.dataset_service import DatasetService, DocumentService from services.entities.knowledge_entities.knowledge_entities import ( + DocForm, + IndexingTechnique, KnowledgeConfig, ProcessRule, RetrievalModel, @@ -70,16 +74,44 @@ from services.summary_index_service import SummaryIndexService class DocumentTextCreatePayload(BaseModel): - name: str - text: str - process_rule: ProcessRule | None = None - original_document_id: str | None = None - doc_form: str = Field(default="text_model") - doc_language: str = Field(default="English") - indexing_technique: str | None = None - retrieval_model: RetrievalModel | None = None - embedding_model: str | None = None - embedding_model_provider: str | None = None + name: str = Field(description="Document name.") + text: str = Field(description="Document text content.") + process_rule: ProcessRule | None = Field(default=None, description="Processing rules for chunking.") + original_document_id: str | None = Field(default=None, description="Original document ID for replacement.") + doc_form: DocForm = Field( + default="text_model", + description=( + "`text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, " + "`qa_model` for question-answer pair extraction." + ), + ) + doc_language: str = Field(default="English", description="Language of the document for processing optimization.") + indexing_technique: IndexingTechnique = Field( + default=None, + description=( + "`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. " + "Required when adding the first document to a knowledge base; subsequent documents inherit the " + "knowledge base's indexing technique if omitted." + ), + ) + retrieval_model: RetrievalModel | None = Field( + default=None, + description="Retrieval model configuration. Controls how chunks are searched and ranked.", + ) + embedding_model: str | None = Field( + default=None, + description=( + "Embedding model name. Use the `model` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + embedding_model_provider: str | None = Field( + default=None, + description=( + "Embedding model provider. Use the `provider` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) @field_validator("doc_form") @classmethod @@ -90,12 +122,21 @@ class DocumentTextCreatePayload(BaseModel): class DocumentTextUpdate(BaseModel): - name: str | None = None - text: str | None = None - process_rule: ProcessRule | None = None - doc_form: str = "text_model" - doc_language: str = "English" - retrieval_model: RetrievalModel | None = None + name: str | None = Field(default=None, description="Document name. Required when `text` is provided.") + text: str | None = Field(default=None, description="Document text content.") + process_rule: ProcessRule | None = Field(default=None, description="Processing rules for chunking.") + doc_form: DocForm = Field( + default="text_model", + description=( + "`text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, " + "`qa_model` for question-answer pair extraction." + ), + ) + doc_language: str = Field(default="English", description="Language of the document for processing optimization.") + retrieval_model: RetrievalModel | None = Field( + default=None, + description="Retrieval model configuration. Controls how chunks are searched and ranked.", + ) @field_validator("doc_form") @classmethod @@ -104,6 +145,36 @@ class DocumentTextUpdate(BaseModel): raise ValueError("Invalid doc_form.") return value + @classmethod + @override + def __get_pydantic_json_schema__(cls, core_schema: Any, handler: GetJsonSchemaHandler) -> dict[str, Any]: + schema = handler.resolve_ref_schema(handler(core_schema)) + properties = schema.get("properties") + if not isinstance(properties, dict): + return schema + + text_branch_properties = deepcopy(properties) + text_branch_properties["text"] = _non_null_property_schema(properties.get("text")) + text_branch_properties["name"] = _non_null_property_schema(properties.get("name")) + + no_text_branch_properties = deepcopy(properties) + no_text_branch_properties["text"] = {"description": "Document text content.", "type": "null"} + + return { + **schema, + "anyOf": [ + { + "properties": text_branch_properties, + "required": ["name", "text"], + "type": "object", + }, + { + "properties": no_text_branch_properties, + "type": "object", + }, + ], + } + @model_validator(mode="after") def check_text_and_name(self) -> Self: if self.text is not None and self.name is None: @@ -111,19 +182,59 @@ class DocumentTextUpdate(BaseModel): return self +def _non_null_property_schema(property_schema: object) -> dict[str, Any]: + if not isinstance(property_schema, dict): + return {} + + any_of = property_schema.get("anyOf") + if isinstance(any_of, list): + non_null_candidates = [ + candidate for candidate in any_of if isinstance(candidate, dict) and candidate.get("type") != "null" + ] + if len(non_null_candidates) == 1: + return { + **{key: value for key, value in property_schema.items() if key != "anyOf"}, + **deepcopy(non_null_candidates[0]), + } + + return deepcopy(property_schema) + + +DocumentDisplayStatus = Annotated[ + str | None, + WithJsonSchema( + { + "anyOf": [ + { + "enum": ["queuing", "indexing", "paused", "error", "available", "disabled", "archived"], + "type": "string", + }, + {"type": "null"}, + ] + } + ), +] + + class DocumentListQuery(BaseModel): - page: int = Field(default=1, description="Page number") - limit: int = Field(default=20, description="Number of items per page") - keyword: str | None = Field(default=None, description="Search keyword") - status: str | None = Field(default=None, description="Document status filter") + page: int = Field(default=1, description="Page number to retrieve.") + limit: int = Field(default=20, description="Number of items per page. Server caps at `100`.") + keyword: str | None = Field(default=None, description="Search keyword to filter by document name.") + status: DocumentDisplayStatus = Field(default=None, description="Filter by display status.") class DocumentGetQuery(BaseModel): - metadata: Literal["all", "only", "without"] = Field(default="all", description="Metadata response mode") + metadata: Literal["all", "only", "without"] = Field( + default="all", + description=( + "`all` returns all fields including metadata. `only` returns only `id`, `doc_type`, and " + "`doc_metadata`. `without` returns all fields except `doc_metadata`." + ), + ) DOCUMENT_CREATE_BY_FILE_PARAMS = { - "dataset_id": "Dataset ID", + "dataset_id": "Knowledge base ID.", "file": { "in": "formData", "type": "file", @@ -134,23 +245,32 @@ DOCUMENT_CREATE_BY_FILE_PARAMS = { "in": "formData", "type": "string", "required": False, - "description": "Optional JSON string with document creation settings.", + "description": ( + "JSON string containing configuration. Accepts the same fields as " + "[Create Document by Text](/api-reference/documents/create-document-by-text) (`indexing_technique`, " + "`doc_form`, `doc_language`, `process_rule`, `retrieval_model`, `embedding_model`, " + "`embedding_model_provider`) except `name` and `text`." + ), }, } DOCUMENT_UPDATE_BY_FILE_PARAMS = { - "dataset_id": "Dataset ID", - "document_id": "Document ID", + "dataset_id": "Knowledge base ID.", + "document_id": "Document ID.", "file": { "in": "formData", "type": "file", "required": False, - "description": "Replacement document file.", + "description": "Replacement document file to upload.", }, "data": { "in": "formData", "type": "string", "required": False, - "description": "Optional JSON string with document update settings.", + "description": ( + "JSON string containing document update settings such as `doc_form`, `doc_language`, `process_rule`, " + "`retrieval_model`, `embedding_model`, and `embedding_model_provider`. `name` and `text` are not used " + "for file updates." + ), }, } @@ -351,10 +471,28 @@ def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID class DocumentAddByTextApi(DatasetApiResource): """Resource for the canonical text document creation route.""" + @service_api_ns.doc( + summary="Create Document by Text", + description=( + "Create a document from raw text content. The document is processed asynchronously — use the " + "returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/" + "get-document-indexing-status) to track progress." + ), + tags=["Documents"], + responses={ + 200: "Document created successfully.", + 400: ( + "- `provider_not_initialize` : No valid model provider credentials found. Please go to " + "Settings -> Model Provider to complete your provider credentials.\n" + "- `invalid_param` : Knowledge base does not exist. / indexing_technique is required. / " + "Invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`)." + ), + }, + ) @service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__]) @service_api_ns.doc("create_document_by_text") @service_api_ns.doc(description="Create a new document by providing text content") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Document created successfully", @@ -386,7 +524,7 @@ class DeprecatedDocumentAddByTextApi(DatasetApiResource): "Use /datasets/{dataset_id}/document/create-by-text instead." ) ) - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Document created successfully", @@ -409,10 +547,29 @@ class DeprecatedDocumentAddByTextApi(DatasetApiResource): class DocumentUpdateByTextApi(DatasetApiResource): """Resource for the canonical text document update route.""" + @service_api_ns.doc( + summary="Update Document by Text", + description=( + "Update an existing document's text content, name, or processing configuration. Re-triggers " + "indexing if content changes — use the returned `batch` ID with [Get Document Indexing " + "Status](/api-reference/documents/get-document-indexing-status) to track progress." + ), + tags=["Documents"], + responses={ + 200: "Document updated successfully.", + 400: ( + "- `provider_not_initialize` : No valid model provider credentials found. Please go to " + "Settings -> Model Provider to complete your provider credentials.\n" + "- `invalid_param` : Knowledge base does not exist, name is required when text is " + "provided, or invalid doc_form (must be `text_model`, `hierarchical_model`, or " + "`qa_model`)." + ), + }, + ) @service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__]) @service_api_ns.doc("update_document_by_text") @service_api_ns.doc(description="Update an existing document by providing text content") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}) @service_api_ns.doc( responses={ 200: "Document updated successfully", @@ -443,7 +600,7 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource): "Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead." ) ) - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}) @service_api_ns.doc( responses={ 200: "Document updated successfully", @@ -463,11 +620,42 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource): @service_api_ns.route( "/datasets//document/create_by_file", - "/datasets//document/create-by-file", + doc={ + "post": { + "deprecated": True, + "description": ( + "Deprecated legacy alias for creating a new document by uploading a file. " + "Use /datasets/{dataset_id}/document/create-by-file instead." + ), + } + }, ) +@service_api_ns.route("/datasets//document/create-by-file") class DocumentAddByFileApi(DatasetApiResource): """Resource for documents.""" + @service_api_ns.doc( + summary="Create Document by File", + description=( + "Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, " + "etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document " + "Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress." + ), + tags=["Documents"], + responses={ + 200: "Document created successfully.", + 400: ( + "- `no_file_uploaded` : Please upload your file.\n" + "- `too_many_files` : Only one file is allowed.\n" + "- `filename_not_exists_error` : The specified filename does not exist.\n" + "- `provider_not_initialize` : No valid model provider credentials found. Please go to " + "Settings -> Model Provider to complete your provider credentials.\n" + "- `invalid_param` : Knowledge base does not exist, external datasets not supported, " + "file too large, unsupported file type, missing required fields, or invalid doc_form " + "(must be `text_model`, `hierarchical_model`, or `qa_model`)." + ), + }, + ) @service_api_ns.doc("create_document_by_file") @service_api_ns.doc(description="Create a new document by uploading a file") @service_api_ns.doc(consumes=["multipart/form-data"], params=DOCUMENT_CREATE_BY_FILE_PARAMS) @@ -658,6 +846,27 @@ def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID class DeprecatedDocumentUpdateByFileApi(DatasetApiResource): """Deprecated resource aliases for file document updates.""" + @service_api_ns.doc( + summary="Update Document by File", + description=( + "Update an existing document by uploading a new file. Re-triggers indexing — use the returned " + "`batch` ID with [Get Document Indexing Status](/api-reference/documents/" + "get-document-indexing-status) to track progress." + ), + tags=["Documents"], + responses={ + 200: "Document updated successfully.", + 400: ( + "- `too_many_files` : Only one file is allowed.\n" + "- `filename_not_exists_error` : The specified filename does not exist.\n" + "- `provider_not_initialize` : No valid model provider credentials found. Please go to " + "Settings -> Model Provider to complete your provider credentials.\n" + "- `invalid_param` : Knowledge base does not exist, external datasets not supported, " + "file too large, unsupported file type, or invalid doc_form (must be `text_model`, " + "`hierarchical_model`, or `qa_model`)." + ), + }, + ) @service_api_ns.doc("update_document_by_file_deprecated") @service_api_ns.doc(deprecated=True) @service_api_ns.doc( @@ -686,9 +895,21 @@ class DeprecatedDocumentUpdateByFileApi(DatasetApiResource): @service_api_ns.route("/datasets//documents") class DocumentListApi(DatasetApiResource): + @service_api_ns.doc( + summary="List Documents", + description=( + "Returns a paginated list of documents in the knowledge base. Supports filtering by keyword " + "and indexing status." + ), + tags=["Documents"], + responses={ + 200: "List of documents.", + 404: "`not_found` : Knowledge base not found.", + }, + ) @service_api_ns.doc("list_documents") @service_api_ns.doc(description="List all documents in a dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", **query_params_from_model(DocumentListQuery)}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", **query_params_from_model(DocumentListQuery)}) @service_api_ns.doc( responses={ 200: "Documents retrieved successfully", @@ -746,10 +967,23 @@ class DocumentListApi(DatasetApiResource): class DocumentBatchDownloadZipApi(DatasetApiResource): """Download multiple uploaded-file documents as a single ZIP archive.""" + @service_api_ns.doc( + summary="Download Documents as ZIP", + description=( + "Download multiple uploaded-file documents as a single ZIP archive. Accepts up to `100` document IDs." + ), + tags=["Documents"], + responses={ + 200: "ZIP archive containing the requested documents.", + 403: "`forbidden` : Insufficient permissions.", + 404: "`not_found` : Document or dataset not found.", + }, + ) + @binary_response(service_api_ns, "application/zip") @service_api_ns.expect(service_api_ns.models[DocumentBatchDownloadZipPayload.__name__]) @service_api_ns.doc("download_documents_as_zip") @service_api_ns.doc(description="Download selected uploaded documents as a single ZIP archive") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "ZIP archive generated successfully", @@ -758,11 +992,7 @@ class DocumentBatchDownloadZipApi(DatasetApiResource): 404: "Document or dataset not found", } ) - @service_api_ns.response( - 200, - "ZIP archive generated successfully", - service_api_ns.models[BinaryFileResponse.__name__], - ) + @service_api_ns.response(200, "ZIP archive generated successfully") @cloud_edition_billing_rate_limit_check("knowledge", "dataset") def post(self, tenant_id, dataset_id: UUID): payload = DocumentBatchDownloadZipPayload.model_validate(service_api_ns.payload or {}) @@ -789,9 +1019,23 @@ class DocumentBatchDownloadZipApi(DatasetApiResource): @service_api_ns.route("/datasets//documents//indexing-status") class DocumentIndexingStatusApi(DatasetApiResource): + @service_api_ns.doc( + summary="Get Document Indexing Status", + description=( + "Check the indexing progress of documents in a batch. Returns the current processing stage " + "and chunk completion counts for each document. Poll this endpoint until `indexing_status` " + "reaches `completed` or `error`. The status progresses through: `waiting` → `parsing` → " + "`cleaning` → `splitting` → `indexing` → `completed`." + ), + tags=["Documents"], + responses={ + 200: "Indexing status for documents in the batch.", + 404: "`not_found` : Knowledge base not found. / Documents not found.", + }, + ) @service_api_ns.doc("get_document_indexing_status") @service_api_ns.doc(description="Get indexing status for documents in a batch") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "batch": "Batch ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "batch": "Batch ID."}) @service_api_ns.doc( responses={ 200: "Indexing status retrieved successfully", @@ -861,9 +1105,19 @@ class DocumentIndexingStatusApi(DatasetApiResource): class DocumentDownloadApi(DatasetApiResource): """Return a signed download URL for a document's original uploaded file.""" + @service_api_ns.doc( + summary="Download Document", + description="Get a signed download URL for a document's original uploaded file.", + tags=["Documents"], + responses={ + 200: "Download URL generated successfully.", + 403: "`forbidden` : No permission to access this document.", + 404: "`not_found` : Document not found.", + }, + ) @service_api_ns.doc("get_document_download_url") @service_api_ns.doc(description="Get a signed download URL for a document's original uploaded file") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}) @service_api_ns.doc( responses={ 200: "Download URL generated successfully", @@ -895,9 +1149,27 @@ class DocumentDownloadApi(DatasetApiResource): class DocumentApi(DatasetApiResource): METADATA_CHOICES = {"all", "only", "without"} + @service_api_ns.doc( + summary="Get Document", + description=( + "Retrieve detailed information about a specific document, including its indexing status, " + "metadata, and processing statistics." + ), + tags=["Documents"], + responses={ + 200: ( + "Document details. The response shape varies based on the `metadata` query parameter. When " + "`metadata` is `only`, only `id`, `doc_type`, and `doc_metadata` are returned. When " + "`metadata` is `without`, `doc_type` and `doc_metadata` are omitted." + ), + 400: "`invalid_metadata` : Invalid metadata value for the specified key.", + 403: "`forbidden` : No permission.", + 404: "`not_found` : Document not found.", + }, + ) @service_api_ns.doc("get_document") @service_api_ns.doc(description="Get a specific document by ID") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}) @service_api_ns.doc(params=query_params_from_model(DocumentGetQuery)) @service_api_ns.doc( responses={ @@ -1036,9 +1308,20 @@ class DocumentApi(DatasetApiResource): """Update document by file on the canonical document resource.""" return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id) + @service_api_ns.doc( + summary="Delete Document", + description="Permanently delete a document and all its chunks from the knowledge base.", + tags=["Documents"], + responses={ + 204: "Success.", + 400: "`document_indexing` : Cannot delete document during indexing.", + 403: "`archived_document_immutable` : The archived document is not editable.", + 404: "`not_found` : Document Not Exists.", + }, + ) @service_api_ns.doc("delete_document") @service_api_ns.doc(description="Delete a document") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}) @service_api_ns.doc( responses={ 204: "Document deleted successfully", diff --git a/api/controllers/service_api/dataset/hit_testing.py b/api/controllers/service_api/dataset/hit_testing.py index 55a1c47c425..31881e86322 100644 --- a/api/controllers/service_api/dataset/hit_testing.py +++ b/api/controllers/service_api/dataset/hit_testing.py @@ -13,9 +13,35 @@ register_response_schema_models(service_api_ns, HitTestingResponse) @service_api_ns.route("/datasets//hit-testing", "/datasets//retrieve") class HitTestingApi(DatasetApiResource, DatasetsHitTestingBase): + @service_api_ns.doc( + summary="Retrieve Chunks from a Knowledge Base / Test Retrieval", + description=( + "Performs a search query against a knowledge base to retrieve the most relevant chunks. This " + "endpoint can be used for both production retrieval and test retrieval." + ), + tags=["Knowledge Bases"], + responses={ + 200: "Retrieval results.", + 400: ( + "- `dataset_not_initialized` : The dataset is still being initialized or indexing. Please " + "wait a moment.\n" + "- `provider_not_initialize` : No valid model provider credentials found. Please go to " + "Settings -> Model Provider to complete your provider credentials.\n" + "- `provider_quota_exceeded` : Your quota for Dify Hosted OpenAI has been exhausted. Please " + "go to Settings -> Model Provider to complete your own provider credentials.\n" + "- `model_currently_not_support` : Dify Hosted OpenAI trial currently not support the GPT-4 " + "model.\n" + "- `completion_request_error` : Completion request failed.\n" + "- `invalid_param` : Invalid parameter value." + ), + 403: "`forbidden` : Insufficient permissions.", + 404: "`not_found` : Knowledge base not found.", + 500: "`internal_server_error` : An internal error occurred during retrieval.", + }, + ) @service_api_ns.doc("dataset_hit_testing") @service_api_ns.doc(description="Perform hit testing on a dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.response( 200, "Hit testing results", diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py index 293a77fc5ec..426d008c412 100644 --- a/api/controllers/service_api/dataset/metadata.py +++ b/api/controllers/service_api/dataset/metadata.py @@ -24,6 +24,12 @@ from services.entities.knowledge_entities.knowledge_entities import ( ) from services.metadata_service import MetadataService +BUILT_IN_METADATA_ACTION_PARAM = { + "description": "`enable` to activate built-in metadata fields, `disable` to deactivate them.", + "enum": ["enable", "disable"], + "type": "string", +} + register_schema_model(service_api_ns, MetadataUpdatePayload) register_schema_models( service_api_ns, @@ -43,10 +49,21 @@ register_response_schema_models( @service_api_ns.route("/datasets//metadata") class DatasetMetadataCreateServiceApi(DatasetApiResource): + @service_api_ns.doc( + summary="Create Metadata Field", + description=( + "Create a custom metadata field for the knowledge base. Metadata fields can be used to " + "annotate documents with structured information." + ), + tags=["Metadata"], + responses={ + 201: "Metadata field created successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[MetadataArgs.__name__]) @service_api_ns.doc("create_dataset_metadata") @service_api_ns.doc(description="Create metadata for a dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 201: "Metadata created successfully", @@ -71,9 +88,20 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource): metadata = MetadataService.create_metadata(dataset_id_str, metadata_args) return dump_response(DatasetMetadataResponse, metadata), 201 + @service_api_ns.doc( + summary="List Metadata Fields", + description=( + "Returns the list of all metadata fields (both custom and built-in) for the knowledge base, " + "along with the count of documents using each field." + ), + tags=["Metadata"], + responses={ + 200: "Metadata fields for the knowledge base.", + }, + ) @service_api_ns.doc("get_dataset_metadata") @service_api_ns.doc(description="Get all metadata for a dataset") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Metadata retrieved successfully", @@ -96,10 +124,18 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource): @service_api_ns.route("/datasets//metadata/") class DatasetMetadataServiceApi(DatasetApiResource): + @service_api_ns.doc( + summary="Update Metadata Field", + description="Rename a custom metadata field.", + tags=["Metadata"], + responses={ + 200: "Metadata field updated successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[MetadataUpdatePayload.__name__]) @service_api_ns.doc("update_dataset_metadata") @service_api_ns.doc(description="Update metadata name") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "metadata_id": "Metadata ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "metadata_id": "Metadata field ID."}) @service_api_ns.doc( responses={ 200: "Metadata updated successfully", @@ -125,9 +161,20 @@ class DatasetMetadataServiceApi(DatasetApiResource): metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, payload.name) return dump_response(DatasetMetadataResponse, metadata), 200 + @service_api_ns.doc( + summary="Delete Metadata Field", + description=( + "Permanently delete a custom metadata field. Documents using this field will lose their " + "metadata values for it." + ), + tags=["Metadata"], + responses={ + 204: "Success.", + }, + ) @service_api_ns.doc("delete_dataset_metadata") @service_api_ns.doc(description="Delete metadata") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "metadata_id": "Metadata ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "metadata_id": "Metadata field ID."}) @service_api_ns.doc( responses={ 204: "Metadata deleted successfully", @@ -152,8 +199,19 @@ class DatasetMetadataServiceApi(DatasetApiResource): @service_api_ns.route("/datasets//metadata/built-in") class DatasetMetadataBuiltInFieldServiceApi(DatasetApiResource): + @service_api_ns.doc( + summary="Get Built-in Metadata Fields", + description=( + "Returns the list of built-in metadata fields provided by the system (e.g., document type, source URL)." + ), + tags=["Metadata"], + responses={ + 200: "Built-in metadata fields.", + }, + ) @service_api_ns.doc("get_built_in_fields") @service_api_ns.doc(description="Get all built-in metadata fields") + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Built-in fields retrieved successfully", @@ -173,9 +231,17 @@ class DatasetMetadataBuiltInFieldServiceApi(DatasetApiResource): @service_api_ns.route("/datasets//metadata/built-in/") class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource): + @service_api_ns.doc( + summary="Update Built-in Metadata Field", + description="Enable or disable built-in metadata fields for the knowledge base.", + tags=["Metadata"], + responses={ + 200: "Built-in metadata field toggled successfully.", + }, + ) @service_api_ns.doc("toggle_built_in_field") @service_api_ns.doc(description="Enable or disable built-in metadata field") - @service_api_ns.doc(params={"dataset_id": "Dataset ID", "action": "Action to perform: 'enable' or 'disable'"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "action": BUILT_IN_METADATA_ACTION_PARAM}) @service_api_ns.doc( responses={ 200: "Action completed successfully", @@ -205,10 +271,21 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource): @service_api_ns.route("/datasets//documents/metadata") class DocumentMetadataEditServiceApi(DatasetApiResource): + @service_api_ns.doc( + summary="Update Document Metadata in Batch", + description=( + "Update metadata values for multiple documents at once. Each document in the request " + "receives the specified metadata key-value pairs." + ), + tags=["Metadata"], + responses={ + 200: "Document metadata updated successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[MetadataOperationData.__name__]) @service_api_ns.doc("update_documents_metadata") @service_api_ns.doc(description="Update metadata for multiple documents") - @service_api_ns.doc(params={"dataset_id": "Dataset ID"}) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Documents metadata updated successfully", 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 a1a8b588c42..a6a61262cdc 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 @@ -19,6 +19,11 @@ from controllers.common.schema import ( from controllers.service_api import service_api_ns from controllers.service_api.dataset.error import PipelineRunError from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file +from controllers.service_api.schema import ( + event_stream_response, + json_or_event_stream_response, + multipart_file_params, +) from controllers.service_api.wraps import DatasetApiResource from core.app.apps.pipeline.pipeline_generator import PipelineGenerator from core.app.entities.app_invoke_entities import InvokeFrom @@ -32,6 +37,7 @@ from services.errors.file import FileTooLargeError, UnsupportedFileTypeError from services.file_service import FileService from services.rag_pipeline.entity.pipeline_service_api_entities import ( DatasourceNodeRunApiEntity, + DatasourceType, PipelineRunApiEntity, ) from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService @@ -39,14 +45,27 @@ from services.rag_pipeline.rag_pipeline import RagPipelineService class DatasourceNodeRunPayload(BaseModel): - inputs: dict[str, Any] - datasource_type: str - credential_id: str | None = None - is_published: bool + inputs: dict[str, Any] = Field(description="Input variables for the datasource node.") + datasource_type: DatasourceType = Field(description="Type of the datasource.") + credential_id: str | None = Field( + default=None, description="Datasource credential ID. Uses the default if omitted." + ) + is_published: bool = Field( + description=( + "Whether to run the published or draft version of the node. `true` runs the published version, " + "`false` runs the draft." + ) + ) class DatasourcePluginsQuery(BaseModel): - is_published: bool = True + is_published: bool = Field( + default=True, + description=( + "Whether to retrieve nodes from the published or draft pipeline. `true` returns nodes from the published " + "version, `false` returns nodes from the draft." + ), + ) class DatasourceCredentialInfoResponse(ResponseModel): @@ -95,13 +114,21 @@ register_response_schema_models( class DatasourcePluginsApi(DatasetApiResource): """Resource for datasource plugins.""" + @service_api_ns.doc( + summary="List Datasource Plugins", + description=( + "List the datasource nodes configured in the knowledge pipeline. Each node includes the " + "plugin it uses plus the metadata needed to run it." + ), + tags=["Knowledge Pipeline"], + responses={ + 200: "List of datasource nodes configured in the pipeline.", + 404: "`not_found` : Dataset not found.", + }, + ) @service_api_ns.doc(shortcut="list_rag_pipeline_datasource_plugins") @service_api_ns.doc(description="List all datasource plugins for a rag pipeline") - @service_api_ns.doc( - path={ - "dataset_id": "Dataset ID", - } - ) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc(params=query_params_from_model(DatasourcePluginsQuery)) @service_api_ns.doc( responses={ @@ -137,13 +164,22 @@ class DatasourcePluginsApi(DatasetApiResource): class DatasourceNodeRunApi(DatasetApiResource): """Resource for datasource node run.""" + @service_api_ns.doc( + summary="Run Datasource Node", + description=( + "Execute a single datasource node within the knowledge pipeline. Returns a streaming " + "response with the node execution results." + ), + tags=["Knowledge Pipeline"], + responses={ + 200: "Streaming response with node execution events.", + 404: "`not_found` : Dataset not found.", + }, + ) + @event_stream_response(service_api_ns) @service_api_ns.doc(shortcut="pipeline_datasource_node_run") @service_api_ns.doc(description="Run a datasource node for a rag pipeline") - @service_api_ns.doc( - path={ - "dataset_id": "Dataset ID", - } - ) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "node_id": "ID of the datasource node to execute."}) @service_api_ns.doc( responses={ 200: "Datasource node run successfully", @@ -195,13 +231,27 @@ class DatasourceNodeRunApi(DatasetApiResource): class PipelineRunApi(DatasetApiResource): """Resource for datasource node run.""" + @service_api_ns.doc( + summary="Run Pipeline", + description=( + "Execute the full knowledge pipeline for a knowledge base. Supports both streaming and " + "blocking response modes." + ), + tags=["Knowledge Pipeline"], + responses={ + 200: ( + "Pipeline execution result. Format depends on `response_mode`: streaming returns a " + "`text/event-stream`, blocking returns a JSON object." + ), + 403: "`forbidden` : Forbidden.", + 404: "`not_found` : Dataset not found.", + 500: "`pipeline_run_error` : Pipeline execution failed.", + }, + ) + @json_or_event_stream_response(service_api_ns) @service_api_ns.doc(shortcut="pipeline_datasource_node_run") @service_api_ns.doc(description="Run a datasource node for a rag pipeline") - @service_api_ns.doc( - path={ - "dataset_id": "Dataset ID", - } - ) + @service_api_ns.doc(params={"dataset_id": "Knowledge base ID."}) @service_api_ns.doc( responses={ 200: "Pipeline run successfully", @@ -248,8 +298,24 @@ class PipelineRunApi(DatasetApiResource): class KnowledgebasePipelineFileUploadApi(DatasetApiResource): """Resource for uploading a file to a knowledgebase pipeline.""" + @service_api_ns.doc( + summary="Upload Pipeline File", + description="Upload a file for use in a knowledge pipeline. Accepts a single file via `multipart/form-data`.", + tags=["Knowledge Pipeline"], + responses={ + 201: "File uploaded successfully.", + 400: ( + "- `no_file_uploaded` : Please upload your file.\n" + "- `filename_not_exists_error` : The specified filename does not exist.\n" + "- `too_many_files` : Only one file is allowed." + ), + 413: "`file_too_large` : File size exceeded.", + 415: "`unsupported_file_type` : File type not allowed.", + }, + ) @service_api_ns.doc(shortcut="knowledgebase_pipeline_file_upload") @service_api_ns.doc(description="Upload a file to a knowledgebase pipeline") + @service_api_ns.doc(consumes=["multipart/form-data"], params=multipart_file_params(include_user=False)) @service_api_ns.doc( responses={ 201: "File uploaded successfully", diff --git a/api/controllers/service_api/dataset/segment.py b/api/controllers/service_api/dataset/segment.py index f93eb6a4bf1..dd8d7c76632 100644 --- a/api/controllers/service_api/dataset/segment.py +++ b/api/controllers/service_api/dataset/segment.py @@ -47,10 +47,10 @@ from services.summary_index_service import SummaryIndexService class SegmentCreateItemPayload(BaseModel): - content: str = Field(min_length=1) - answer: str | None = None - keywords: list[str] | None = None - attachment_ids: list[str] | None = None + content: str = Field(min_length=1, description="Chunk text content.") + answer: str | None = Field(default=None, description="Answer content for QA mode.") + keywords: list[str] | None = Field(default=None, description="Keywords for the chunk.") + attachment_ids: list[str] | None = Field(default=None, description="Attachment file IDs.") @field_validator("content") @classmethod @@ -61,31 +61,34 @@ class SegmentCreateItemPayload(BaseModel): class SegmentCreatePayload(BaseModel): - segments: list[SegmentCreateItemPayload] = Field(min_length=1) + segments: list[SegmentCreateItemPayload] = Field(min_length=1, description="Array of chunk objects to create.") class SegmentListQuery(BaseModel): - limit: int = Field(default=20, ge=1) - page: int = Field(default=1, ge=1) - status: list[str] = Field(default_factory=list) - keyword: str | None = None + limit: int = Field(default=20, ge=1, description="Number of items per page. Server caps at `100`.") + page: int = Field(default=1, ge=1, description="Page number to retrieve.") + status: list[str] = Field( + default_factory=list, + description="Filter chunks by indexing status, such as `completed`, `indexing`, or `error`.", + ) + keyword: str | None = Field(default=None, description="Search keyword.") class SegmentUpdatePayload(BaseModel): - segment: SegmentUpdateArgs + segment: SegmentUpdateArgs = Field(description="Chunk update payload.") class ChildChunkListQuery(BaseModel): - limit: int = Field(default=20, ge=1) - keyword: str | None = None - page: int = Field(default=1, ge=1) + limit: int = Field(default=20, ge=1, description="Number of items per page. Server caps at `100`.") + keyword: str | None = Field(default=None, description="Search keyword.") + page: int = Field(default=1, ge=1, description="Page number to retrieve.") class SegmentDocParams: - DATASET_DOCUMENT = {"dataset_id": "Dataset ID", "document_id": "Document ID"} - DATASET_DOCUMENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Segment ID"} - DATASET_DOCUMENT_PARENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Parent segment ID"} - DATASET_DOCUMENT_CHILD_CHUNK = {**DATASET_DOCUMENT_PARENT_SEGMENT, "child_chunk_id": "Child chunk ID"} + DATASET_DOCUMENT = {"dataset_id": "Knowledge base ID.", "document_id": "Document ID."} + DATASET_DOCUMENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Chunk ID."} + DATASET_DOCUMENT_PARENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Chunk ID."} + DATASET_DOCUMENT_CHILD_CHUNK = {**DATASET_DOCUMENT_PARENT_SEGMENT, "child_chunk_id": "Child chunk ID."} class SegmentCreateListResponse(ResponseModel): @@ -128,6 +131,18 @@ register_response_schema_models( class SegmentApi(DatasetApiResource): """Resource for segments.""" + @service_api_ns.doc( + summary="Create Chunks", + description=( + "Create one or more chunks within a document. Each chunk can include optional keywords and an " + "answer field (for QA-mode documents)." + ), + tags=["Chunks"], + responses={ + 200: "Chunks created successfully.", + 404: "`not_found` : Document is not completed or is disabled.", + }, + ) @service_api_ns.expect(service_api_ns.models[SegmentCreatePayload.__name__]) @service_api_ns.doc("create_segments") @service_api_ns.doc(description="Create segments in a document") @@ -209,6 +224,14 @@ class SegmentApi(DatasetApiResource): } return dump_response(SegmentCreateListResponse, response), 200 + @service_api_ns.doc( + summary="List Chunks", + description="Returns a paginated list of chunks within a document. Supports filtering by keyword and status.", + tags=["Chunks"], + responses={ + 200: "List of chunks.", + }, + ) @service_api_ns.doc("list_segments") @service_api_ns.doc(description="List segments in a document") @service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT) @@ -294,6 +317,14 @@ class SegmentApi(DatasetApiResource): @service_api_ns.route("/datasets//documents//segments/") class DatasetSegmentApi(DatasetApiResource): + @service_api_ns.doc( + summary="Delete Chunk", + description="Permanently delete a chunk from the document.", + tags=["Chunks"], + responses={ + 204: "Success.", + }, + ) @service_api_ns.doc("delete_segment") @service_api_ns.doc(description="Delete a specific segment") @service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_SEGMENT) @@ -329,6 +360,14 @@ class DatasetSegmentApi(DatasetApiResource): SegmentService.delete_segment(segment, document, dataset) return "", 204 + @service_api_ns.doc( + summary="Update Chunk", + description="Update a chunk's content, keywords, or answer. Re-triggers indexing for the modified chunk.", + tags=["Chunks"], + responses={ + 200: "Chunk updated successfully.", + }, + ) @service_api_ns.expect(service_api_ns.models[SegmentUpdatePayload.__name__]) @service_api_ns.doc("update_segment") @service_api_ns.doc(description="Update a specific segment") @@ -391,6 +430,17 @@ class DatasetSegmentApi(DatasetApiResource): } return dump_response(SegmentDetailResponse, response), 200 + @service_api_ns.doc( + summary="Get Chunk", + description=( + "Retrieve detailed information about a specific chunk, including its content, keywords, and " + "indexing status." + ), + tags=["Chunks"], + responses={ + 200: "Chunk details.", + }, + ) @service_api_ns.doc("get_segment") @service_api_ns.doc(description="Get a specific segment by ID") @service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_SEGMENT) @@ -442,6 +492,15 @@ class DatasetSegmentApi(DatasetApiResource): class ChildChunkApi(DatasetApiResource): """Resource for child chunks.""" + @service_api_ns.doc( + summary="Create Child Chunk", + description="Create a child chunk under the specified segment.", + tags=["Chunks"], + responses={ + 200: "Child chunk created successfully.", + 400: "`invalid_param` : Create child chunk index failed.", + }, + ) @service_api_ns.expect(service_api_ns.models[ChildChunkCreatePayload.__name__]) @service_api_ns.doc("create_child_chunk") @service_api_ns.doc(description="Create a new child chunk for a segment") @@ -511,6 +570,14 @@ class ChildChunkApi(DatasetApiResource): return dump_response(ChildChunkDetailResponse, {"data": child_chunk}), 200 + @service_api_ns.doc( + summary="List Child Chunks", + description="Returns a paginated list of child chunks under a specific parent chunk.", + tags=["Chunks"], + responses={ + 200: "List of child chunks.", + }, + ) @service_api_ns.doc("list_child_chunks") @service_api_ns.doc(description="List child chunks for a segment") @service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_PARENT_SEGMENT) @@ -576,6 +643,15 @@ class ChildChunkApi(DatasetApiResource): class DatasetChildChunkApi(DatasetApiResource): """Resource for updating child chunks.""" + @service_api_ns.doc( + summary="Delete Child Chunk", + description="Permanently delete a child chunk from its parent chunk.", + tags=["Chunks"], + responses={ + 204: "Success.", + 400: "`invalid_param` : Delete child chunk index failed.", + }, + ) @service_api_ns.doc("delete_child_chunk") @service_api_ns.doc(description="Delete a specific child chunk") @service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_CHILD_CHUNK) @@ -634,6 +710,15 @@ class DatasetChildChunkApi(DatasetApiResource): return "", 204 + @service_api_ns.doc( + summary="Update Child Chunk", + description="Update the content of an existing child chunk.", + tags=["Chunks"], + responses={ + 200: "Child chunk updated successfully.", + 400: "`invalid_param` : Update child chunk index failed.", + }, + ) @service_api_ns.expect(service_api_ns.models[ChildChunkUpdatePayload.__name__]) @service_api_ns.doc("update_child_chunk") @service_api_ns.doc(description="Update a specific child chunk") diff --git a/api/controllers/service_api/end_user/end_user.py b/api/controllers/service_api/end_user/end_user.py index d2d018492a5..607ed12e5b6 100644 --- a/api/controllers/service_api/end_user/end_user.py +++ b/api/controllers/service_api/end_user/end_user.py @@ -17,6 +17,18 @@ register_response_schema_models(service_api_ns, EndUserDetail) class EndUserApi(Resource): """Resource for retrieving end user details by ID.""" + @service_api_ns.doc( + summary="Get End User Info", + description=( + "Retrieve an end user by ID. Useful when other APIs return an end-user ID (e.g., " + "`created_by` from [Upload File](/api-reference/files/upload-file))." + ), + tags=["End Users"], + responses={ + 200: "End user retrieved successfully.", + 404: "`end_user_not_found` : End user not found.", + }, + ) @service_api_ns.doc("get_end_user") @service_api_ns.doc(description="Get an end user by ID") @service_api_ns.doc( diff --git a/api/controllers/service_api/schema.py b/api/controllers/service_api/schema.py new file mode 100644 index 00000000000..87f8aa54287 --- /dev/null +++ b/api/controllers/service_api/schema.py @@ -0,0 +1,167 @@ +"""Service API OpenAPI documentation helpers. + +These helpers keep documentation-only request shapes next to controller +definitions without changing the Pydantic models used for runtime validation. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from copy import deepcopy +from typing import Annotated, Any, cast + +from flask_restx import Namespace +from pydantic import BaseModel, WithJsonSchema + +USER_DESCRIPTION = ( + "User identifier, unique within the application. This identifier scopes data access; resources created with " + "one `user` value are only visible when queried with the same `user` value." +) +USER_PROPERTY_SCHEMA: dict[str, object] = {"description": USER_DESCRIPTION, "type": "string"} +USER_QUERY_PARAM: dict[str, object] = { + "description": "User identifier, used for end-user context.", + "in": "query", + "type": "string", +} +USER_FORM_PARAM: dict[str, object] = { + "description": USER_DESCRIPTION, + "in": "formData", + "type": "string", +} +FILE_FORM_PARAM: dict[str, object] = { + "description": "The file to upload.", + "in": "formData", + "required": True, + "type": "file", +} +USER_FETCH_FROM_ATTR = "_dify_service_api_user_fetch_from" +USER_REQUIRED_ATTR = "_dify_service_api_user_required" +JSON_USER_FETCH_FROM = "JSON" + +INPUT_FILE_ITEM_SCHEMA: dict[str, object] = { + "type": "object", + "required": ["type", "transfer_method"], + "properties": { + "type": { + "description": "File type.", + "enum": ["document", "image", "audio", "video", "custom"], + "type": "string", + }, + "transfer_method": { + "description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.", + "enum": ["remote_url", "local_file"], + "type": "string", + }, + "url": { + "description": "File URL when `transfer_method` is `remote_url`.", + "format": "url", + "type": "string", + }, + "upload_file_id": { + "description": ( + "Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when " + "`transfer_method` is `local_file`." + ), + "type": "string", + }, + }, +} +INPUT_FILE_LIST_SCHEMA: dict[str, object] = { + "anyOf": [{"items": INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}] +} +InputFileList = Annotated[list[dict[str, Any]] | None, WithJsonSchema(INPUT_FILE_LIST_SCHEMA)] + + +def expect_with_user(namespace: Namespace, model: type[BaseModel]): + """Document a JSON request body as ``model`` plus Service API ``user``.""" + + source_model = namespace.models[model.__name__] + model_name = f"{model.__name__}WithUser" + + def decorator(view_func): + required = _json_user_required(view_func) + schema = cast(dict[str, object], deepcopy(source_model.__schema__)) + _add_user_property(schema, required=required) + if model_name not in namespace.models: + namespace.schema_model(model_name, schema) + return namespace.expect(namespace.models[model_name], validate=False)(view_func) + + return decorator + + +def expect_user_json(namespace: Namespace): + """Document a JSON request body that only carries the Service API ``user``.""" + + def decorator(view_func): + required = _json_user_required(view_func) + schema: dict[str, object] = {"properties": {}, "title": "ServiceApiUserPayload", "type": "object"} + _add_user_property(schema, required=required) + model_name = "RequiredServiceApiUserPayload" if required else "OptionalServiceApiUserPayload" + if model_name not in namespace.models: + namespace.schema_model(model_name, schema) + return namespace.expect(namespace.models[model_name], validate=False)(view_func) + + return decorator + + +def multipart_file_params(*, include_user: bool, file_description: str | None = None) -> dict[str, dict[str, object]]: + file_param = deepcopy(FILE_FORM_PARAM) + if file_description is not None: + file_param["description"] = file_description + + params: dict[str, dict[str, object]] = {"file": file_param} + if include_user: + params["user"] = USER_FORM_PARAM + return deepcopy(params) + + +def json_or_event_stream_response(namespace: Namespace): + return namespace.doc(produces=["application/json", "text/event-stream"]) + + +def event_stream_response(namespace: Namespace): + return namespace.doc(produces=["text/event-stream"]) + + +def binary_response(namespace: Namespace, media_type: str | Sequence[str]): + media_types = [media_type] if isinstance(media_type, str) else list(media_type) + return namespace.doc(produces=media_types) + + +def _json_user_required(view_func) -> bool: + fetch_from = getattr(view_func, USER_FETCH_FROM_ATTR, None) + if fetch_from != JSON_USER_FETCH_FROM: + raise ValueError("JSON user documentation must match validate_app_token(fetch_user_arg=WhereisUserArg.JSON)") + + return bool(getattr(view_func, USER_REQUIRED_ATTR, False)) + + +def _add_user_property(schema: dict[str, object], *, required: bool) -> None: + variants: list[dict[str, object]] = [] + for keyword in ("anyOf", "oneOf"): + candidates = schema.get(keyword) + if isinstance(candidates, list): + variants.extend(candidate for candidate in candidates if isinstance(candidate, dict)) + + if variants: + for variant in variants: + _add_user_property_to_object_schema(variant, required=required) + + _add_user_property_to_object_schema(schema, required=required) + + +def _add_user_property_to_object_schema(schema: dict[str, object], *, required: bool) -> None: + properties = schema.setdefault("properties", {}) + if isinstance(properties, dict): + cast(dict[str, object], properties)["user"] = USER_PROPERTY_SCHEMA + + if required: + required_fields = schema.setdefault("required", []) + if isinstance(required_fields, list) and "user" not in required_fields: + required_fields.append("user") + else: + required_fields = schema.get("required") + if isinstance(required_fields, list) and "user" in required_fields: + required_fields.remove("user") + if required_fields == []: + schema.pop("required", None) diff --git a/api/controllers/service_api/workspace/models.py b/api/controllers/service_api/workspace/models.py index 63806ab252f..c2ea1a72cf5 100644 --- a/api/controllers/service_api/workspace/models.py +++ b/api/controllers/service_api/workspace/models.py @@ -9,6 +9,12 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder from services.entities.model_provider_entities import ProviderWithModelsResponse from services.model_provider_service import ModelProviderService +MODEL_TYPE_PARAM = { + "description": "Type of model to retrieve.", + "enum": ["text-embedding", "rerank", "llm", "tts", "speech2text", "moderation"], + "type": "string", +} + class ProviderWithModelsListResponse(ResponseModel): data: list[ProviderWithModelsResponse] @@ -19,9 +25,20 @@ register_response_schema_models(service_api_ns, ProviderWithModelsListResponse) @service_api_ns.route("/workspaces/current/models/model-types/") class ModelProviderAvailableModelApi(Resource): + @service_api_ns.doc( + summary="Get Available Models", + description=( + "Retrieve the list of available models by type. Primarily used to query `text-embedding` and " + "`rerank` models for knowledge base configuration." + ), + tags=["Models"], + responses={ + 200: "Available models for the specified type.", + }, + ) @service_api_ns.doc("get_available_models") @service_api_ns.doc(description="Get available models by model type") - @service_api_ns.doc(params={"model_type": "Type of model to retrieve"}) + @service_api_ns.doc(params={"model_type": MODEL_TYPE_PARAM}) @service_api_ns.doc( responses={ 200: "Models retrieved successfully", diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 013ea34a6ab..32e95b481f8 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -4,16 +4,23 @@ import time from collections.abc import Callable from enum import StrEnum, auto from functools import wraps -from typing import cast, overload +from typing import Protocol, cast, overload from flask import current_app, request from flask_login import user_logged_in from flask_restx import Resource +from flask_restx.utils import merge from pydantic import BaseModel from sqlalchemy import select from werkzeug.exceptions import Forbidden, NotFound, Unauthorized from configs import dify_config +from controllers.service_api.schema import ( + USER_FETCH_FROM_ATTR, + USER_FORM_PARAM, + USER_QUERY_PARAM, + USER_REQUIRED_ATTR, +) from enums.cloud_plan import CloudPlan from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -28,6 +35,12 @@ from services.feature_service import FeatureService logger = logging.getLogger(__name__) +class _RestxDocumentedView(Protocol): + """Callable view object carrying Flask-RESTX documentation metadata.""" + + __apidoc__: dict[str, object] + + class WhereisUserArg(StrEnum): """ Enum for whereis_user_arg. @@ -43,6 +56,35 @@ class FetchUserArg(BaseModel): required: bool = False +APP_TOKEN_FORBIDDEN_RESPONSE = { + 403: "Forbidden - token scope, app, dataset, or workspace access denied", +} + +DATASET_TOKEN_AUTH_RESPONSES = { + 401: "Unauthorized - invalid API token", + 403: "Forbidden - dataset API access or workspace access denied", +} + + +def _document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None: + doc: dict[str, object] = {"responses": APP_TOKEN_FORBIDDEN_RESPONSE} + if fetch_user_arg is not None: + setattr(view_func, USER_FETCH_FROM_ATTR, fetch_user_arg.fetch_from.name) + setattr(view_func, USER_REQUIRED_ATTR, fetch_user_arg.required) + match fetch_user_arg.fetch_from: + case WhereisUserArg.QUERY: + doc["params"] = {"user": {**USER_QUERY_PARAM, "required": fetch_user_arg.required}} + case WhereisUserArg.FORM: + doc["params"] = {"user": {**USER_FORM_PARAM, "required": fetch_user_arg.required}} + case WhereisUserArg.JSON: + pass + + cast(_RestxDocumentedView, view_func).__apidoc__ = cast( + dict[str, object], + merge(getattr(view_func, "__apidoc__", {}), doc), + ) + + @overload def validate_app_token[**P, R](view: Callable[P, R]) -> Callable[P, R]: ... @@ -126,6 +168,7 @@ def validate_app_token[**P, R]( return view_func(*args, **kwargs) + _document_app_token_contract(decorated_view, fetch_user_arg) return decorated_view if view is None: @@ -343,6 +386,8 @@ def validate_and_get_api_token(scope: str | None = None): class DatasetApiResource(Resource): + __apidoc__ = {"responses": DATASET_TOKEN_AUTH_RESPONSES} + method_decorators = [validate_dataset_token] def get_dataset(self, dataset_id: str, tenant_id: str) -> Dataset: diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 00439ffca4e..99b75776280 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -17,6 +17,7 @@ from controllers.web.error import WebAppAuthRequiredError from extensions.ext_database import db from libs.passport import PassportService from libs.token import extract_webapp_access_token +from models.enums import EndUserType from models.model import App, EndUser, Site from services.feature_service import FeatureService from services.webapp_auth_service import WebAppAuthService, WebAppAuthType @@ -82,7 +83,7 @@ class PassportResource(Resource): end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=user_id, ) @@ -92,7 +93,7 @@ class PassportResource(Resource): end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=generate_session_id(), ) @@ -181,7 +182,7 @@ def exchange_token_for_existing_web_user( end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=session_id, ) @@ -225,7 +226,7 @@ def _exchange_for_public_app_token(app_model, site, token_decoded): end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=generate_session_id(), ) diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py index e3baa028e50..6e59a85e2b0 100644 --- a/api/controllers/web/saved_message.py +++ b/api/controllers/web/saved_message.py @@ -9,6 +9,7 @@ from controllers.common.schema import query_params_from_model, register_response from controllers.web import web_ns from controllers.web.error import NotCompletionAppError from controllers.web.wraps import WebApiResource +from extensions.ext_database import db from fields.conversation_fields import ResultResponse from fields.message_fields import SavedMessageInfiniteScrollPagination, SavedMessageItem from models.model import App, EndUser @@ -42,7 +43,9 @@ class SavedMessageListApi(WebApiResource): raw_args = request.args.to_dict() query = SavedMessageListQuery.model_validate(raw_args) - pagination = SavedMessageService.pagination_by_last_id(app_model, end_user, query.last_id, query.limit) + pagination = SavedMessageService.pagination_by_last_id( + db.session(), app_model, end_user, query.last_id, query.limit + ) adapter = TypeAdapter(SavedMessageItem) items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] return SavedMessageInfiniteScrollPagination( @@ -77,7 +80,7 @@ class SavedMessageListApi(WebApiResource): payload = SavedMessageCreatePayload.model_validate(web_ns.payload or {}) try: - SavedMessageService.save(app_model, end_user, payload.message_id) + SavedMessageService.save(db.session(), app_model, end_user, payload.message_id) except MessageNotExistsError: raise NotFound("Message Not Exists.") @@ -105,6 +108,6 @@ class SavedMessageApi(WebApiResource): if app_model.mode != "completion": raise NotCompletionAppError() - SavedMessageService.delete(app_model, end_user, message_id_str) + SavedMessageService.delete(db.session(), app_model, end_user, message_id_str) return "", 204 diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index 694d6331483..55a31563d69 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -118,7 +118,7 @@ class BaseAgentRunner(AppRunner): features = model_schema.features if model_schema and model_schema.features else [] self.stream_tool_call = ModelFeature.STREAM_TOOL_CALL in features self.files = application_generate_entity.files if ModelFeature.VISION in features else [] - self.query: str | None = "" + self.query: str = "" self._current_thoughts: list[PromptMessage] = [] def _repack_app_generate_entity( diff --git a/api/core/app/app_config/easy_ui_based_app/dataset/manager.py b/api/core/app/app_config/easy_ui_based_app/dataset/manager.py index 3d857a4e9c0..be538455afb 100644 --- a/api/core/app/app_config/easy_ui_based_app/dataset/manager.py +++ b/api/core/app/app_config/easy_ui_based_app/dataset/manager.py @@ -213,6 +213,11 @@ class DatasetConfigManager: PlanningStrategy.REACT_ROUTER, }: for tool in config.get("agent_mode", {}).get("tools", []): + if not tool: + # Skip malformed empty tool entries; list(tool.keys())[0] + # would otherwise raise IndexError. The sibling convert() + # already guards this with `if len(tool) == 1`. + continue key = list(tool.keys())[0] if key == "dataset": # old style, use tool name as key diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 92c75276e02..f816b1fa477 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -72,8 +72,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): query = query.replace("\x00", "") inputs = args["inputs"] - # Resolve the bound roster Agent + its published Agent Soul snapshot. + # Resolve the bound roster Agent + its current Agent Soul snapshot. agent, snapshot, agent_soul = self._resolve_agent(app_model) + runtime_session_snapshot_id = self._runtime_session_snapshot_id( + invoke_from=invoke_from, + snapshot_id=snapshot.id, + ) conversation = None conversation_id = args.get("conversation_id") @@ -120,6 +124,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): trace_manager=trace_manager, agent_id=agent.id, agent_config_snapshot_id=snapshot.id, + agent_runtime_session_snapshot_id=runtime_session_snapshot_id, ) conversation, message = self._init_generate_records(application_generate_entity, conversation) @@ -341,6 +346,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): message_id=message.id, model_name=application_generate_entity.model_conf.model, queue_manager=queue_manager, + session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id, ) except GenerateTaskStoppedError: pass @@ -373,7 +379,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): app_config = application_generate_entity.app_config model_name = application_generate_entity.model_conf.model - query = application_generate_entity.query + query = application_generate_entity.query or "" # content moderation (sensitive_word_avoidance); a blocked input yields a # preset answer, an "overridden" action returns a sanitized query. @@ -388,7 +394,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): trace_manager=application_generate_entity.trace_manager, ) except ModerationError as e: - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e)) + publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query) return True, query # annotation reply: a matching annotation answers the turn deterministically. @@ -405,7 +411,12 @@ class AgentAppGenerator(MessageBasedAppGenerator): QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id), PublishFrom.APPLICATION_MANAGER, ) - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content) + publish_text_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=annotation_reply.content, + user_query=query, + ) return True, query return False, query @@ -425,6 +436,21 @@ class AgentAppGenerator(MessageBasedAppGenerator): tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id ) + @staticmethod + def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None: + """Return the session scope snapshot id for Agent App runtime state. + + Console preview/debug chat is an editing workspace: saving Agent Soul + creates replacement snapshots, but the user expects the same preview + conversation to keep context while trying prompt changes. Use a stable + NULL snapshot scope for debugger runs so each turn can use the latest + Agent Soul while reusing the conversation history. Published/web/API + runs keep snapshot-scoped sessions for reproducible runtime state. + """ + if invoke_from == InvokeFrom.DEBUGGER: + return None + return snapshot_id + @staticmethod def _resolve_agent_by_id( *, tenant_id: str, agent_id: str, snapshot_id: str | None diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index d4d2a754bfa..b482afcdf43 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -46,13 +46,32 @@ from core.repositories.human_input_repository import HumanInputFormRepository, H from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage -from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage from models.agent_config_entities import AgentSoulConfig logger = logging.getLogger(__name__) -def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None: +class _DefaultSessionScopeSnapshotId: + pass + + +_DEFAULT_SESSION_SCOPE_SNAPSHOT_ID = _DefaultSessionScopeSnapshotId() + + +def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]: + if not user_query: + return [] + return [UserPromptMessage(content=user_query)] + + +def publish_text_answer( + *, + queue_manager: AppQueueManager, + model_name: str, + answer: str, + user_query: str | None = None, +) -> None: """Publish a complete assistant answer as one chunk + message-end. The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed @@ -60,17 +79,53 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ both the backend-produced answer and short-circuited answers (moderation / annotation reply) share the exact same persistence + SSE path. """ + publish_text_delta( + queue_manager=queue_manager, + model_name=model_name, + delta=answer, + user_query=user_query, + ) + publish_message_end( + queue_manager=queue_manager, + model_name=model_name, + answer=answer, + user_query=user_query, + ) + + +def publish_text_delta( + *, + queue_manager: AppQueueManager, + model_name: str, + delta: str, + user_query: str | None = None, +) -> None: + """Publish one assistant text delta through the EasyUI chat pipeline.""" + if not delta: + return + prompt_messages = _prompt_messages_from_query(user_query) chunk = LLMResultChunk( model=model_name, - prompt_messages=[], - delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)), + prompt_messages=prompt_messages, + delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=delta)), ) queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER) + + +def publish_message_end( + *, + queue_manager: AppQueueManager, + model_name: str, + answer: str, + user_query: str | None = None, +) -> None: + """Publish the terminal assistant result without emitting another delta.""" + prompt_messages = _prompt_messages_from_query(user_query) queue_manager.publish( QueueMessageEndEvent( llm_result=LLMResult( model=model_name, - prompt_messages=[], + prompt_messages=prompt_messages, message=AssistantPromptMessage(content=answer), usage=LLMUsage.empty_usage(), ), @@ -107,13 +162,18 @@ class AgentAppRunner: message_id: str, model_name: str, queue_manager: AppQueueManager, + session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID, ) -> None: + if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId): + effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id + else: + effective_session_scope_snapshot_id = session_scope_snapshot_id scope = AgentAppSessionScope( tenant_id=dify_context.tenant_id, app_id=dify_context.app_id, conversation_id=conversation_id, agent_id=agent_id, - agent_config_snapshot_id=agent_config_snapshot_id, + agent_config_snapshot_id=effective_session_scope_snapshot_id, ) # ENG-638: if a prior turn paused on ask_human and the form is now answered, # resume by threading the human's reply into this run as deferred_tool_results. @@ -138,7 +198,12 @@ class AgentAppRunner: ) create_response = self._agent_backend_client.create_run(runtime.request) - terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager) + terminal, streamed_answer = self._consume_stream( + create_response.run_id, + queue_manager=queue_manager, + model_name=model_name, + query=query, + ) if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent): # ENG-635: the agent asked a human. End this turn with the question and @@ -153,6 +218,7 @@ class AgentAppRunner: model_name=model_name, runtime=runtime, queue_manager=queue_manager, + query=query, ) return @@ -161,7 +227,13 @@ class AgentAppRunner: raise AgentBackendError(str(error)) answer = self._extract_answer(terminal.output) - self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) + self._publish_terminal_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=answer, + query=query, + streamed_answer=streamed_answer, + ) self._save_session( scope=scope, backend_run_id=terminal.run_id, @@ -181,6 +253,7 @@ class AgentAppRunner: model_name: str, runtime: AgentAppRuntimeRequest, queue_manager: AppQueueManager, + query: str, ) -> None: """End the chat turn on a dify.ask_human call: create a conversation-owned HITL form, persist the pause correlation, and surface the question.""" @@ -214,6 +287,7 @@ class AgentAppRunner: queue_manager=queue_manager, model_name=model_name, answer=self._ask_human_message(created.args), + query=query, ) def _resolve_pending_ask_human( @@ -256,8 +330,16 @@ class AgentAppRunner: parts.append(args.markdown) return "\n\n".join(parts) - def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager): + def _consume_stream( + self, + run_id: str, + *, + queue_manager: AppQueueManager, + model_name: str, + query: str | None, + ): terminal = None + streamed_answer_parts: list[str] = [] for public_event in self._agent_backend_client.stream_events(run_id): if queue_manager.is_stopped(): self._cancel_run(run_id) @@ -270,16 +352,23 @@ class AgentAppRunner: AgentBackendInternalEventType.RUN_STARTED, AgentBackendInternalEventType.STREAM_EVENT, ): - # Stream deltas are accumulated by the backend into the - # terminal output; token-level forwarding is an S3 refinement. if isinstance(internal_event, AgentBackendStreamInternalEvent): + text_delta = self._extract_stream_text_delta(internal_event) + if text_delta: + streamed_answer_parts.append(text_delta) + publish_text_delta( + queue_manager=queue_manager, + model_name=model_name, + delta=text_delta, + user_query=query, + ) continue continue terminal = internal_event break if terminal is not None: break - return terminal + return terminal, "".join(streamed_answer_parts) def _cancel_run(self, run_id: str) -> None: try: @@ -287,10 +376,41 @@ class AgentAppRunner: except Exception: logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True) - def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None: + def _publish_answer( + self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None + ) -> None: # MVP: emit the full answer as a single chunk + message-end. The chat # task pipeline streams the chunk over SSE and persists the message. - publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer) + publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query) + + def _publish_terminal_answer( + self, + *, + queue_manager: AppQueueManager, + model_name: str, + answer: str, + query: str | None, + streamed_answer: str, + ) -> None: + """Finish a successful streamed turn without duplicating the final text.""" + if not streamed_answer: + self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query) + return + + if answer.startswith(streamed_answer): + publish_text_delta( + queue_manager=queue_manager, + model_name=model_name, + delta=answer[len(streamed_answer) :], + user_query=query, + ) + elif answer != streamed_answer: + logger.warning( + "Agent App streamed answer does not match terminal output; " + "using terminal output for message persistence." + ) + + publish_message_end(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query) def _save_session( self, @@ -339,5 +459,27 @@ class AgentAppRunner: return json.dumps(output, ensure_ascii=False) return json.dumps(output, ensure_ascii=False) + @staticmethod + def _extract_stream_text_delta(event: AgentBackendStreamInternalEvent) -> str | None: + data = event.data + if not isinstance(data, dict): + return None -__all__ = ["AgentAppRunner", "publish_text_answer"] + if data.get("event_kind") == "part_delta": + delta = data.get("delta") + if isinstance(delta, dict) and delta.get("part_delta_kind") == "text": + content_delta = delta.get("content_delta") + if isinstance(content_delta, str): + return content_delta + + if data.get("event_kind") == "part_start": + part = data.get("part") + if isinstance(part, dict) and part.get("part_kind") == "text": + content = part.get("content") + if isinstance(content, str): + return content + + return None + + +__all__ = ["AgentAppRunner", "publish_message_end", "publish_text_answer", "publish_text_delta"] diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 71cc0385f97..01206b12db6 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -2,8 +2,10 @@ Mirrors the workflow ``WorkflowAgentRuntimeRequestBuilder`` but for the Agent App surface: the user prompt is the chat message (no workflow-node job / no -previous-node context), and multi-turn continuity flows through the -conversation-keyed ``session_snapshot`` plus the history layer. +previous-node context), multi-turn continuity flows through the +conversation-keyed ``session_snapshot`` plus the history layer, and Agent Soul +knowledge config is mapped into the same fixed ``dify.knowledge_base`` layer +used by workflow runs. """ from __future__ import annotations @@ -36,6 +38,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import ( append_runtime_warnings, build_ask_human_layer_config, build_drive_layer_config, + build_knowledge_layer_config, build_shell_layer_config, ) from models.agent_config_entities import AgentSoulConfig @@ -123,6 +126,7 @@ class AgentAppRuntimeRequestBuilder: if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id) append_runtime_warnings(metadata, drive_warnings) + knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_agent_app( AgentBackendAgentAppRunInput( @@ -156,6 +160,7 @@ class AgentAppRuntimeRequestBuilder: or None, user_prompt=context.user_query, tools=tools_layer, + knowledge=knowledge_config, drive_config=drive_config, ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py index 8c68e218d1f..35213114e2f 100644 --- a/api/core/app/apps/agent_app/session_store.py +++ b/api/core/app/apps/agent_app/session_store.py @@ -45,7 +45,7 @@ class AgentAppSessionScope: app_id: str conversation_id: str agent_id: str - agent_config_snapshot_id: str + agent_config_snapshot_id: str | None @dataclass(frozen=True, slots=True) @@ -194,13 +194,15 @@ class AgentAppRuntimeSessionStore: @staticmethod def _scope_stmt(scope: AgentAppSessionScope): - return select(AgentRuntimeSession).where( + stmt = select(AgentRuntimeSession).where( AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION, AgentRuntimeSession.tenant_id == scope.tenant_id, AgentRuntimeSession.conversation_id == scope.conversation_id, AgentRuntimeSession.agent_id == scope.agent_id, - AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id, ) + if scope.agent_config_snapshot_id is None: + return stmt.where(AgentRuntimeSession.agent_config_snapshot_id.is_(None)) + return stmt.where(AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id) @classmethod def _active_stmt(cls, scope: AgentAppSessionScope): diff --git a/api/core/app/apps/base_app_runner.py b/api/core/app/apps/base_app_runner.py index a89a0cf70db..7b854fec34a 100644 --- a/api/core/app/apps/base_app_runner.py +++ b/api/core/app/apps/base_app_runner.py @@ -231,22 +231,23 @@ class AppRunner: :param tenant_id: tenant id for multimodal output :return: """ - if not stream and isinstance(invoke_result, LLMResult): - self._handle_invoke_result_direct( - invoke_result=invoke_result, - queue_manager=queue_manager, - ) - elif stream and isinstance(invoke_result, Generator): - self._handle_invoke_result_stream( - invoke_result=invoke_result, - queue_manager=queue_manager, - agent=agent, - message_id=message_id, - user_id=user_id, - tenant_id=tenant_id, - ) - else: - raise NotImplementedError(f"unsupported invoke result type: {type(invoke_result)}") + match invoke_result: + case LLMResult() if not stream: + self._handle_invoke_result_direct( + invoke_result=invoke_result, + queue_manager=queue_manager, + ) + case _ if stream and isinstance(invoke_result, Generator): + self._handle_invoke_result_stream( + invoke_result=invoke_result, + queue_manager=queue_manager, + agent=agent, + message_id=message_id, + user_id=user_id, + tenant_id=tenant_id, + ) + case _: + raise NotImplementedError(f"unsupported invoke result type: {type(invoke_result)}") def _handle_invoke_result_direct( self, diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 93493a4ad8d..67f37e78ab9 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -888,7 +888,7 @@ class WorkflowResponseConverter: return files @classmethod - def _get_file_var_from_value(cls, value: Union[dict, list]) -> Mapping[str, Any] | None: + def _get_file_var_from_value(cls, value: object) -> Mapping[str, Any] | None: """ Get file var from value :param value: variable value @@ -897,10 +897,11 @@ class WorkflowResponseConverter: if not value: return None - if isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY: - return value - elif isinstance(value, File): - return value.to_dict() + match value: + case dict() if value.get("dify_model_identity") == FILE_MODEL_IDENTITY: + return value + case File(): + return value.to_dict() return None diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 47b950ca086..255740b86a1 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -138,6 +138,10 @@ class PipelineGenerator(BaseAppGenerator): documents: list[Document] = [] if invoke_from == InvokeFrom.PUBLISHED_PIPELINE and not is_retry and not args.get("original_document_id"): from services.dataset_service import DocumentService + from services.feature_service import FeatureService + + features = FeatureService.get_features(pipeline.tenant_id) + DocumentService.check_document_creation_limits(len(datasource_info_list), features) for datasource_info in datasource_info_list: position = DocumentService.get_documents_position(dataset.id) diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 2153289e0e6..f588c98eefe 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -224,6 +224,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity): agent_id: str agent_config_snapshot_id: str + agent_runtime_session_snapshot_id: str | None = None class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity): diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 9cac379b7ea..3a8107e0461 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -241,7 +241,7 @@ class WorkflowFinishStreamResponse(StreamResponse): created_by: Mapping[str, object] = Field(default_factory=dict) created_at: int finished_at: int | None - exceptions_count: int | None = 0 + exceptions_count: int = 0 files: Sequence[Mapping[str, Any]] | None = [] event: StreamEvent = StreamEvent.WORKFLOW_FINISHED diff --git a/api/core/entities/provider_entities.py b/api/core/entities/provider_entities.py index ad9a1f4a02a..4c9cd858e13 100644 --- a/api/core/entities/provider_entities.py +++ b/api/core/entities/provider_entities.py @@ -113,7 +113,7 @@ class CustomModelConfiguration(BaseModel): current_credential_id: str | None = None current_credential_name: str | None = None available_model_credentials: list[CredentialConfiguration] = [] - unadded_to_model_list: bool | None = False + unadded_to_model_list: bool = False # pydantic configs model_config = ConfigDict(protected_namespaces=()) @@ -209,7 +209,7 @@ class ProviderConfig(BasicProviderConfig): required: bool = False default: Union[int, str, float, bool] | None = None options: list[Option] | None = None - multiple: bool | None = False + multiple: bool = False label: I18nObject | None = None help: I18nObject | None = None url: str | None = None diff --git a/api/core/helper/trace_id_helper.py b/api/core/helper/trace_id_helper.py index 82b5f42885a..8b022c1d065 100644 --- a/api/core/helper/trace_id_helper.py +++ b/api/core/helper/trace_id_helper.py @@ -144,15 +144,16 @@ def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str, 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: + match parent_trace_context: + case ParentTraceContext(): + context = parent_trace_context + case Mapping(): + try: + context = ParentTraceContext.model_validate(parent_trace_context) + except ValidationError: + return {} + case _: return {} - else: - return {} if context.parent_node_execution_id is None: return {} diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py index ba305690664..14ed8af3ef7 100644 --- a/api/core/plugin/entities/parameters.py +++ b/api/core/plugin/entities/parameters.py @@ -116,20 +116,21 @@ def cast_parameter_value(typ: StrEnum, value: Any, /): return value if isinstance(value, str) else str(value) case PluginParameterType.BOOLEAN: - if value is None: - return False - elif isinstance(value, str): - # Allowed YAML boolean value strings: https://yaml.org/type/bool.html - # and also '0' for False and '1' for True - match value.lower(): - case "true" | "yes" | "y" | "1": - return True - case "false" | "no" | "n" | "0": - return False - case _: - return bool(value) - else: - return value if isinstance(value, bool) else bool(value) + match value: + case None: + return False + case str(): + # Allowed YAML boolean value strings: https://yaml.org/type/bool.html + # and also '0' for False and '1' for True + match value.lower(): + case "true" | "yes" | "y" | "1": + return True + case "false" | "no" | "n" | "0": + return False + case _: + return bool(value) + case _: + return value if isinstance(value, bool) else bool(value) case PluginParameterType.NUMBER: match value: diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index d47dac9eaf5..fecad81c032 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -73,7 +73,7 @@ class RequestInvokeLLM(BaseRequestInvokeModel): prompt_messages: list[PromptMessage] = Field(default_factory=list) tools: list[PromptMessageTool] | None = Field(default_factory=list[PromptMessageTool]) stop: list[str] | None = Field(default_factory=list[str]) - stream: bool | None = False + stream: bool = False model_config = ConfigDict(protected_namespaces=()) diff --git a/api/core/plugin/impl/base.py b/api/core/plugin/impl/base.py index 7a74b89cf51..6977f643859 100644 --- a/api/core/plugin/impl/base.py +++ b/api/core/plugin/impl/base.py @@ -20,6 +20,7 @@ from core.plugin.impl.exc import ( PluginDaemonNotFoundError, PluginDaemonUnauthorizedError, PluginInvokeError, + PluginLLMPollingUnsupportedError, PluginNotFoundError, PluginPermissionDeniedError, PluginUniqueIdentifierError, @@ -370,6 +371,10 @@ class BasePluginClient: raise TriggerInvokeError(error_object.get("message")) case EventIgnoreError.__name__: raise EventIgnoreError(description=error_object.get("message")) + # NOTE: current plugin sdk / plugin daemon does not raise exception with + # type `PluginLLMPollingUnsupportedError`. + case PluginLLMPollingUnsupportedError.__name__: + raise PluginLLMPollingUnsupportedError(description=error_object.get("message")) case _: raise PluginInvokeError(description=message) case PluginDaemonInternalServerError.__name__: diff --git a/api/core/plugin/impl/exc.py b/api/core/plugin/impl/exc.py index 9a4f51ef121..abb9f0b1713 100644 --- a/api/core/plugin/impl/exc.py +++ b/api/core/plugin/impl/exc.py @@ -5,6 +5,13 @@ from pydantic import TypeAdapter from extensions.ext_logging import get_request_id +# NOTE: Avoid renaming exception classes in this file, since +# the `_handle_plugin_daemon_error` in api/core/plugin/impl/base.py +# build exception instances based on the class name. +# +# Renaming of exception classes could result in incorrect exception +# being raised. + class PluginDaemonError(Exception): """Base class for all plugin daemon errors.""" @@ -75,6 +82,10 @@ class PluginInvokeError(PluginDaemonClientSideError, ValueError): ) +class PluginLLMPollingUnsupportedError(PluginInvokeError): + """Plugin-backed LLM polling is unavailable for the requested model.""" + + class PluginUniqueIdentifierError(PluginDaemonClientSideError): description: str = "Unique Identifier Error" diff --git a/api/core/plugin/impl/model.py b/api/core/plugin/impl/model.py index 47608bdfa6e..80a83fb3f21 100644 --- a/api/core/plugin/impl/model.py +++ b/api/core/plugin/impl/model.py @@ -13,13 +13,17 @@ from core.plugin.entities.plugin_daemon import ( PluginVoicesResponse, ) from core.plugin.impl.base import BasePluginClient -from graphon.model_runtime.entities.llm_entities import LLMResultChunk +from core.plugin.impl.exc import PluginInvokeError, PluginLLMPollingUnsupportedError +from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMResultChunk from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool -from graphon.model_runtime.entities.model_entities import AIModelEntity +from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult from graphon.model_runtime.entities.text_embedding_entities import EmbeddingResult from graphon.model_runtime.utils.encoders import jsonable_encoder +_POLLING_UNSUPPORTED_INVOKE_ERROR_TYPES = frozenset((NotImplementedError.__name__,)) +_POLLING_UNSUPPORTED_ERROR_MESSAGE = "does not support polling" + class PluginModelClient(BasePluginClient): @staticmethod @@ -197,6 +201,103 @@ class PluginModelClient(BasePluginClient): except PluginDaemonInnerError as e: raise ValueError(e.message + str(e.code)) + def start_llm_polling( + self, + tenant_id: str, + user_id: str | None, + plugin_id: str, + provider: str, + model: str, + credentials: dict[str, Any], + prompt_messages: list[PromptMessage], + model_parameters: dict[str, Any] | None = None, + tools: list[PromptMessageTool] | None = None, + stop: list[str] | None = None, + json_schema: dict[str, Any] | None = None, + ) -> LLMPollingResult: + """Start an LLM polling request for plugin-backed long-running jobs.""" + try: + return self._request_with_plugin_daemon_response( + method="POST", + path=f"plugin/{tenant_id}/dispatch/model/polling/start", + type_=LLMPollingResult, + data=jsonable_encoder( + self._dispatch_payload( + user_id=user_id, + data={ + "provider": provider, + "model_type": ModelType.LLM.value, + "model": model, + "credentials": credentials, + "prompt_messages": prompt_messages, + "model_parameters": model_parameters, + "tools": tools, + "stop": stop, + "stream": False, + "json_schema": json_schema, + }, + ) + ), + headers={ + "X-Plugin-ID": plugin_id, + "Content-Type": "application/json", + }, + ) + except PluginInvokeError as error: + self._raise_typed_polling_unsupported_error(error) + raise + + def check_llm_polling( + self, + tenant_id: str, + user_id: str | None, + plugin_id: str, + provider: str, + model: str, + credentials: dict[str, Any], + plugin_state: dict[str, Any], + ) -> LLMPollingResult: + """Check the latest state for a plugin-backed LLM polling job.""" + try: + return self._request_with_plugin_daemon_response( + method="POST", + path=f"plugin/{tenant_id}/dispatch/model/polling/check", + type_=LLMPollingResult, + data=jsonable_encoder( + self._dispatch_payload( + user_id=user_id, + data={ + "provider": provider, + "model_type": ModelType.LLM.value, + "model": model, + "credentials": credentials, + "plugin_state": plugin_state, + }, + ) + ), + headers={ + "X-Plugin-ID": plugin_id, + "Content-Type": "application/json", + }, + ) + except PluginInvokeError as error: + self._raise_typed_polling_unsupported_error(error) + raise + + @staticmethod + def _raise_typed_polling_unsupported_error(error: PluginInvokeError) -> None: + """Convert plugin polling capability failures into a dedicated Dify exception.""" + if error.get_error_type() == PluginLLMPollingUnsupportedError.__name__: + raise PluginLLMPollingUnsupportedError(description=error.description) from error + + if ( + error.get_error_type() in _POLLING_UNSUPPORTED_INVOKE_ERROR_TYPES + # This is ugly, we should not rely on error messages while checking + # error types. + and _POLLING_UNSUPPORTED_ERROR_MESSAGE in error.get_error_message().lower() + ): + raise PluginLLMPollingUnsupportedError(description=error.description) from error + def get_llm_num_tokens( self, tenant_id: str, diff --git a/api/core/plugin/impl/model_runtime.py b/api/core/plugin/impl/model_runtime.py index 3d5ba94f2bf..c1b976d8f4f 100644 --- a/api/core/plugin/impl/model_runtime.py +++ b/api/core/plugin/impl/model_runtime.py @@ -6,6 +6,7 @@ from collections.abc import Generator, Iterable, Sequence from typing import IO, Any, Literal, cast, overload, override from pydantic import ValidationError +from pydantic.json_schema import JsonValue from redis import RedisError from configs import dify_config @@ -17,6 +18,7 @@ from core.plugin.impl.model import PluginModelClient from core.plugin.plugin_service import PluginService from extensions.ext_redis import redis_client from graphon.model_runtime.entities.llm_entities import ( + LLMPollingResult, LLMResult, LLMResultChunk, LLMResultChunkWithStructuredOutput, @@ -430,6 +432,54 @@ class PluginModelRuntime(ModelRuntime): tools=list(tools) if tools else None, ) + def start_llm_polling( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + model_parameters: dict[str, Any], + prompt_messages: Sequence[PromptMessage], + tools: Sequence[PromptMessageTool] | None, + stop: Sequence[str] | None, + json_schema: dict[str, Any] | None, + ) -> LLMPollingResult: + """Start a plugin-side polling job for long-running LLM invocations.""" + plugin_id, provider_name = self._split_provider(provider) + return self.client.start_llm_polling( + tenant_id=self.tenant_id, + user_id=self.user_id, + plugin_id=plugin_id, + provider=provider_name, + model=model, + credentials=credentials, + prompt_messages=list(prompt_messages), + model_parameters=model_parameters, + tools=list(tools) if tools else None, + stop=list(stop) if stop else None, + json_schema=json_schema, + ) + + def check_llm_polling( + self, + *, + provider: str, + model: str, + credentials: dict[str, Any], + plugin_state: dict[str, JsonValue], + ) -> LLMPollingResult: + """Check the latest plugin-side polling state for an LLM invocation.""" + plugin_id, provider_name = self._split_provider(provider) + return self.client.check_llm_polling( + tenant_id=self.tenant_id, + user_id=self.user_id, + plugin_id=plugin_id, + provider=provider_name, + model=model, + credentials=credentials, + plugin_state=plugin_state, + ) + @override def invoke_text_embedding( self, diff --git a/api/core/rag/entities/metadata_entities.py b/api/core/rag/entities/metadata_entities.py index a2ac44807f8..347d5df8237 100644 --- a/api/core/rag/entities/metadata_entities.py +++ b/api/core/rag/entities/metadata_entities.py @@ -1,7 +1,7 @@ from collections.abc import Sequence -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, WithJsonSchema SupportedComparisonOperator = Literal[ # for string or array @@ -26,6 +26,19 @@ SupportedComparisonOperator = Literal[ "before", "after", ] +ConditionValue = Annotated[ + str | Sequence[str] | None | int | float, + WithJsonSchema( + { + "anyOf": [ + {"type": "string"}, + {"items": {"type": "string"}, "type": "array"}, + {"type": "number"}, + {"type": "null"}, + ] + } + ), +] class Condition(BaseModel): @@ -33,9 +46,23 @@ class Condition(BaseModel): Condition detail """ - name: str - comparison_operator: SupportedComparisonOperator - value: str | Sequence[str] | None | int | float = None + name: str = Field(description="Metadata field name to compare against.") + comparison_operator: SupportedComparisonOperator = Field( + description=( + "Comparison to apply. String operators (`contains`, `not contains`, `start with`, `end with`, `is`, " + "`is not`, `empty`, `not empty`, `in`, `not in`) act on string or array metadata; numeric operators " + "(`=`, `≠`, `>`, `<`, `≥`, `≤`) act on numeric metadata; time operators (`before`, `after`) act on " + "time metadata." + ) + ) + value: ConditionValue = Field( + default=None, + description=( + "Value to compare against. Type depends on `comparison_operator`: string for most string operators, " + "array of strings for `in` and `not in`, number for numeric operators, and omit or use `null` for " + "`empty` and `not empty`." + ), + ) class MetadataFilteringCondition(BaseModel): @@ -43,5 +70,12 @@ class MetadataFilteringCondition(BaseModel): Metadata Filtering Condition. """ - logical_operator: Literal["and", "or"] | None = "and" - conditions: list[Condition] | None = Field(default=None, deprecated=True) + logical_operator: Literal["and", "or"] | None = Field( + default="and", + description="How to combine multiple conditions.", + ) + conditions: list[Condition] | None = Field( + default=None, + deprecated=True, + description="List of metadata conditions to evaluate.", + ) diff --git a/api/core/rag/entities/processing_entities.py b/api/core/rag/entities/processing_entities.py index 1b54444a198..46360ec086f 100644 --- a/api/core/rag/entities/processing_entities.py +++ b/api/core/rag/entities/processing_entities.py @@ -1,7 +1,7 @@ from enum import StrEnum -from typing import Literal +from typing import Annotated, Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field, WithJsonSchema class ParentMode(StrEnum): @@ -9,19 +9,39 @@ class ParentMode(StrEnum): PARAGRAPH = "paragraph" +PreProcessingRuleID = Annotated[ + str, + WithJsonSchema( + { + "enum": ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"], + "type": "string", + } + ), +] + + class PreProcessingRule(BaseModel): - id: str - enabled: bool + id: PreProcessingRuleID = Field(description="Rule identifier.") + enabled: bool = Field(description="Whether this preprocessing rule is enabled.") class Segmentation(BaseModel): - separator: str = "\n" - max_tokens: int - chunk_overlap: int = 0 + separator: str = Field(default="\n", description="Custom separator for splitting text.") + max_tokens: int = Field(description="Maximum token count per chunk.") + chunk_overlap: int = Field(default=0, description="Token overlap between chunks.") class Rule(BaseModel): - pre_processing_rules: list[PreProcessingRule] | None = None - segmentation: Segmentation | None = None - parent_mode: Literal["full-doc", "paragraph"] | None = None - subchunk_segmentation: Segmentation | None = None + pre_processing_rules: list[PreProcessingRule] | None = Field( + default=None, + description="Pre-processing rules to apply before segmentation.", + ) + segmentation: Segmentation | None = Field(default=None, description="Parent chunk segmentation settings.") + parent_mode: Literal["full-doc", "paragraph"] | None = Field( + default=None, + description="Parent-child segmentation mode.", + ) + subchunk_segmentation: Segmentation | None = Field( + default=None, + description="Child chunk segmentation settings.", + ) diff --git a/api/core/rag/extractor/watercrawl/client.py b/api/core/rag/extractor/watercrawl/client.py index 1f4adc0d418..b37bd38ec3e 100644 --- a/api/core/rag/extractor/watercrawl/client.py +++ b/api/core/rag/extractor/watercrawl/client.py @@ -12,6 +12,8 @@ from core.rag.extractor.watercrawl.exceptions import ( WaterCrawlPermissionError, ) +WATERCRAWL_REQUEST_TIMEOUT: httpx.Timeout = httpx.Timeout(30.0, connect=5.0) + class SpiderOptions(TypedDict): max_depth: int @@ -48,7 +50,7 @@ class BaseAPIClient: "User-Agent": "WaterCrawl-Plugin", "Accept-Language": "en-US", } - return httpx.Client(headers=headers, timeout=None) + return httpx.Client(headers=headers, timeout=WATERCRAWL_REQUEST_TIMEOUT) def _request( self, diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index f4e850d34ed..474c9f90c78 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -123,7 +123,7 @@ class DatasetRetrieval: if not available_datasets_ids: return [] - if not request.query: + if not request.query and not request.attachment_ids: return [] metadata_filter_document_ids, metadata_condition = None, None diff --git a/api/core/rbac/entities.py b/api/core/rbac/entities.py index 5389e16eefc..7f08a530f57 100644 --- a/api/core/rbac/entities.py +++ b/api/core/rbac/entities.py @@ -25,6 +25,7 @@ class RBACPermission(StrEnum): APP_CREATE_AND_MANAGEMENT = "app_create_and_management" APP_RELEASE_AND_VERSION = "app_release_and_version" APP_IMPORT_EXPORT_DSL = "app_import_export_dsl" + APP_EDIT = "app_edit" APP_MONITOR = "app_monitor" APP_DELETE = "app_delete" @@ -33,5 +34,22 @@ class RBACPermission(StrEnum): DATASET_CREATE_AND_MANAGEMENT = "dataset_create_and_management" DATASET_PIPELINE_TEST = "dataset_pipeline_test" DATASET_DOCUMENT_DOWNLOAD = "dataset_document_download" + DATASET_API_KEY_MANAGE = "dataset_api_key_manage" + DATASET_EXTERNAL_CONNECT = "dataset_external_connect" + DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl" WORKSPACE_ROLE_MANAGE = "workspace_role_manage" + + SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify" + SNIPPETS_MANAGE = "snippets_management" + + PLUGIN_INSTALL = "plugin_install" + PLUGIN_PREFERENCES = "plugin_preferences" + PLUGIN_MANAGE = "plugin_manage" + PLUGIN_DEBUG = "plugin_debug" + + CREDENTIAL_USE = "credential_use" + CREDENTIAL_MANAGE = "credential_manage" + + TOOL_MANAGE = "tool_manage" + MCP_MANAGE = "mcp_manage" diff --git a/api/core/schemas/resolver.py b/api/core/schemas/resolver.py index cd86aebc060..6d959e0e87a 100644 --- a/api/core/schemas/resolver.py +++ b/api/core/schemas/resolver.py @@ -304,22 +304,23 @@ def _has_dify_refs_recursive(schema: SchemaType) -> bool: Returns: True if any Dify $ref is found, False otherwise """ - if isinstance(schema, dict): - # Check if this dict has a $ref field - ref_uri = schema.get("$ref") - if ref_uri and _is_dify_schema_ref(ref_uri): - return True - - # Check nested values - for value in schema.values(): - if _has_dify_refs_recursive(value): + match schema: + case dict(): + # Check if this dict has a $ref field + ref_uri = schema.get("$ref") + if ref_uri and _is_dify_schema_ref(ref_uri): return True - elif isinstance(schema, list): - # Check each item in the list - for item in schema: - if _has_dify_refs_recursive(item): - return True + # Check nested values + for value in schema.values(): + if _has_dify_refs_recursive(value): + return True + + case list(): + # Check each item in the list + for item in schema: + if _has_dify_refs_recursive(item): + return True # Primitive types don't contain refs return False diff --git a/api/core/tools/builtin_tool/providers/time/tools/localtime_to_timestamp.py b/api/core/tools/builtin_tool/providers/time/tools/localtime_to_timestamp.py index 1ebb7ab3a7f..57363349458 100644 --- a/api/core/tools/builtin_tool/providers/time/tools/localtime_to_timestamp.py +++ b/api/core/tools/builtin_tool/providers/time/tools/localtime_to_timestamp.py @@ -1,6 +1,6 @@ from collections.abc import Generator -from datetime import datetime -from typing import Any, override +from datetime import datetime, tzinfo +from typing import Any, cast, override import pytz # type: ignore[import-untyped] @@ -35,17 +35,26 @@ class LocaltimeToTimestampTool(BuiltinTool): yield self.create_text_message(f"{timestamp}") - # TODO: this method's type is messy @staticmethod - def localtime_to_timestamp(localtime: str, time_format: str, local_tz=None) -> int | None: + def localtime_to_timestamp(localtime: str, time_format: str, local_tz: str | tzinfo | None = None) -> int | None: try: local_time = datetime.strptime(localtime, time_format) - if local_tz is None: - localtime = local_time.astimezone() # type: ignore - elif isinstance(local_tz, str): - local_tz = pytz.timezone(local_tz) - localtime = local_tz.localize(local_time) # type: ignore - timestamp = int(localtime.timestamp()) # type: ignore + converted_localtime: datetime + match local_tz: + case None: + converted_localtime = local_time.astimezone() + case str() as timezone_name: + timezone = pytz.timezone(timezone_name) + converted_localtime = timezone.localize(local_time) + case tzinfo(): + localize = getattr(local_tz, "localize", None) + if callable(localize): + converted_localtime = cast(datetime, localize(local_time)) + else: + converted_localtime = local_time.replace(tzinfo=local_tz) + case _: + raise ValueError("local_tz must be None, a timezone name, or a tzinfo instance") + timestamp = int(converted_localtime.timestamp()) return timestamp except Exception as e: raise ToolInvokeError(str(e)) diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py index 7a1553a4b15..195acd6e1ad 100644 --- a/api/core/tools/mcp_tool/tool.py +++ b/api/core/tools/mcp_tool/tool.py @@ -122,13 +122,14 @@ class MCPTool(Tool): def _process_json_content(self, content_json: Any) -> Generator[ToolInvokeMessage, None, None]: """Process JSON content based on its type.""" - if isinstance(content_json, dict): - yield self.create_json_message(content_json) - elif isinstance(content_json, list): - yield from self._process_json_list(content_json) - else: - # For primitive types (str, int, bool, etc.), convert to string - yield self.create_text_message(str(content_json)) + match content_json: + case dict(): + yield self.create_json_message(content_json) + case list(): + yield from self._process_json_list(content_json) + case _: + # For primitive types (str, int, bool, etc.), convert to string + yield self.create_text_message(str(content_json)) def _process_json_list(self, json_list: list) -> Generator[ToolInvokeMessage, None, None]: """Process a list of JSON items.""" @@ -222,16 +223,17 @@ class MCPTool(Tool): # Recursively search through nested structures for value in payload.values(): - if isinstance(value, Mapping): - found = cls._extract_usage_dict(value) - if found is not None: - return found - elif isinstance(value, list) and not isinstance(value, (str, bytes, bytearray)): - for item in value: - if isinstance(item, Mapping): - found = cls._extract_usage_dict(item) - if found is not None: - return found + match value: + case _ if isinstance(value, Mapping): + found = cls._extract_usage_dict(value) + if found is not None: + return found + case list() if not isinstance(value, (str, bytes, bytearray)): + for item in value: + if isinstance(item, Mapping): + found = cls._extract_usage_dict(item) + if found is not None: + return found return None @override diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py index 99be960a200..850571c3f19 100644 --- a/api/core/tools/tool_manager.py +++ b/api/core/tools/tool_manager.py @@ -398,6 +398,8 @@ class ToolManager: user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: "VariablePool | None" = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: """ get the agent tool runtime @@ -415,7 +417,12 @@ class ToolManager: runtime_parameters: dict[str, Any] = {} parameters = tool_entity.get_merged_runtime_parameters() runtime_parameters = cls._convert_tool_parameters_type( - parameters, variable_pool, agent_tool.tool_parameters, typ="agent" + parameters, + variable_pool, + agent_tool.tool_parameters, + typ="agent", + allow_file_parameters=allow_file_parameters, + use_default_for_missing_form_parameters=use_default_for_missing_form_parameters, ) # decrypt runtime parameters encryption_manager = ToolParameterConfigurationManager( @@ -1063,6 +1070,8 @@ class ToolManager: variable_pool: "VariablePool | None", tool_configurations: Mapping[str, Any], typ: Literal["agent", "workflow", "tool"] = "workflow", + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> dict[str, Any]: """ Convert tool parameters type @@ -1081,6 +1090,7 @@ class ToolManager: } and parameter.required and typ == "agent" + and not allow_file_parameters ): raise ValueError(f"file type parameter {parameter.name} not supported in agent") # save tool parameter to tool entity memory @@ -1117,7 +1127,19 @@ class ToolManager: runtime_parameters[parameter.name] = parameter_value else: - value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name)) + parameter_value = tool_configurations.get(parameter.name) + if use_default_for_missing_form_parameters and parameter_value is None: + if parameter.default is not None: + parameter_value = parameter.default + elif ( + parameter.required + and parameter.type == ToolParameter.ToolParameterType.SELECT + and parameter.options + ): + parameter_value = parameter.options[0].value + else: + continue + value = parameter.init_frontend_parameter(parameter_value) runtime_parameters[parameter.name] = value return runtime_parameters diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index 7e7b1e33008..97222f3cfae 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -196,16 +196,17 @@ class WorkflowTool(Tool): return usage_candidate for value in payload.values(): - if isinstance(value, Mapping): - found = cls._extract_usage_dict(value) - if found is not None: - return found - elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - for item in value: - if isinstance(item, Mapping): - found = cls._extract_usage_dict(item) - if found is not None: - return found + match value: + case _ if isinstance(value, Mapping): + found = cls._extract_usage_dict(value) + if found is not None: + return found + case _ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for item in value: + if isinstance(item, Mapping): + found = cls._extract_usage_dict(item) + if found is not None: + return found return None @override @@ -393,24 +394,25 @@ class WorkflowTool(Tool): files: list[File] = [] result = {} for key, value in outputs.items(): - if isinstance(value, list): - for item in value: - if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY: - item = self._update_file_mapping(item) - file = build_from_mapping( - mapping=item, - tenant_id=str(self.runtime.tenant_id), - access_controller=_file_access_controller, - ) - files.append(file) - elif isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY: - value = self._update_file_mapping(value) - file = build_from_mapping( - mapping=value, - tenant_id=str(self.runtime.tenant_id), - access_controller=_file_access_controller, - ) - files.append(file) + match value: + case list(): + for item in value: + if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY: + item = self._update_file_mapping(item) + file = build_from_mapping( + mapping=item, + tenant_id=str(self.runtime.tenant_id), + access_controller=_file_access_controller, + ) + files.append(file) + case dict() if value.get("dify_model_identity") == FILE_MODEL_IDENTITY: + value = self._update_file_mapping(value) + file = build_from_mapping( + mapping=value, + tenant_id=str(self.runtime.tenant_id), + access_controller=_file_access_controller, + ) + files.append(file) result[key] = value diff --git a/api/core/trigger/entities/entities.py b/api/core/trigger/entities/entities.py index a922e881cdf..06a29bed111 100644 --- a/api/core/trigger/entities/entities.py +++ b/api/core/trigger/entities/entities.py @@ -46,8 +46,8 @@ class EventParameter(BaseModel): ) template: PluginParameterTemplate | None = Field(default=None, description="The template of the parameter") scope: str | None = None - required: bool | None = False - multiple: bool | None = Field( + required: bool = False + multiple: bool = Field( default=False, description="Whether the parameter is multiple select, only valid for select or dynamic-select type", ) diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 54c6c55949e..ebeb189ab19 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -26,6 +26,7 @@ from core.workflow.node_runtime import ( DifyFileReferenceFactory, DifyHumanInputNodeRuntime, DifyPreparedLLM, + DifyPreparedPollingLLM, DifyPromptMessageSerializer, DifyRetrieverAttachmentLoader, DifyToolFileManager, @@ -531,7 +532,11 @@ class DifyNodeFactory(NodeFactory): node_init_kwargs: dict[str, object] = { "credentials_provider": self._llm_credentials_provider, "model_factory": self._llm_model_factory, - "model_instance": DifyPreparedLLM(model_instance) if wrap_model_instance else model_instance, + "model_instance": ( + self._wrap_model_instance_for_node(node_data=validated_node_data, model_instance=model_instance) + if wrap_model_instance + else model_instance + ), "memory": self._build_memory_for_llm_node( node_data=validated_node_data, model_instance=model_instance, @@ -555,6 +560,23 @@ class DifyNodeFactory(NodeFactory): node_init_kwargs["default_query_selector"] = system_variable_selector(SystemVariableKey.QUERY) return node_init_kwargs + @staticmethod + def _wrap_model_instance_for_node( + *, + node_data: LLMCompatibleNodeData, + model_instance: ModelInstance, + ) -> DifyPreparedLLM: + # Only graphon's LLM node consumes the polling protocol. Keep classifier + # and extractor nodes on the existing wrapper even if the same model + # advertises polling support. + if node_data.type == BuiltinNodeTypes.LLM and DifyNodeFactory._supports_plugin_llm_polling(model_instance): + return DifyPreparedPollingLLM(model_instance) + return DifyPreparedLLM(model_instance) + + @staticmethod + def _supports_plugin_llm_polling(model_instance: ModelInstance) -> bool: + return model_instance.get_model_schema().support_polling + def _build_retriever_attachment_loader(self, node_data: LLMNodeData) -> DifyRetrieverAttachmentLoader: return DifyRetrieverAttachmentLoader( file_reference_factory=self._file_reference_factory, diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index 4eced02cd10..9964f65d0b6 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -4,6 +4,7 @@ from collections.abc import Callable, Generator, Mapping, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, cast, overload, override +from pydantic import JsonValue from sqlalchemy import select from sqlalchemy.orm import Session @@ -38,6 +39,7 @@ from factories import file_factory from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities import LLMMode from graphon.model_runtime.entities.llm_entities import ( + LLMPollingResult, LLMResult, LLMResultChunk, LLMResultChunkWithStructuredOutput, @@ -54,6 +56,7 @@ from graphon.nodes.human_input.entities import ( HumanInputNodeData, ) from graphon.nodes.llm.runtime_protocols import ( + LLMPollingCapableProtocol, LLMProtocol, PromptMessageSerializerProtocol, RetrieverAttachmentLoaderProtocol, @@ -278,6 +281,58 @@ class DifyPreparedLLM(LLMProtocol): return isinstance(error, OutputParserError) +class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol): + """Prepared workflow LLM adapter that exposes Graphon's polling protocol.""" + + def __init__(self, model_instance: ModelInstance) -> None: + from core.plugin.impl.model_runtime import PluginModelRuntime + + super().__init__(model_instance) + model_type_instance = model_instance.model_type_instance + if not isinstance(model_type_instance, LargeLanguageModel): + raise TypeError("Polling wrapper requires a large-language-model instance.") + + plugin_model_runtime = model_type_instance.model_runtime + if not isinstance(plugin_model_runtime, PluginModelRuntime): + raise TypeError("Polling wrapper requires a plugin-backed model runtime.") + + self._plugin_model_runtime = plugin_model_runtime + + @override + def start_llm_polling( + self, + *, + prompt_messages: Sequence[PromptMessage], + model_parameters: Mapping[str, Any], + tools: Sequence[PromptMessageTool] | None, + stop: Sequence[str] | None, + json_schema: Mapping[str, Any] | None, + ) -> LLMPollingResult: + return self._plugin_model_runtime.start_llm_polling( + provider=self.provider, + model=self.model_name, + credentials=self._model_instance.credentials, + prompt_messages=prompt_messages, + model_parameters=dict(model_parameters), + tools=tools, + stop=stop, + json_schema=dict(json_schema) if json_schema is not None else None, + ) + + @override + def check_llm_polling( + self, + *, + plugin_state: Mapping[str, JsonValue], + ) -> LLMPollingResult: + return self._plugin_model_runtime.check_llm_polling( + provider=self.provider, + model=self.model_name, + credentials=self._model_instance.credentials, + plugin_state=dict(plugin_state), + ) + + class DifyPromptMessageSerializer(PromptMessageSerializerProtocol): @override def serialize( diff --git a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py index ccf8f9fa171..80366cc5193 100644 --- a/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/plugin_tools_builder.py @@ -42,6 +42,8 @@ class AgentToolRuntimeProvider(Protocol): user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: ... @@ -176,6 +178,8 @@ class WorkflowAgentPluginToolsBuilder: user_id=user_id, invoke_from=invoke_from, variable_pool=None, + allow_file_parameters=True, + use_default_for_missing_form_parameters=True, ) except ToolProviderNotFoundError as exc: raise WorkflowAgentPluginToolsBuildError( diff --git a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py index 8e0578d1a15..65c5d42e916 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py +++ b/api/core/workflow/nodes/agent_v2/runtime_feature_manifest.py @@ -13,6 +13,7 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( "structured_output", "tools.dify_tools", "tools.cli_tools", + "knowledge", "env", "sandbox", # ENG-623: exposed at runtime as the dify.drive declaration layer @@ -26,7 +27,6 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset( RESERVED_AGENT_BACKEND_FEATURES = frozenset( { - "knowledge", "memory", } ) @@ -80,6 +80,9 @@ def build_runtime_feature_manifest( ) reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed") + reserved_status["knowledge"] = ( + "supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured" + ) reserved_status["skills_files"] = ( "supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled" ) @@ -97,6 +100,17 @@ def build_runtime_feature_manifest( } +def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]: + """Return the normalized knowledge dataset ids that can produce a runtime layer. + + ``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()`` + must stay aligned: both decide knowledge support from this effective, + non-blank dataset-id set rather than from raw + ``agent_soul.knowledge.datasets`` entries. + """ + return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())] + + def _get_nested(value: dict[str, Any], path: str) -> Any: current: Any = value for part in path.split("."): diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index d4aa43898d0..8aaa4fcc1d3 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -16,6 +16,7 @@ from dify_agent.layers.execution_context import ( DifyExecutionContextLayerConfig, DifyExecutionContextUserFrom, ) +from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig from dify_agent.layers.shell import ( DifyShellCliToolConfig, DifyShellEnvVarConfig, @@ -40,8 +41,10 @@ from graphon.file import FileTransferMethod from graphon.variables.segments import Segment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( + AgentKnowledgeQueryConfig, AgentSoulConfig, DeclaredArrayItem, + DeclaredOutputChildConfig, DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, @@ -59,6 +62,7 @@ from services.agent.prompt_mentions import ( from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError +from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) _DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation") @@ -68,7 +72,6 @@ _DANGEROUS_ACK_KEYS = ( "risk_accepted", "approved", ) -from .runtime_feature_manifest import build_runtime_feature_manifest class WorkflowAgentRuntimeRequestBuildError(ValueError): @@ -182,6 +185,7 @@ class WorkflowAgentRuntimeRequestBuilder: if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id) append_runtime_warnings(metadata, drive_warnings) + knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_workflow_node( AgentBackendWorkflowNodeRunInput( @@ -196,10 +200,11 @@ class WorkflowAgentRuntimeRequestBuilder: model_settings=agent_soul.model.model_settings.model_dump(mode="json", exclude_none=True), ), # The execution-context layer is now the only public protocol - # carrier for Dify tenant/user/run identifiers. ``user_id`` must - # be forwarded here because downstream plugin-daemon provider and - # tool clients read it from this layer rather than from any - # parallel top-level request field. + # carrier for Dify tenant/user/run identifiers. ``user_id`` and + # ``user_from`` must be forwarded here because downstream plugin- + # daemon provider/tool clients and knowledge-base layers read + # caller identity from this layer rather than from any parallel + # top-level request field. execution_context=DifyExecutionContextLayerConfig( tenant_id=context.dify_context.tenant_id, user_id=context.dify_context.user_id, @@ -220,6 +225,7 @@ class WorkflowAgentRuntimeRequestBuilder: user_prompt=user_prompt, output=self._build_output_config(node_job.declared_outputs), tools=tools_layer, + knowledge=knowledge_config, drive_config=drive_config, ask_human_config=build_ask_human_layer_config(agent_soul), include_shell=dify_config.AGENT_SHELL_ENABLED, @@ -395,7 +401,11 @@ class WorkflowAgentRuntimeRequestBuilder: @staticmethod def _schema_for_declared_output(output: DeclaredOutputConfig) -> dict[str, Any]: - schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type(output.type, array_item=output.array_item) + schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( + output.type, + array_item=output.array_item, + children=output.children, + ) if output.description: schema["description"] = output.description return schema @@ -405,6 +415,7 @@ class WorkflowAgentRuntimeRequestBuilder: output_type: DeclaredOutputType, *, array_item: DeclaredArrayItem | None = None, + children: Sequence[DeclaredOutputChildConfig] | None = None, ) -> dict[str, Any]: match output_type: case DeclaredOutputType.STRING: @@ -414,18 +425,23 @@ class WorkflowAgentRuntimeRequestBuilder: case DeclaredOutputType.BOOLEAN: return {"type": "boolean"} case DeclaredOutputType.OBJECT: - return {"type": "object"} + object_schema: dict[str, Any] = {"type": "object"} + WorkflowAgentRuntimeRequestBuilder._apply_child_properties(object_schema, children or []) + return object_schema case DeclaredOutputType.ARRAY: # Stage 4 §4.2: items shape mirrors the declared array_item. # Validator guarantees array_item is set when type is array. item_type = array_item.type if array_item else DeclaredOutputType.OBJECT - schema: dict[str, Any] = { + array_schema: dict[str, Any] = { "type": "array", - "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type(item_type), + "items": WorkflowAgentRuntimeRequestBuilder._schema_for_type( + item_type, + children=array_item.children if array_item else None, + ), } if array_item is not None and array_item.description: - schema["items"]["description"] = array_item.description - return schema + array_schema["items"]["description"] = array_item.description + return array_schema case DeclaredOutputType.FILE: return { "oneOf": [ @@ -469,6 +485,27 @@ class WorkflowAgentRuntimeRequestBuilder: } assert_never(output_type) + @staticmethod + def _apply_child_properties(schema: dict[str, Any], children: Sequence[DeclaredOutputChildConfig]) -> None: + if not children: + return + properties: dict[str, Any] = {} + required: list[str] = [] + for child in children: + child_schema = WorkflowAgentRuntimeRequestBuilder._schema_for_type( + child.type, + array_item=child.array_item, + children=child.children, + ) + if child.description: + child_schema["description"] = child.description + properties[child.name] = child_schema + if child.required: + required.append(child.name) + schema["properties"] = properties + if required: + schema["required"] = required + @staticmethod def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]: normalized: dict[str, str | int | float | bool | None] = {} @@ -502,6 +539,45 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi ) +def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None: + """Map Agent Soul knowledge config into the fixed Dify knowledge-base layer. + + Normalization intentionally matches the current dify-agent runtime contract: + + - blank or missing dataset ids are ignored; + - if no valid dataset ids remain, no knowledge layer is injected; + - retrieval mode is always forced to ``multiple`` in this first wiring pass; + - ``top_k`` falls back to a stable runtime default when the soul omits it; + - ``score_threshold`` is only forwarded when the product config explicitly + enables it, otherwise the layer keeps the disabled/default ``0.0`` value; + - metadata filtering stays at the layer DTO default (disabled). + """ + dataset_ids = list_configured_knowledge_dataset_ids(agent_soul) + if not dataset_ids: + return None + + query_config = agent_soul.knowledge.query_config + return DifyKnowledgeBaseLayerConfig( + dataset_ids=dataset_ids, + retrieval=DifyKnowledgeRetrievalConfig( + mode="multiple", + top_k=_knowledge_top_k(query_config), + score_threshold=_knowledge_score_threshold(query_config), + ), + ) + + +def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int: + top_k = query_config.top_k + return top_k if isinstance(top_k, int) and top_k >= 1 else 4 + + +def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float: + if query_config.score_threshold_enabled and query_config.score_threshold is not None: + return query_config.score_threshold + return 0.0 + + def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None: """Enable the dify.ask_human deferred tool when the soul configures human involvement. @@ -669,7 +745,13 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None: name = _name_from_mapping(data) if name is None: return None - ref = data.get("ref") or data.get("id") or data.get("credential_id") or data.get("provider_credential_id") + ref = ( + data.get("ref") + or data.get("value") + or data.get("id") + or data.get("credential_id") + or data.get("provider_credential_id") + ) return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None) 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 1d938dd04c5..6f1660390d3 100644 --- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py +++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py @@ -297,25 +297,26 @@ class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node[KnowledgeRetrievalNodeD for cond in conditions.conditions or []: value = cond.value resolved_value: str | Sequence[str] | int | float | None - if isinstance(value, str): - segment_group = variable_pool.convert_template(value) - if len(segment_group.value) == 1: - resolved_value = _normalize_metadata_filter_scalar(segment_group.value[0].to_object()) - else: - resolved_value = segment_group.text - elif isinstance(value, Sequence) and all(isinstance(v, str) for v in value): - resolved_values: list[str] = [] - for v in value: - segment_group = variable_pool.convert_template(v) + match value: + case str(): + segment_group = variable_pool.convert_template(value) if len(segment_group.value) == 1: - resolved_values.append( - _normalize_metadata_filter_sequence_item(segment_group.value[0].to_object()) - ) + resolved_value = _normalize_metadata_filter_scalar(segment_group.value[0].to_object()) else: - resolved_values.append(segment_group.text) - resolved_value = resolved_values - else: - resolved_value = value + resolved_value = segment_group.text + case _ if isinstance(value, Sequence) and all(isinstance(v, str) for v in value): + resolved_values: list[str] = [] + for v in value: + segment_group = variable_pool.convert_template(v) + if len(segment_group.value) == 1: + resolved_values.append( + _normalize_metadata_filter_sequence_item(segment_group.value[0].to_object()) + ) + else: + resolved_values.append(segment_group.text) + resolved_value = resolved_values + case _: + resolved_value = value resolved_conditions.append( Condition( name=cond.name, diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 7028f740e02..72bc56daf87 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -167,6 +167,12 @@ def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str: return markdown +def _strip_trailing_line_whitespace(markdown: str) -> str: + """Remove converter-emitted trailing whitespace without changing line structure.""" + + return "\n".join(line.rstrip(" \t") for line in markdown.split("\n")) + + 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}-") as temp_dir: @@ -201,6 +207,7 @@ def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: temp_markdown_path.read_text(encoding="utf-8"), spec_path, ) + converted_markdown = _strip_trailing_line_whitespace(converted_markdown) if not converted_markdown.strip(): raise RuntimeError(f"swagger-markdown wrote an empty document for {markdown_path}") diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index d3b62511ea6..868f9e87776 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -104,11 +104,14 @@ def _field_signature(field: object) -> object: "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)) @@ -154,9 +157,9 @@ def create_spec_app() -> Flask: apply_runtime_defaults() - from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts + from libs.flask_restx_compat import install_swagger_compatibility - patch_swagger_for_inline_nested_dicts() + install_swagger_compatibility() app = Flask(__name__) diff --git a/api/dev/lint_response_contracts.py b/api/dev/lint_response_contracts.py index 4ba79e0fedb..75c5f67b8ff 100644 --- a/api/dev/lint_response_contracts.py +++ b/api/dev/lint_response_contracts.py @@ -354,11 +354,12 @@ def iter_method_nodes(method: MethodNode) -> Iterable[ast.AST]: def target_names(target: ast.AST) -> Iterable[str]: - if isinstance(target, ast.Name): - yield target.id - elif isinstance(target, ast.Tuple | ast.List): - for item in target.elts: - yield from target_names(item) + match target: + case ast.Name(): + yield target.id + case ast.Tuple() | ast.List(): + for item in target.elts: + yield from target_names(item) def record_assignment( diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index 4d60bdb5f65..6cd4b08b900 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -26,6 +26,7 @@ def init_app(app: DifyApp): install_plugins, install_rag_pipeline_plugins, migrate_data_for_plugin, + migrate_member_roles_to_rbac, migrate_oss, migration_data_wizard, old_metadata_migration, @@ -54,6 +55,7 @@ def init_app(app: DifyApp): upgrade_db, fix_app_site_missing, migrate_data_for_plugin, + migrate_member_roles_to_rbac, backfill_plugin_auto_upgrade, extract_plugins, extract_unique_plugins, diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index ee469cd9a5b..0ae018f6a1d 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -14,6 +14,7 @@ from extensions.ext_database import db from libs.passport import PassportService from libs.token import extract_access_token, extract_console_cookie_token, extract_webapp_passport from models import Account, Tenant, TenantAccountJoin +from models.enums import EndUserType from models.model import AppMCPServer, EndUser from services.account_service import AccountService @@ -136,7 +137,7 @@ def load_user_from_request(request_from_flask_login: Request) -> LoginUser | Non if not app_mcp_server: raise NotFound("App MCP server not found.") end_user = db.session.scalar( - select(EndUser).where(EndUser.session_id == app_mcp_server.id, EndUser.type == "mcp").limit(1) + select(EndUser).where(EndUser.session_id == app_mcp_server.id, EndUser.type == EndUserType.MCP).limit(1) ) if not end_user: raise NotFound("End user not found.") diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 0f22fd9a869..ec64395d6fd 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -1,8 +1,10 @@ +from datetime import datetime from typing import Annotated, Literal -from pydantic import Field +from pydantic import Field, field_validator from fields.base import ResponseModel +from libs.helper import to_timestamp from models.agent import ( AgentConfigRevisionOperation, AgentIconType, @@ -34,7 +36,13 @@ from services.entities.agent_entities import ( class AgentConfigSnapshotSummaryResponse(ResponseModel): id: str agent_id: str | None = None + # User-facing version number among visible published versions. version: int + # Alias for the user-facing version number; kept explicit for clients that + # want to distinguish it from the immutable snapshot sequence. + display_version: int | None = None + # Immutable snapshot sequence number used internally for audit/history. + snapshot_version: int | None = None summary: str | None = None version_note: str | None = None created_by: str | None = None @@ -70,6 +78,7 @@ class AgentRosterResponse(ResponseModel): workflow_node_id: str | None = None active_config_snapshot_id: str | None = None active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + active_config_is_published: bool = False status: AgentStatus created_by: str | None = None updated_by: str | None = None @@ -104,6 +113,163 @@ class AgentInviteOptionsResponse(ResponseModel): has_more: bool +class AgentLogSourceResponse(ResponseModel): + id: str + type: Literal["webapp", "workflow"] + app_id: str + app_name: str + app_icon_type: str | None = None + app_icon: str | None = None + app_icon_background: str | None = None + workflow_id: str | None = None + workflow_version: str | None = None + node_id: str | None = None + + +class AgentLogSourceGroupResponse(ResponseModel): + type: Literal["webapp", "workflow"] + label: str + sources: list[AgentLogSourceResponse] = Field(default_factory=list) + + +class AgentLogSourceListResponse(ResponseModel): + data: list[AgentLogSourceResponse] + groups: list[AgentLogSourceGroupResponse] + + +class AgentLogConversationItemResponse(ResponseModel): + id: str + conversation_id: str + title: str | None = None + end_user_id: str | None = None + message_count: int + user_rate: float | None = None + operation_rate: float | None = None + unread: bool + source: AgentLogSourceResponse | None = None + status: Literal["success", "failed", "paused"] + created_at: int | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class AgentLogMessageItemResponse(ResponseModel): + id: str + message_id: str + conversation_id: str + query: str + answer: str + status: str + error: str | None = None + from_end_user_id: str | None = None + from_account_id: str | None = None + message_tokens: int + answer_tokens: int + total_tokens: int + total_price: str + currency: str + latency: float + created_at: int | None = None + updated_at: int | None = None + + @field_validator("created_at", "updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int | None) -> int | None: + return to_timestamp(value) + + +class AgentLogListResponse(ResponseModel): + data: list[AgentLogConversationItemResponse] + page: int + limit: int + total: int + has_more: bool + + +class AgentLogMessageListResponse(ResponseModel): + data: list[AgentLogMessageItemResponse] + page: int + limit: int + total: int + has_more: bool + + +class AgentStatisticSummaryResponse(ResponseModel): + total_messages: int + total_conversations: int + total_end_users: int + total_tokens: int + total_price: str + currency: str + average_session_interactions: float + average_response_time: float + tokens_per_second: float + user_satisfaction_rate: float + + +class AgentDailyMessageStatisticResponse(ResponseModel): + date: str + message_count: int + + +class AgentDailyConversationStatisticResponse(ResponseModel): + date: str + conversation_count: int + + +class AgentDailyEndUserStatisticResponse(ResponseModel): + date: str + terminal_count: int + + +class AgentTokenUsageStatisticResponse(ResponseModel): + date: str + token_count: int + total_price: str + currency: str + + +class AgentAverageSessionInteractionStatisticResponse(ResponseModel): + date: str + interactions: float + + +class AgentAverageResponseTimeStatisticResponse(ResponseModel): + date: str + latency: float + + +class AgentTokensPerSecondStatisticResponse(ResponseModel): + date: str + tps: float + + +class AgentUserSatisfactionRateStatisticResponse(ResponseModel): + date: str + rate: float + + +class AgentStatisticChartsResponse(ResponseModel): + daily_messages: list[AgentDailyMessageStatisticResponse] = Field(default_factory=list) + daily_conversations: list[AgentDailyConversationStatisticResponse] = Field(default_factory=list) + daily_end_users: list[AgentDailyEndUserStatisticResponse] = Field(default_factory=list) + token_usage: list[AgentTokenUsageStatisticResponse] = Field(default_factory=list) + average_session_interactions: list[AgentAverageSessionInteractionStatisticResponse] = Field(default_factory=list) + average_response_time: list[AgentAverageResponseTimeStatisticResponse] = Field(default_factory=list) + tokens_per_second: list[AgentTokensPerSecondStatisticResponse] = Field(default_factory=list) + user_satisfaction_rate: list[AgentUserSatisfactionRateStatisticResponse] = Field(default_factory=list) + + +class AgentStatisticSummaryEnvelopeResponse(ResponseModel): + source: str + summary: AgentStatisticSummaryResponse + charts: AgentStatisticChartsResponse + + class AgentConfigRevisionResponse(ResponseModel): id: str previous_snapshot_id: str | None = None diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 9b197da4433..96d8fbdf34c 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -100,6 +100,7 @@ app_detail_fields = { "updated_at": TimestampField, "access_mode": fields.String, "tags": fields.List(fields.Nested(tag_fields)), + "permission_keys": fields.List(fields.String()), } prompt_config_fields = { @@ -137,6 +138,7 @@ app_partial_fields = { "create_user_name": fields.String, "author_name": fields.String, "has_draft_trigger": fields.Boolean, + "permission_keys": fields.List(fields.String()), } @@ -217,6 +219,7 @@ app_detail_fields_with_site = { "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), "access_mode": fields.String, "tags": fields.List(fields.Nested(tag_fields)), + "permission_keys": fields.List(fields.String()), "site": fields.Nested(site_fields), } diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index 35a22ea4044..ea506d2a7e4 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -15,6 +15,7 @@ dataset_fields = { "indexing_technique": fields.String, "created_by": fields.String, "created_at": TimestampField, + "permission_keys": fields.List(fields.String()), } @@ -143,6 +144,7 @@ dataset_detail_fields = { "total_available_documents": fields.Integer, "enable_api": fields.Boolean, "is_multimodal": fields.Boolean, + "permission_keys": fields.List(fields.String()), } @@ -267,6 +269,8 @@ class DatasetDetailResponse(ResponseModel): total_available_documents: int enable_api: bool is_multimodal: bool + permission_keys: list[str] = Field(default_factory=list) + maintainer: str | None = None @field_validator("created_at", "updated_at", mode="before") @classmethod diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 7ae5e3b652b..9bbcbef8429 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from flask_restx import fields -from pydantic import computed_field, field_validator +from pydantic import Field, computed_field, field_validator from fields.base import ResponseModel from libs.helper import build_avatar_url, to_timestamp @@ -56,6 +56,7 @@ class AccountWithRole(_AccountAvatar): last_active_at: int | None = None created_at: int | None = None role: str + roles: list[dict[str, str]] = Field(default_factory=list) status: str @field_validator("last_login_at", "last_active_at", "created_at", mode="before") diff --git a/api/libs/broadcast_channel/redis/_subscription.py b/api/libs/broadcast_channel/redis/_subscription.py index 15355a77620..5af42d12538 100644 --- a/api/libs/broadcast_channel/redis/_subscription.py +++ b/api/libs/broadcast_channel/redis/_subscription.py @@ -94,12 +94,13 @@ class RedisSubscriptionBase(Subscription): continue channel_field = raw_message.get("channel") - if isinstance(channel_field, bytes): - channel_name = channel_field.decode("utf-8") - elif isinstance(channel_field, str): - channel_name = channel_field - else: - channel_name = str(channel_field) + match channel_field: + case bytes(): + channel_name = channel_field.decode("utf-8") + case str(): + channel_name = channel_field + case _: + channel_name = str(channel_field) if channel_name != self._topic: _logger.warning( diff --git a/api/libs/broadcast_channel/redis/sharded_channel.py b/api/libs/broadcast_channel/redis/sharded_channel.py index a7303c07823..68e9f8b23ef 100644 --- a/api/libs/broadcast_channel/redis/sharded_channel.py +++ b/api/libs/broadcast_channel/redis/sharded_channel.py @@ -88,22 +88,23 @@ class _RedisShardedSubscription(RedisSubscriptionBase): # # Since we have already filtered at the caller's site, we can safely set # `ignore_subscribe_messages=False`. - if isinstance(self._client, RedisCluster): - # NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message` without - # specifying the `target_node` argument would use busy-looping to wait - # for incoming message, consuming excessive CPU quota. - # - # Here we specify the `target_node` to mitigate this problem. - node = self._client.get_node_from_key(self._topic) - return self._pubsub.get_sharded_message( # type: ignore[attr-defined] - ignore_subscribe_messages=False, - timeout=1, - target_node=node, - ) - elif isinstance(self._client, Redis): - return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=1) # type: ignore[attr-defined] - else: - raise AssertionError("client should be either Redis or RedisCluster.") + match self._client: + case RedisCluster(): + # NOTE(QuantumGhost): due to an issue in upstream code, calling `get_sharded_message` without + # specifying the `target_node` argument would use busy-looping to wait + # for incoming message, consuming excessive CPU quota. + # + # Here we specify the `target_node` to mitigate this problem. + node = self._client.get_node_from_key(self._topic) + return self._pubsub.get_sharded_message( # type: ignore[attr-defined] + ignore_subscribe_messages=False, + timeout=1, + target_node=node, + ) + case Redis(): + return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=1) # type: ignore[attr-defined] + case _: + raise AssertionError("client should be either Redis or RedisCluster.") @override def _get_message_type(self) -> str: diff --git a/api/libs/broadcast_channel/redis/streams_channel.py b/api/libs/broadcast_channel/redis/streams_channel.py index 30c14585793..62e58798ab3 100644 --- a/api/libs/broadcast_channel/redis/streams_channel.py +++ b/api/libs/broadcast_channel/redis/streams_channel.py @@ -138,10 +138,11 @@ class _StreamsSubscription(Subscription): if isinstance(fields, dict): data = fields.get(b"data") data_bytes: bytes | None = None - if isinstance(data, str): - data_bytes = data.encode() - elif isinstance(data, (bytes, bytearray)): - data_bytes = bytes(data) + match data: + case str(): + data_bytes = data.encode() + case bytes() | bytearray(): + data_bytes = bytes(data) if data_bytes is not None: self._queue.put_nowait(data_bytes) last_id = entry_id diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 43f7c409f5b..06419b16f88 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,7 +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.flask_restx_compat import install_swagger_compatibility from libs.token import build_force_logout_cookie_headers @@ -127,16 +127,22 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte class ExternalApi(Api): _authorizations = { "Bearer": { - "type": "apiKey", - "in": "header", - "name": "Authorization", - "description": "Type: Bearer {your-api-key}", + "bearerFormat": "API_KEY", + "description": "Use the Service API key as a Bearer token in the Authorization header.", + "scheme": "bearer", + "type": "http", } } - def __init__(self, app: Blueprint | Flask, *args, error_body_formatter: ErrorBodyFormatter | None = None, **kwargs): + def __init__( + self, + app: Blueprint | Flask, + *args, + error_body_formatter: ErrorBodyFormatter | None = None, + **kwargs, + ): self._error_body_formatter = error_body_formatter - patch_swagger_for_inline_nested_dicts() + install_swagger_compatibility() 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 index 08fd3d9055d..75f1121af00 100644 --- a/api/libs/flask_restx_compat.py +++ b/api/libs/flask_restx_compat.py @@ -8,12 +8,14 @@ spec export fail or succeed in the same way. import hashlib import json -from typing import TypeGuard +from typing import TypeGuard, cast from flask import current_app from flask_restx import fields +from flask_restx import swagger as restx_swagger from flask_restx.model import Model, OrderedModel, instance from flask_restx.swagger import Swagger +from flask_restx.utils import not_none def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: @@ -98,20 +100,29 @@ def _inline_model_name(nested_fields: dict[object, object]) -> str: return f"_AnonymousInlineModel_{digest}" -def patch_swagger_for_inline_nested_dicts() -> None: - """Allow OpenAPI generation to handle legacy inline Flask-RESTX field dicts. +def install_swagger_compatibility() -> None: + """Install Dify's Flask-RESTX OpenAPI compatibility hooks. Some existing controllers use raw field mappings in `fields.Nested({...})` or directly in `@namespace.response(...)`. Runtime marshalling accepts that, but Flask-RESTX registration expects a named model. Convert those anonymous mappings into temporary named models during docs generation. + + Flask-RESTX also drops parameter descriptions from generated schemas and + does not expose the Werkzeug `uuid` route converter as `format: uuid`. """ - if getattr(Swagger, "_dify_inline_nested_dict_patch", False): + if getattr(Swagger, "_dify_swagger_compatibility_installed", False): return original_register_model = Swagger.register_model original_register_field = Swagger.register_field + original_extract_path_params = restx_swagger.extract_path_params + original_schema_from_parameter = Swagger.schema_from_parameter + original_description_for = Swagger.description_for + original_serialize_operation = Swagger.serialize_operation + original_parameters_and_request_body_for = Swagger.parameters_and_request_body_for + original_request_body_from_form_params = Swagger.request_body_from_form_params original_as_dict = Swagger.as_dict def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: @@ -134,6 +145,94 @@ def patch_swagger_for_inline_nested_dicts() -> None: original_register_field(self, field) + def schema_from_parameter_with_description(self: Swagger, param: dict[str, object]) -> dict[str, object]: + schema = cast(dict[str, object], original_schema_from_parameter(self, param)) + description = param.get("description") + if isinstance(description, str): + schema["description"] = description + return schema + + def extract_path_params_with_uuid_format(path: str): + params = original_extract_path_params(path) + for converter, _arguments, variable in restx_swagger.parse_rule(path): + if converter == "uuid" and variable in params: + params[variable]["format"] = "uuid" + return params + + def description_for_with_explicit_summary(self: Swagger, doc: dict[str, object], method: str): + method_doc = doc.get(method) + if ( + isinstance(method_doc, dict) + and isinstance(method_doc.get("summary"), str) + and isinstance(method_doc.get("description"), str) + ): + return method_doc["description"] + return original_description_for(self, doc, method) + + def serialize_operation_with_explicit_summary_tags( + self: Swagger, doc: dict[str, object], method: str, inherited_request_body=None + ): + operation = original_serialize_operation(self, doc, method, inherited_request_body) + method_doc = doc.get(method) + if not isinstance(method_doc, dict): + return operation + + summary = method_doc.get("summary") + if isinstance(summary, str): + operation["summary"] = summary + + tags = method_doc.get("tags") + if isinstance(tags, list) and all(isinstance(tag, str) for tag in tags): + operation["tags"] = tags + + return operation + + def serialize_resource_with_explicit_operation_tags(self: Swagger, ns, resource, url, route_doc=None, **kwargs): + doc = self.extract_resource_doc(resource, url, route_doc=route_doc) + if doc is False: + return None + + path_params, path_request_body = original_parameters_and_request_body_for(self, doc) + path: dict[str, object] = {"parameters": path_params or None} + methods = [method.lower() for method in resource.methods or []] + requested_methods = [method.lower() for method in kwargs.get("methods", [])] + for method in methods: + if doc[method] is False or requested_methods and method not in requested_methods: + continue + operation = self.serialize_operation(doc, method, path_request_body) + operation.setdefault("tags", [ns.name]) + path[method] = operation + return not_none(path) + + def request_body_from_form_params_with_file_description(self: Swagger, params: list[dict[str, object]]): + request_body = original_request_body_from_form_params(self, params) + for param in params: + if param.get("type") != "file": + continue + + name = param.get("name") + description = param.get("description") + if not isinstance(name, str) or not isinstance(description, str): + continue + + content = request_body.get("content") + if not isinstance(content, dict): + continue + multipart = content.get("multipart/form-data") + if not isinstance(multipart, dict): + continue + schema = multipart.get("schema") + if not isinstance(schema, dict): + continue + properties = schema.get("properties") + if not isinstance(properties, dict): + continue + file_schema = properties.get(name) + if isinstance(file_schema, dict): + file_schema["description"] = description + + return request_body + 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) @@ -145,5 +244,11 @@ def patch_swagger_for_inline_nested_dicts() -> None: Swagger.register_model = register_model_with_inline_dict_support Swagger.register_field = register_field_with_inline_dict_support + restx_swagger.extract_path_params = extract_path_params_with_uuid_format + Swagger.schema_from_parameter = schema_from_parameter_with_description + Swagger.description_for = description_for_with_explicit_summary + Swagger.serialize_operation = serialize_operation_with_explicit_summary_tags + Swagger.serialize_resource = serialize_resource_with_explicit_operation_tags + Swagger.request_body_from_form_params = request_body_from_form_params_with_file_description Swagger.as_dict = as_dict_with_inline_dict_support - Swagger._dify_inline_nested_dict_patch = True + Swagger._dify_swagger_compatibility_installed = True diff --git a/api/libs/helper.py b/api/libs/helper.py index ac85e88ef7f..7066f9eab45 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast, overload, over from uuid import UUID from zoneinfo import available_timezones -from flask import Response, stream_with_context +from flask import Request, Response, stream_with_context from flask_restx import fields from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config from pydantic.functional_validators import AfterValidator @@ -167,19 +167,6 @@ def build_avatar_url(avatar: str | None) -> str | None: return file_helpers.get_signed_file_url(avatar) -class AvatarUrlField(fields.Raw): - @override - def output(self, key, obj, **kwargs): - if obj is None: - return None - - from models import Account - - if isinstance(obj, Account) and obj.avatar is not None: - return build_avatar_url(obj.avatar) - return None - - class TimestampField(fields.Raw): @override def schema(self) -> dict[str, object]: @@ -397,7 +384,7 @@ def generate_string(n): return result -def extract_remote_ip(request) -> str: +def extract_remote_ip(request: Request) -> str: if request.headers.get("CF-Connecting-IP"): return cast(str, request.headers.get("CF-Connecting-IP")) elif request.headers.getlist("X-Forwarded-For"): diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py index 7433c6c177a..36de4b85ae0 100644 --- a/api/libs/oauth_bearer.py +++ b/api/libs/oauth_bearer.py @@ -490,7 +490,8 @@ def check_workspace_membership( account_id: uuid.UUID | str, tenant_id: str, token_hash: str, - membership_cache: dict[str, bool], + membership_cache: dict[str, bool] | None = None, + cached_verdicts: dict[str, bool] | None = None, ) -> None: """Layer-0 enforcement core. Raises `Forbidden` on deny, returns on allow. @@ -499,7 +500,8 @@ def check_workspace_membership( short-circuiting on EE / SSO subjects before invoking — this function runs the membership + active-status checks unconditionally. """ - cached = membership_cache.get(tenant_id) + cache = membership_cache if membership_cache is not None else cached_verdicts or {} + cached = cache.get(tenant_id) if cached is True: return if cached is False: diff --git a/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py b/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py new file mode 100644 index 00000000000..455d7446ca1 --- /dev/null +++ b/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py @@ -0,0 +1,41 @@ +"""add resource maintainers + +Revision ID: a7c4e9d2f681 +Revises: d2f1a4b8c3e0 +Create Date: 2026-06-15 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models.types + +# revision identifiers, used by Alembic. +revision = "a7c4e9d2f681" +down_revision = "d2f1a4b8c3e0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("maintainer", models.types.StringUUID(), nullable=True)) + batch_op.create_index("app_tenant_maintainer_idx", ["tenant_id", "maintainer"], unique=False) + + with op.batch_alter_table("datasets", schema=None) as batch_op: + batch_op.add_column(sa.Column("maintainer", models.types.StringUUID(), nullable=True)) + batch_op.create_index("dataset_tenant_maintainer_idx", ["tenant_id", "maintainer"], unique=False) + + op.execute(sa.text("UPDATE apps SET maintainer = created_by WHERE maintainer IS NULL")) + op.execute(sa.text("UPDATE datasets SET maintainer = created_by WHERE maintainer IS NULL")) + + +def downgrade() -> None: + with op.batch_alter_table("datasets", schema=None) as batch_op: + batch_op.drop_index("dataset_tenant_maintainer_idx") + batch_op.drop_column("maintainer") + + with op.batch_alter_table("apps", schema=None) as batch_op: + batch_op.drop_index("app_tenant_maintainer_idx") + batch_op.drop_column("maintainer") diff --git a/api/migrations/versions/2026_06_15_1500-4f7b2c8d9a10_normalize_legacy_end_user_type.py b/api/migrations/versions/2026_06_15_1500-4f7b2c8d9a10_normalize_legacy_end_user_type.py new file mode 100644 index 00000000000..9494d9af5f5 --- /dev/null +++ b/api/migrations/versions/2026_06_15_1500-4f7b2c8d9a10_normalize_legacy_end_user_type.py @@ -0,0 +1,24 @@ +"""normalize legacy end user type + +Revision ID: 4f7b2c8d9a10 +Revises: a7c4e9d2f681 +Create Date: 2026-06-15 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "4f7b2c8d9a10" +down_revision = "a7c4e9d2f681" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(sa.text("UPDATE end_users SET type = 'service-api' WHERE type = 'service_api'")) + + +def downgrade() -> None: + op.execute(sa.text("UPDATE end_users SET type = 'service_api' WHERE type = 'service-api'")) diff --git a/api/models/account.py b/api/models/account.py index 66766693a5b..df152e1783c 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -11,6 +11,8 @@ from sqlalchemy import DateTime, String, func, select from sqlalchemy.orm import Mapped, Session, mapped_column from typing_extensions import deprecated +from configs import dify_config + from .base import TypeBase from .engine import db from .types import EnumText, LongText, StringUUID @@ -187,10 +189,14 @@ class Account(UserMixin, TypeBase): # check current_user.current_tenant.current_role in ['admin', 'owner'] @property def is_admin_or_owner(self): + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_privileged_role(self.role) @property def is_admin(self): + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_admin_role(self.role) @property @@ -216,14 +222,20 @@ class Account(UserMixin, TypeBase): - `ADMIN` - `EDITOR` """ + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_editing_role(self.role) @property def is_dataset_editor(self): + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_dataset_edit_role(self.role) @property def is_dataset_operator(self): + if dify_config.RBAC_ENABLED: + return True return self.role == TenantAccountRole.DATASET_OPERATOR diff --git a/api/models/agent.py b/api/models/agent.py index 649835f5220..1a13ccde77b 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -324,8 +324,10 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base): ``workflow_id / workflow_run_id / node_id / binding_id / agent_config_snapshot_id / composition_layer_specs`` columns are set. - Agent App conversations: ``owner_type = conversation``; the - ``conversation_id`` and ``agent_config_snapshot_id`` columns are set and - the workflow columns stay NULL. + ``conversation_id`` column is set and the workflow columns stay NULL. + Published/web/API runs scope runtime state by ``agent_config_snapshot_id``; + console debugger runs may keep it NULL so prompt-only draft saves can reuse + the same preview conversation state while executing the latest Agent Soul. The snapshot is runtime state returned by Agent backend, kept separate from Agent Soul snapshots and workflow node-job config. diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 1f50924681f..76108f271d4 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -2,9 +2,9 @@ from __future__ import annotations import re from enum import StrEnum -from typing import Any, Final, Literal +from typing import Annotated, Any, Final, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator from core.workflow.file_reference import is_canonical_file_reference from graphon.file import FileTransferMethod @@ -29,6 +29,44 @@ class DeclaredOutputType(StrEnum): FILE = "file" +_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "name": {"type": "string"}, + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "required": {"type": "boolean"}, + "file": {"type": "object", "additionalProperties": True}, + "array_item": { + "type": "object", + "additionalProperties": True, + "properties": { + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, + }, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, + "required": ["name", "type"], + }, +} + +DeclaredOutputChildren = Annotated[ + list["DeclaredOutputChildConfig"], + WithJsonSchema(_DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA), +] + + class AgentCliToolAuthorizationStatus(StrEnum): """Authorization state for Agent-scoped CLI tools. @@ -148,6 +186,9 @@ class AgentSecretRefConfig(AgentFlexibleConfig): env_name: str | None = Field(default=None, max_length=255) variable: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) + # UI-facing selected secret reference. This is a credential/ref id, not the + # plaintext secret value; runtime maps it to the shell-layer ``ref``. + value: str | None = Field(default=None, max_length=255) id: str | None = Field(default=None, max_length=255) ref: str | None = Field(default=None, max_length=255) credential_id: str | None = Field(default=None, max_length=255) @@ -507,11 +548,55 @@ class DeclaredArrayItem(BaseModel): type: DeclaredOutputType description: str | None = None + children: DeclaredOutputChildren = Field(default_factory=list) @model_validator(mode="after") def _reject_nested_array(self) -> DeclaredArrayItem: if self.type == DeclaredOutputType.ARRAY: raise ValueError("nested arrays are not supported as array_item.type") + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("array_item.children is only allowed when array_item.type is object") + return self + + +class DeclaredOutputChildConfig(BaseModel): + """Nested field under an object-shaped declared output. + + The first backend version keeps child fields lightweight: they describe the + variable-picker/schema tree but do not own independent retry/check behavior. + """ + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + type: DeclaredOutputType + description: str | None = None + required: bool = True + file: DeclaredOutputFileConfig | None = None + array_item: DeclaredArrayItem | None = None + children: DeclaredOutputChildren = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_shape(self) -> DeclaredOutputChildConfig: + if not _OUTPUT_NAME_PATTERN.fullmatch(self.name): + raise ValueError( + f"output child name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} " + "(JSON-schema-friendly identifier)" + ) + if self.type == DeclaredOutputType.FILE: + if self.file is None: + self.file = DeclaredOutputFileConfig() + elif self.file is not None: + raise ValueError("file metadata is only allowed for file output children") + + if self.type == DeclaredOutputType.ARRAY: + if self.array_item is None: + self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT) + elif self.array_item is not None: + raise ValueError("array_item is only allowed when child type is array") + + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("children is only allowed for object output children") return self @@ -592,6 +677,7 @@ class DeclaredOutputConfig(BaseModel): required: bool = True file: DeclaredOutputFileConfig | None = None array_item: DeclaredArrayItem | None = None + children: DeclaredOutputChildren = Field(default_factory=list) check: DeclaredOutputCheckConfig | None = None failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy) @@ -625,6 +711,9 @@ class DeclaredOutputConfig(BaseModel): elif self.array_item is not None: raise ValueError("array_item is only allowed when type is array") + if self.children and self.type != DeclaredOutputType.OBJECT: + raise ValueError("children is only allowed for object outputs") + # Per PRD §OUTPUT 配置框: output check is file-only. if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE: raise ValueError("output check is only allowed for file outputs") diff --git a/api/models/dataset.py b/api/models/dataset.py index 1644551925b..998bc02ee85 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -167,6 +167,7 @@ class Dataset(Base): __table_args__ = ( sa.PrimaryKeyConstraint("id", name="dataset_pkey"), sa.Index("dataset_tenant_idx", "tenant_id"), + sa.Index("dataset_tenant_maintainer_idx", "tenant_id", "maintainer"), adjusted_json_index("retrieval_model_idx", "retrieval_model"), ) @@ -188,6 +189,7 @@ class Dataset(Base): indexing_technique: Mapped[IndexTechniqueType | None] = mapped_column(EnumText(IndexTechniqueType, length=255)) index_struct = mapped_column(LongText, nullable=True) created_by = mapped_column(StringUUID, nullable=False) + maintainer: Mapped[str | None] = mapped_column(StringUUID, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) updated_by = mapped_column(StringUUID, nullable=True) updated_at = mapped_column( diff --git a/api/models/enums.py b/api/models/enums.py index cdd2b136cfd..ae05fa242be 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -205,6 +205,16 @@ class InvokeFrom(StrEnum): return source_mapping.get(self, "dev") +class EndUserType(StrEnum): + """Persisted type values for the ``end_users.type`` column.""" + + BROWSER = "browser" + MCP = "mcp" + OPENAPI = "openapi" + SERVICE_API = "service-api" + TRIGGER = "trigger" + + class DocumentDocType(StrEnum): """Document doc_type classification""" diff --git a/api/models/model.py b/api/models/model.py index 09809b85f6b..4c73385f3aa 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -41,6 +41,7 @@ from .enums import ( ConversationStatus, CreatorUserRole, CustomizeTokenStrategy, + EndUserType, FeedbackFromSource, FeedbackRating, InvokeFrom, @@ -395,7 +396,11 @@ class IconType(StrEnum): class App(Base): __tablename__ = "apps" - __table_args__ = (sa.PrimaryKeyConstraint("id", name="app_pkey"), sa.Index("app_tenant_id_idx", "tenant_id")) + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="app_pkey"), + sa.Index("app_tenant_id_idx", "tenant_id"), + sa.Index("app_tenant_maintainer_idx", "tenant_id", "maintainer"), + ) if TYPE_CHECKING: # Response-only attributes attached by app list/detail enrichers. @@ -426,6 +431,7 @@ class App(Base): tracing = mapped_column(LongText, nullable=True) max_active_requests: Mapped[int | None] created_by = mapped_column(StringUUID, nullable=True) + maintainer: Mapped[str | None] = mapped_column(StringUUID, nullable=True) created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) updated_by = mapped_column(StringUUID, nullable=True) updated_at: Mapped[datetime] = mapped_column( @@ -1174,34 +1180,32 @@ class Conversation(Base): # Convert file mapping to File object for key, value in inputs.items(): - if ( - isinstance(value, dict) - and cast(dict[str, Any], value).get("dify_model_identity") == FILE_MODEL_IDENTITY - ): - value_dict = cast(dict[str, Any], value) - inputs[key] = build_file_from_input_mapping( - file_mapping=value_dict, - tenant_resolver=tenant_resolver, - ) - elif isinstance(value, list): - value_list = value - if all( - isinstance(item, dict) - and cast(dict[str, Any], item).get("dify_model_identity") == FILE_MODEL_IDENTITY - for item in value_list - ): - file_list: list[File] = [] - for item in value_list: - if not isinstance(item, dict): - continue - item_dict = cast(dict[str, Any], item) - file_list.append( - build_file_from_input_mapping( - file_mapping=item_dict, - tenant_resolver=tenant_resolver, + match value: + case dict() if cast(dict[str, Any], value).get("dify_model_identity") == FILE_MODEL_IDENTITY: + value_dict = cast(dict[str, Any], value) + inputs[key] = build_file_from_input_mapping( + file_mapping=value_dict, + tenant_resolver=tenant_resolver, + ) + case list(): + value_list = value + if all( + isinstance(item, dict) + and cast(dict[str, Any], item).get("dify_model_identity") == FILE_MODEL_IDENTITY + for item in value_list + ): + file_list: list[File] = [] + for item in value_list: + if not isinstance(item, dict): + continue + item_dict = cast(dict[str, Any], item) + file_list.append( + build_file_from_input_mapping( + file_mapping=item_dict, + tenant_resolver=tenant_resolver, + ) ) - ) - inputs[key] = file_list + inputs[key] = file_list return inputs @@ -1516,46 +1520,45 @@ class Message(Base): owner_tenant_id=cast(str | None, getattr(self, "_owner_tenant_id", None)), ) for key, value in inputs.items(): - if ( - isinstance(value, dict) - and cast(dict[str, Any], value).get("dify_model_identity") == FILE_MODEL_IDENTITY - ): - value_dict = cast(dict[str, Any], value) - inputs[key] = build_file_from_input_mapping( - file_mapping=value_dict, - tenant_resolver=tenant_resolver, - ) - elif isinstance(value, list): - value_list = value - if all( - isinstance(item, dict) - and cast(dict[str, Any], item).get("dify_model_identity") == FILE_MODEL_IDENTITY - for item in value_list - ): - file_list: list[File] = [] - for item in value_list: - if not isinstance(item, dict): - continue - item_dict = cast(dict[str, Any], item) - file_list.append( - build_file_from_input_mapping( - file_mapping=item_dict, - tenant_resolver=tenant_resolver, + match value: + case dict() if cast(dict[str, Any], value).get("dify_model_identity") == FILE_MODEL_IDENTITY: + value_dict = cast(dict[str, Any], value) + inputs[key] = build_file_from_input_mapping( + file_mapping=value_dict, + tenant_resolver=tenant_resolver, + ) + case list(): + value_list = value + if all( + isinstance(item, dict) + and cast(dict[str, Any], item).get("dify_model_identity") == FILE_MODEL_IDENTITY + for item in value_list + ): + file_list: list[File] = [] + for item in value_list: + if not isinstance(item, dict): + continue + item_dict = cast(dict[str, Any], item) + file_list.append( + build_file_from_input_mapping( + file_mapping=item_dict, + tenant_resolver=tenant_resolver, + ) ) - ) - inputs[key] = file_list + inputs[key] = file_list return inputs @inputs.setter def inputs(self, value: Mapping[str, Any]): inputs = dict(value) for k, v in inputs.items(): - if isinstance(v, File): - inputs[k] = v.model_dump() - elif isinstance(v, list): - v_list = v - if all(isinstance(item, File) for item in v_list): - inputs[k] = [item.model_dump() for item in v_list if isinstance(item, File)] + match v: + case File(): + inputs[k] = v.model_dump() + case list(): + v_list = v + if all(isinstance(item, File) for item in v_list): + inputs[k] = [item.model_dump() for item in v_list if isinstance(item, File)] self._inputs = inputs @property @@ -2081,7 +2084,7 @@ class EndUser(Base, UserMixin): id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id = mapped_column(StringUUID, nullable=True) - type: Mapped[str] = mapped_column(String(255), nullable=False) + type: Mapped[EndUserType] = mapped_column(EnumText(EndUserType, length=255), nullable=False) external_user_id = mapped_column(String(255), nullable=True) name = mapped_column(String(255)) _is_anonymous: Mapped[bool] = mapped_column( diff --git a/api/models/types.py b/api/models/types.py index 092db638565..c5a9231ad4a 100644 --- a/api/models/types.py +++ b/api/models/types.py @@ -96,12 +96,13 @@ class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]): def process_bind_param(self, value: T | dict[str, Any] | str | None, dialect: Dialect) -> str | None: if value is None: return None - if isinstance(value, self._model_class): - model = value - elif isinstance(value, str): - model = self._model_class.model_validate_json(value) - else: - model = self._model_class.model_validate(value) + match value: + case _ if isinstance(value, self._model_class): + model = value + case str(): + model = self._model_class.model_validate_json(value) + case _: + model = self._model_class.model_validate(value) return json.dumps(model.model_dump(mode="json"), ensure_ascii=False, sort_keys=True, separators=(",", ":")) @override diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 688f22e0e84..123a2e6e04b 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -4,10 +4,9 @@ Console management APIs for app configuration, monitoring, and administration ## Version: 1.0 ### Available authorizations -#### Bearer (API Key Authentication) -Type: Bearer {your-api-key} -**Name:** Authorization -**In:** header +#### Bearer (HTTP, bearer) +Use the Service API key as a Bearer token in the Authorization header. +Bearer format: API_KEY --- ## console @@ -311,7 +310,7 @@ Check if activation token is valid | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent app list | **application/json**: [AppPagination](#apppagination)
| +| 200 | Agent app list | **application/json**: [AgentAppPagination](#agentapppagination)
| ### [POST] /agent #### Request Body @@ -324,7 +323,7 @@ Check if activation token is valid | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Agent app created successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 201 | Agent app created successfully | **application/json**: [AgentAppDetailWithSite](#agentappdetailwithsite)
| | 400 | Invalid request parameters | | | 403 | Insufficient permissions | | @@ -349,7 +348,7 @@ Check if activation token is valid | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Responses @@ -363,20 +362,20 @@ Check if activation token is valid | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent app detail | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 200 | Agent app detail | **application/json**: [AgentAppDetailWithSite](#agentappdetailwithsite)
| ### [PUT] /agent/{agent_id} #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Request Body @@ -388,7 +387,7 @@ Check if activation token is valid | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Agent app updated successfully | **application/json**: [AppDetailWithSite](#appdetailwithsite)
| +| 200 | Agent app updated successfully | **application/json**: [AgentAppDetailWithSite](#agentappdetailwithsite)
| | 400 | Invalid request parameters | | | 403 | Insufficient permissions | | @@ -399,7 +398,7 @@ Get Agent App chat messages for a conversation with pagination | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | conversation_id | query | Conversation ID | Yes | string | | first_id | query | First message ID for pagination | No | string | | limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | @@ -418,8 +417,8 @@ Get suggested questions for an Agent App message | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | -| message_id | path | Message ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | +| message_id | path | Message ID | Yes | string (uuid) | #### Responses @@ -435,7 +434,7 @@ Stop a running Agent App chat message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | task_id | path | Task ID to stop | Yes | string | #### Responses @@ -449,7 +448,7 @@ Stop a running Agent App chat message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Responses @@ -462,7 +461,7 @@ Stop a running Agent App chat message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Request Body @@ -481,7 +480,7 @@ Stop a running Agent App chat message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Responses @@ -494,7 +493,7 @@ Stop a running Agent App chat message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Request Body @@ -508,6 +507,27 @@ Stop a running Agent App chat message generation | ---- | ----------- | ------ | | 200 | Agent app composer validation result | **application/json**: [AgentComposerValidateResponse](#agentcomposervalidateresponse)
| +### [POST] /agent/{agent_id}/copy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CopyAppPayload](#copyapppayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Agent app copied successfully | **application/json**: [AgentAppDetailWithSite](#agentappdetailwithsite)
| +| 400 | Invalid request parameters | | +| 403 | Insufficient permissions | | + ### [GET] /agent/{agent_id}/drive/files List agent drive entries for an Agent App @@ -515,7 +535,7 @@ List agent drive entries for an Agent App | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | #### Responses @@ -531,7 +551,7 @@ Time-limited external signed URL for one Agent App drive value | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | #### Responses @@ -547,7 +567,7 @@ Truncated text preview of one Agent App drive value | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | #### Responses @@ -563,7 +583,7 @@ Update an Agent App's presentation features (opener, follow-up, citations, ...) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | #### Request Body @@ -586,7 +606,7 @@ Create or update Agent App message feedback | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | #### Request Body @@ -608,7 +628,7 @@ Delete one Agent App drive file by key | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | key | query | Drive key, e.g. files/sample.pdf | Yes | string | #### Responses @@ -624,7 +644,7 @@ Commit an uploaded file into the Agent App drive under files/ | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | #### Request Body @@ -638,6 +658,68 @@ Commit an uploaded file into the Agent App drive under files/ | ---- | ----------- | ------ | | 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)
| +### [GET] /agent/{agent_id}/log-sources +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent log sources | **application/json**: [AgentLogSourceListResponse](#agentlogsourcelistresponse)
| + +### [GET] /agent/{agent_id}/logs +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search query, answer, or conversation name | No | string | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| sort_by | query | Sort by created_at or updated_at | No | string,
**Default:** updated_at | +| sort_order | query | Sort order: asc or desc | No | string,
**Default:** desc | +| source | query | Deprecated single source filter | No | string | +| sources | query | Filter by one or more source IDs, e.g. webapp: or workflow:::: | No | [ string ] | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| status | query | Deprecated single status filter | No | string | +| statuses | query | Filter by one or more of success, failed, paused | No | [ string ] | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent logs | **application/json**: [AgentLogListResponse](#agentloglistresponse)
| + +### [GET] /agent/{agent_id}/logs/{conversation_id}/messages +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| keyword | query | Search query, answer, or conversation name | No | string | +| limit | query | Page size | No | integer,
**Default:** 20 | +| page | query | Page number | No | integer,
**Default:** 1 | +| sort_by | query | Sort by created_at or updated_at | No | string,
**Default:** updated_at | +| sort_order | query | Sort order: asc or desc | No | string,
**Default:** desc | +| source | query | Deprecated single source filter | No | string | +| sources | query | Filter by one or more source IDs, e.g. webapp: or workflow:::: | No | [ string ] | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| status | query | Deprecated single status filter | No | string | +| statuses | query | Filter by one or more of success, failed, paused | No | [ string ] | +| agent_id | path | | Yes | string (uuid) | +| conversation_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent log messages | **application/json**: [AgentLogMessageListResponse](#agentlogmessagelistresponse)
| + ### [GET] /agent/{agent_id}/messages/{message_id} Get Agent App message details by ID @@ -645,8 +727,8 @@ Get Agent App message details by ID | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | -| message_id | path | Message ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | +| message_id | path | Message ID | Yes | string (uuid) | #### Responses @@ -662,7 +744,7 @@ List workflow apps that reference this Agent App's bound Agent (read-only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | #### Responses @@ -678,7 +760,7 @@ List a directory in an Agent App conversation sandbox | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | conversation_id | query | Agent App conversation ID | Yes | string | | path | query | Directory path relative to the sandbox workspace | No | string,
**Default:** . | @@ -695,7 +777,7 @@ Read a text/binary preview file in an Agent App conversation sandbox | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | conversation_id | query | Agent App conversation ID | Yes | string | | path | query | File path relative to the sandbox workspace | Yes | string | @@ -712,7 +794,7 @@ Upload one Agent App sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Request Body @@ -726,37 +808,27 @@ Upload one Agent App sandbox file as a Dify ToolFile mapping | ---- | ----------- | ------ | | 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)
| -### [POST] /agent/{agent_id}/skills/standardize -Validate + standardize a Skill into an Agent App drive - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Skill standardized into drive | **application/json**: [AgentSkillStandardizeResponse](#agentskillstandardizeresponse)
| -| 400 | Invalid skill package or no bound agent | | - ### [POST] /agent/{agent_id}/skills/upload -Upload + validate a Skill package for an Agent App +Upload + standardize a Skill into an Agent App drive #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary }
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| -| 400 | Invalid skill package | | +| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| +| 400 | Invalid skill package or no bound agent | | ### [DELETE] /agent/{agent_id}/skills/{slug} Delete a standardized skill from an Agent App drive @@ -765,7 +837,7 @@ Delete a standardized skill from an Agent App drive | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | slug | path | Skill slug (single path segment) | Yes | string | #### Responses @@ -781,7 +853,7 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | Agent ID | Yes | string | +| agent_id | path | Agent ID | Yes | string (uuid) | | slug | path | Skill slug (single path segment) | Yes | string | #### Responses @@ -790,12 +862,28 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | ---- | ----------- | ------ | | 200 | Inference result (draft suggestions, nothing persisted) | **application/json**: [SkillToolInferenceResult](#skilltoolinferenceresult)
| +### [GET] /agent/{agent_id}/statistics/summary +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| end | query | End date (YYYY-MM-DD HH:MM) | No | string | +| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| start | query | Start date (YYYY-MM-DD HH:MM) | No | string | +| agent_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Agent monitoring summary and chart data | **application/json**: [AgentStatisticSummaryEnvelopeResponse](#agentstatisticsummaryenveloperesponse)
| + ### [GET] /agent/{agent_id}/versions #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | #### Responses @@ -808,8 +896,8 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| agent_id | path | | Yes | string | -| version_id | path | | Yes | string | +| agent_id | path | | Yes | string (uuid) | +| version_id | path | | Yes | string (uuid) | #### Responses @@ -862,7 +950,7 @@ Delete API-based extension | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| id | path | Extension ID | Yes | string | +| id | path | Extension ID | Yes | string (uuid) | #### Responses @@ -877,7 +965,7 @@ Get API-based extension by ID | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| id | path | Extension ID | Yes | string | +| id | path | Extension ID | Yes | string (uuid) | #### Responses @@ -892,7 +980,7 @@ Update API-based extension | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| id | path | Extension ID | Yes | string | +| id | path | Extension ID | Yes | string (uuid) | #### Request Body @@ -931,7 +1019,7 @@ Update API-based extension | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| binding_id | path | | Yes | string | +| binding_id | path | | Yes | string (uuid) | #### Responses @@ -1100,7 +1188,7 @@ Delete application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -1118,7 +1206,7 @@ Get application details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -1135,7 +1223,7 @@ Update application details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1160,7 +1248,7 @@ Get advanced chat workflow run list | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | last_id | query | Last run ID for pagination | No | string | | limit | query | Number of items per page (1-100) | No | integer,
**Default:** 20 | | status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | @@ -1179,7 +1267,7 @@ Get advanced chat workflow run list | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | 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,
**Available values:** "app-run", "debugging" | @@ -1199,7 +1287,7 @@ Get human input form preview for advanced chat workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -1223,7 +1311,7 @@ Submit human input form preview for advanced chat workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -1247,7 +1335,7 @@ Run draft workflow iteration node for advanced chat | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -1273,7 +1361,7 @@ Run draft workflow loop node for advanced chat | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -1299,7 +1387,7 @@ Run draft workflow for advanced chat application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1322,7 +1410,7 @@ List agent drive entries (read-only inspector; one endpoint for both tabs) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | query | Workflow node ID (workflow composer variant) | No | string | | prefix | query | Key prefix filter: '/' for one skill, 'files/' for files | No | string | @@ -1339,7 +1427,7 @@ Time-limited external signed URL for one drive value (no streaming proxy) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | | node_id | query | Workflow node ID (workflow composer variant) | No | string | @@ -1356,7 +1444,7 @@ Truncated text preview of one drive value (binary-safe; SKILL.md is the main cas | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | key | query | Drive key, e.g. tender-analyzer/SKILL.md | Yes | string | | node_id | query | Workflow node ID (workflow composer variant) | No | string | @@ -1373,7 +1461,7 @@ Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | key | query | Drive key, e.g. files/sample.pdf | Yes | string | | node_id | query | Workflow node ID (workflow composer variant) | No | string | @@ -1392,7 +1480,7 @@ Commit an uploaded file into the agent drive under files/ (ENG-625 D3) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | query | Workflow node ID (workflow composer variant) | No | string | #### Request Body @@ -1416,7 +1504,7 @@ Get agent execution logs for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | conversation_id | query | Conversation UUID | Yes | string | | message_id | query | Message UUID | Yes | string | @@ -1427,45 +1515,31 @@ Get agent execution logs for an application | 200 | Agent logs retrieved successfully | **application/json**: [AgentLogResponse](#agentlogresponse)
| | 400 | Invalid request parameters | | -### [POST] /apps/{app_id}/agent/skills/standardize -**Upload a Skill, validate it, and standardize it into the app agent's drive** +### [POST] /apps/{app_id}/agent/skills/upload +**Upload a Skill, validate it, and commit drive-backed skill files** -Validate + standardize a Skill into the agent drive (ENG-594) +Upload + standardize a Skill into the agent drive #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | query | Workflow node ID (workflow composer variant) | No | string | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary }
| + #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Skill standardized into drive | **application/json**: [AgentSkillStandardizeResponse](#agentskillstandardizeresponse)
| +| 201 | Skill uploaded into drive | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| | 400 | Invalid skill package or no bound agent | | -### [POST] /apps/{app_id}/agent/skills/upload -**Validate an uploaded Skill package and persist the archive** - -Upload + validate a Skill package (.zip/.skill) and extract its manifest -Returns a validated skill ref (to bind into the Agent soul config on save) -plus its manifest. Standardizing into the agent drive is ENG-594. - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Skill validated | **application/json**: [AgentSkillUploadResponse](#agentskilluploadresponse)
| -| 400 | Invalid skill package | | - ### [DELETE] /apps/{app_id}/agent/skills/{slug} Delete a standardized skill: soul ref first, then the / drive prefix (ENG-625 D5) @@ -1473,7 +1547,7 @@ Delete a standardized skill: soul ref first, then the / drive prefix (ENG- | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | slug | path | Skill slug (single path segment) | Yes | string | | node_id | query | Workflow node ID (workflow composer variant) | No | string | @@ -1493,7 +1567,7 @@ Saving still goes through composer validation. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | slug | path | Skill slug (single path segment) | Yes | string | | node_id | query | Workflow node ID (workflow composer variant) | No | string | @@ -1511,7 +1585,7 @@ Enable or disable annotation reply for an app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | Action to perform (enable/disable) | Yes | string | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1534,8 +1608,8 @@ Get status of annotation reply action job | 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 | +| app_id | path | Application ID | Yes | string (uuid) | +| job_id | path | Job ID | Yes | string (uuid) | #### Responses @@ -1551,7 +1625,7 @@ Get annotation settings for an app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -1567,8 +1641,8 @@ Update annotation settings for an app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| annotation_setting_id | path | Annotation setting ID | Yes | string | -| app_id | path | Application ID | Yes | string | +| annotation_setting_id | path | Annotation setting ID | Yes | string (uuid) | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1588,7 +1662,7 @@ Update annotation settings for an app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -1603,7 +1677,7 @@ Get annotations for an app with pagination | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | keyword | query | Search keyword | No | string | | limit | query | Page size | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | @@ -1622,7 +1696,7 @@ Create a new annotation for an app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1644,7 +1718,7 @@ Batch import annotations from CSV file with rate limiting and security checks | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -1663,8 +1737,8 @@ Get status of batch import job | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| job_id | path | Job ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| job_id | path | Job ID | Yes | string (uuid) | #### Responses @@ -1680,7 +1754,7 @@ Get count of message annotations for the app | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -1695,7 +1769,7 @@ Export all annotations for an app with CSV injection protection | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -1709,8 +1783,8 @@ Export all annotations for an app with CSV injection protection | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| annotation_id | path | | Yes | string | -| app_id | path | | Yes | string | +| annotation_id | path | | Yes | string (uuid) | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -1725,8 +1799,8 @@ Update or delete an annotation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| annotation_id | path | Annotation ID | Yes | string | -| app_id | path | Application ID | Yes | string | +| annotation_id | path | Annotation ID | Yes | string (uuid) | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1749,8 +1823,8 @@ Get hit histories for an annotation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| annotation_id | path | Annotation ID | Yes | string | -| app_id | path | Application ID | Yes | string | +| annotation_id | path | Annotation ID | Yes | string (uuid) | +| app_id | path | Application ID | Yes | string (uuid) | | limit | query | Page size | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | @@ -1768,7 +1842,7 @@ Enable or disable app API | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1790,7 +1864,7 @@ Transcript audio to text for chat messages | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | App ID | Yes | string | +| app_id | path | App ID | Yes | string (uuid) | #### Responses @@ -1807,7 +1881,7 @@ Get chat conversations with pagination, filtering and summary | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | annotation_status | query | Annotation status filter | No | string,
**Available values:** "all", "annotated", "not_annotated",
**Default:** all | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | keyword | query | Search keyword | No | string | @@ -1830,8 +1904,8 @@ Delete a chat conversation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | path | Conversation ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| conversation_id | path | Conversation ID | Yes | string (uuid) | #### Responses @@ -1848,8 +1922,8 @@ Get chat conversation details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | path | Conversation ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| conversation_id | path | Conversation ID | Yes | string (uuid) | #### Responses @@ -1866,7 +1940,7 @@ Get chat messages for a conversation with pagination | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | conversation_id | query | Conversation ID | Yes | string | | first_id | query | First message ID for pagination | No | string | | limit | query | Number of messages to return (1-100) | No | integer,
**Default:** 20 | @@ -1885,8 +1959,8 @@ Get suggested questions for a message | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| message_id | path | Message ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| message_id | path | Message ID | Yes | string (uuid) | #### Responses @@ -1902,7 +1976,7 @@ Stop a running chat message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | task_id | path | Task ID to stop | Yes | string | #### Responses @@ -1918,7 +1992,7 @@ Get completion conversations with pagination and filtering | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | annotation_status | query | Annotation status filter | No | string,
**Available values:** "all", "annotated", "not_annotated",
**Default:** all | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | keyword | query | Search keyword | No | string | @@ -1940,8 +2014,8 @@ Delete a completion conversation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | path | Conversation ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| conversation_id | path | Conversation ID | Yes | string (uuid) | #### Responses @@ -1958,8 +2032,8 @@ Get completion conversation details with messages | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| conversation_id | path | Conversation ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| conversation_id | path | Conversation ID | Yes | string (uuid) | #### Responses @@ -1976,7 +2050,7 @@ Generate completion message for debugging | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -1999,7 +2073,7 @@ Stop a running completion message generation | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | task_id | path | Task ID to stop | Yes | string | #### Responses @@ -2015,7 +2089,7 @@ Get conversation variables for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | conversation_id | query | Conversation ID to filter variables | Yes | string | #### Responses @@ -2035,7 +2109,7 @@ Convert Completion App to Workflow App | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2060,7 +2134,7 @@ Create a copy of an existing application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID to copy | Yes | string | +| app_id | path | Application ID to copy | Yes | string (uuid) | #### Request Body @@ -2084,7 +2158,7 @@ Export application configuration as DSL | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID to export | Yes | string | +| app_id | path | Application ID to export | Yes | string (uuid) | | include_secret | query | Include secrets in export | No | boolean | | workflow_id | query | Specific workflow ID to export | No | string | @@ -2102,7 +2176,7 @@ Create or update message feedback (like/dislike) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2125,7 +2199,7 @@ Export user feedback data for Google Sheets | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end_date | query | End date (YYYY-MM-DD) | No | string | | format | query | Export format | No | string,
**Available values:** "csv", "json",
**Default:** csv | | from_source | query | Filter by feedback source | No | string,
**Available values:** "admin", "user" | @@ -2148,7 +2222,7 @@ Update application icon | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2170,8 +2244,8 @@ Get message details by ID | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| message_id | path | Message ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| message_id | path | Message ID | Yes | string (uuid) | #### Responses @@ -2189,7 +2263,7 @@ Update application model configuration | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2212,7 +2286,7 @@ Check if app name is available | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2233,7 +2307,7 @@ Check if app name is available | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -2248,7 +2322,7 @@ Get MCP server configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -2263,7 +2337,7 @@ Create MCP server configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2285,7 +2359,7 @@ Update MCP server configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2308,7 +2382,7 @@ Update application site configuration | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2331,7 +2405,7 @@ Enable or disable app site | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2353,7 +2427,7 @@ Reset access token for application site | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -2370,7 +2444,7 @@ Remove the current account's star from an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -2386,7 +2460,7 @@ Star an application for the current account | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -2402,7 +2476,7 @@ Get average response time statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2419,7 +2493,7 @@ Get average session interaction statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2436,7 +2510,7 @@ Get daily conversation statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2453,7 +2527,7 @@ Get daily terminal/end-user statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2470,7 +2544,7 @@ Get daily message statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2487,7 +2561,7 @@ Get daily token cost statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2504,7 +2578,7 @@ Get tokens per second statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2521,7 +2595,7 @@ Get user satisfaction rate statistics for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | @@ -2538,7 +2612,7 @@ Convert text to speech for chat messages | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | App ID | Yes | string | +| app_id | path | App ID | Yes | string (uuid) | #### Request Body @@ -2560,7 +2634,7 @@ Get available TTS voices for a specific language | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | App ID | Yes | string | +| app_id | path | App ID | Yes | string (uuid) | | language | query | Language code | Yes | string | #### Responses @@ -2579,7 +2653,7 @@ Get app tracing configuration | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -2594,7 +2668,7 @@ Update app tracing configuration | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2618,7 +2692,7 @@ Delete an existing tracing configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | tracing_provider | query | Tracing provider name | Yes | string | #### Responses @@ -2635,7 +2709,7 @@ Get tracing configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | tracing_provider | query | Tracing provider name | Yes | string | #### Responses @@ -2654,7 +2728,7 @@ Update an existing tracing configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2678,7 +2752,7 @@ Create a new tracing configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2700,7 +2774,7 @@ Create a new tracing configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -2721,7 +2795,7 @@ Create a new tracing configuration for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -2738,7 +2812,7 @@ Get workflow application execution logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | created_at__after | query | Filter logs created after this timestamp | No | dateTime | | created_at__before | query | Filter logs created before this timestamp | No | dateTime | | created_by_account | query | Filter by account | No | string | @@ -2764,7 +2838,7 @@ Get workflow archived execution logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | created_at__after | query | Filter logs created after this timestamp | No | dateTime | | created_at__before | query | Filter logs created before this timestamp | No | dateTime | | created_by_account | query | Filter by account | No | string | @@ -2788,7 +2862,7 @@ Get workflow archived execution logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | last_id | query | Last run ID for pagination | No | string | | limit | query | Number of items per page (1-100) | No | integer,
**Default:** 20 | | status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | @@ -2807,7 +2881,7 @@ Get workflow archived execution logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | status | query | Workflow run status filter | No | string,
**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | 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,
**Available values:** "app-run", "debugging" | @@ -2827,7 +2901,7 @@ Stop running workflow task | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | task_id | path | Task ID | Yes | string | #### Responses @@ -2845,8 +2919,8 @@ Stop running workflow task | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -2862,8 +2936,8 @@ Generate a download URL for an archived workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -2878,8 +2952,8 @@ Generate a download URL for an archived workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -2895,9 +2969,9 @@ List a directory in a workflow Agent node sandbox | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Workflow Agent node ID | Yes | string | -| workflow_run_id | path | Workflow run ID | Yes | string | +| workflow_run_id | path | Workflow run ID | Yes | string (uuid) | | node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | | path | query | Directory path relative to the sandbox workspace | No | string,
**Default:** . | @@ -2914,9 +2988,9 @@ Read a text/binary preview file in a workflow Agent node sandbox | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Workflow Agent node ID | Yes | string | -| workflow_run_id | path | Workflow run ID | Yes | string | +| workflow_run_id | path | Workflow run ID | Yes | string (uuid) | | node_execution_id | query | Optional workflow node execution ID. When omitted, the latest active session for the node is used. | No | string | | path | query | File path relative to the sandbox workspace | Yes | string | @@ -2933,9 +3007,9 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | -| workflow_run_id | path | | Yes | string | +| workflow_run_id | path | | Yes | string (uuid) | #### Request Body @@ -2956,7 +3030,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -2971,7 +3045,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -2992,7 +3066,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -3007,7 +3081,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | #### Responses @@ -3023,7 +3097,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | #### Responses @@ -3039,7 +3113,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | #### Request Body @@ -3061,7 +3135,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | #### Request Body @@ -3083,7 +3157,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | | reply_id | path | Reply ID | Yes | string | @@ -3100,7 +3174,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | | reply_id | path | Reply ID | Yes | string | @@ -3123,7 +3197,7 @@ Upload one workflow Agent sandbox file as a Dify ToolFile mapping | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | comment_id | path | Comment ID | Yes | string | #### Responses @@ -3139,7 +3213,7 @@ Get workflow average app interaction statistics | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | @@ -3156,7 +3230,7 @@ Get workflow daily runs statistics | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | @@ -3173,7 +3247,7 @@ Get workflow daily terminals statistics | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | @@ -3190,7 +3264,7 @@ Get workflow daily token cost statistics | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | end | query | End date and time (YYYY-MM-DD HH:MM) | No | string | | start | query | Start date and time (YYYY-MM-DD HH:MM) | No | string | @@ -3209,7 +3283,7 @@ Get all published workflows for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | limit | query | | No | integer,
**Default:** 10 | | named_only | query | | No | boolean | | page | query | | No | integer,
**Default:** 1 | @@ -3230,7 +3304,7 @@ Get default block configurations for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -3247,7 +3321,7 @@ Get default block configuration by type | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | block_type | path | Block type | Yes | string | | q | query | | No | string | @@ -3267,7 +3341,7 @@ Get draft workflow for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -3285,7 +3359,7 @@ Sync draft workflow configuration | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -3308,7 +3382,7 @@ Get conversation variables for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -3324,7 +3398,7 @@ Update conversation variables for workflow draft | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -3347,7 +3421,7 @@ Get environment variables for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -3363,7 +3437,7 @@ Update environment variables for workflow draft | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -3384,7 +3458,7 @@ Update draft workflow features | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -3407,7 +3481,7 @@ Test human input delivery for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -3431,7 +3505,7 @@ Get human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -3455,7 +3529,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -3477,7 +3551,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -3501,7 +3575,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -3523,7 +3597,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Responses @@ -3537,7 +3611,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Request Body @@ -3557,7 +3631,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Responses @@ -3571,7 +3645,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Request Body @@ -3591,7 +3665,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Request Body @@ -3611,7 +3685,7 @@ Submit human input form preview for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Request Body @@ -3633,7 +3707,7 @@ Get last run result for draft workflow node | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Responses @@ -3651,7 +3725,7 @@ Get last run result for draft workflow node | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Request Body @@ -3675,7 +3749,7 @@ Get last run result for draft workflow node | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Responses @@ -3693,7 +3767,7 @@ Delete all variables for a specific node | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | node_id | path | | Yes | string | #### Responses @@ -3709,7 +3783,7 @@ Get variables for a specific node | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID | Yes | string | #### Responses @@ -3725,7 +3799,7 @@ Get variables for a specific node | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -3747,8 +3821,8 @@ Snapshot of every node's declared outputs for a draft workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -3764,8 +3838,8 @@ Server-Sent Events stream of inspector deltas for a draft workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -3781,9 +3855,9 @@ One node's declared outputs for a draft workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID inside the workflow graph | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -3799,10 +3873,10 @@ Full value for one declared output, including signed download URL for files. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID inside the workflow graph | Yes | string | | output_name | path | Declared output name as exposed by Composer | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -3818,7 +3892,7 @@ Get system variables for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -3833,7 +3907,7 @@ Get system variables for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -3856,7 +3930,7 @@ Get system variables for workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Request Body @@ -3879,7 +3953,7 @@ Delete all draft workflow variables | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -3896,7 +3970,7 @@ Get draft workflow variables | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | limit | query | Items per page | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | @@ -3913,8 +3987,8 @@ Delete a workflow variable | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -3930,8 +4004,8 @@ Get a specific workflow variable | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| variable_id | path | Variable ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| variable_id | path | Variable ID | Yes | string (uuid) | #### Responses @@ -3947,8 +4021,8 @@ Update a workflow variable | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Request Body @@ -3970,8 +4044,8 @@ Reset a workflow variable to its default value | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| variable_id | path | Variable ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| variable_id | path | Variable ID | Yes | string (uuid) | #### Responses @@ -3990,7 +4064,7 @@ Get published workflow for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | #### Responses @@ -4005,7 +4079,7 @@ Get published workflow for an application | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -4026,8 +4100,8 @@ Snapshot of every node's declared outputs for a published workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -4043,8 +4117,8 @@ Server-Sent Events stream of inspector deltas for a published workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -4060,9 +4134,9 @@ One node's declared outputs for a published workflow run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID inside the workflow graph | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -4078,10 +4152,10 @@ Full value for one declared output of a published run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | node_id | path | Node ID inside the workflow graph | Yes | string | | output_name | path | Declared output name as exposed by Composer | Yes | string | -| run_id | path | Workflow run ID | Yes | string | +| run_id | path | Workflow run ID | Yes | string (uuid) | #### Responses @@ -4098,7 +4172,7 @@ Full value for one declared output of a published run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | query | | Yes | string | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -4113,7 +4187,7 @@ Full value for one declared output of a published run. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | workflow_id | path | | Yes | string | #### Responses @@ -4131,7 +4205,7 @@ Update workflow by ID | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | workflow_id | path | Workflow ID | Yes | string | #### Request Body @@ -4155,7 +4229,7 @@ Restore a published workflow version into the draft workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | Application ID | Yes | string | +| app_id | path | Application ID | Yes | string (uuid) | | workflow_id | path | Published workflow ID | Yes | string | #### Responses @@ -4173,7 +4247,7 @@ Restore a published workflow version into the draft workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| resource_id | path | App ID | Yes | string | +| resource_id | path | App ID | Yes | string (uuid) | #### Responses @@ -4188,7 +4262,7 @@ Restore a published workflow version into the draft workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| resource_id | path | App ID | Yes | string | +| resource_id | path | App ID | Yes | string (uuid) | #### Responses @@ -4204,8 +4278,8 @@ Restore a published workflow version into the draft workflow | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| api_key_id | path | API key ID | Yes | string | -| resource_id | path | App ID | Yes | string | +| api_key_id | path | API key ID | Yes | string (uuid) | +| resource_id | path | App ID | Yes | string (uuid) | #### Responses @@ -4220,7 +4294,7 @@ Refresh MCP server configuration and regenerate server code | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| server_id | path | Server ID | Yes | string | +| server_id | path | Server ID | Yes | string (uuid) | #### Responses @@ -4477,7 +4551,7 @@ Get compliance document download link | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | -| binding_id | path | | Yes | string | +| binding_id | path | | Yes | string (uuid) | #### Responses @@ -4491,7 +4565,7 @@ Get compliance document download link | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | -| binding_id | path | | Yes | string | +| binding_id | path | | Yes | string (uuid) | #### Responses @@ -4568,7 +4642,7 @@ Delete dataset API key | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| api_key_id | path | API key ID | Yes | string | +| api_key_id | path | API key ID | Yes | string (uuid) | #### Responses @@ -4581,7 +4655,7 @@ Delete dataset API key | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| job_id | path | | Yes | string | +| job_id | path | | Yes | string (uuid) | #### Responses @@ -4594,7 +4668,7 @@ Delete dataset API key | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| job_id | path | | Yes | string | +| job_id | path | | Yes | string (uuid) | #### Request Body @@ -4660,7 +4734,7 @@ Get external knowledge API templates | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| external_knowledge_api_id | path | | Yes | string | +| external_knowledge_api_id | path | | Yes | string (uuid) | #### Responses @@ -4675,7 +4749,7 @@ Get external knowledge API template details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| external_knowledge_api_id | path | External knowledge API ID | Yes | string | +| external_knowledge_api_id | path | External knowledge API ID | Yes | string (uuid) | #### Responses @@ -4689,7 +4763,7 @@ Get external knowledge API template details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| external_knowledge_api_id | path | | Yes | string | +| external_knowledge_api_id | path | | Yes | string (uuid) | #### Request Body @@ -4710,7 +4784,7 @@ Check if external knowledge API is being used | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| external_knowledge_api_id | path | External knowledge API ID | Yes | string | +| external_knowledge_api_id | path | External knowledge API ID | Yes | string (uuid) | #### Responses @@ -4813,7 +4887,7 @@ Get mock dataset retrieval settings by vector type | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -4828,7 +4902,7 @@ Get dataset details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -4845,7 +4919,7 @@ Update dataset details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Request Body @@ -4866,7 +4940,7 @@ Update dataset details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | | status | path | | Yes | string | #### Responses @@ -4882,7 +4956,7 @@ Get dataset auto disable logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -4897,7 +4971,7 @@ Get dataset auto disable logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | batch | path | | Yes | string | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -4911,7 +4985,7 @@ Get dataset auto disable logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | batch | path | | Yes | string | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -4924,7 +4998,7 @@ Get dataset auto disable logs | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -4939,7 +5013,7 @@ Get documents in a dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | | 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 | @@ -4958,7 +5032,7 @@ Get documents in a dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Request Body @@ -4981,7 +5055,7 @@ Download selected dataset documents as a single ZIP archive (upload-file only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Request Body @@ -5007,7 +5081,7 @@ then asynchronously generates summary indexes for the provided documents. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Request Body @@ -5029,7 +5103,7 @@ then asynchronously generates summary indexes for the provided documents. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Request Body @@ -5049,7 +5123,7 @@ then asynchronously generates summary indexes for the provided documents. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -5062,8 +5136,8 @@ then asynchronously generates summary indexes for the provided documents. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5078,8 +5152,8 @@ Get document details | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | | metadata | query | Metadata inclusion (all/only/without) | No | string | #### Responses @@ -5096,8 +5170,8 @@ Get a signed download URL for a dataset document's original uploaded file | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5112,8 +5186,8 @@ Estimate document indexing cost | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | #### Responses @@ -5130,8 +5204,8 @@ Get document indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | #### Responses @@ -5147,8 +5221,8 @@ Update document metadata | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | #### Request Body @@ -5169,8 +5243,8 @@ Update document metadata | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5183,8 +5257,8 @@ Update document metadata | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5199,8 +5273,8 @@ Update document metadata | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5215,8 +5289,8 @@ Update document metadata | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5232,8 +5306,8 @@ Update document processing status (pause/resume) | 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 | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | #### Responses @@ -5248,8 +5322,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Request Body @@ -5268,8 +5342,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | #### Request Body @@ -5289,8 +5363,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | Action | Yes | string | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | | segment_id | query | Segment IDs | No | [ string ] | #### Responses @@ -5304,8 +5378,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | | segment_id | query | Segment IDs | No | [ string ] | #### Responses @@ -5319,8 +5393,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | | enabled | query | | No | string,
**Default:** all | | hit_count_gte | query | | No | integer | | keyword | query | | No | string | @@ -5339,8 +5413,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5353,8 +5427,8 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Request Body @@ -5373,9 +5447,9 @@ Update document processing status (pause/resume) | 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 | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Segment ID | Yes | string (uuid) | #### Responses @@ -5388,9 +5462,9 @@ Update document processing status (pause/resume) | 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 | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Segment ID | Yes | string (uuid) | #### Request Body @@ -5409,9 +5483,9 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| segment_id | path | Parent segment ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Parent segment ID | Yes | string (uuid) | | keyword | query | | No | string | | limit | query | | No | integer,
**Default:** 20 | | page | query | | No | integer,
**Default:** 1 | @@ -5427,9 +5501,9 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| segment_id | path | Parent segment ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Parent segment ID | Yes | string (uuid) | #### Request Body @@ -5448,9 +5522,9 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| segment_id | path | Parent segment ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Parent segment ID | Yes | string (uuid) | #### Request Body @@ -5469,10 +5543,10 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| child_chunk_id | path | Child chunk ID | Yes | string | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| segment_id | path | Parent segment ID | Yes | string | +| child_chunk_id | path | Child chunk ID | Yes | string (uuid) | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Parent segment ID | Yes | string (uuid) | #### Responses @@ -5485,10 +5559,10 @@ Update document processing status (pause/resume) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| child_chunk_id | path | Child chunk ID | Yes | string | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| segment_id | path | Parent segment ID | Yes | string | +| child_chunk_id | path | Child chunk ID | Yes | string (uuid) | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | +| segment_id | path | Parent segment ID | Yes | string (uuid) | #### Request Body @@ -5519,8 +5593,8 @@ Returns: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | +| document_id | path | Document ID | Yes | string (uuid) | #### Responses @@ -5536,8 +5610,8 @@ Returns: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| document_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| document_id | path | | Yes | string (uuid) | #### Responses @@ -5552,7 +5626,7 @@ Get dataset error documents | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5568,7 +5642,7 @@ Test external knowledge retrieval for dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Request Body @@ -5591,7 +5665,7 @@ Test dataset knowledge retrieval | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Request Body @@ -5614,7 +5688,7 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5627,7 +5701,7 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -5640,7 +5714,7 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Request Body @@ -5660,7 +5734,7 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | action | path | | Yes | string | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -5673,8 +5747,8 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| metadata_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| metadata_id | path | | Yes | string (uuid) | #### Responses @@ -5687,8 +5761,8 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| metadata_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | +| metadata_id | path | | Yes | string (uuid) | #### Request Body @@ -5707,7 +5781,7 @@ Get dataset indexing status | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -5722,7 +5796,7 @@ Get dataset permission user list | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5739,7 +5813,7 @@ Get dataset query history | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5754,7 +5828,7 @@ Get applications related to dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5769,7 +5843,7 @@ Get applications related to dataset | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Request Body @@ -5790,7 +5864,7 @@ Check if dataset is in use | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5805,7 +5879,7 @@ Check if dataset is in use | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| resource_id | path | Dataset ID | Yes | string | +| resource_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5820,7 +5894,7 @@ Check if dataset is in use | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| resource_id | path | Dataset ID | Yes | string | +| resource_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5836,8 +5910,8 @@ Check if dataset is in use | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| api_key_id | path | API key ID | Yes | string | -| resource_id | path | Dataset ID | Yes | string | +| api_key_id | path | API key ID | Yes | string (uuid) | +| resource_id | path | Dataset ID | Yes | string (uuid) | #### Responses @@ -5941,7 +6015,7 @@ Check if dataset is in use | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -5993,7 +6067,7 @@ Check if dataset is in use | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| file_id | path | | Yes | string | +| file_id | path | | Yes | string (uuid) | #### Responses @@ -6135,7 +6209,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6148,7 +6222,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6167,7 +6241,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6180,7 +6254,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6199,7 +6273,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | | task_id | path | | Yes | string | #### Responses @@ -6213,7 +6287,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6232,7 +6306,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | | task_id | path | | Yes | string | #### Responses @@ -6249,7 +6323,7 @@ Request body: | last_id | query | | No | string | | limit | query | | No | integer,
**Default:** 20 | | pinned | query | | No | boolean | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6262,8 +6336,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | | Yes | string | -| installed_app_id | path | | Yes | string | +| c_id | path | | Yes | string (uuid) | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6276,8 +6350,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | | Yes | string | -| installed_app_id | path | | Yes | string | +| c_id | path | | Yes | string (uuid) | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6296,8 +6370,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | | Yes | string | -| installed_app_id | path | | Yes | string | +| c_id | path | | Yes | string (uuid) | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6310,8 +6384,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | | Yes | string | -| installed_app_id | path | | Yes | string | +| c_id | path | | Yes | string (uuid) | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6324,10 +6398,10 @@ Request body: | 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,
**Default:** 20 | -| installed_app_id | path | | Yes | string | +| conversation_id | query | Conversation ID. | Yes | string | +| first_id | query | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | string | +| limit | query | Number of chat history messages to return per request. | No | integer,
**Default:** 20 | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6340,8 +6414,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | -| message_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | +| message_id | path | | Yes | string (uuid) | #### Request Body @@ -6361,8 +6435,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | response_mode | query | | Yes | string,
**Available values:** "blocking", "streaming" | -| installed_app_id | path | | Yes | string | -| message_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | +| message_id | path | | Yes | string (uuid) | #### Responses @@ -6375,8 +6449,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | -| message_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | +| message_id | path | | Yes | string (uuid) | #### Responses @@ -6391,7 +6465,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6406,7 +6480,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6421,7 +6495,7 @@ Request body: | ---- | ---------- | ----------- | -------- | ------ | | last_id | query | | No | string | | limit | query | | No | integer,
**Default:** 20 | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Responses @@ -6434,7 +6508,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6453,8 +6527,8 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | -| message_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | +| message_id | path | | Yes | string (uuid) | #### Responses @@ -6467,7 +6541,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6488,7 +6562,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | #### Request Body @@ -6509,7 +6583,7 @@ Request body: | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| installed_app_id | path | | Yes | string | +| installed_app_id | path | | Yes | string (uuid) | | task_id | path | | Yes | string | #### Responses @@ -6619,7 +6693,7 @@ Mark a notification as dismissed for the current user. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | credential_id | query | Credential ID | Yes | string | -| page_id | path | | Yes | string | +| page_id | path | | Yes | string (uuid) | | page_type | path | | Yes | string | #### Responses @@ -6719,7 +6793,7 @@ Sync data from OAuth data source | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| binding_id | path | Data source binding ID | Yes | string | +| binding_id | path | Data source binding ID | Yes | string (uuid) | | provider | path | Data source provider name (notion) | Yes | string | #### Responses @@ -7032,7 +7106,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | | Yes | string (uuid) | #### Responses @@ -7082,7 +7156,7 @@ Initiate OAuth login process | ---- | ---------- | ----------- | -------- | ------ | | last_id | query | | No | string | | limit | query | | No | integer,
**Default:** 20 | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7097,7 +7171,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | | task_id | path | | Yes | string | #### Responses @@ -7113,8 +7187,8 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| run_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | +| run_id | path | | Yes | string (uuid) | #### Responses @@ -7129,8 +7203,8 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| run_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | +| run_id | path | | Yes | string (uuid) | #### Responses @@ -7149,7 +7223,7 @@ Initiate OAuth login process | named_only | query | | No | boolean | | page | query | | No | integer,
**Default:** 1 | | user_id | query | | No | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7165,7 +7239,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7182,7 +7256,7 @@ Initiate OAuth login process | ---- | ---------- | ----------- | -------- | ------ | | q | query | | No | string | | block_type | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7197,7 +7271,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7213,7 +7287,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7235,7 +7309,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7256,7 +7330,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7277,7 +7351,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7293,7 +7367,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7315,7 +7389,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7335,7 +7409,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7351,7 +7425,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7371,7 +7445,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7385,7 +7459,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7401,7 +7475,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | query | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7417,7 +7491,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | query | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7432,7 +7506,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7451,7 +7525,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7464,7 +7538,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7481,7 +7555,7 @@ Initiate OAuth login process | ---- | ---------- | ----------- | -------- | ------ | | limit | query | | No | integer,
**Default:** 20 | | page | query | | No | integer,
**Default:** 1 | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7494,8 +7568,8 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -7508,8 +7582,8 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -7522,8 +7596,8 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Request Body @@ -7542,8 +7616,8 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -7559,7 +7633,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7574,7 +7648,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7590,7 +7664,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7612,7 +7686,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7634,7 +7708,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | query | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7650,7 +7724,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | query | | Yes | string | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Responses @@ -7665,7 +7739,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | #### Request Body @@ -7686,7 +7760,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | | workflow_id | path | | Yes | string | #### Responses @@ -7702,7 +7776,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | | workflow_id | path | | Yes | string | #### Request Body @@ -7725,7 +7799,7 @@ Initiate OAuth login process | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| pipeline_id | path | | Yes | string | +| pipeline_id | path | | Yes | string (uuid) | | workflow_id | path | | Yes | string | #### Responses @@ -7840,7 +7914,7 @@ Generate structured output rules using LLM | ---- | ---------- | ----------- | -------- | ------ | | last_id | query | | No | string | | limit | query | | No | integer,
**Default:** 20 | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -7858,7 +7932,7 @@ command channel for backward compatibility. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | | task_id | path | | Yes | string | #### Responses @@ -7875,8 +7949,8 @@ command channel for backward compatibility. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| run_id | path | | Yes | string | -| snippet_id | path | | Yes | string | +| run_id | path | | Yes | string (uuid) | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -7892,8 +7966,8 @@ command channel for backward compatibility. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| run_id | path | | Yes | string | -| snippet_id | path | | Yes | string | +| run_id | path | | Yes | string (uuid) | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -7910,7 +7984,7 @@ Get all published workflows for a snippet | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | | limit | query | | No | integer,
**Default:** 10 | | page | query | | No | integer,
**Default:** 1 | @@ -7927,7 +8001,7 @@ Get all published workflows for a snippet | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -7942,7 +8016,7 @@ Get all published workflows for a snippet | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -7958,7 +8032,7 @@ Get all published workflows for a snippet | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Request Body @@ -7980,7 +8054,7 @@ Get all published workflows for a snippet | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -7995,7 +8069,7 @@ Conversation variables are not used in snippet workflows; returns an empty list | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8010,7 +8084,7 @@ Get environment variables from snippet draft workflow graph | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8031,7 +8105,7 @@ Returns an SSE event stream with iteration progress and results. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | #### Request Body @@ -8058,7 +8132,7 @@ Returns an SSE event stream with loop progress and results. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | #### Request Body @@ -8085,7 +8159,7 @@ including status, inputs, outputs, and timing information. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | #### Responses @@ -8106,7 +8180,7 @@ Returns the node execution result including status, outputs, and timing. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | Node ID | Yes | string | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | #### Request Body @@ -8129,7 +8203,7 @@ Delete all variables for a specific node (snippet draft workflow) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8145,7 +8219,7 @@ Get variables for a specific node (snippet draft workflow) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | node_id | path | | Yes | string | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8163,7 +8237,7 @@ and returns an SSE event stream with execution progress and results. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Request Body @@ -8185,7 +8259,7 @@ System variables are not used in snippet workflows; returns an empty list for AP | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8200,7 +8274,7 @@ Delete all draft workflow variables for the current user (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8217,7 +8291,7 @@ List draft workflow variables without values (paginated, snippet scope) | ---- | ---------- | ----------- | -------- | ------ | | limit | query | Items per page | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8232,8 +8306,8 @@ Delete a draft workflow variable (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -8249,8 +8323,8 @@ Get a specific draft workflow variable (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -8266,8 +8340,8 @@ Update a draft workflow variable (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Request Body @@ -8289,8 +8363,8 @@ Reset a draft workflow variable to its default value (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | -| variable_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | +| variable_id | path | | Yes | string (uuid) | #### Responses @@ -8307,7 +8381,7 @@ Reset a draft workflow variable to its default value (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8323,7 +8397,7 @@ Reset a draft workflow variable to its default value (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Request Body @@ -8345,7 +8419,7 @@ Reset a draft workflow variable to its default value (snippet scope) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | | workflow_id | path | Published workflow ID | Yes | string | #### Responses @@ -8444,7 +8518,7 @@ Remove one or more tag bindings from a target. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| tag_id | path | | Yes | string | +| tag_id | path | | Yes | string (uuid) | #### Responses @@ -8457,7 +8531,7 @@ Remove one or more tag bindings from a target. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| tag_id | path | | Yes | string | +| tag_id | path | | Yes | string (uuid) | #### Request Body @@ -8493,7 +8567,7 @@ Bedrock retrieval test (internal use only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -8506,7 +8580,7 @@ Bedrock retrieval test (internal use only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -8519,7 +8593,7 @@ Bedrock retrieval test (internal use only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -8538,7 +8612,7 @@ Bedrock retrieval test (internal use only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -8560,7 +8634,7 @@ Bedrock retrieval test (internal use only) | ids | query | Dataset IDs | No | [ string ] | | limit | query | Number of items per page | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -8573,8 +8647,8 @@ Bedrock retrieval test (internal use only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | -| message_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | +| message_id | path | | Yes | string (uuid) | #### Responses @@ -8589,7 +8663,7 @@ Bedrock retrieval test (internal use only) | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -8606,7 +8680,7 @@ Returns the site configuration for the application including theme, icons, and t | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -8619,7 +8693,7 @@ Returns the site configuration for the application including theme, icons, and t | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -8640,7 +8714,7 @@ Returns the site configuration for the application including theme, icons, and t | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Responses @@ -8655,7 +8729,7 @@ Returns the site configuration for the application including theme, icons, and t | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | #### Request Body @@ -8676,7 +8750,7 @@ Returns the site configuration for the application including theme, icons, and t | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| app_id | path | | Yes | string | +| app_id | path | | Yes | string (uuid) | | task_id | path | | Yes | string | #### Responses @@ -8901,7 +8975,7 @@ Get list of available agent providers | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8917,7 +8991,7 @@ Get list of available agent providers | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Responses @@ -8933,7 +9007,7 @@ Get list of available agent providers | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | | Yes | string | +| snippet_id | path | | Yes | string (uuid) | #### Request Body @@ -8956,7 +9030,7 @@ Get list of available agent providers | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | #### Responses @@ -8974,7 +9048,7 @@ Export snippet configuration as DSL | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | Snippet ID to export | Yes | string | +| snippet_id | path | Snippet ID to export | Yes | string (uuid) | | include_secret | query | Whether to include secret variables | No | string,
**Default:** false | #### Responses @@ -8993,7 +9067,7 @@ Increment snippet use count by 1 | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| snippet_id | path | Snippet ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string (uuid) | #### Responses @@ -9262,7 +9336,7 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| member_id | path | | Yes | string | +| member_id | path | | Yes | string (uuid) | #### Responses @@ -9275,7 +9349,7 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| member_id | path | | Yes | string | +| member_id | path | | Yes | string (uuid) | #### Request Body @@ -9294,7 +9368,7 @@ Update a plugin endpoint | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| member_id | path | | Yes | string | +| member_id | path | | Yes | string (uuid) | #### Request Body @@ -10128,6 +10202,539 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [PluginCategoryListResponse](#plugincategorylistresponse)
| +### [GET] /workspaces/current/rbac/access-policies +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [_AccessPolicyList](#_accesspolicylist)
| + +### [POST] /workspaces/current/rbac/access-policies +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Policy created | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [DELETE] /workspaces/current/rbac/access-policies/{policy_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [GET] /workspaces/current/rbac/access-policies/{policy_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [PUT] /workspaces/current/rbac/access-policies/{policy_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [POST] /workspaces/current/rbac/access-policies/{policy_id}/copy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Policy copied | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [PUT] /workspaces/current/rbac/access-policy-bindings/{binding_id}/lock +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| binding_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicyBindingState](#accesspolicybindingstate)
| + +### [PUT] /workspaces/current/rbac/access-policy-bindings/{binding_id}/unlock +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| binding_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicyBindingState](#accesspolicybindingstate)
| + +### [DELETE] /workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/access-policy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AppAccessMatrix](#appaccessmatrix)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/user-access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceUserAccessPoliciesResponse](#resourceuseraccesspoliciesresponse)
| + +### [PUT] /workspaces/current/rbac/apps/{app_id}/users/{target_account_id}/access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| target_account_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ReplaceUserAccessPoliciesResponse](#replaceuseraccesspoliciesresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [PUT] /workspaces/current/rbac/apps/{app_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [DELETE] /workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/access-policy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [DatasetAccessMatrix](#datasetaccessmatrix)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/user-access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceUserAccessPoliciesResponse](#resourceuseraccesspoliciesresponse)
| + +### [PUT] /workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| target_account_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ReplaceUserAccessPoliciesResponse](#replaceuseraccesspoliciesresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [PUT] /workspaces/current/rbac/datasets/{dataset_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [GET] /workspaces/current/rbac/members/{member_id}/rbac-roles +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberRolesResponse](#memberrolesresponse)
| + +### [PUT] /workspaces/current/rbac/members/{member_id}/rbac-roles +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberRolesResponse](#memberrolesresponse)
| + +### [GET] /workspaces/current/rbac/my-permissions +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MyPermissionsResponse](#mypermissionsresponse)
| + +### [GET] /workspaces/current/rbac/role-permissions/catalog +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PermissionCatalogResponse](#permissioncatalogresponse)
| + +### [GET] /workspaces/current/rbac/role-permissions/catalog/app +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PermissionCatalogResponse](#permissioncatalogresponse)
| + +### [GET] /workspaces/current/rbac/role-permissions/catalog/dataset +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PermissionCatalogResponse](#permissioncatalogresponse)
| + +### [GET] /workspaces/current/rbac/roles +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [_RBACRoleList](#_rbacrolelist)
| + +### [POST] /workspaces/current/rbac/roles +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Role created | **application/json**: [RBACRole](#rbacrole)
| + +### [DELETE] /workspaces/current/rbac/roles/{role_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RBACRole](#rbacrole)
| + +### [GET] /workspaces/current/rbac/roles/{role_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RBACRole](#rbacrole)
| + +### [PUT] /workspaces/current/rbac/roles/{role_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RBACRole](#rbacrole)
| + +### [POST] /workspaces/current/rbac/roles/{role_id}/copy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Role copied | **application/json**: [RBACRole](#rbacrole)
| + +### [GET] /workspaces/current/rbac/roles/{role_id}/members +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [_MembersInRoleList](#_membersinrolelist)
| + +### [PUT] /workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessMatrixItem](#accessmatrixitem)
| + +### [GET] /workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/apps/access-policy +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)
| + +### [PUT] /workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessMatrixItem](#accessmatrixitem)
| + +### [GET] /workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/datasets/access-policy +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)
| + ### [GET] /workspaces/current/tool-labels #### Responses @@ -11056,6 +11663,84 @@ Default namespace | id | string | | Yes | | name | string | | Yes | +#### AccessMatrixItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| accounts | [ [AccessPolicyAccount](#accesspolicyaccount) ] | | No | +| policy | [AccessPolicy](#accesspolicy) | | No | +| roles | [ [AccessPolicyRole](#accesspolicyrole) ] | | No | + +#### AccessPolicy + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | No | +| created_at | integer | | No | +| description | string | | No | +| id | string | | Yes | +| is_builtin | boolean | | No | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| policy_key | string | | No | +| resource_type | string | | Yes | +| tenant_id | string | | No | +| updated_at | integer | | No | + +#### AccessPolicyAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | Yes | +| account_name | string | | Yes | +| avatar | string | | No | +| binding_id | string | | Yes | +| email | string | | No | +| is_locked | boolean | | No | + +#### AccessPolicyBindingState + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_id | string | | Yes | +| is_locked | boolean | | No | + +#### AccessPolicyMemberBinding + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policy_id | string | | Yes | +| account_id | string | | Yes | +| account_name | string | | No | +| created_at | integer | | No | +| id | string | | Yes | +| resource_id | string | | No | +| resource_type | string | | Yes | +| tenant_id | string | | No | + +#### AccessPolicyRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_id | string | | Yes | +| is_locked | boolean | | No | +| role_id | string | | Yes | +| role_name | string | | Yes | +| role_tag | string | | No | + +#### AccessPolicyRoleBinding + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policy_id | string | | Yes | +| created_at | integer | | No | +| id | string | | Yes | +| resource_id | string | | No | +| resource_type | string | | Yes | +| role_id | string | | Yes | +| role_name | string | | No | +| tenant_id | string | | No | + #### Account | Name | Type | Description | Required | @@ -11166,6 +11851,7 @@ Default namespace | last_login_at | integer | | No | | name | string | | Yes | | role | string | | Yes | +| roles | [ object ] | | No | | status | string | | Yes | #### AccountWithRoleList @@ -11187,9 +11873,9 @@ Default namespace | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | email | string | | No | -| interface_language | string | | Yes | -| name | string | | Yes | -| timezone | string | | Yes | +| interface_language | string | | No | +| name | string | | No | +| timezone | string | | No | | token | string | | Yes | | workspace_id | string | | No | @@ -11197,7 +11883,9 @@ Default namespace | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| account_status | string | | No | | email | string | | Yes | +| requires_setup | boolean | | No | | workspace_id | string | | Yes | | workspace_name | string | | Yes | @@ -11286,7 +11974,42 @@ Default namespace | icon_background | string | Icon background color | No | | icon_type | [IconType](#icontype) | Icon type | No | | name | string | Agent name | Yes | -| role | string | Agent role | No | +| role | string | Agent role | Yes | + +#### AgentAppDetailWithSite + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| active_config_is_published | boolean | | No | +| api_base_url | string | | No | +| app_id | string | | No | +| bound_agent_id | string | | 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 | +| icon_url | string | | Yes | +| id | string | | Yes | +| maintainer | string | | No | +| max_active_requests | integer | | No | +| mode | string | | Yes | +| model_config | [ModelConfig](#modelconfig) | | No | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| role | string | | No | +| 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 | #### AgentAppFeaturesPayload @@ -11305,6 +12028,61 @@ default (the config form sends the full desired feature state on save). | suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No | | text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No | +#### AgentAppPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentAppPartial](#agentapppartial) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AgentAppPartial + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_mode | string | | No | +| active_config_is_published | boolean | | No | +| app_id | string | | No | +| author_name | string | | No | +| bound_agent_id | string | | No | +| create_user_name | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| description | string | | No | +| has_draft_trigger | boolean | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | | No | +| icon_url | string | | Yes | +| id | string | | Yes | +| is_starred | boolean | | No | +| maintainer | string | | No | +| max_active_requests | integer | | No | +| mode | string | | Yes | +| model_config | [ModelConfigPartial](#modelconfigpartial) | | No | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| published_reference_count | integer | | No | +| published_references | [ [AgentAppPublishedReferenceResponse](#agentapppublishedreferenceresponse) ] | | No | +| role | string | | No | +| tags | [ [Tag](#tag) ] | | No | +| updated_at | integer | | No | +| updated_by | string | | No | +| use_icon_as_answer_icon | boolean | | No | +| workflow | [WorkflowPartial](#workflowpartial) | | No | + +#### AgentAppPublishedReferenceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | +| app_id | string | | Yes | +| app_name | string | | Yes | + #### AgentAppUpdatePayload | Name | Type | Description | Required | @@ -11315,9 +12093,23 @@ default (the config form sends the full desired feature state on save). | icon_type | [IconType](#icontype) | Icon type | No | | max_active_requests | integer | Maximum active requests | No | | name | string | App name | Yes | -| role | string | Agent role | No | +| role | string | Agent role | Yes | | use_icon_as_answer_icon | boolean | Use icon as answer icon | No | +#### AgentAverageResponseTimeStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| latency | number | | Yes | + +#### AgentAverageSessionInteractionStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| interactions | number | | Yes | + #### AgentCliToolAuthorizationStatus Authorization state for Agent-scoped CLI tools. @@ -11534,8 +12326,10 @@ Audit operation recorded for Agent Soul version/revision changes. | config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes | | created_at | integer | | No | | created_by | string | | No | +| display_version | integer | | No | | id | string | | Yes | | revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No | +| snapshot_version | integer | | No | | summary | string | | No | | version | integer | | Yes | | version_note | string | | No | @@ -11553,11 +12347,34 @@ Audit operation recorded for Agent Soul version/revision changes. | agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | +| display_version | integer | | No | | id | string | | Yes | +| snapshot_version | integer | | No | | summary | string | | No | | version | integer | | Yes | | version_note | string | | No | +#### AgentDailyConversationStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_count | integer | | Yes | +| date | string | | Yes | + +#### AgentDailyEndUserStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| terminal_count | integer | | Yes | + +#### AgentDailyMessageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| message_count | integer | | Yes | + #### AgentDriveDeleteFileByAgentQuery | Name | Type | Description | Required | @@ -11703,6 +12520,7 @@ Supported icon storage formats for Agent roster entries. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | No | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | active_config_snapshot_id | string | | No | | agent_kind | [AgentKind](#agentkind) | | Yes | @@ -11796,6 +12614,65 @@ the current roster/workflow APIs scoped to Dify Agent. | ---- | ---- | ----------- | -------- | | AgentKnowledgeQueryMode | string | | | +#### AgentLogConversationItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| end_user_id | string | | No | +| id | string | | Yes | +| message_count | integer | | Yes | +| operation_rate | number | | No | +| source | [AgentLogSourceResponse](#agentlogsourceresponse) | | No | +| status | string,
**Available values:** "failed", "paused", "success" | *Enum:* `"failed"`, `"paused"`, `"success"` | Yes | +| title | string | | No | +| unread | boolean | | Yes | +| updated_at | integer | | No | +| user_rate | number | | No | + +#### AgentLogListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentLogConversationItemResponse](#agentlogconversationitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AgentLogMessageItemResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| answer | string | | Yes | +| answer_tokens | integer | | Yes | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| currency | string | | Yes | +| error | string | | No | +| from_account_id | string | | No | +| from_end_user_id | string | | No | +| id | string | | Yes | +| latency | number | | Yes | +| message_id | string | | Yes | +| message_tokens | integer | | Yes | +| query | string | | Yes | +| status | string | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| updated_at | integer | | No | + +#### AgentLogMessageListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentLogMessageItemResponse](#agentlogmessageitemresponse) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + #### AgentLogMetaResponse | Name | Type | Description | Required | @@ -11823,6 +12700,52 @@ the current roster/workflow APIs scoped to Dify Agent. | iterations | [ [AgentIterationLogResponse](#agentiterationlogresponse) ] | | Yes | | meta | [AgentLogMetaResponse](#agentlogmetaresponse) | | Yes | +#### AgentLogSourceGroupResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| label | string | | Yes | +| sources | [ [AgentLogSourceResponse](#agentlogsourceresponse) ] | | No | +| type | string,
**Available values:** "webapp", "workflow" | *Enum:* `"webapp"`, `"workflow"` | Yes | + +#### AgentLogSourceListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AgentLogSourceResponse](#agentlogsourceresponse) ] | | Yes | +| groups | [ [AgentLogSourceGroupResponse](#agentlogsourcegroupresponse) ] | | Yes | + +#### AgentLogSourceResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_icon | string | | No | +| app_icon_background | string | | No | +| app_icon_type | string | | No | +| app_id | string | | Yes | +| app_name | string | | Yes | +| id | string | | Yes | +| node_id | string | | No | +| type | string,
**Available values:** "webapp", "workflow" | *Enum:* `"webapp"`, `"workflow"` | Yes | +| workflow_id | string | | No | +| workflow_version | string | | No | + +#### AgentLogsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| keyword | string | Search query, answer, or conversation name | No | +| limit | integer,
**Default:** 20 | Page size | No | +| page | integer,
**Default:** 1 | Page number | No | +| sort_by | string,
**Default:** updated_at | Sort by created_at or updated_at | No | +| sort_order | string,
**Default:** desc | Sort order: asc or desc | No | +| source | string | Deprecated single source filter | No | +| sources | [ string ] | Filter by one or more source IDs, e.g. webapp: or workflow:::: | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | +| status | string | Deprecated single status filter | No | +| statuses | [ string ] | Filter by one or more of success, failed, paused | No | + #### AgentMemoryArtifactConfig | Name | Type | Description | Required | @@ -11924,6 +12847,7 @@ the current roster/workflow APIs scoped to Dify Agent. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | No | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | active_config_snapshot_id | string | | No | | agent_kind | [AgentKind](#agentkind) | | Yes | @@ -11989,6 +12913,7 @@ Visibility and lifecycle scope of an Agent record. | provider_credential_id | string | | No | | ref | string | | No | | type | string | | No | +| value | string | | No | | variable | string | | No | #### AgentSensitiveWordAvoidanceFeatureConfig @@ -12014,13 +12939,6 @@ Visibility and lifecycle scope of an Agent record. | skill_md_file_id | string | | No | | skill_md_key | string | | No | -#### AgentSkillStandardizeResponse - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| manifest | [SkillManifest](#skillmanifest) | | Yes | -| skill | [AgentSkillRefConfig](#agentskillrefconfig) | | Yes | - #### AgentSkillUploadResponse | Name | Type | Description | Required | @@ -12194,6 +13112,50 @@ Origin that created or imported the Agent. | ---- | ---- | ----------- | -------- | | AgentSource | string | Origin that created or imported the Agent. | | +#### AgentStatisticChartsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | [ [AgentAverageResponseTimeStatisticResponse](#agentaverageresponsetimestatisticresponse) ] | | No | +| average_session_interactions | [ [AgentAverageSessionInteractionStatisticResponse](#agentaveragesessioninteractionstatisticresponse) ] | | No | +| daily_conversations | [ [AgentDailyConversationStatisticResponse](#agentdailyconversationstatisticresponse) ] | | No | +| daily_end_users | [ [AgentDailyEndUserStatisticResponse](#agentdailyenduserstatisticresponse) ] | | No | +| daily_messages | [ [AgentDailyMessageStatisticResponse](#agentdailymessagestatisticresponse) ] | | No | +| token_usage | [ [AgentTokenUsageStatisticResponse](#agenttokenusagestatisticresponse) ] | | No | +| tokens_per_second | [ [AgentTokensPerSecondStatisticResponse](#agenttokenspersecondstatisticresponse) ] | | No | +| user_satisfaction_rate | [ [AgentUserSatisfactionRateStatisticResponse](#agentusersatisfactionratestatisticresponse) ] | | No | + +#### AgentStatisticSummaryEnvelopeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| charts | [AgentStatisticChartsResponse](#agentstatisticchartsresponse) | | Yes | +| source | string | | Yes | +| summary | [AgentStatisticSummaryResponse](#agentstatisticsummaryresponse) | | Yes | + +#### AgentStatisticSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| average_response_time | number | | Yes | +| average_session_interactions | number | | Yes | +| currency | string | | Yes | +| tokens_per_second | number | | Yes | +| total_conversations | integer | | Yes | +| total_end_users | integer | | Yes | +| total_messages | integer | | Yes | +| total_price | string | | Yes | +| total_tokens | integer | | Yes | +| user_satisfaction_rate | number | | Yes | + +#### AgentStatisticsQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| end | string | End date (YYYY-MM-DD HH:MM) | No | +| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| start | string | Start date (YYYY-MM-DD HH:MM) | No | + #### AgentStatus Soft lifecycle state for Agent records. @@ -12236,6 +13198,22 @@ Soft lifecycle state for Agent records. | tool_input | string | | No | | tool_labels | [JSONValue](#jsonvalue) | | Yes | +#### AgentTokenUsageStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| currency | string | | Yes | +| date | string | | Yes | +| token_count | integer | | Yes | +| total_price | string | | Yes | + +#### AgentTokensPerSecondStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| tps | number | | Yes | + #### AgentToolCallResponse | Name | Type | Description | Required | @@ -12250,6 +13228,13 @@ Soft lifecycle state for Agent records. | tool_output | object | | Yes | | tool_parameters | object | | Yes | +#### AgentUserSatisfactionRateStatisticResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| date | string | | Yes | +| rate | number | | Yes | + #### AllowedExtensionsResponse | Name | Type | Description | Required | @@ -12481,6 +13466,13 @@ Enum class for api provider schema type. | schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | | tool_name | string | | Yes | +#### AppAccessMatrix + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| items | [ [AccessMatrixItem](#accessmatrixitem) ] | | No | + #### AppApiStatusPayload | Name | Type | Description | Required | @@ -12501,8 +13493,10 @@ Enum class for api provider schema type. | icon | string | | No | | icon_background | string | | No | | id | string | | Yes | +| maintainer | string | | No | | mode_compatible_with_agent | string | | Yes | | name | string | | Yes | +| permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | | updated_at | integer | | No | @@ -12529,11 +13523,12 @@ Enum class for api provider schema type. | icon_type | string | | No | | icon_url | string | | Yes | | id | string | | Yes | +| maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | | model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | -| role | string | | No | +| permission_keys | [ string ] | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | @@ -12652,11 +13647,12 @@ AppMCPServer Status Enum | icon_url | string | | Yes | | id | string | | Yes | | is_starred | boolean | | No | +| maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | | model_config | [ModelConfigPartial](#modelconfigpartial) | | No | | name | string | | Yes | -| role | string | | No | +| permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -13001,7 +13997,7 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | Yes | +| content | string | Child chunk text content. | Yes | #### ChildChunkDetailResponse @@ -13044,14 +14040,14 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | Yes | -| id | string | | No | +| content | string | Child chunk text content. | Yes | +| id | string | Existing child chunk ID. Omit to create a new child chunk. | No | #### ChildChunkUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | Yes | +| content | string | Child chunk text content. | Yes | #### CliToolSuggestion @@ -13209,9 +14205,9 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | -| name | string | | Yes | -| value | string
[ string ]
integer
number | | No | +| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | Comparison to apply. String operators (`contains`, `not contains`, `start with`, `end with`, `is`, `is not`, `empty`, `not empty`, `in`, `not in`) act on string or array metadata; numeric operators (`=`, `≠`, `>`, `<`, `≥`, `≤`) act on numeric metadata; time operators (`before`, `after`) act on time metadata.
*Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| name | string | Metadata field name to compare against. | Yes | +| value | string
[ string ]
number | Value to compare against. Type depends on `comparison_operator`: string for most string operators, array of strings for `in` and `not in`, number for numeric operators, and omit or use `null` for `empty` and `not empty`. | No | #### ConfigurateMethod @@ -13353,8 +14349,8 @@ Enum class for configurate method of provider model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate | boolean | | No | -| name | string | | No | +| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No | +| name | string | Conversation name. Required when `auto_generate` is `false`. | No | #### ConversationVariableResponse @@ -13652,6 +14648,13 @@ Model class for provider custom model configuration. | workspace_id | string | | Yes | | workspace_name | string | | Yes | +#### DatasetAccessMatrix + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dataset_id | string | | No | +| items | [ [AccessMatrixItem](#accessmatrixitem) ] | | No | + #### DatasetAndDocumentResponse | Name | Type | Description | Required | @@ -13700,6 +14703,7 @@ Model class for provider custom model configuration. | is_published | boolean | | No | | name | string | | No | | permission | string | | No | +| permission_keys | [ string ] | | No | | pipeline_id | string | | No | | provider | string | | No | | retrieval_model_dict | [DatasetRetrievalModel](#datasetretrievalmodel) | | No | @@ -13738,8 +14742,10 @@ Model class for provider custom model configuration. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -13778,9 +14784,11 @@ Model class for provider custom model configuration. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | No | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -13882,9 +14890,11 @@ Model class for provider custom model configuration. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | Yes | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -14201,6 +15211,7 @@ about. Stage 4 §4.2. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | @@ -14229,6 +15240,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards. | ---- | ---- | ----------- | -------- | | array_item | [DeclaredArrayItem](#declaredarrayitem) | | No | | check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No | +| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | | file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | @@ -14329,15 +15341,15 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| document_ids | [ string (uuid) ] | | Yes | +| document_ids | [ string (uuid) ] | List of document IDs to include in the ZIP download. | Yes | #### DocumentMetadataOperation | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| document_id | string | | Yes | -| metadata_list | [ [MetadataDetail](#metadatadetail) ] | | Yes | -| partial_update | boolean | | No | +| document_id | string | Document ID whose metadata should be updated. | Yes | +| metadata_list | [ [MetadataDetail](#metadatadetail) ] | Metadata fields to update. | Yes | +| partial_update | boolean | Whether to partially update metadata, keeping existing values for unspecified fields. | No | #### DocumentMetadataResponse @@ -15119,10 +16131,10 @@ Enum class for form type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachment_ids | [ string ] | | No | -| external_retrieval_model | object | | No | -| query | string | | Yes | -| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| attachment_ids | [ string ] | List of attachment IDs to include in the retrieval context. | No | +| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No | +| query | string | Search query text. | Yes | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked. | No | #### HitTestingQuery @@ -15308,6 +16320,7 @@ How Dify forwards the end-user's identity to an MCP server. | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | +| permission_keys | [ string ] | | No | | status | [ImportStatus](#importstatus) | | Yes | #### ImportStatus @@ -15504,19 +16517,19 @@ Input field definition for snippet parameters. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data_source | [DataSource](#datasource) | | No | -| doc_form | string,
**Default:** text_model | | No | -| doc_language | string,
**Default:** English | | No | -| duplicate | boolean,
**Default:** true | | No | -| embedding_model | string | | No | -| embedding_model_provider | string | | No | -| indexing_technique | string,
**Available values:** "economy", "high_quality" | *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 | +| data_source | [DataSource](#datasource) | Document data source configuration. | No | +| doc_form | string,
**Available values:** "hierarchical_model", "qa_model", "text_model",
**Default:** text_model | `text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, `qa_model` for question-answer pair extraction.
*Enum:* `"hierarchical_model"`, `"qa_model"`, `"text_model"` | No | +| doc_language | string,
**Default:** English | Language of the document for processing optimization. | No | +| duplicate | boolean,
**Default:** true | Whether duplicate document content is allowed. | No | +| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| indexing_technique | string,
**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted.
*Enum:* `"economy"`, `"high_quality"` | Yes | +| is_multimodal | boolean | Whether the document uses multimodal indexing. | No | +| name | string | Document name. | No | +| original_document_id | string | Original document ID for replacement updates. | No | +| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked in this knowledge base. | No | +| summary_index_setting | object | Summary index configuration. | No | #### KnowledgePipeline @@ -15697,13 +16710,19 @@ Enum class for large language model mode. | result | string | | Yes | | tenant_id | string | | Yes | +#### MemberBindingsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccessPolicyMemberBinding](#accesspolicymemberbinding) ] | | No | + #### MemberInvitePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | emails | [ string ] | | No | | language | string | | No | -| role | [TenantAccountRole](#tenantaccountrole) | | Yes | +| role | string | | Yes | #### MemberInviteResponse @@ -15728,6 +16747,20 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | role | string | | Yes | +#### MemberRolesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | Yes | +| roles | [ [RBACRole](#rbacrole) ] | | No | + +#### MembersInRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | No | +| account_name | string | | No | + #### MessageDetail | Name | Type | Description | Required | @@ -15789,9 +16822,9 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | +| content | string | Optional text feedback providing additional detail. | No | | message_id | string | Message ID | Yes | -| rating | string | | No | +| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFile @@ -15846,24 +16879,24 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conversation_id | string | Conversation UUID | Yes | -| first_id | string | First message ID for pagination | No | -| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | +| conversation_id | string | Conversation ID. | Yes | +| first_id | string | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | +| limit | integer,
**Default:** 20 | Number of chat history messages to return per request. | No | #### MetadataArgs | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | -| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | +| name | string | Metadata field name. | Yes | +| type | string,
**Available values:** "number", "string", "time" | `string` for text values, `number` for numeric values, `time` for date/time values.
*Enum:* `"number"`, `"string"`, `"time"` | Yes | #### MetadataDetail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | Yes | -| name | string | | Yes | -| value | string
integer
number | | No | +| id | string | Metadata field ID. | Yes | +| name | string | Metadata field name. | Yes | +| value | string
integer
number | Metadata value. Can be a string, number, or `null`. | No | #### MetadataFilteringCondition @@ -15871,8 +16904,8 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conditions | [ [Condition](#condition) ] | | No | -| logical_operator | string | | No | +| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No | +| logical_operator | string | How to combine multiple conditions. | No | #### MetadataOperationData @@ -15880,13 +16913,13 @@ Metadata operation data | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| operation_data | [ [DocumentMetadataOperation](#documentmetadataoperation) ] | | Yes | +| operation_data | [ [DocumentMetadataOperation](#documentmetadataoperation) ] | Array of document metadata update operations. Each entry maps a document ID to its metadata values. | Yes | #### MetadataUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | +| name | string | New metadata field name. | Yes | #### ModelConfig @@ -16038,11 +17071,20 @@ Model with provider entity. | ---- | ---- | ----------- | -------- | | response_mode | string,
**Available values:** "blocking", "streaming" | *Enum:* `"blocking"`, `"streaming"` | Yes | +#### MyPermissionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | +| dataset | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | +| workspace | [WorkspacePermissionSnapshot](#workspacepermissionsnapshot) | | No | + #### NewAppResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | new_app_id | string | | Yes | +| permission_keys | [ string ] | | No | #### NodeIdQuery @@ -16348,6 +17390,15 @@ output check fails and any configured retry attempts have been exhausted. | page | integer | | Yes | | total | integer | | Yes | +#### Pagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_page | integer | | No | +| per_page | integer | | No | +| total_count | integer | | No | +| total_pages | integer | | No | + #### PaginationQuery | Name | Type | Description | Required | @@ -16723,6 +17774,29 @@ Enum class for parameter type. | node_title | string | | Yes | | pause_type | [HumanInputPauseTypeResponse](#humaninputpausetyperesponse) | | Yes | +#### PermissionCatalogGroup + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| group_key | string | | Yes | +| group_name | string | | Yes | +| permissions | [ [PermissionCatalogItem](#permissioncatalogitem) ] | | No | + +#### PermissionCatalogItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| key | string | | Yes | +| name | string | | Yes | + +#### PermissionCatalogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| groups | [ [PermissionCatalogGroup](#permissioncataloggroup) ] | | No | + #### PermissionEnum Shared permission levels for resources (datasets, credentials, etc.) @@ -17062,8 +18136,8 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| enabled | boolean | | Yes | -| id | string | | Yes | +| enabled | boolean | Whether this preprocessing rule is enabled. | Yes | +| id | string,
**Available values:** "remove_extra_spaces", "remove_stopwords", "remove_urls_emails" | Rule identifier.
*Enum:* `"remove_extra_spaces"`, `"remove_stopwords"`, `"remove_urls_emails"` | Yes | #### PreviewDetail @@ -17088,8 +18162,8 @@ Serialized pricing info with codegen-safe decimal string patterns. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| mode | [ProcessRuleMode](#processrulemode) | | Yes | -| rules | [Rule](#rule) | | No | +| mode | [ProcessRuleMode](#processrulemode) | Processing mode. `automatic` uses built-in rules, `custom` allows manual configuration, and `hierarchical` enables parent-child chunk structure for `doc_form: hierarchical_model`. | Yes | +| rules | [Rule](#rule) | Custom processing rules. | No | #### ProcessRuleMode @@ -17281,6 +18355,29 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | QuotaUnit | string | | | +#### RBACRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | No | +| description | string | | No | +| id | string | | Yes | +| is_builtin | boolean | | No | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| role_tag | string | | No | +| tenant_id | string | | No | +| type | string | | Yes | + +#### RBACRoleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | Yes | +| account_name | string | | No | +| avatar | string | | No | +| email | string | | No | + #### RagPipelineDatasetImportPayload | Name | Type | Description | Required | @@ -17442,12 +18539,53 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | url | string | URL to fetch | Yes | +#### ReplaceUserAccessPoliciesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policies | [ [AccessPolicy](#accesspolicy) ] | | No | + #### RerankingModel | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| reranking_model_name | string | | No | -| reranking_provider_name | string | | No | +| reranking_model_name | string | Name of the reranking model. | No | +| reranking_provider_name | string | Provider name of the reranking model. | No | + +#### ResourcePermissionKeys + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| permission_keys | [ string ] | | No | +| resource_id | string | | Yes | + +#### ResourcePermissionSnapshot + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_permission_keys | [ string ] | | No | +| overrides | [ [ResourcePermissionKeys](#resourcepermissionkeys) ] | | No | + +#### ResourceUserAccessPolicies + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policies | [ [AccessPolicy](#accesspolicy) ] | | No | +| account | [RBACRoleAccount](#rbacroleaccount) | | Yes | +| roles | [ [RBACRole](#rbacrole) ] | | No | + +#### ResourceUserAccessPoliciesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ResourceUserAccessPolicies](#resourceuseraccesspolicies) ] | | No | +| scope | string | | Yes | + +#### ResourceWhitelist + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_ids | [ string ] | | No | #### RestrictModel @@ -17473,15 +18611,15 @@ Model class for provider quota configuration. | 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 | +| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No | +| reranking_enable | boolean | Whether reranking is enabled. | Yes | +| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No | +| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No | +| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No | +| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes | +| search_method | [RetrievalMethod](#retrievalmethod) | Search method used for retrieval. | Yes | +| top_k | integer | Maximum number of results to return. | Yes | +| weights | [WeightModel](#weightmodel) | Weight configuration for hybrid search. | No | #### RetrievalSettingResponse @@ -17511,6 +18649,12 @@ Model class for provider quota configuration. | summary | string | | No | | word_count | integer | | No | +#### RoleBindingsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccessPolicyRoleBinding](#accesspolicyrolebinding) ] | | No | + #### RosterListQuery | Name | Type | Description | Required | @@ -17523,10 +18667,10 @@ Model class for provider quota configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | | No | -| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | | No | -| segmentation | [Segmentation](#segmentation) | | No | -| subchunk_segmentation | [Segmentation](#segmentation) | | No | +| parent_mode | string | Parent-child segmentation mode. | No | +| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No | +| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No | +| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No | #### RuleCodeGeneratePayload @@ -17730,10 +18874,10 @@ Model class for provider quota configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| chunk_overlap | integer | | No | -| max_tokens | integer | | Yes | -| separator | string,
**Default:** - | | No | +| chunk_overlap | integer | Token overlap between chunks. | No | +| max_tokens | integer | Maximum token count per chunk. | Yes | +| separator | string,
**Default:** + | Custom separator for splitting text. | No | #### SelectInputConfig @@ -18021,7 +19165,7 @@ Payload for running a loop node in snippet draft workflow. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| data | [ [_AnonymousInlineModel_efd591151ea9](#_anonymousinlinemodel_efd591151ea9) ] | | No | +| data | [ [_AnonymousInlineModel_744ff9cc03e6](#_anonymousinlinemodel_744ff9cc03e6) ] | | No | | has_more | boolean | | No | | limit | integer | | No | | page | integer | | No | @@ -18189,6 +19333,7 @@ Model class for provider system configuration response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | @@ -18205,6 +19350,7 @@ Model class for provider system configuration response. | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | +| rbac_enabled | boolean | | Yes | | sso_enforced_for_signin | boolean | | Yes | | sso_enforced_for_signin_protocol | string | | Yes | | webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | @@ -18336,10 +19482,10 @@ Tag type | 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 | +| message_id | string | Message ID. Takes priority over `text` when both are provided. | No | +| streaming | boolean | Reserved for compatibility; TTS response streaming is determined by the provider output. | No | +| text | string | Speech content to convert. | No | +| voice | string | Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. Omit to use the app's configured voice when available; that value is exposed by [Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`. | No | #### TextToSpeechPayload @@ -18478,6 +19624,7 @@ Enum class for tool provider | mode | string | | No | | model_config | [TrialAppModelConfig](#trialappmodelconfig) | | No | | name | string | | No | +| permission_keys | [ string ] | | No | | site | [TrialSite](#trialsite) | | No | | tags | [ [TrialTag](#trialtag) ] | | No | | updated_at | long | | No | @@ -18536,6 +19683,7 @@ Enum class for tool provider | indexing_technique | string | | No | | name | string | | No | | permission | string | | No | +| permission_keys | [ string ] | | No | #### TrialDatasetList @@ -18909,23 +20057,23 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword_weight | number | | Yes | +| keyword_weight | number | Weight assigned to keyword search results. | Yes | #### WeightModel | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | | No | -| vector_setting | [WeightVectorSetting](#weightvectorsetting) | | No | -| weight_type | string | | No | +| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No | +| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No | +| weight_type | string | Strategy for balancing semantic and keyword search weights. | No | #### WeightVectorSetting | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| embedding_model_name | string | | Yes | -| embedding_provider_name | string | | Yes | -| vector_weight | number | | Yes | +| embedding_model_name | string | Name of the embedding model used for vector search. | Yes | +| embedding_provider_name | string | Provider of the embedding model used for vector search. | Yes | +| vector_weight | number | Weight assigned to semantic vector search results. | Yes | #### WorkflowAgentBindingType @@ -19621,8 +20769,8 @@ can reuse its existing handler. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ object ] | | No | -| inputs | object | | Yes | +| files | [ object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | #### WorkflowRunQuery @@ -19737,6 +20885,13 @@ Workflow tool configuration | marked_comment | string | | No | | marked_name | string | | No | +#### WorkspaceAccessMatrix + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AccessMatrixItem](#accessmatrixitem) ] | | No | +| pagination | [Pagination](#pagination) | | No | + #### WorkspaceCustomConfigPayload | Name | Type | Description | Required | @@ -19804,6 +20959,38 @@ Workflow tool configuration | allow_owner_transfer | boolean | | Yes | | workspace_id | string | | Yes | +#### WorkspacePermissionSnapshot + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| permission_keys | [ string ] | | No | + +#### _AccessPolicyList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccessPolicy](#accesspolicy) ] | | No | +| pagination | [Pagination](#pagination) | | No | + +#### _AnonymousInlineModel_744ff9cc03e6 + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author_name | string | | No | +| created_at | long | | No | +| created_by | string | | No | +| description | string | | No | +| icon_info | object | | No | +| id | string | | No | +| is_published | boolean | | No | +| name | string | | No | +| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | +| type | string | | No | +| updated_at | long | | No | +| updated_by | string | | No | +| use_count | integer | | No | +| version | integer | | No | + #### _AnonymousInlineModel_7b8b49ca164e | Name | Type | Description | Required | @@ -19829,24 +21016,26 @@ Workflow tool configuration | model_provider_name | string | | No | | summary_prompt | string | | No | -#### _AnonymousInlineModel_efd591151ea9 +#### _MembersInRoleList | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| author_name | string | | No | -| created_at | long | | No | -| created_by | string | | No | -| description | string | | No | -| icon_info | object | | No | -| id | string | | No | -| is_published | boolean | | No | -| name | string | | No | -| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | -| type | string | | No | -| updated_at | long | | No | -| updated_by | string | | No | -| use_count | integer | | No | -| version | integer | | No | +| data | [ [MembersInRole](#membersinrole) ] | | No | +| pagination | [Pagination](#pagination) | | No | + +#### _RBACRoleAccountList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [RBACRoleAccount](#rbacroleaccount) ] | | No | +| pagination | [Pagination](#pagination) | | No | + +#### _RBACRoleList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [RBACRole](#rbacrole) ] | | No | +| pagination | [Pagination](#pagination) | | No | #### core__tools__entities__common_entities__I18nObject diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index d203eaa4c97..ce0150e8e88 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -4,10 +4,9 @@ User-scoped programmatic API (bearer auth) ## Version: 1.0 ### Available authorizations -#### Bearer (API Key Authentication) -Type: Bearer {your-api-key} -**Name:** Authorization -**In:** header +#### Bearer (HTTP, bearer) +Use the Service API key as a Bearer token in the Authorization header. +Bearer format: API_KEY --- ## openapi @@ -793,7 +792,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| action | string | | Yes | +| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | | inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### Import @@ -806,6 +805,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | +| permission_keys | [ string ] | | No | | status | [ImportStatus](#importstatus) | | Yes | #### ImportStatus diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 5a0a128b4f9..8fc5e75e3cf 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -4,10 +4,9 @@ API for application services ## Version: 1.0 ### Available authorizations -#### Bearer (API Key Authentication) -Type: Bearer {your-api-key} -**Name:** Authorization -**In:** header +#### Bearer (HTTP, bearer) +Use the Service API key as a Bearer token in the Authorization header. +Bearer format: API_KEY --- ## service_api @@ -20,648 +19,6 @@ Service operations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [IndexInfoResponse](#indexinforesponse)
| -### [GET] /app/feedbacks -**Get all feedbacks for the application** - -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 | -| ---- | ---------- | ----------- | -------- | ------ | -| limit | query | Number of feedbacks per page | No | integer,
**Default:** 20 | -| page | query | Page number | No | integer,
**Default:** 1 | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Feedbacks retrieved successfully | **application/json**: [AppFeedbackListResponse](#appfeedbacklistresponse)
| -| 401 | Unauthorized - invalid API token | | - -### [POST] /apps/annotation-reply/{action} -**Enable or disable annotation reply feature** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| action | path | Action to perform: 'enable' or 'disable' | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AnnotationReplyActionPayload](#annotationreplyactionpayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Action completed successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| -| 401 | Unauthorized - invalid API token | | - -### [GET] /apps/annotation-reply/{action}/status/{job_id} -**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 | Schema | -| ---- | ----------- | ------ | -| 200 | Job status retrieved successfully | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Job not found | | - -### [GET] /apps/annotations -**List annotations for the application** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| keyword | query | Keyword to search annotations | No | string | -| limit | query | Number of annotations per page | No | integer,
**Default:** 20 | -| page | query | Page number | No | integer,
**Default:** 1 | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Annotations retrieved successfully | **application/json**: [AnnotationList](#annotationlist)
| -| 401 | Unauthorized - invalid API token | | - -### [POST] /apps/annotations -**Create a new annotation** - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AnnotationCreatePayload](#annotationcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | Annotation created successfully | **application/json**: [Annotation](#annotation)
| -| 401 | Unauthorized - invalid API token | | - -### [DELETE] /apps/annotations/{annotation_id} -**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] /apps/annotations/{annotation_id} -**Update an existing annotation** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| annotation_id | path | Annotation ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [AnnotationCreatePayload](#annotationcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Annotation updated successfully | **application/json**: [Annotation](#annotation)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Annotation not found | | - -### [POST] /audio-to-text -**Convert audio to text using speech-to-text** - -Convert audio to text using speech-to-text -Accepts an audio file upload and returns the transcribed text. - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Audio successfully transcribed | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| -| 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 | | - -### [POST] /chat-messages -**Send a message in a chat conversation** - -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. - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ChatRequestPayload](#chatrequestpayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Message sent successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 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 | | - -### [POST] /chat-messages/{task_id}/stop -**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 | Schema | -| ---- | ----------- | ------ | -| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Task not found | | - -### [POST] /completion-messages -**Create a completion for the given prompt** - -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. - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [CompletionRequestPayload](#completionrequestpayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Completion created successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad request - invalid parameters | | -| 401 | Unauthorized - invalid API token | | -| 404 | Conversation not found | | -| 500 | Internal server error | | - -### [POST] /completion-messages/{task_id}/stop -**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 | Schema | -| ---- | ----------- | ------ | -| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Task not found | | - -### [GET] /conversations -**List all conversations for the current user** - -List all conversations for the current user -Supports pagination using last_id and limit parameters. - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| last_id | query | Last conversation ID for pagination | No | string | -| limit | query | Number of conversations to return | No | integer,
**Default:** 20 | -| sort_by | query | Sort order for conversations | No | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Conversations retrieved successfully | **application/json**: [ConversationInfiniteScrollPagination](#conversationinfinitescrollpagination)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Last conversation not found | | - -### [DELETE] /conversations/{c_id} -**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 | - -### [POST] /conversations/{c_id}/name -**Rename a conversation or auto-generate a name** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ConversationRenamePayload](#conversationrenamepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Conversation renamed successfully | **application/json**: [SimpleConversation](#simpleconversation)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Conversation not found | | - -### [GET] /conversations/{c_id}/variables -**List all variables for a conversation** - -List all variables for a conversation -Conversational variables are only available for chat applications. - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation ID | Yes | string | -| last_id | query | Last variable ID for pagination | No | string | -| limit | query | Number of variables to return | No | integer,
**Default:** 20 | -| variable_name | query | Filter variables by name | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Variables retrieved successfully | **application/json**: [ConversationVariableInfiniteScrollPaginationResponse](#conversationvariableinfinitescrollpaginationresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Conversation not found | | - -### [PUT] /conversations/{c_id}/variables/{variable_id} -**Update a conversation variable's value** - -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 | -| ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation ID | Yes | string | -| variable_id | path | Variable ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ConversationVariableUpdatePayload](#conversationvariableupdatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Variable updated successfully | **application/json**: [ConversationVariableResponse](#conversationvariableresponse)
| -| 400 | Bad request - type mismatch | | -| 401 | Unauthorized - invalid API token | | -| 404 | Conversation or variable not found | | - -### [GET] /datasets -**Resource for getting datasets** - -List all datasets - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| include_all | query | Include all datasets | No | boolean | -| keyword | query | Search keyword | No | string | -| limit | query | Number of items per page | No | integer,
**Default:** 20 | -| page | query | Page number | No | integer,
**Default:** 1 | -| tag_ids | query | Filter by tag IDs | No | [ string ] | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Datasets retrieved successfully | **application/json**: [DatasetListResponse](#datasetlistresponse)
| -| 401 | Unauthorized - invalid API token | | - -### [POST] /datasets -**Resource for creating datasets** - -Create a new dataset - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [DatasetCreatePayload](#datasetcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Dataset created successfully | **application/json**: [DatasetDetailResponse](#datasetdetailresponse)
| -| 400 | Bad request - invalid parameters | | -| 401 | Unauthorized - invalid API token | | - -### [POST] /datasets/pipeline/file-upload -**Upload a file for use in conversations** - -Upload a file to a knowledgebase pipeline -Accepts a single file upload via multipart/form-data. - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 201 | File uploaded successfully | **application/json**: [PipelineUploadFileResponse](#pipelineuploadfileresponse)
| -| 400 | Bad request - no file or invalid file | | -| 401 | Unauthorized - invalid API token | | -| 413 | File too large | | -| 415 | Unsupported file type | | - -### [DELETE] /datasets/tags -**Delete a knowledge type tag** - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [TagDeletePayload](#tagdeletepayload)
| - -#### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Tag deleted successfully | -| 401 | Unauthorized - invalid API token | -| 403 | Forbidden - insufficient permissions | - -### [GET] /datasets/tags -**Get all knowledge type tags** - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Tags retrieved successfully | **application/json**: [KnowledgeTagListResponse](#knowledgetaglistresponse)
| -| 401 | Unauthorized - invalid API token | | - -### [PATCH] /datasets/tags -Update a knowledge type tag - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [TagUpdatePayload](#tagupdatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Tag updated successfully | **application/json**: [KnowledgeTagResponse](#knowledgetagresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | - -### [POST] /datasets/tags -**Add a knowledge type tag** - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [TagCreatePayload](#tagcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Tag created successfully | **application/json**: [KnowledgeTagResponse](#knowledgetagresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | - -### [POST] /datasets/tags/binding -Bind tags to a dataset - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [TagBindingPayload](#tagbindingpayload)
| - -#### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Tags bound successfully | -| 401 | Unauthorized - invalid API token | -| 403 | Forbidden - insufficient permissions | - -### [POST] /datasets/tags/unbinding -Unbind tags from a dataset - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [TagUnbindingPayload](#tagunbindingpayload)
| - -#### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Tags unbound successfully | -| 401 | Unauthorized - invalid API token | -| 403 | Forbidden - insufficient permissions | - -### [DELETE] /datasets/{dataset_id} -**Deletes a dataset given its ID** - -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] /datasets/{dataset_id} -Get a specific dataset by ID - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Dataset retrieved successfully | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Dataset not found | | - -### [PATCH] /datasets/{dataset_id} -Update an existing dataset - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [DatasetUpdatePayload](#datasetupdatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Dataset updated successfully | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Dataset not found | | - -### [POST] /datasets/{dataset_id}/document/create-by-file -Create a new document by uploading a file - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | Bad request - invalid file or parameters | | -| 401 | Unauthorized - invalid API token | | - -### [POST] /datasets/{dataset_id}/document/create-by-text -Create a new document by providing text content - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [DocumentTextCreatePayload](#documenttextcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | Bad request - invalid parameters | | -| 401 | Unauthorized - invalid API token | | - -### [POST] /datasets/{dataset_id}/document/create_by_file -Create a new document by uploading a file - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 400 | Bad request - invalid file or parameters | | -| 401 | Unauthorized - invalid API token | | - ### ~~[POST] /datasets/{dataset_id}/document/create_by_text~~ ***DEPRECATED*** @@ -672,7 +29,7 @@ Deprecated legacy alias for creating a new document by providing text content. U | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | #### Request Body @@ -687,174 +44,52 @@ Deprecated legacy alias for creating a new document by providing text content. U | 200 | Document created successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 400 | Bad request - invalid parameters | | | 401 | Unauthorized - invalid API token | | - -### [GET] /datasets/{dataset_id}/documents -List all documents in a dataset - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| keyword | query | Search keyword | No | string | -| limit | query | Number of items per page | No | integer,
**Default:** 20 | -| page | query | Page number | No | integer,
**Default:** 1 | -| status | query | Document status filter | No | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Documents retrieved successfully | **application/json**: [DocumentListResponse](#documentlistresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | - -### [POST] /datasets/{dataset_id}/documents/download-zip -Download selected uploaded documents as a single ZIP archive - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | ZIP archive generated successfully | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Document or dataset not found | | - -### [POST] /datasets/{dataset_id}/documents/metadata -**Update metadata for multiple documents** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [MetadataOperationData](#metadataoperationdata)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Documents metadata updated successfully | **application/json**: [DatasetMetadataActionResponse](#datasetmetadataactionresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | - -### [PATCH] /datasets/{dataset_id}/documents/status/{action} -**Batch update document status** - -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 | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [DocumentStatusPayload](#documentstatuspayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document status updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| -| 400 | Bad request - invalid action | | -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Dataset not found | | - -### [GET] /datasets/{dataset_id}/documents/{batch}/indexing-status -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 | Schema | -| ---- | ----------- | ------ | -| 200 | Indexing status retrieved successfully | **application/json**: [DocumentStatusListResponse](#documentstatuslistresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset or documents not found | | +| 403 | Forbidden - dataset API access or workspace access denied | | ### [DELETE] /datasets/{dataset_id}/documents/{document_id} -**Delete document** +**Delete Document** -Delete a document +Permanently delete a document and all its chunks from the knowledge base. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | #### Responses | Code | Description | | ---- | ----------- | -| 204 | Document deleted successfully | +| 204 | Success. | +| 400 | `document_indexing` : Cannot delete document during indexing. | | 401 | Unauthorized - invalid API token | -| 403 | Forbidden - document is archived | -| 404 | Document not found | +| 403 | `archived_document_immutable` : The archived document is not editable. | +| 404 | `not_found` : Document Not Exists. | ### [GET] /datasets/{dataset_id}/documents/{document_id} -Get a specific document by ID +**Get Document** + +Retrieve detailed information about a specific document, including its indexing status, metadata, and processing statistics. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| metadata | query | Metadata response mode | No | string,
**Available values:** "all", "only", "without",
**Default:** all | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| metadata | query | `all` returns all fields including metadata. `only` returns only `id`, `doc_type`, and `doc_metadata`. `without` returns all fields except `doc_metadata`. | No | string,
**Available values:** "all", "only", "without",
**Default:** all | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Document retrieved successfully | **application/json**: [DocumentDetailResponse](#documentdetailresponse)
| +| 200 | Document details. The response shape varies based on the `metadata` query parameter. When `metadata` is `only`, only `id`, `doc_type`, and `doc_metadata` are returned. When `metadata` is `without`, `doc_type` and `doc_metadata` are omitted. | **application/json**: [DocumentDetailResponse](#documentdetailresponse)
| +| 400 | `invalid_metadata` : Invalid metadata value for the specified key. | | | 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Document not found | | +| 403 | `forbidden` : No permission. | | +| 404 | `not_found` : Document not found. | | ### [PATCH] /datasets/{dataset_id}/documents/{document_id} Update an existing document by uploading a file @@ -863,308 +98,8 @@ Update an existing document by uploading a file | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Document not found | | - -### [GET] /datasets/{dataset_id}/documents/{document_id}/download -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 | Schema | -| ---- | ----------- | ------ | -| 200 | Download URL generated successfully | **application/json**: [UrlResponse](#urlresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - insufficient permissions | | -| 404 | Document or upload file not found | | - -### [GET] /datasets/{dataset_id}/documents/{document_id}/segments -List segments in a document - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| keyword | query | | No | string | -| limit | query | | No | integer,
**Default:** 20 | -| page | query | | No | integer,
**Default:** 1 | -| status | query | | No | [ string ] | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Segments retrieved successfully | **application/json**: [SegmentListResponse](#segmentlistresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset or document not found | | - -### [POST] /datasets/{dataset_id}/documents/{document_id}/segments -Create segments in a document - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [SegmentCreatePayload](#segmentcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Segments created successfully | **application/json**: [SegmentCreateListResponse](#segmentcreatelistresponse)
| -| 400 | Bad request - segments data is missing | | -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset or document not found | | - -### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} -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 | Yes | string | - -#### Responses - -| Code | Description | -| ---- | ----------- | -| 204 | Segment deleted successfully | -| 401 | Unauthorized - invalid API token | -| 404 | Dataset, document, or segment not found | - -### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} -Get a specific segment by ID - -#### 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 | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Segment retrieved successfully | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset, document, or segment not found | | - -### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} -Update 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 | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [SegmentUpdatePayload](#segmentupdatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Segment updated successfully | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset, document, or segment not found | | - -### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks -List child chunks for a 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 | Parent segment ID | Yes | string | -| keyword | query | | No | string | -| limit | query | | No | integer,
**Default:** 20 | -| page | query | | No | integer,
**Default:** 1 | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Child chunks retrieved successfully | **application/json**: [ChildChunkListResponse](#childchunklistresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset, document, or segment not found | | - -### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks -Create a new child chunk for a 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 | Parent segment ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ChildChunkCreatePayload](#childchunkcreatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Child chunk created successfully | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset, document, or segment not found | | - -### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} -Delete a specific child chunk - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| child_chunk_id | path | Child chunk ID | 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] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} -Update a specific child chunk - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| child_chunk_id | path | Child chunk ID | Yes | string | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | -| segment_id | path | Parent segment ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [ChildChunkUpdatePayload](#childchunkupdatepayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Child chunk updated successfully | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset, document, segment, or child chunk not found | | - -### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update-by-file~~ - -***DEPRECATED*** - -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 | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Document not found | | - -### [POST] /datasets/{dataset_id}/documents/{document_id}/update-by-text -Update an existing document by providing text content - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [DocumentTextUpdate](#documenttextupdate)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Document not found | | - -### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update_by_file~~ - -***DEPRECATED*** - -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 | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | #### Request Body @@ -1178,6 +113,7 @@ Deprecated legacy alias for updating an existing document by uploading a file. U | ---- | ----------- | ------ | | 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | | 404 | Document not found | | ### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update_by_text~~ @@ -1190,8 +126,8 @@ Deprecated legacy alias for updating an existing document by providing text cont | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| document_id | path | Document ID | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | #### Request Body @@ -1205,19 +141,799 @@ Deprecated legacy alias for updating an existing document by providing text cont | ---- | ----------- | ------ | | 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | | 404 | Document not found | | -### [POST] /datasets/{dataset_id}/hit-testing -**Perform hit testing on a dataset** +--- +## default -Perform hit testing on a dataset -Tests retrieval performance for the specified dataset. +### [GET] /app/feedbacks +**List App Feedbacks** + +Retrieve a paginated list of all feedback submitted for messages in this application, including both end-user and admin feedback. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| limit | query | Number of records per page. | No | integer,
**Default:** 20 | +| page | query | Page number for pagination. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | A list of application feedbacks. | **application/json**: [AppFeedbackListResponse](#appfeedbacklistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | + +### [POST] /messages/{message_id}/feedbacks +**Submit Message Feedback** + +Submit feedback for a message. End users can rate messages as `like` or `dislike`, and optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted feedback. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MessageFeedbackPayloadWithUser](#messagefeedbackpayloadwithuser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Feedback submitted successfully | **application/json**: [ResultResponse](#resultresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Message does not exist. | | + +--- +## default + +### [POST] /apps/annotation-reply/{action} +**Configure Annotation Reply** + +Enables or disables the annotation reply feature. Requires embedding model configuration when enabling. Executes asynchronously — use [Get Annotation Reply Job Status](/api-reference/annotations/get-annotation-reply-job-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action to perform: `enable` or `disable`. | Yes | string,
**Available values:** "disable", "enable" | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationReplyActionPayload](#annotationreplyactionpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation reply settings task initiated. | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | + +### [GET] /apps/annotation-reply/{action}/status/{job_id} +**Get Annotation Reply Job Status** + +Retrieves the status of an asynchronous annotation reply configuration job started by [Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply). + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action to perform: `enable` or `disable`. | Yes | string,
**Available values:** "disable", "enable" | +| job_id | path | Job ID returned by [Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply). | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved task status. | **application/json**: [AnnotationJobStatusResponse](#annotationjobstatusresponse)
| +| 400 | `invalid_param` : The specified job does not exist. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | Job not found | | + +### [GET] /apps/annotations +**List Annotations** + +Retrieves a paginated list of annotations for the application. Supports keyword search filtering. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| keyword | query | Keyword to filter annotations by question or answer content. | No | string | +| limit | query | Number of items per page. | No | integer,
**Default:** 20 | +| page | query | Page number for pagination. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved annotation list. | **application/json**: [AnnotationList](#annotationlist)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | + +### [POST] /apps/annotations +**Create Annotation** + +Creates a new annotation. Annotations provide predefined question-answer pairs that the app can match and return directly instead of generating a response. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationCreatePayload](#annotationcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Annotation created successfully. | **application/json**: [Annotation](#annotation)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | + +### [DELETE] /apps/annotations/{annotation_id} +**Delete Annotation** + +Deletes an annotation and its associated hit history. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| annotation_id | path | The unique identifier of the annotation to delete. | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Annotation deleted successfully. | +| 401 | Unauthorized - invalid API token | +| 403 | `forbidden` : Insufficient permissions to edit annotations. | +| 404 | `not_found` : Annotation does not exist. | + +### [PUT] /apps/annotations/{annotation_id} +**Update Annotation** + +Updates the question and answer of an existing annotation. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| annotation_id | path | The unique identifier of the annotation to update. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [AnnotationCreatePayload](#annotationcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Annotation updated successfully. | **application/json**: [Annotation](#annotation)
| +| 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : Insufficient permissions to edit annotations. | | +| 404 | `not_found` : Annotation does not exist. | | + +--- +## default + +### [POST] /audio-to-text +**Convert Audio to Text** + +Convert audio file to text. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, `audio/wav`, and `audio/amr`. File size limit is `30 MB`. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary, **"user"**: string }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully converted audio to text. | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)
| +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_support_speech_to_text` : Model provider does not support speech-to-text. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model does not support this operation. - `completion_request_error` : Speech recognition request failed. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 413 | `audio_too_large` : Audio file size exceeded the limit. | | +| 415 | `unsupported_audio_type` : Audio type is not allowed. | | +| 500 | `internal_server_error` : Internal server error. | | + +### [POST] /text-to-audio +**Convert Text to Audio** + +Convert text to speech. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TextToAudioPayloadWithUser](#texttoaudiopayloadwithuser)
| + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly. | +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model does not support this operation. - `completion_request_error` : Text-to-speech request failed. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 500 | `internal_server_error` : Internal server error. | + +--- +## default + +### [POST] /chat-messages +**Send Chat Message** + +Send a request to the chat application. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatRequestPayloadWithUser](#chatrequestpayloadwithuser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `not_chat_app` : App mode does not match the API route. - `conversation_completed` : The conversation has ended. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Conversation does not exist. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | +| 500 | `internal_server_error` : Internal server error. | | + +### [POST] /chat-messages/{task_id}/stop +**Stop Chat Message Generation** + +Stops a chat message generation task. Only supported in `streaming` mode. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID, obtained from a streaming chunk returned by the Send Chat Message API. | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RequiredServiceApiUserPayload](#requiredserviceapiuserpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | `not_chat_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | Task not found | | + +### [GET] /messages/{message_id}/suggested +**Get Next Suggested Questions** + +Get next questions suggestions for the current message. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message ID | Yes | string (uuid) | +| user | query | User identifier, used for end-user context. | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)
| +| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Suggested questions feature is disabled. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Message does not exist. | | +| 500 | `internal_server_error` : Internal server error. | | + +### [GET] /workflow/{task_id}/events +**Stream Workflow Events** + +Resume the Server-Sent Events stream for a workflow run after a pause or a dropped SSE connection. For runs that have already finished, the stream emits a single `workflow_finished` event and closes. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Workflow run ID returned by the original workflow run request. | Yes | string | +| continue_on_pause | query | Set to `true` to keep the stream open across multiple `workflow_paused` events, which is useful when the workflow has more than one Human Input node in sequence. By default, the stream closes after the first pause. | No | boolean | +| include_state_snapshot | query | When `true`, replay from the persisted state snapshot to include a status summary of already-executed nodes before streaming new events. | No | boolean | +| user | query | End-user identifier that originally triggered the run. Must match the creator of the run. | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Server-Sent Events stream. Each event is delivered as `data: {JSON}\\n\\n`. Event payloads follow the same schemas as the original streaming response. | **text/event-stream**: [EventStreamResponse](#eventstreamresponse)
| +| 400 | `not_workflow_app` : Please check if your app mode matches the right API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Workflow run not found. | | + +### [GET] /workflows/logs +**List Workflow Logs** + +Retrieve paginated workflow execution logs with filtering options. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| created_at__after | query | Filter logs created after this ISO 8601 timestamp. | No | dateTime | +| created_at__before | query | Filter logs created before this ISO 8601 timestamp. | No | dateTime | +| created_by_account | query | Filter by account ID. | No | string | +| created_by_end_user_session_id | query | Filter by end user session ID. | No | string | +| keyword | query | Keyword to search in logs. | No | string | +| limit | query | Number of items per page. | No | integer,
**Default:** 20 | +| page | query | Page number for pagination. | No | integer,
**Default:** 1 | +| status | query | Filter by execution status. | No | string,
**Available values:** "failed", "stopped", "succeeded" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved workflow logs. | **application/json**: [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | + +### [GET] /workflows/run/{workflow_run_id} +**Get Workflow Run Detail** + +Retrieve the current execution results of a workflow task based on the workflow execution ID. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workflow_run_id | path | Workflow run ID, obtained from the workflow execution response or streaming events. | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved workflow run details. | **application/json**: [WorkflowRunResponse](#workflowrunresponse)
| +| 400 | `not_workflow_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Workflow run not found. | | + +--- +## default + +### [POST] /chat-messages +**Send Chat Message** + +Send a request to the chat application. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChatRequestPayloadWithUser](#chatrequestpayloadwithuser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `not_chat_app` : App mode does not match the API route. - `conversation_completed` : The conversation has ended. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Conversation does not exist. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | +| 500 | `internal_server_error` : Internal server error. | | + +### [POST] /chat-messages/{task_id}/stop +**Stop Chat Message Generation** + +Stops a chat message generation task. Only supported in `streaming` mode. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID, obtained from a streaming chunk returned by the Send Chat Message API. | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RequiredServiceApiUserPayload](#requiredserviceapiuserpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | `not_chat_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | Task not found | | + +### [GET] /messages/{message_id}/suggested +**Get Next Suggested Questions** + +Get next questions suggestions for the current message. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| message_id | path | Message ID | Yes | string (uuid) | +| user | query | User identifier, used for end-user context. | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)
| +| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Suggested questions feature is disabled. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Message does not exist. | | +| 500 | `internal_server_error` : Internal server error. | | + +--- +## default + +### [POST] /completion-messages +**Send Completion Message** + +Send a request to the text generation application. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [CompletionRequestPayloadWithUser](#completionrequestpayloadwithuser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `CompletionResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkCompletionEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Text generation failed. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | Conversation not found | | +| 429 | `too_many_requests` : Too many concurrent requests for this app. | | +| 500 | `internal_server_error` : Internal server error. | | + +### [POST] /completion-messages/{task_id}/stop +**Stop Completion Message Generation** + +Stops a completion message generation task. Only supported in `streaming` mode. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| task_id | path | Task ID, obtained from a streaming chunk returned by the Send Completion Message API. | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RequiredServiceApiUserPayload](#requiredserviceapiuserpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | `app_unavailable` : App unavailable or misconfigured. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | Task not found | | + +--- +## default + +### [GET] /conversations +**List Conversations** + +Retrieve the conversation list for the current user, ordered by most recently active. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| last_id | query | The ID of the last record on the current page. Used to fetch the next page. | No | string | +| limit | query | Number of records to return. | No | integer,
**Default:** 20 | +| sort_by | query | Sorting field. Use the `-` prefix for descending order. | No | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | +| user | query | User identifier, used for end-user context. | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved conversations list. | **application/json**: [ConversationInfiniteScrollPagination](#conversationinfinitescrollpagination)
| +| 400 | `not_chat_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Last conversation does not exist (invalid `last_id`). | | + +### [DELETE] /conversations/{c_id} +**Delete Conversation** + +Delete a conversation. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [OptionalServiceApiUserPayload](#optionalserviceapiuserpayload)
| + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Conversation deleted successfully. | +| 400 | `not_chat_app` : App mode does not match the API route. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | +| 404 | `not_found` : Conversation does not exist. | + +### [POST] /conversations/{c_id}/name +**Rename Conversation** + +Rename a conversation or auto-generate a name. The conversation name is used for display on clients that support multiple conversations. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationRenamePayloadWithUser](#conversationrenamepayloadwithuser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation renamed successfully. | **application/json**: [SimpleConversation](#simpleconversation)
| +| 400 | `not_chat_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Conversation does not exist. | | + +### [GET] /conversations/{c_id}/variables +**List Conversation Variables** + +Retrieve variables from a specific conversation. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation ID. | Yes | string (uuid) | +| last_id | query | The ID of the last record on the current page. Used to fetch the next page. | No | string | +| limit | query | Number of records to return. | No | integer,
**Default:** 20 | +| user | query | User identifier, used for end-user context. | No | string | +| variable_name | query | Filter variables by a specific name. | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved conversation variables. | **application/json**: [ConversationVariableInfiniteScrollPaginationResponse](#conversationvariableinfinitescrollpaginationresponse)
| +| 400 | `not_chat_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Conversation does not exist. | | + +### [PUT] /conversations/{c_id}/variables/{variable_id} +**Update Conversation Variable** + +Update the value of a specific conversation variable. The value must match the expected type. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| c_id | path | Conversation ID. | Yes | string (uuid) | +| variable_id | path | Variable ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConversationVariableUpdatePayloadWithUser](#conversationvariableupdatepayloadwithuser)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable updated successfully. | **application/json**: [ConversationVariableResponse](#conversationvariableresponse)
| +| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Variable value type mismatch. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | - `not_found` : Conversation does not exist. - `not_found` : Conversation variable does not exist. | | + +### [GET] /messages +**List Conversation Messages** + +Returns historical chat records in a scrolling load format, with the first page returning the latest `limit` messages, i.e., in reverse order. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| conversation_id | query | Conversation ID. | Yes | string | +| first_id | query | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | string | +| limit | query | Number of chat history messages to return per request. | No | integer,
**Default:** 20 | +| user | query | User identifier, used for end-user context. | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Successfully retrieved conversation history. | **application/json**: [MessageInfiniteScrollPagination](#messageinfinitescrollpagination)
| +| 400 | `not_chat_app` : App mode does not match the API route. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | - `not_found` : Conversation does not exist. - `not_found` : First message does not exist. | | + +--- +## default + +### [GET] /datasets +**List Knowledge Bases** + +Returns a paginated list of knowledge bases. Supports filtering by keyword and tags. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| include_all | query | Whether to include all knowledge bases regardless of permissions. | No | boolean | +| keyword | query | Search keyword to filter by name. | No | string | +| limit | query | Number of items per page. Server caps at `100`. | No | integer,
**Default:** 20 | +| page | query | Page number to retrieve. | No | integer,
**Default:** 1 | +| tag_ids | query | Tag IDs to filter by. | No | [ string ] | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | List of knowledge bases. | **application/json**: [DatasetListResponse](#datasetlistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +### [POST] /datasets +**Create an Empty Knowledge Base** + +Create a new empty knowledge base. After creation, use [Create Document by Text](/api-reference/documents/create-document-by-text) or [Create Document by File](/api-reference/documents/create-document-by-file) to add documents. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasetCreatePayload](#datasetcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Knowledge base created successfully. | **application/json**: [DatasetDetailResponse](#datasetdetailresponse)
| +| 400 | Bad request - invalid parameters | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 409 | `dataset_name_duplicate` : The dataset name already exists. Please modify your dataset name. | | + +### [DELETE] /datasets/{dataset_id} +**Delete Knowledge Base** + +Permanently delete a knowledge base and all its documents. The knowledge base must not be in use by any application. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - dataset API access or workspace access denied | +| 404 | `not_found` : Dataset not found. | +| 409 | `dataset_in_use` : The knowledge base is being used by some apps. Please remove it from the apps before deleting. | + +### [GET] /datasets/{dataset_id} +**Get Knowledge Base** + +Retrieve detailed information about a specific knowledge base, including its embedding model, retrieval configuration, and document statistics. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Knowledge base details. | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : Insufficient permissions to access this knowledge base. | | +| 404 | `not_found` : Dataset not found. | | + +### [PATCH] /datasets/{dataset_id} +**Update Knowledge Base** + +Update the name, description, permissions, or retrieval settings of an existing knowledge base. Only the fields provided in the request body are updated. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DatasetUpdatePayload](#datasetupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Knowledge base updated successfully. | **application/json**: [DatasetDetailWithPartialMembersResponse](#datasetdetailwithpartialmembersresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : Insufficient permissions to access this knowledge base. | | +| 404 | `not_found` : Dataset not found. | | + +### [POST] /datasets/{dataset_id}/hit-testing +**Retrieve Chunks from a Knowledge Base / Test Retrieval** + +Performs a search query against a knowledge base to retrieve the most relevant chunks. This endpoint can be used for both production retrieval and test retrieval. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | #### Request Body @@ -1229,156 +945,98 @@ Tests retrieval performance for the specified dataset. | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Hit testing results | **application/json**: [HitTestingResponse](#hittestingresponse)
| +| 200 | Retrieval results. | **application/json**: [HitTestingResponse](#hittestingresponse)
| +| 400 | - `dataset_not_initialized` : The dataset is still being initialized or indexing. Please wait a moment. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `provider_quota_exceeded` : Your quota for Dify Hosted OpenAI has been exhausted. Please go to Settings -> Model Provider to complete your own provider credentials. - `model_currently_not_support` : Dify Hosted OpenAI trial currently not support the GPT-4 model. - `completion_request_error` : Completion request failed. - `invalid_param` : Invalid parameter value. | | | 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | +| 403 | `forbidden` : Insufficient permissions. | | +| 404 | `not_found` : Knowledge base not found. | | +| 500 | `internal_server_error` : An internal error occurred during retrieval. | | -### [GET] /datasets/{dataset_id}/metadata -**Get all metadata for a dataset** +### [POST] /datasets/{dataset_id}/retrieve +**Retrieve Chunks from a Knowledge Base / Test Retrieval** + +Performs a search query against a knowledge base to retrieve the most relevant chunks. This endpoint can be used for both production retrieval and test retrieval. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Metadata retrieved successfully | **application/json**: [DatasetMetadataListResponse](#datasetmetadatalistresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | - -### [POST] /datasets/{dataset_id}/metadata -**Create metadata for a dataset** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | #### Request Body | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [MetadataArgs](#metadataargs)
| +| Yes | **application/json**: [HitTestingPayload](#hittestingpayload)
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | Metadata created successfully | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| +| 200 | Retrieval results. | **application/json**: [HitTestingResponse](#hittestingresponse)
| +| 400 | - `dataset_not_initialized` : The dataset is still being initialized or indexing. Please wait a moment. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `provider_quota_exceeded` : Your quota for Dify Hosted OpenAI has been exhausted. Please go to Settings -> Model Provider to complete your own provider credentials. - `model_currently_not_support` : Dify Hosted OpenAI trial currently not support the GPT-4 model. - `completion_request_error` : Completion request failed. - `invalid_param` : Invalid parameter value. | | | 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | +| 403 | `forbidden` : Insufficient permissions. | | +| 404 | `not_found` : Knowledge base not found. | | +| 500 | `internal_server_error` : An internal error occurred during retrieval. | | -### [GET] /datasets/{dataset_id}/metadata/built-in -**Get all built-in metadata fields** +--- +## default -#### Parameters +### [POST] /datasets/pipeline/file-upload +**Upload Pipeline File** -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Built-in fields retrieved successfully | **application/json**: [DatasetMetadataBuiltInFieldsResponse](#datasetmetadatabuiltinfieldsresponse)
| -| 401 | Unauthorized - invalid API token | | - -### [POST] /datasets/{dataset_id}/metadata/built-in/{action} -**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 | Schema | -| ---- | ----------- | ------ | -| 200 | Action completed successfully | **application/json**: [DatasetMetadataActionResponse](#datasetmetadataactionresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | - -### [DELETE] /datasets/{dataset_id}/metadata/{metadata_id} -**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] /datasets/{dataset_id}/metadata/{metadata_id} -**Update metadata name** - -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | -| metadata_id | path | Metadata ID | Yes | string | +Upload a file for use in a knowledge pipeline. Accepts a single file via `multipart/form-data`. #### Request Body | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [MetadataUpdatePayload](#metadataupdatepayload)
| +| Yes | **multipart/form-data**: { **"file"**: binary }
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Metadata updated successfully | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| +| 201 | File uploaded successfully. | **application/json**: [PipelineUploadFileResponse](#pipelineuploadfileresponse)
| +| 400 | - `no_file_uploaded` : Please upload your file. - `filename_not_exists_error` : The specified filename does not exist. - `too_many_files` : Only one file is allowed. | | | 401 | Unauthorized - invalid API token | | -| 404 | Dataset or metadata not found | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 413 | `file_too_large` : File size exceeded. | | +| 415 | `unsupported_file_type` : File type not allowed. | | ### [GET] /datasets/{dataset_id}/pipeline/datasource-plugins -**Resource for getting datasource plugins** +**List Datasource Plugins** -List all datasource plugins for a rag pipeline +List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses plus the metadata needed to run it. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| is_published | query | | No | boolean,
**Default:** true | -| dataset_id | path | | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| is_published | query | Whether to retrieve nodes from the published or draft pipeline. `true` returns nodes from the published version, `false` returns nodes from the draft. | No | boolean,
**Default:** true | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Datasource plugins retrieved successfully | **application/json**: [DatasourcePluginListResponse](#datasourcepluginlistresponse)
| +| 200 | List of datasource nodes configured in the pipeline. | **application/json**: [DatasourcePluginListResponse](#datasourcepluginlistresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | `not_found` : Dataset not found. | | ### [POST] /datasets/{dataset_id}/pipeline/datasource/nodes/{node_id}/run -**Resource for getting datasource plugins** +**Run Datasource Node** -Run a datasource node for a rag pipeline +Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the node execution results. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | -| node_id | path | | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| node_id | path | ID of the datasource node to execute. | Yes | string | #### Request Body @@ -1390,19 +1048,21 @@ Run a datasource node for a rag pipeline | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Datasource node run successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 200 | Streaming response with node execution events. | **text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | `not_found` : Dataset not found. | | ### [POST] /datasets/{dataset_id}/pipeline/run -**Resource for running a rag pipeline** +**Run Pipeline** -Run a datasource node for a rag pipeline +Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response modes. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | #### Request Body @@ -1414,115 +1074,982 @@ Run a datasource node for a rag pipeline | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Pipeline run successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| +| 200 | Pipeline execution result. Format depends on `response_mode`: streaming returns a `text/event-stream`, blocking returns a JSON object. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : Forbidden. | | +| 404 | `not_found` : Dataset not found. | | +| 500 | `pipeline_run_error` : Pipeline execution failed. | | -### [POST] /datasets/{dataset_id}/retrieve -**Perform hit testing on a dataset** +--- +## default -Perform hit testing on a dataset -Tests retrieval performance for the specified dataset. +### [DELETE] /datasets/tags +**Delete Knowledge Tag** -#### Parameters - -| Name | Located in | Description | Required | Schema | -| ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +Permanently delete a knowledge base tag. Does not delete the knowledge bases that were tagged. #### Request Body | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [HitTestingPayload](#hittestingpayload)
| +| Yes | **application/json**: [TagDeletePayload](#tagdeletepayload)
| + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +### [GET] /datasets/tags +**List Knowledge Tags** + +Returns the list of all knowledge base tags in the workspace. #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Hit testing results | **application/json**: [HitTestingResponse](#hittestingresponse)
| +| 200 | List of tags. | **application/json**: [KnowledgeTagListResponse](#knowledgetaglistresponse)
| | 401 | Unauthorized - invalid API token | | -| 404 | Dataset not found | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +### [PATCH] /datasets/tags +**Update Knowledge Tag** + +Rename an existing knowledge base tag. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagUpdatePayload](#tagupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tag updated successfully. | **application/json**: [KnowledgeTagResponse](#knowledgetagresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - insufficient permissions | | + +### [POST] /datasets/tags +**Create Knowledge Tag** + +Create a new tag for organizing knowledge bases. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagCreatePayload](#tagcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Tag created successfully. | **application/json**: [KnowledgeTagResponse](#knowledgetagresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - insufficient permissions | | + +### [POST] /datasets/tags/binding +**Create Tag Binding** + +Bind one or more tags to a knowledge base. A knowledge base can have multiple tags. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagBindingPayload](#tagbindingpayload)
| + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | + +### [POST] /datasets/tags/unbinding +**Delete Tag Binding** + +Remove one or more tags from a knowledge base. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [TagUnbindingPayload](#tagunbindingpayload)
| + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - insufficient permissions | ### [GET] /datasets/{dataset_id}/tags -**Get all knowledge type tags** +**Get Knowledge Base Tags** -Get tags bound to a specific dataset +Returns the list of tags bound to a specific knowledge base. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| dataset_id | path | Dataset ID | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Tags retrieved successfully | **application/json**: [DatasetBoundTagListResponse](#datasetboundtaglistresponse)
| +| 200 | Tags bound to the knowledge base. | **application/json**: [DatasetBoundTagListResponse](#datasetboundtaglistresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +--- +## default + +### [POST] /datasets/{dataset_id}/document/create-by-file +**Create Document by File** + +Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document created successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `no_file_uploaded` : Please upload your file. - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, missing required fields, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +### [POST] /datasets/{dataset_id}/document/create-by-text +**Create Document by Text** + +Create a document from raw text content. The document is processed asynchronously — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentTextCreatePayload](#documenttextcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document created successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist. / indexing_technique is required. / Invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +### ~~[POST] /datasets/{dataset_id}/document/create_by_file~~ + +***DEPRECATED*** + +**Create Document by File** + +Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document created successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `no_file_uploaded` : Please upload your file. - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, missing required fields, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +### [GET] /datasets/{dataset_id}/documents +**List Documents** + +Returns a paginated list of documents in the knowledge base. Supports filtering by keyword and indexing status. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| keyword | query | Search keyword to filter by document name. | No | string | +| limit | query | Number of items per page. Server caps at `100`. | No | integer,
**Default:** 20 | +| page | query | Page number to retrieve. | No | integer,
**Default:** 1 | +| status | query | Filter by display status. | No | string,
**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | List of documents. | **application/json**: [DocumentListResponse](#documentlistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | `not_found` : Knowledge base not found. | | + +### [POST] /datasets/{dataset_id}/documents/download-zip +**Download Documents as ZIP** + +Download multiple uploaded-file documents as a single ZIP archive. Accepts up to `100` document IDs. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentBatchDownloadZipPayload](#documentbatchdownloadzippayload)
| + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | ZIP archive containing the requested documents. | +| 401 | Unauthorized - invalid API token | +| 403 | `forbidden` : Insufficient permissions. | +| 404 | `not_found` : Document or dataset not found. | + +### [PATCH] /datasets/{dataset_id}/documents/status/{action} +**Update Document Status in Batch** + +Enable, disable, archive, or unarchive multiple documents at once. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | Action to perform: 'enable', 'disable', 'archive', or 'un_archive' | Yes | string,
**Available values:** "archive", "disable", "enable", "un_archive" | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentStatusPayload](#documentstatuspayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document status updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | `invalid_action` : Invalid action. | | +| 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : Insufficient permissions. | | +| 404 | `not_found` : Knowledge base not found. | | + +### [GET] /datasets/{dataset_id}/documents/{batch}/indexing-status +**Get Document Indexing Status** + +Check the indexing progress of documents in a batch. Returns the current processing stage and chunk completion counts for each document. Poll this endpoint until `indexing_status` reaches `completed` or `error`. The status progresses through: `waiting` → `parsing` → `cleaning` → `splitting` → `indexing` → `completed`. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| batch | path | Batch ID. | Yes | string | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Indexing status for documents in the batch. | **application/json**: [DocumentStatusListResponse](#documentstatuslistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | `not_found` : Knowledge base not found. / Documents not found. | | + +### [DELETE] /datasets/{dataset_id}/documents/{document_id} +**Delete Document** + +Permanently delete a document and all its chunks from the knowledge base. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 400 | `document_indexing` : Cannot delete document during indexing. | +| 401 | Unauthorized - invalid API token | +| 403 | `archived_document_immutable` : The archived document is not editable. | +| 404 | `not_found` : Document Not Exists. | + +### [GET] /datasets/{dataset_id}/documents/{document_id} +**Get Document** + +Retrieve detailed information about a specific document, including its indexing status, metadata, and processing statistics. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| metadata | query | `all` returns all fields including metadata. `only` returns only `id`, `doc_type`, and `doc_metadata`. `without` returns all fields except `doc_metadata`. | No | string,
**Available values:** "all", "only", "without",
**Default:** all | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document details. The response shape varies based on the `metadata` query parameter. When `metadata` is `only`, only `id`, `doc_type`, and `doc_metadata` are returned. When `metadata` is `without`, `doc_type` and `doc_metadata` are omitted. | **application/json**: [DocumentDetailResponse](#documentdetailresponse)
| +| 400 | `invalid_metadata` : Invalid metadata value for the specified key. | | +| 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : No permission. | | +| 404 | `not_found` : Document not found. | | + +### [PATCH] /datasets/{dataset_id}/documents/{document_id} +Update an existing document by uploading a file + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document updated successfully | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Document not found | | + +### [GET] /datasets/{dataset_id}/documents/{document_id}/download +**Download Document** + +Get a signed download URL for a document's original uploaded file. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Download URL generated successfully. | **application/json**: [UrlResponse](#urlresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | `forbidden` : No permission to access this document. | | +| 404 | `not_found` : Document not found. | | + +### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update-by-file~~ + +***DEPRECATED*** + +**Update Document by File** + +Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Document not found | | + +### [POST] /datasets/{dataset_id}/documents/{document_id}/update-by-text +**Update Document by Text** + +Update an existing document's text content, name, or processing configuration. Re-triggers indexing if content changes — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [DocumentTextUpdate](#documenttextupdate)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, name is required when text is provided, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Document not found | | + +### ~~[POST] /datasets/{dataset_id}/documents/{document_id}/update_by_file~~ + +***DEPRECATED*** + +**Update Document by File** + +Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **multipart/form-data**: { **"data"**: string, **"file"**: binary }
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document updated successfully. | **application/json**: [DocumentAndBatchResponse](#documentandbatchresponse)
| +| 400 | - `too_many_files` : Only one file is allowed. - `filename_not_exists_error` : The specified filename does not exist. - `provider_not_initialize` : No valid model provider credentials found. Please go to Settings -> Model Provider to complete your provider credentials. - `invalid_param` : Knowledge base does not exist, external datasets not supported, file too large, unsupported file type, or invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`). | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Document not found | | + +--- +## default + +### [POST] /datasets/{dataset_id}/documents/metadata +**Update Document Metadata in Batch** + +Update metadata values for multiple documents at once. Each document in the request receives the specified metadata key-value pairs. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataOperationData](#metadataoperationdata)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Document metadata updated successfully. | **application/json**: [DatasetMetadataActionResponse](#datasetmetadataactionresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset not found | | + +### [GET] /datasets/{dataset_id}/metadata +**List Metadata Fields** + +Returns the list of all metadata fields (both custom and built-in) for the knowledge base, along with the count of documents using each field. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Metadata fields for the knowledge base. | **application/json**: [DatasetMetadataListResponse](#datasetmetadatalistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset not found | | + +### [POST] /datasets/{dataset_id}/metadata +**Create Metadata Field** + +Create a custom metadata field for the knowledge base. Metadata fields can be used to annotate documents with structured information. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataArgs](#metadataargs)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Metadata field created successfully. | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset not found | | + +### [GET] /datasets/{dataset_id}/metadata/built-in +**Get Built-in Metadata Fields** + +Returns the list of built-in metadata fields provided by the system (e.g., document type, source URL). + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Built-in metadata fields. | **application/json**: [DatasetMetadataBuiltInFieldsResponse](#datasetmetadatabuiltinfieldsresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | + +### [POST] /datasets/{dataset_id}/metadata/built-in/{action} +**Update Built-in Metadata Field** + +Enable or disable built-in metadata fields for the knowledge base. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| action | path | `enable` to activate built-in metadata fields, `disable` to deactivate them. | Yes | string,
**Available values:** "disable", "enable" | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Built-in metadata field toggled successfully. | **application/json**: [DatasetMetadataActionResponse](#datasetmetadataactionresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset not found | | + +### [DELETE] /datasets/{dataset_id}/metadata/{metadata_id} +**Delete Metadata Field** + +Permanently delete a custom metadata field. Documents using this field will lose their metadata values for it. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| metadata_id | path | Metadata field ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - dataset API access or workspace access denied | +| 404 | Dataset or metadata not found | + +### [PATCH] /datasets/{dataset_id}/metadata/{metadata_id} +**Update Metadata Field** + +Rename a custom metadata field. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| metadata_id | path | Metadata field ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [MetadataUpdatePayload](#metadataupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Metadata field updated successfully. | **application/json**: [DatasetMetadataResponse](#datasetmetadataresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset or metadata not found | | + +--- +## default + +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments +**List Chunks** + +Returns a paginated list of chunks within a document. Supports filtering by keyword and status. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| keyword | query | Search keyword. | No | string | +| limit | query | Number of items per page. Server caps at `100`. | No | integer,
**Default:** 20 | +| page | query | Page number to retrieve. | No | integer,
**Default:** 1 | +| status | query | Filter chunks by indexing status, such as `completed`, `indexing`, or `error`. | No | [ string ] | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | List of chunks. | **application/json**: [SegmentListResponse](#segmentlistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset or document not found | | + +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments +**Create Chunks** + +Create one or more chunks within a document. Each chunk can include optional keywords and an answer field (for QA-mode documents). + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SegmentCreatePayload](#segmentcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Chunks created successfully. | **application/json**: [SegmentCreateListResponse](#segmentcreatelistresponse)
| +| 400 | Bad request - segments data is missing | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | `not_found` : Document is not completed or is disabled. | | + +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} +**Delete Chunk** + +Permanently delete a chunk from the document. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - dataset API access or workspace access denied | +| 404 | Dataset, document, or segment not found | + +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} +**Get Chunk** + +Retrieve detailed information about a specific chunk, including its content, keywords, and indexing status. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Chunk details. | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset, document, or segment not found | | + +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id} +**Update Chunk** + +Update a chunk's content, keywords, or answer. Re-triggers indexing for the modified chunk. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [SegmentUpdatePayload](#segmentupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Chunk updated successfully. | **application/json**: [SegmentDetailResponse](#segmentdetailresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset, document, or segment not found | | + +### [GET] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks +**List Child Chunks** + +Returns a paginated list of child chunks under a specific parent chunk. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | +| keyword | query | Search keyword. | No | string | +| limit | query | Number of items per page. Server caps at `100`. | No | integer,
**Default:** 20 | +| page | query | Page number to retrieve. | No | integer,
**Default:** 1 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | List of child chunks. | **application/json**: [ChildChunkListResponse](#childchunklistresponse)
| +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset, document, or segment not found | | + +### [POST] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks +**Create Child Chunk** + +Create a child chunk under the specified segment. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkCreatePayload](#childchunkcreatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Child chunk created successfully. | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| +| 400 | `invalid_param` : Create child chunk index failed. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset, document, or segment not found | | + +### [DELETE] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} +**Delete Child Chunk** + +Permanently delete a child chunk from its parent chunk. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| child_chunk_id | path | Child chunk ID. | Yes | string (uuid) | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | + +#### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Success. | +| 400 | `invalid_param` : Delete child chunk index failed. | +| 401 | Unauthorized - invalid API token | +| 403 | Forbidden - dataset API access or workspace access denied | +| 404 | Dataset, document, segment, or child chunk not found | + +### [PATCH] /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id} +**Update Child Chunk** + +Update the content of an existing child chunk. + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| child_chunk_id | path | Child chunk ID. | Yes | string (uuid) | +| dataset_id | path | Knowledge base ID. | Yes | string (uuid) | +| document_id | path | Document ID. | Yes | string (uuid) | +| segment_id | path | Chunk ID. | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ChildChunkUpdatePayload](#childchunkupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Child chunk updated successfully. | **application/json**: [ChildChunkDetailResponse](#childchunkdetailresponse)
| +| 400 | `invalid_param` : Update child chunk index failed. | | +| 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - dataset API access or workspace access denied | | +| 404 | Dataset, document, segment, or child chunk not found | | + +--- +## default ### [GET] /end-users/{end_user_id} -**Get end user detail** +**Get End User Info** -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. +Retrieve an end user by ID. Useful when other APIs return an end-user ID (e.g., `created_by` from [Upload File](/api-reference/files/upload-file)). #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| end_user_id | path | End user ID | Yes | string | +| end_user_id | path | End user ID | Yes | string (uuid) | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | End user retrieved successfully | **application/json**: [EndUserDetail](#enduserdetail)
| +| 200 | End user retrieved successfully. | **application/json**: [EndUserDetail](#enduserdetail)
| | 401 | Unauthorized - invalid API token | | -| 404 | End user not found | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `end_user_not_found` : End user not found. | | + +--- +## default ### [POST] /files/upload -**Upload a file for use in conversations** +**Upload File** -Upload a file for use in conversations -Accepts a single file upload via multipart/form-data. +Upload a file for use when sending messages, enabling multimodal understanding of images, documents, audio, and video. Uploaded files are for use by the current end-user only. + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"file"**: binary, **"user"**: string }
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)
| -| 400 | Bad request - no file or invalid file | | +| 201 | File uploaded successfully. | **application/json**: [FileResponse](#fileresponse)
| +| 400 | - `no_file_uploaded` : No file was provided in the request. - `too_many_files` : Only one file is allowed per request. - `filename_not_exists_error` : The uploaded file has no filename. | | | 401 | Unauthorized - invalid API token | | -| 413 | File too large | | -| 415 | Unsupported file type | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 413 | `file_too_large` : File size exceeded. | | +| 415 | `unsupported_file_type` : File type not allowed. | | ### [GET] /files/{file_id}/preview -**Preview/Download a file that was uploaded via Service API** +**Download File** -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. +Preview or download uploaded files previously uploaded via the [Upload File](/api-reference/files/upload-file) API. Files can only be accessed if they belong to messages within the requesting application. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| file_id | path | UUID of the file to preview | Yes | string | -| as_attachment | query | Download as attachment | No | boolean | +| file_id | path | The unique identifier of the file to preview, obtained from the [Upload File](/api-reference/files/upload-file) API response. | Yes | string (uuid) | +| as_attachment | query | If `true`, forces the file to download as an attachment instead of previewing in browser. | No | boolean | +| user | query | User identifier, used for end-user context. | No | string | #### Responses -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | File retrieved successfully | **application/json**: [BinaryFileResponse](#binaryfileresponse)
| -| 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - file access denied | | -| 404 | File not found | | +| Code | Description | +| ---- | ----------- | +| 200 | Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If `as_attachment` is `true`, the file is returned as a download with `Content-Disposition: attachment`. | +| 401 | Unauthorized - invalid API token | +| 403 | `file_access_denied` : Access to the requested file is denied. | +| 404 | `file_not_found` : The requested file was not found. | + +--- +## default ### [GET] /form/human_input/{form_token} -Get a paused human input form by token +**Get Human Input Form** + +Retrieve a paused Human Input form's contents using the `form_token` from a `human_input_required` event. Requires **WebApp** delivery. #### Parameters @@ -1534,13 +2061,16 @@ Get a paused human input form by token | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Form retrieved successfully | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| +| 200 | Form contents retrieved successfully. | **application/json**: [HumanInputFormDefinitionResponse](#humaninputformdefinitionresponse)
| | 401 | Unauthorized - invalid API token | | -| 404 | Form not found | | -| 412 | Form already submitted or expired | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Form not found. | | +| 412 | - `human_input_form_submitted` : Form already submitted. Forms are one-shot; the first response wins regardless of which user submits it. - `human_input_form_expired` : The form's expiration time passed before submission arrived. | | ### [POST] /form/human_input/{form_token} -Submit a paused human input form by token +**Submit Human Input Form** + +Submit the recipient's response to a paused Human Input form. The workflow resumes on acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) to follow subsequent events. Requires **WebApp** delivery. #### Parameters @@ -1552,318 +2082,250 @@ Submit a paused human input form by token | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [HumanInputFormSubmitPayload](#humaninputformsubmitpayload)
| +| Yes | **application/json**: [HumanInputFormSubmitPayloadWithUser](#humaninputformsubmitpayloadwithuser)
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Form submitted successfully | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| -| 400 | Bad request - invalid submission data | | +| 200 | Form submitted successfully. The response body is an empty object. | **application/json**: [HumanInputFormSubmitResponse](#humaninputformsubmitresponse)
| +| 400 | - `bad_request` : Form recipient type is invalid. - `invalid_form_data` : Submission failed validation against the form definition. | | | 401 | Unauthorized - invalid API token | | -| 404 | Form not found | | -| 412 | Form already submitted or expired | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Form not found. | | +| 412 | - `human_input_form_submitted` : Form already submitted. Forms are one-shot; the first response wins regardless of which user submits it. - `human_input_form_expired` : The form's expiration time passed before submission arrived. | | + +--- +## default ### [GET] /info -**Get app information** +**Get App Info** -Get basic application information -Returns basic information about the application including name, description, tags, and mode. +Retrieve basic information about this application, including name, description, tags, and mode. #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Application info retrieved successfully | **application/json**: [AppInfoResponse](#appinforesponse)
| +| 200 | Basic information of the application. | **application/json**: [AppInfoResponse](#appinforesponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | | 404 | Application not found | | -### [GET] /messages -**List messages in a conversation** - -List messages in a conversation -Retrieves messages with pagination support using first_id. - -#### 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,
**Default:** 20 | - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Messages retrieved successfully | **application/json**: [MessageInfiniteScrollPagination](#messageinfinitescrollpagination)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Conversation or first message not found | | - -### [POST] /messages/{message_id}/feedbacks -**Submit feedback for a message** - -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 | -| ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message ID | Yes | string | - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Feedback submitted successfully | **application/json**: [ResultResponse](#resultresponse)
| -| 401 | Unauthorized - invalid API token | | -| 404 | Message not found | | - -### [GET] /messages/{message_id}/suggested -**Get suggested follow-up questions for a message** - -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 | Schema | -| ---- | ----------- | ------ | -| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)
| -| 400 | Suggested questions feature is disabled | | -| 401 | Unauthorized - invalid API token | | -| 404 | Message not found | | -| 500 | Internal server error | | - ### [GET] /meta -**Get app metadata** +**Get App Meta** -Get application metadata -Returns metadata about the application including configuration and settings. +Retrieve metadata about this application, including tool icons and other configuration details. #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Metadata retrieved successfully | **application/json**: [AppMetaResponse](#appmetaresponse)
| +| 200 | Successfully retrieved application meta information. | **application/json**: [AppMetaResponse](#appmetaresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | | 404 | Application not found | | ### [GET] /parameters -**Retrieve app parameters** +**Get App Parameters** -Retrieve application input parameters and configuration -Returns the input form parameters and configuration for the application. +Retrieve the application's input form configuration, including feature switches, input parameter names, types, and default values. #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Parameters retrieved successfully | **application/json**: [Parameters](#parameters)
| +| 200 | Application parameters information. | **application/json**: [Parameters](#parameters)
| +| 400 | `app_unavailable` : App unavailable or misconfigured. | | | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | | 404 | Application not found | | ### [GET] /site -**Retrieve app site info** +**Get App WebApp Settings** -Get application site configuration -Returns the site configuration for the application including theme, icons, and text. +Retrieve the WebApp settings of this application, including site configuration, theme, and customization options. #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Site configuration retrieved successfully | **application/json**: [Site](#site)
| +| 200 | WebApp settings of the application. | **application/json**: [Site](#site)
| | 401 | Unauthorized - invalid API token | | -| 403 | Forbidden - site not found or tenant archived | | +| 403 | `forbidden` : Site not found for this application or the workspace has been archived. | | -### [POST] /text-to-audio -**Convert text to audio using text-to-speech** - -Convert text to audio using text-to-speech -Converts the provided text to audio using the specified voice. - -#### Request Body - -| Required | Schema | -| -------- | ------ | -| Yes | **application/json**: [TextToAudioPayload](#texttoaudiopayload)
| - -#### Responses - -| Code | Description | Schema | -| ---- | ----------- | ------ | -| 200 | Text successfully converted to audio | **application/json**: [AudioBinaryResponse](#audiobinaryresponse)
| -| 400 | Bad request - invalid parameters | | -| 401 | Unauthorized - invalid API token | | -| 500 | Internal server error | | +--- +## default ### [GET] /workflow/{task_id}/events -Get workflow execution events stream after resume +**Stream Workflow Events** + +Resume the Server-Sent Events stream for a workflow run after a pause or a dropped SSE connection. For runs that have already finished, the stream emits a single `workflow_finished` event and closes. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| task_id | path | Workflow run ID | Yes | string | -| continue_on_pause | query | Keep the stream open across workflow_paused events | No | boolean | -| include_state_snapshot | query | Replay from persisted state snapshot | No | boolean | -| user | query | End user identifier | Yes | string | +| task_id | path | Workflow run ID returned by the original workflow run request. | Yes | string | +| continue_on_pause | query | Set to `true` to keep the stream open across multiple `workflow_paused` events, which is useful when the workflow has more than one Human Input node in sequence. By default, the stream closes after the first pause. | No | boolean | +| include_state_snapshot | query | When `true`, replay from the persisted state snapshot to include a status summary of already-executed nodes before streaming new events. | No | boolean | +| user | query | End-user identifier that originally triggered the run. Must match the creator of the run. | Yes | string | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | SSE event stream | **application/json**: [EventStreamResponse](#eventstreamresponse)
| +| 200 | Server-Sent Events stream. Each event is delivered as `data: {JSON}\\n\\n`. Event payloads follow the same schemas as the original streaming response. | **text/event-stream**: [EventStreamResponse](#eventstreamresponse)
| +| 400 | `not_workflow_app` : Please check if your app mode matches the right API route. | | | 401 | Unauthorized - invalid API token | | -| 404 | Workflow run not found | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Workflow run not found. | | ### [GET] /workflows/logs -**Get workflow app logs** +**List Workflow Logs** -Get workflow execution logs -Returns paginated workflow execution logs with filtering options. +Retrieve paginated workflow execution logs with filtering options. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| created_at__after | query | | No | string | -| created_at__before | query | | No | string | -| created_by_account | query | | No | string | -| created_by_end_user_session_id | query | | No | string | -| keyword | query | | No | string | -| limit | query | | No | integer,
**Default:** 20 | -| page | query | | No | integer,
**Default:** 1 | -| status | query | | No | string,
**Available values:** "failed", "stopped", "succeeded" | +| created_at__after | query | Filter logs created after this ISO 8601 timestamp. | No | dateTime | +| created_at__before | query | Filter logs created before this ISO 8601 timestamp. | No | dateTime | +| created_by_account | query | Filter by account ID. | No | string | +| created_by_end_user_session_id | query | Filter by end user session ID. | No | string | +| keyword | query | Keyword to search in logs. | No | string | +| limit | query | Number of items per page. | No | integer,
**Default:** 20 | +| page | query | Page number for pagination. | No | integer,
**Default:** 1 | +| status | query | Filter by execution status. | No | string,
**Available values:** "failed", "stopped", "succeeded" | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Logs retrieved successfully | **application/json**: [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse)
| +| 200 | Successfully retrieved workflow logs. | **application/json**: [WorkflowAppLogPaginationResponse](#workflowapplogpaginationresponse)
| | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | ### [POST] /workflows/run -**Execute a workflow** +**Run Workflow** -Execute a workflow -Runs a workflow with the provided inputs and returns the results. -Supports both blocking and streaming response modes. +Execute a workflow. Cannot be executed without a published workflow. #### Request Body | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
| +| Yes | **application/json**: [WorkflowRunPayloadWithUser](#workflowrunpayloadwithuser)
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad request - invalid parameters or workflow issues | | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | | | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | | 404 | Workflow not found | | -| 429 | Rate limit exceeded | | -| 500 | Internal server error | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | +| 500 | `internal_server_error` : Internal server error. | | ### [GET] /workflows/run/{workflow_run_id} -**Get a workflow task running detail** +**Get Workflow Run Detail** -Get workflow run details -Returns detailed information about a specific workflow run. +Retrieve the current execution results of a workflow task based on the workflow execution ID. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| workflow_run_id | path | Workflow run ID | Yes | string | +| workflow_run_id | path | Workflow run ID, obtained from the workflow execution response or streaming events. | Yes | string | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow run details retrieved successfully | **application/json**: [WorkflowRunResponse](#workflowrunresponse)
| +| 200 | Successfully retrieved workflow run details. | **application/json**: [WorkflowRunResponse](#workflowrunresponse)
| +| 400 | `not_workflow_app` : App mode does not match the API route. | | | 401 | Unauthorized - invalid API token | | -| 404 | Workflow run not found | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Workflow run not found. | | ### [POST] /workflows/tasks/{task_id}/stop -**Stop a running workflow task** +**Stop Workflow Task** + +Stop a running workflow task. Only supported in `streaming` mode. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| task_id | path | Task ID to stop | Yes | string | +| task_id | path | Task ID, obtained from the streaming chunk returned by the Run Workflow API. | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [RequiredServiceApiUserPayload](#requiredserviceapiuserpayload)
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)
| +| 400 | - `not_workflow_app` : App mode does not match the API route. - `invalid_param` : Required parameter missing or invalid. | | | 401 | Unauthorized - invalid API token | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | | 404 | Task not found | | ### [POST] /workflows/{workflow_id}/run -**Run specific workflow by ID** +**Run Workflow by ID** -Execute a specific workflow by ID -Executes a specific workflow version identified by its ID. +Execute a specific workflow version identified by its ID. Useful for running a particular published version of the workflow. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| workflow_id | path | Workflow ID to execute | Yes | string | +| workflow_id | path | Workflow ID of the specific version to execute. This value is returned in the `workflow_id` field of workflow run responses. | Yes | string | #### Request Body | Required | Schema | | -------- | ------ | -| Yes | **application/json**: [WorkflowRunPayload](#workflowrunpayload)
| +| Yes | **application/json**: [WorkflowRunPayloadWithUser](#workflowrunpayloadwithuser)
| #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Workflow executed successfully | **application/json**: [GeneratedAppResponse](#generatedappresponse)
| -| 400 | Bad request - invalid parameters or workflow issues | | +| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)
**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)
| +| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | | | 401 | Unauthorized - invalid API token | | -| 404 | Workflow not found | | -| 429 | Rate limit exceeded | | -| 500 | Internal server error | | +| 403 | Forbidden - token scope, app, dataset, or workspace access denied | | +| 404 | `not_found` : Workflow not found. | | +| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | | +| 500 | `internal_server_error` : Internal server error. | | + +--- +## default ### [GET] /workspaces/current/models/model-types/{model_type} -**Get available models by model type** +**Get Available Models** -Get available models by model type -Returns a list of available models for the specified model type. +Retrieve the list of available models by type. Primarily used to query `text-embedding` and `rerank` models for knowledge base configuration. #### Parameters | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| model_type | path | Type of model to retrieve | Yes | string | +| model_type | path | Type of model to retrieve. | Yes | string,
**Available values:** "llm", "moderation", "rerank", "speech2text", "text-embedding", "tts" | #### Responses | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Models retrieved successfully | **application/json**: [ProviderWithModelsListResponse](#providerwithmodelslistresponse)
| +| 200 | Available models for the specified type. | **application/json**: [ProviderWithModelsListResponse](#providerwithmodelslistresponse)
| | 401 | Unauthorized - invalid API token | | --- @@ -1899,8 +2361,8 @@ Returns a list of available models for the specified model type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| answer | string | Annotation answer | Yes | -| question | string | Annotation question | Yes | +| answer | string | Annotation answer. | Yes | +| question | string | Annotation question. | Yes | #### AnnotationJobStatusResponse @@ -1924,17 +2386,17 @@ Returns a list of available models for the specified model type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword | string | Keyword to search annotations | No | -| limit | integer,
**Default:** 20 | Number of annotations per page | No | -| page | integer,
**Default:** 1 | Page number | No | +| keyword | string | Keyword to filter annotations by question or answer content. | No | +| limit | integer,
**Default:** 20 | Number of items per page. | No | +| page | integer,
**Default:** 1 | Page number for pagination. | No | #### 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 | +| embedding_model_name | string | Name of the embedding model to use for annotation matching. | Yes | +| embedding_provider_name | string | Name of the embedding model provider. | Yes | +| score_threshold | float | Minimum similarity score for an annotation to be considered a match. Higher values require closer matches. | Yes | #### AppFeedbackListResponse @@ -2004,21 +2466,32 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate_name | boolean,
**Default:** true | Auto generate conversation name | No | -| conversation_id | string | Conversation UUID | No | -| files | [ object ] | | No | -| inputs | object | | Yes | -| query | string | | Yes | -| response_mode | string | | No | -| retriever_from | string,
**Default:** dev | | No | -| trace_session_id | string | Trace session ID for observability grouping | No | -| workflow_id | string | Workflow ID for advanced chat | No | +| auto_generate_name | boolean,
**Default:** true | Auto-generate the conversation title. If `false`, use the Rename Conversation API with `auto_generate: true` to generate the title asynchronously. | No | +| conversation_id | string | Conversation ID to continue a conversation. Omit this field or pass an empty string to start a new conversation, then pass the returned `conversation_id` in subsequent requests. | No | +| files | [ object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | +| query | string | User input or question content. | Yes | +| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | +| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No | + +#### ChatRequestPayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate_name | boolean,
**Default:** true | Auto-generate the conversation title. If `false`, use the Rename Conversation API with `auto_generate: true` to generate the title asynchronously. | No | +| conversation_id | string | Conversation ID to continue a conversation. Omit this field or pass an empty string to start a new conversation, then pass the returned `conversation_id` in subsequent requests. | No | +| files | [ object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | +| query | string | User input or question content. | Yes | +| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | +| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No | #### ChildChunkCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | Yes | +| content | string | Child chunk text content. | Yes | #### ChildChunkDetailResponse @@ -2030,9 +2503,9 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword | string | | No | -| limit | integer,
**Default:** 20 | | No | -| page | integer,
**Default:** 1 | | No | +| keyword | string | Search keyword. | No | +| limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No | +| page | integer,
**Default:** 1 | Page number to retrieve. | No | #### ChildChunkListResponse @@ -2061,18 +2534,26 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | Yes | +| content | string | Child chunk text content. | Yes | #### CompletionRequestPayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ object ] | | No | -| inputs | object | | Yes | -| query | string | | No | -| response_mode | string | | No | -| retriever_from | string,
**Default:** dev | | No | -| trace_session_id | string | Trace session ID for observability grouping | No | +| files | [ object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | +| query | string | User input or prompt content. | No | +| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | + +#### CompletionRequestPayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes | +| query | string | User input or prompt content. | No | +| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### Condition @@ -2080,9 +2561,9 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | -| name | string | | Yes | -| value | string
[ string ]
integer
number | | No | +| comparison_operator | string,
**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | Comparison to apply. String operators (`contains`, `not contains`, `start with`, `end with`, `is`, `is not`, `empty`, `not empty`, `in`, `not in`) act on string or array metadata; numeric operators (`=`, `≠`, `>`, `<`, `≥`, `≤`) act on numeric metadata; time operators (`before`, `after`) act on time metadata.
*Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes | +| name | string | Metadata field name to compare against. | Yes | +| value | string
[ string ]
number | Value to compare against. Type depends on `comparison_operator`: string for most string operators, array of strings for `in` and `not in`, number for numeric operators, and omit or use `null` for `empty` and `not empty`. | No | #### ConversationInfiniteScrollPagination @@ -2096,16 +2577,24 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| last_id | string | Last conversation ID for pagination | No | -| limit | integer,
**Default:** 20 | Number of conversations to return | No | -| sort_by | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | Sort order for conversations
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | +| last_id | string | The ID of the last record on the current page. Used to fetch the next page. | No | +| limit | integer,
**Default:** 20 | Number of records to return. | No | +| sort_by | string,
**Available values:** "-created_at", "-updated_at", "created_at", "updated_at",
**Default:** -updated_at | Sorting field. Use the `-` prefix for descending order.
*Enum:* `"-created_at"`, `"-updated_at"`, `"created_at"`, `"updated_at"` | No | #### ConversationRenamePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate | boolean | | No | -| name | string | | No | +| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No | +| name | string | Conversation name. Required when `auto_generate` is `false`. | No | + +#### ConversationRenamePayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No | +| name | string | Conversation name. Required when `auto_generate` is `false`. | No | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | No | #### ConversationVariableInfiniteScrollPaginationResponse @@ -2131,15 +2620,22 @@ Condition detail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| value | | | Yes | +| value | | The new value for the variable. Must match the variable's expected type. | Yes | + +#### ConversationVariableUpdatePayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | No | +| value | | The new value for the variable. Must match the variable's expected type. | Yes | #### ConversationVariablesQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| last_id | string | Last variable ID for pagination | No | -| limit | integer,
**Default:** 20 | Number of variables to return | No | -| variable_name | string | Filter variables by name | No | +| last_id | string | The ID of the last record on the current page. Used to fetch the next page. | No | +| limit | integer,
**Default:** 20 | Number of records to return. | No | +| variable_name | string | Filter variables by a specific name. | No | #### CustomConfigurationStatus @@ -2167,17 +2663,17 @@ Enum class for custom configuration status. | 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 | | No | -| name | string | | Yes | -| permission | [PermissionEnum](#permissionenum) | | No | -| provider | string,
**Default:** vendor | | No | -| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | -| summary_index_setting | object | | No | +| description | string | Description of the knowledge base. | No | +| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| external_knowledge_api_id | string | ID of the external knowledge API. | No | +| external_knowledge_id | string | ID of the external knowledge base. | No | +| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | +| name | string | Name of the knowledge base. | Yes | +| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No | +| provider | string,
**Available values:** "external", "vendor",
**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.
*Enum:* `"external"`, `"vendor"` | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked. | No | +| summary_index_setting | object | Summary index configuration. | No | #### DatasetDetailResponse @@ -2205,8 +2701,10 @@ Enum class for custom configuration status. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -2245,9 +2743,11 @@ Enum class for custom configuration status. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | No | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -2304,11 +2804,11 @@ Enum class for custom configuration status. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| include_all | boolean | Include all datasets | No | -| keyword | string | Search keyword | No | -| limit | integer,
**Default:** 20 | Number of items per page | No | -| page | integer,
**Default:** 1 | Page number | No | -| tag_ids | [ string ] | Filter by tag IDs | No | +| include_all | boolean | Whether to include all knowledge bases regardless of permissions. | No | +| keyword | string | Search keyword to filter by name. | No | +| limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No | +| page | integer,
**Default:** 1 | Page number to retrieve. | No | +| tag_ids | [ string ] | Tag IDs to filter by. | No | #### DatasetListResponse @@ -2404,17 +2904,17 @@ Enum class for custom configuration status. | 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 | | No | -| name | string | | No | -| partial_member_list | [ object ] | | No | -| permission | [PermissionEnum](#permissionenum) | | No | -| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| description | string | Description of the knowledge base. | No | +| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| external_knowledge_api_id | string | ID of the external knowledge API. | No | +| external_knowledge_id | string | ID of the external knowledge base. | No | +| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No | +| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No | +| name | string | Name of the knowledge base. | No | +| partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No | +| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked. | No | #### DatasetVectorSettingResponse @@ -2445,10 +2945,10 @@ Enum class for custom configuration status. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| credential_id | string | | No | -| datasource_type | string | | Yes | -| inputs | object | | Yes | -| is_published | boolean | | Yes | +| credential_id | string | Datasource credential ID. Uses the default if omitted. | No | +| datasource_type | string,
**Available values:** "local_file", "online_document", "online_drive", "website_crawl" | Type of the datasource.
*Enum:* `"local_file"`, `"online_document"`, `"online_drive"`, `"website_crawl"` | Yes | +| inputs | object | Input variables for the datasource node. | Yes | +| is_published | boolean | Whether to run the published or draft version of the node. `true` runs the published version, `false` runs the draft. | Yes | #### DatasourcePluginListResponse @@ -2472,7 +2972,7 @@ Enum class for custom configuration status. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| is_published | boolean,
**Default:** true | | No | +| is_published | boolean,
**Default:** true | Whether to retrieve nodes from the published or draft pipeline. `true` returns nodes from the published version, `false` returns nodes from the draft. | No | #### DocumentAndBatchResponse @@ -2487,7 +2987,7 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| document_ids | [ string (uuid) ] | | Yes | +| document_ids | [ string (uuid) ] | List of document IDs to include in the ZIP download. | Yes | #### DocumentDetailResponse @@ -2529,16 +3029,16 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| metadata | string,
**Available values:** "all", "only", "without",
**Default:** all | Metadata response mode
*Enum:* `"all"`, `"only"`, `"without"` | No | +| metadata | string,
**Available values:** "all", "only", "without",
**Default:** all | `all` returns all fields including metadata. `only` returns only `id`, `doc_type`, and `doc_metadata`. `without` returns all fields except `doc_metadata`.
*Enum:* `"all"`, `"only"`, `"without"` | No | #### DocumentListQuery | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword | string | Search keyword | No | -| limit | integer,
**Default:** 20 | Number of items per page | No | -| page | integer,
**Default:** 1 | Page number | No | -| status | string | Document status filter | No | +| keyword | string | Search keyword to filter by document name. | No | +| limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No | +| page | integer,
**Default:** 1 | Page number to retrieve. | No | +| status | string | Filter by display status. | No | #### DocumentListResponse @@ -2554,9 +3054,9 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| document_id | string | | Yes | -| metadata_list | [ [MetadataDetail](#metadatadetail) ] | | Yes | -| partial_update | boolean | | No | +| document_id | string | Document ID whose metadata should be updated. | Yes | +| metadata_list | [ [MetadataDetail](#metadatadetail) ] | Metadata fields to update. | Yes | +| partial_update | boolean | Whether to partially update metadata, keeping existing values for unspecified fields. | No | #### DocumentMetadataResponse @@ -2606,7 +3106,7 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| document_ids | [ string ] | Document IDs to update | No | +| document_ids | [ string ] | List of document IDs to update. | No | #### DocumentStatusResponse @@ -2629,27 +3129,27 @@ Request payload for bulk downloading documents as a zip archive. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| doc_form | string,
**Default:** text_model | | No | -| doc_language | string,
**Default:** English | | 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 | +| doc_form | string,
**Available values:** "hierarchical_model", "qa_model", "text_model",
**Default:** text_model | `text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, `qa_model` for question-answer pair extraction.
*Enum:* `"hierarchical_model"`, `"qa_model"`, `"text_model"` | No | +| doc_language | string,
**Default:** English | Language of the document for processing optimization. | No | +| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No | +| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No | +| name | string | Document name. | Yes | +| original_document_id | string | Original document ID for replacement. | No | +| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked. | No | +| text | string | Document text content. | Yes | #### DocumentTextUpdate | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| doc_form | string,
**Default:** text_model | | No | -| doc_language | string,
**Default:** English | | No | -| name | string | | No | -| process_rule | [ProcessRule](#processrule) | | No | -| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | -| text | string | | No | +| doc_form | string,
**Available values:** "hierarchical_model", "qa_model", "text_model",
**Default:** text_model | `text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, `qa_model` for question-answer pair extraction.
*Enum:* `"hierarchical_model"`, `"qa_model"`, `"text_model"` | No | +| doc_language | string,
**Default:** English | Language of the document for processing optimization. | No | +| name | string | Document name. Required when `text` is provided. | No | +| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked. | No | +| text | string | Document text content. | No | #### EndUserDetail @@ -2688,8 +3188,8 @@ Note: The SQLAlchemy model defines an `is_anonymous` property for Flask-Login se | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| limit | integer,
**Default:** 20 | Number of feedbacks per page | No | -| page | integer,
**Default:** 1 | Page number | No | +| limit | integer,
**Default:** 20 | Number of records per page. | No | +| page | integer,
**Default:** 1 | Page number for pagination. | No | #### FetchFrom @@ -2724,7 +3224,7 @@ Enum class for fetch from. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| as_attachment | boolean | Download as attachment | No | +| as_attachment | boolean | If `true`, forces the file to download as an attachment instead of previewing in browser. | No | #### FileResponse @@ -2804,10 +3304,10 @@ Enum class for fetch from. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| attachment_ids | [ string ] | | No | -| external_retrieval_model | object | | No | -| query | string | | Yes | -| retrieval_model | [RetrievalModel](#retrievalmodel) | | No | +| attachment_ids | [ string ] | List of attachment IDs to include in the retrieval context. | No | +| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No | +| query | string | Search query text. | Yes | +| retrieval_model | [RetrievalModel](#retrievalmodel) | Retrieval model configuration. Controls how chunks are searched and ranked. | No | #### HitTestingQuery @@ -2911,9 +3411,17 @@ Enum class for fetch from. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| action | string | | Yes | +| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | | inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | +#### HumanInputFormSubmitPayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | +| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | + #### HumanInputFormSubmitResponse | Name | Type | Description | Required | @@ -2979,8 +3487,16 @@ Model class for i18n object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | -| rating | string | | No | +| content | string | Optional text feedback providing additional detail. | No | +| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | + +#### MessageFeedbackPayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| content | string | Optional text feedback providing additional detail. | No | +| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### MessageFile @@ -3027,24 +3543,24 @@ Model class for i18n object. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conversation_id | string | Conversation UUID | Yes | -| first_id | string | First message ID for pagination | No | -| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | +| conversation_id | string | Conversation ID. | Yes | +| first_id | string | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | +| limit | integer,
**Default:** 20 | Number of chat history messages to return per request. | No | #### MetadataArgs | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | -| type | string,
**Available values:** "number", "string", "time" | *Enum:* `"number"`, `"string"`, `"time"` | Yes | +| name | string | Metadata field name. | Yes | +| type | string,
**Available values:** "number", "string", "time" | `string` for text values, `number` for numeric values, `time` for date/time values.
*Enum:* `"number"`, `"string"`, `"time"` | Yes | #### MetadataDetail | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| id | string | | Yes | -| name | string | | Yes | -| value | string
integer
number | | No | +| id | string | Metadata field ID. | Yes | +| name | string | Metadata field name. | Yes | +| value | string
integer
number | Metadata value. Can be a string, number, or `null`. | No | #### MetadataFilteringCondition @@ -3052,8 +3568,8 @@ Metadata Filtering Condition. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conditions | [ [Condition](#condition) ] | | No | -| logical_operator | string | | No | +| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No | +| logical_operator | string | How to combine multiple conditions. | No | #### MetadataOperationData @@ -3061,13 +3577,13 @@ Metadata operation data | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| operation_data | [ [DocumentMetadataOperation](#documentmetadataoperation) ] | | Yes | +| operation_data | [ [DocumentMetadataOperation](#documentmetadataoperation) ] | Array of document metadata update operations. Each entry maps a document ID to its metadata values. | Yes | #### MetadataUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | +| name | string | New metadata field name. | Yes | #### ModelFeature @@ -3101,6 +3617,12 @@ Enum class for model type. | ---- | ---- | ----------- | -------- | | ModelType | string | Enum class for model type. | | +#### OptionalServiceApiUserPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | No | + #### ParagraphInputConfig Form input definition. @@ -3140,12 +3662,12 @@ Shared permission levels for resources (datasets, credentials, etc.) | 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 | +| datasource_info_list | [ ] | List of datasource objects to process. The expected item structure depends on `datasource_type`. | Yes | +| datasource_type | string,
**Available values:** "local_file", "online_document", "online_drive", "website_crawl" | Type of the datasource. Determines which fields are expected in `datasource_info_list` items.
*Enum:* `"local_file"`, `"online_document"`, `"online_drive"`, `"website_crawl"` | Yes | +| inputs | object | Key-value pairs for pipeline input variables defined in the workflow. Pass `{}` if the pipeline has no input variables. | Yes | +| is_published | boolean | Whether to run the published or draft version of the pipeline. `true` runs the latest published version; `false` runs the current draft (useful for testing unpublished changes). | Yes | +| response_mode | string,
**Available values:** "blocking", "streaming" | Response mode. Use `streaming` for SSE or `blocking` for JSON.
*Enum:* `"blocking"`, `"streaming"` | Yes | +| start_node_id | string | ID of the datasource node where the run starts. | Yes | #### PipelineUploadFileResponse @@ -3163,15 +3685,15 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| enabled | boolean | | Yes | -| id | string | | Yes | +| enabled | boolean | Whether this preprocessing rule is enabled. | Yes | +| id | string,
**Available values:** "remove_extra_spaces", "remove_stopwords", "remove_urls_emails" | Rule identifier.
*Enum:* `"remove_extra_spaces"`, `"remove_stopwords"`, `"remove_urls_emails"` | Yes | #### ProcessRule | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| mode | [ProcessRuleMode](#processrulemode) | | Yes | -| rules | [Rule](#rule) | | No | +| mode | [ProcessRuleMode](#processrulemode) | Processing mode. `automatic` uses built-in rules, `custom` allows manual configuration, and `hierarchical` enables parent-child chunk structure for `doc_form: hierarchical_model`. | Yes | +| rules | [Rule](#rule) | Custom processing rules. | No | #### ProcessRuleMode @@ -3218,12 +3740,18 @@ Model class for provider with models response. | status | [CustomConfigurationStatus](#customconfigurationstatus) | | Yes | | tenant_id | string | | Yes | +#### RequiredServiceApiUserPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | + #### RerankingModel | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| reranking_model_name | string | | No | -| reranking_provider_name | string | | No | +| reranking_model_name | string | Name of the reranking model. | No | +| reranking_provider_name | string | Provider name of the reranking model. | No | #### ResultResponse @@ -3241,15 +3769,15 @@ Model class for provider with models response. | 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 | +| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No | +| reranking_enable | boolean | Whether reranking is enabled. | Yes | +| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No | +| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No | +| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No | +| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes | +| search_method | [RetrievalMethod](#retrievalmethod) | Search method used for retrieval. | Yes | +| top_k | integer | Maximum number of results to return. | Yes | +| weights | [WeightModel](#weightmodel) | Weight configuration for hybrid search. | No | #### RetrieverResource @@ -3277,10 +3805,10 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| parent_mode | string | | No | -| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | | No | -| segmentation | [Segmentation](#segmentation) | | No | -| subchunk_segmentation | [Segmentation](#segmentation) | | No | +| parent_mode | string | Parent-child segmentation mode. | No | +| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No | +| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No | +| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No | #### SegmentAttachmentResponse @@ -3297,10 +3825,10 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| answer | string | | No | -| attachment_ids | [ string ] | | No | -| content | string | | Yes | -| keywords | [ string ] | | No | +| answer | string | Answer content for QA mode. | No | +| attachment_ids | [ string ] | Attachment file IDs. | No | +| content | string | Chunk text content. | Yes | +| keywords | [ string ] | Keywords for the chunk. | No | #### SegmentCreateListResponse @@ -3313,7 +3841,7 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| segments | [ [SegmentCreateItemPayload](#segmentcreateitempayload) ] | | Yes | +| segments | [ [SegmentCreateItemPayload](#segmentcreateitempayload) ] | Array of chunk objects to create. | Yes | #### SegmentDetailResponse @@ -3326,10 +3854,10 @@ Model class for provider with models response. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword | string | | No | -| limit | integer,
**Default:** 20 | | No | -| page | integer,
**Default:** 1 | | No | -| status | [ string ] | | No | +| keyword | string | Search keyword. | No | +| limit | integer,
**Default:** 20 | Number of items per page. Server caps at `100`. | No | +| page | integer,
**Default:** 1 | Page number to retrieve. | No | +| status | [ string ] | Filter chunks by indexing status, such as `completed`, `indexing`, or `error`. | No | #### SegmentListResponse @@ -3378,28 +3906,28 @@ Model class for provider with models response. | 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 | +| answer | string | Updated answer content for QA mode. | No | +| attachment_ids | [ string ] | Attachment file IDs. | No | +| content | string | Updated chunk text content. | No | +| enabled | boolean | Whether the chunk is enabled. | No | +| keywords | [ string ] | Updated keywords for the chunk. | No | +| regenerate_child_chunks | boolean | Whether to regenerate child chunks after updating a parent chunk. | No | +| summary | string | Summary content for summary index. | No | #### SegmentUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| segment | [SegmentUpdateArgs](#segmentupdateargs) | | Yes | +| segment | [SegmentUpdateArgs](#segmentupdateargs) | Chunk update payload. | Yes | #### Segmentation | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| chunk_overlap | integer | | No | -| max_tokens | integer | | Yes | -| separator | string,
**Default:** - | | No | +| chunk_overlap | integer | Token overlap between chunks. | No | +| max_tokens | integer | Maximum token count per chunk. | Yes | +| separator | string,
**Default:** + | Custom separator for splitting text. | No | #### SelectInputConfig @@ -3508,46 +4036,54 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| tag_ids | [ string ] | | Yes | -| target_id | string | | Yes | +| tag_ids | [ string ] | Tag IDs to bind. | Yes | +| target_id | string | Knowledge base ID to bind the tags to. | Yes | #### TagCreatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | +| name | string | Tag name. | Yes | #### TagDeletePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| tag_id | string | | Yes | +| tag_id | string | Tag ID to delete. | Yes | #### TagUnbindingPayload -Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally. +Accepts either the legacy tag_id payload or the normalized tag_ids payload. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| tag_id | string | | No | -| tag_ids | [ string ] | | No | -| target_id | string | | Yes | +| TagUnbindingPayload | object
object | Accepts either the legacy tag_id payload or the normalized tag_ids payload. | | #### TagUpdatePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| name | string | | Yes | -| tag_id | string | | Yes | +| name | string | Tag name. | Yes | +| tag_id | string | Tag ID to update. | 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 | +| message_id | string | Message ID. Takes priority over `text` when both are provided. | No | +| streaming | boolean | Reserved for compatibility; TTS response streaming is determined by the provider output. | No | +| text | string | Speech content to convert. | No | +| voice | string | Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. Omit to use the app's configured voice when available; that value is exposed by [Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`. | No | + +#### TextToAudioPayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| message_id | string | Message ID. Takes priority over `text` when both are provided. | No | +| streaming | boolean | Reserved for compatibility; TTS response streaming is determined by the provider output. | No | +| text | string | Speech content to convert. | No | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | No | +| voice | string | Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. Omit to use the app's configured voice when available; that value is exposed by [Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`. | No | #### UrlResponse @@ -3578,23 +4114,23 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword_weight | number | | Yes | +| keyword_weight | number | Weight assigned to keyword search results. | Yes | #### WeightModel | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | | No | -| vector_setting | [WeightVectorSetting](#weightvectorsetting) | | No | -| weight_type | string | | No | +| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No | +| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No | +| weight_type | string | Strategy for balancing semantic and keyword search weights. | No | #### WeightVectorSetting | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| embedding_model_name | string | | Yes | -| embedding_provider_name | string | | Yes | -| vector_weight | number | | Yes | +| embedding_model_name | string | Name of the embedding model used for vector search. | Yes | +| embedding_provider_name | string | Provider of the embedding model used for vector search. | Yes | +| vector_weight | number | Weight assigned to semantic vector search results. | Yes | #### WorkflowAppLogPaginationResponse @@ -3623,22 +4159,22 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| continue_on_pause | boolean | Keep the stream open across workflow_paused events | No | -| include_state_snapshot | boolean | Replay from persisted state snapshot | No | -| user | string | End user identifier | Yes | +| continue_on_pause | boolean | Set to `true` to keep the stream open across multiple `workflow_paused` events, which is useful when the workflow has more than one Human Input node in sequence. By default, the stream closes after the first pause. | No | +| include_state_snapshot | boolean | When `true`, replay from the persisted state snapshot to include a status summary of already-executed nodes before streaming new events. | No | +| user | string | End-user identifier that originally triggered the run. Must match the creator of the run. | Yes | #### 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,
**Default:** 20 | | No | -| page | integer,
**Default:** 1 | | No | -| status | string | | No | +| created_at__after | string | Filter logs created after this ISO 8601 timestamp. | No | +| created_at__before | string | Filter logs created before this ISO 8601 timestamp. | No | +| created_by_account | string | Filter by account ID. | No | +| created_by_end_user_session_id | string | Filter by end user session ID. | No | +| keyword | string | Keyword to search in logs. | No | +| limit | integer,
**Default:** 20 | Number of items per page. | No | +| page | integer,
**Default:** 1 | Page number for pagination. | No | +| status | string | Filter by execution status. | No | #### WorkflowRunForLogResponse @@ -3660,10 +4196,18 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ object ] | | No | -| inputs | object | | Yes | -| response_mode | string | | No | -| trace_session_id | string | Trace session ID for observability grouping | No | +| files | [ object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | +| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | + +#### WorkflowRunPayloadWithUser + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | +| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No | +| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes | #### WorkflowRunResponse diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 302c2a55e43..569e3706caa 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -4,10 +4,9 @@ Public APIs for web applications including file uploads, chat interactions, and ## Version: 1.0 ### Available authorizations -#### Bearer (API Key Authentication) -Type: Bearer {your-api-key} -**Name:** Authorization -**In:** header +#### Bearer (HTTP, bearer) +Use the Service API key as a Bearer token in the Authorization header. +Bearer format: API_KEY --- ## web @@ -140,7 +139,7 @@ Delete a specific conversation. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | +| c_id | path | Conversation UUID | Yes | string (uuid) | #### Responses @@ -160,7 +159,7 @@ Rename a specific conversation with a custom name or auto-generate one. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | +| c_id | path | Conversation UUID | Yes | string (uuid) | | auto_generate | query | Auto-generate conversation name | No | boolean | | name | query | New conversation name | No | string | @@ -188,7 +187,7 @@ Pin a specific conversation to keep it at the top of the list. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | +| c_id | path | Conversation UUID | Yes | string (uuid) | #### Responses @@ -208,7 +207,7 @@ Unpin a specific conversation to remove it from the top of the list. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| c_id | path | Conversation UUID | Yes | string | +| c_id | path | Conversation UUID | Yes | string (uuid) | #### Responses @@ -472,9 +471,9 @@ Retrieve paginated list of messages from a conversation in a chat application. | 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,
**Default:** 20 | +| conversation_id | query | Conversation ID. | Yes | string | +| first_id | query | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | string | +| limit | query | Number of chat history messages to return per request. | No | integer,
**Default:** 20 | #### Responses @@ -494,7 +493,7 @@ Submit feedback (like/dislike) for a specific message. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message UUID | Yes | string | +| message_id | path | Message UUID | Yes | string (uuid) | | content | query | Feedback content | No | string | | rating | query | Feedback rating | No | string,
**Available values:** "dislike", "like" | @@ -523,7 +522,7 @@ Generate a new completion similar to an existing message (completion apps only). | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | response_mode | query | Response mode | Yes | string,
**Available values:** "blocking", "streaming" | -| message_id | path | | Yes | string | +| message_id | path | | Yes | string (uuid) | #### Responses @@ -543,7 +542,7 @@ Get suggested follow-up questions after a message (chat apps only). | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message UUID | Yes | string | +| message_id | path | Message UUID | Yes | string (uuid) | #### Responses @@ -731,7 +730,7 @@ Remove a message from saved messages. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| message_id | path | Message UUID to delete | Yes | string | +| message_id | path | Message UUID to delete | Yes | string (uuid) | #### Responses @@ -1092,8 +1091,8 @@ Button styles for user actions. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| auto_generate | boolean | | No | -| name | string | | No | +| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No | +| name | string | Conversation name. Required when `auto_generate` is `false`. | No | #### EmailCodeLoginSendPayload @@ -1282,7 +1281,7 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| action | string | | Yes | +| action | string | ID of the action button the recipient selected. Must match one of the `id` values from the form's `user_actions` list. | Yes | | inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes | #### HumanInputFormSubmitResponse @@ -1372,8 +1371,8 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| content | string | | No | -| rating | string | | No | +| content | string | Optional text feedback providing additional detail. | No | +| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No | #### MessageFile @@ -1393,9 +1392,9 @@ Parsed multipart form fields for HITL uploads. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| conversation_id | string | Conversation UUID | Yes | -| first_id | string | First message ID for pagination | No | -| limit | integer,
**Default:** 20 | Number of messages to return (1-100) | No | +| conversation_id | string | Conversation ID. | Yes | +| first_id | string | The ID of the first chat record on the current page. Omit this value to fetch the latest messages; for subsequent pages, use the first message ID from the current list to fetch older messages. | No | +| limit | integer,
**Default:** 20 | Number of chat history messages to return per request. | No | #### MessageMoreLikeThisQuery @@ -1597,6 +1596,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | branding | [BrandingModel](#brandingmodel) | | Yes | +| enable_app_deploy | boolean | | Yes | | enable_change_email | boolean,
**Default:** true | | Yes | | enable_collaboration_mode | boolean,
**Default:** true | | Yes | | enable_creators_platform | boolean | | Yes | @@ -1613,6 +1613,7 @@ Default configuration for form inputs. | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | +| rbac_enabled | boolean | | Yes | | sso_enforced_for_signin | boolean | | Yes | | sso_enforced_for_signin_protocol | string | | Yes | | webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | @@ -1631,10 +1632,10 @@ Default configuration for form inputs. | 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 | +| message_id | string | Message ID. Takes priority over `text` when both are provided. | No | +| streaming | boolean | Reserved for compatibility; TTS response streaming is determined by the provider output. | No | +| text | string | Speech content to convert. | No | +| voice | string | Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. Omit to use the app's configured voice when available; that value is exposed by [Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`. | No | #### UserActionConfig @@ -1711,5 +1712,5 @@ in form definiton, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| files | [ object ] | | No | -| inputs | object | | Yes | +| files | [ object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No | +| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes | diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py index ac09060e9d4..12f91212c1f 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py @@ -42,7 +42,9 @@ def trace_client_factory(): class TestTraceClient: @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") @patch("dify_trace_aliyun.data_exporter.traceclient.socket.gethostname") - def test_init(self, mock_gethostname, mock_exporter_class, trace_client_factory): + def test_init( + self, mock_gethostname: MagicMock, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient] + ): mock_gethostname.return_value = "test-host" client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") @@ -56,7 +58,7 @@ class TestTraceClient: assert client.done is True @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_export(self, mock_exporter_class, trace_client_factory): + def test_export(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): mock_exporter = mock_exporter_class.return_value client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") spans = [MagicMock(spec=ReadableSpan)] @@ -65,7 +67,9 @@ class TestTraceClient: @patch("dify_trace_aliyun.data_exporter.traceclient.httpx.head") @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_api_check_success(self, mock_exporter_class, mock_head, trace_client_factory): + def test_api_check_success( + self, mock_exporter_class: MagicMock, mock_head: MagicMock, trace_client_factory: type[TraceClient] + ): mock_response = MagicMock() mock_response.status_code = 405 mock_head.return_value = mock_response @@ -75,7 +79,9 @@ class TestTraceClient: @patch("dify_trace_aliyun.data_exporter.traceclient.httpx.head") @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_api_check_failure_status(self, mock_exporter_class, mock_head, trace_client_factory): + def test_api_check_failure_status( + self, mock_exporter_class: MagicMock, mock_head: MagicMock, trace_client_factory: type[TraceClient] + ): mock_response = MagicMock() mock_response.status_code = 500 mock_head.return_value = mock_response @@ -85,7 +91,9 @@ class TestTraceClient: @patch("dify_trace_aliyun.data_exporter.traceclient.httpx.head") @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_api_check_exception(self, mock_exporter_class, mock_head, trace_client_factory): + def test_api_check_exception( + self, mock_exporter_class: MagicMock, mock_head: MagicMock, trace_client_factory: type[TraceClient] + ): mock_head.side_effect = httpx.RequestError("Connection error") client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") @@ -93,12 +101,12 @@ class TestTraceClient: client.api_check() @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_get_project_url(self, mock_exporter_class, trace_client_factory): + def test_get_project_url(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") assert client.get_project_url() == "https://arms.console.aliyun.com/#/llm" @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_add_span(self, mock_exporter_class, trace_client_factory): + def test_add_span(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): client = trace_client_factory( service_name="test-service", endpoint="http://test-endpoint", @@ -135,7 +143,9 @@ class TestTraceClient: @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") @patch("dify_trace_aliyun.data_exporter.traceclient.logger") - def test_add_span_queue_full(self, mock_logger, mock_exporter_class, trace_client_factory): + def test_add_span_queue_full( + self, mock_logger: MagicMock, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient] + ): client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint", max_queue_size=1) span_data = SpanData( @@ -159,7 +169,7 @@ class TestTraceClient: mock_logger.warning.assert_called_with("Queue is full, likely spans will be dropped.") @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_export_batch_error(self, mock_exporter_class, trace_client_factory): + def test_export_batch_error(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): mock_exporter = mock_exporter_class.return_value mock_exporter.export.side_effect = Exception("Export failed") @@ -172,13 +182,13 @@ class TestTraceClient: mock_logger.warning.assert_called() @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_worker_loop(self, mock_exporter_class, trace_client_factory): + def test_worker_loop(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): # We need to test the wait timeout in _worker # But _worker runs in a thread. Let's mock condition.wait. client = trace_client_factory( service_name="test-service", endpoint="http://test-endpoint", - schedule_delay_sec=0.1, + schedule_delay_sec=1, ) with patch.object(client.condition, "wait") as mock_wait: @@ -189,7 +199,7 @@ class TestTraceClient: assert mock_wait.called or client.done @patch("dify_trace_aliyun.data_exporter.traceclient.OTLPSpanExporter") - def test_shutdown_flushes(self, mock_exporter_class, trace_client_factory): + def test_shutdown_flushes(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]): mock_exporter = mock_exporter_class.return_value client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint") 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 0f24adfd92e..9f779fc43de 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 @@ -1150,13 +1150,14 @@ class ArizePhoenixDataTrace(BaseTraceInstance): try: # Convert outputs to string based on type outputs_mime_type = OpenInferenceMimeTypeValues.TEXT.value - if isinstance(trace_info.outputs, dict | list): - outputs_str = safe_json_dumps(trace_info.outputs) - outputs_mime_type = OpenInferenceMimeTypeValues.JSON.value - elif isinstance(trace_info.outputs, str): - outputs_str = trace_info.outputs - else: - outputs_str = str(trace_info.outputs) + match trace_info.outputs: + case dict() | list(): + outputs_str = safe_json_dumps(trace_info.outputs) + outputs_mime_type = OpenInferenceMimeTypeValues.JSON.value + case str(): + outputs_str = trace_info.outputs + case _: + outputs_str = str(trace_info.outputs) llm_attributes: dict[str, Any] = { SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.LLM.value, @@ -1553,25 +1554,26 @@ class ArizePhoenixDataTrace(BaseTraceInstance): set_attribute(f"{base_path}.{ToolCallAttributes.TOOL_CALL_ID}", call_id) # Handle list of messages - if isinstance(prompts, list): - for message_index, message in enumerate(prompts): - if not isinstance(message, dict): - continue + match prompts: + case list(): + for message_index, message in enumerate(prompts): + if not isinstance(message, dict): + continue - role = message.get("role", "user") - content = message.get("text") or message.get("content") or "" + role = message.get("role", "user") + content = message.get("text") or message.get("content") or "" - set_message_attribute(message_index, MessageAttributes.MESSAGE_ROLE, role) - set_message_attribute(message_index, MessageAttributes.MESSAGE_CONTENT, content) + set_message_attribute(message_index, MessageAttributes.MESSAGE_ROLE, role) + set_message_attribute(message_index, MessageAttributes.MESSAGE_CONTENT, content) - tool_calls = message.get("tool_calls") or [] - if isinstance(tool_calls, list): - for tool_index, tool_call in enumerate(tool_calls): - set_tool_call_attributes(message_index, tool_index, tool_call) + tool_calls = message.get("tool_calls") or [] + if isinstance(tool_calls, list): + for tool_index, tool_call in enumerate(tool_calls): + set_tool_call_attributes(message_index, tool_index, tool_call) - # Handle single dict or plain string prompt - elif isinstance(prompts, (dict, str)): - set_message_attribute(0, MessageAttributes.MESSAGE_CONTENT, prompts) - set_message_attribute(0, MessageAttributes.MESSAGE_ROLE, "user") + # Handle single dict or plain string prompt + case dict() | str(): + set_message_attribute(0, MessageAttributes.MESSAGE_CONTENT, prompts) + set_message_attribute(0, MessageAttributes.MESSAGE_ROLE, "user") return attributes 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 0c2ead2bccc..9e3cac2255b 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 @@ -456,7 +456,7 @@ class TestPhoenixParentSpanBridgeHelpers: 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): + def test_resolve_parent_span_context_rejects_payload_without_traceparent(self, monkeypatch: pytest.MonkeyPatch): mock_redis = MagicMock() mock_redis.get.return_value = '{"tracestate": "vendor=value"}' monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) diff --git a/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/langfuse_trace_entity.py b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/langfuse_trace_entity.py index 76755bf7693..742938f09f4 100644 --- a/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/langfuse_trace_entity.py +++ b/api/providers/trace/trace-langfuse/src/dify_trace_langfuse/entities/langfuse_trace_entity.py @@ -18,24 +18,25 @@ def validate_input_output(v, field_name): """ if v == {} or v is None: return v - if isinstance(v, str): - return [ - { - "role": "assistant" if field_name == "output" else "user", - "content": v, - } - ] - elif isinstance(v, list): - if len(v) > 0 and isinstance(v[0], dict): - v = replace_text_with_content(data=v) - return v - else: + match v: + case str(): return [ { "role": "assistant" if field_name == "output" else "user", - "content": str(v), + "content": v, } ] + case list(): + if len(v) > 0 and isinstance(v[0], dict): + v = replace_text_with_content(data=v) + return v + else: + return [ + { + "role": "assistant" if field_name == "output" else "user", + "content": str(v), + } + ] return v 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 3bac908deb8..8da2dc9fa0d 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 @@ -709,7 +709,9 @@ 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: pytest.MonkeyPatch, caplog): +def test_workflow_trace_handles_usage_extraction_error( + trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): # 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 be9d64ae018..07159d8a7e3 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 @@ -64,40 +64,20 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): "total_tokens": values.get("total_tokens", 0), } file_list = values.get("file_list", []) - if isinstance(v, str): - match field_name: - case "inputs": - return { - "messages": { - "role": "user", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - case "outputs": - return { - "choices": { - "role": "ai", - "content": v, - "usage_metadata": usage_metadata, - "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 v: + case str(): match field_name: case "inputs": - data = { - "messages": v, + return { + "messages": { + "role": "user", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, } case "outputs": - data = { + return { "choices": { "role": "ai", "content": v, @@ -107,16 +87,37 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): } case _: pass - return data - else: - return { - "choices": { - "role": "ai" if field_name == "outputs" else "user", - "content": str(v), - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } + case 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 { + "choices": { + "role": "ai" if field_name == "outputs" else "user", + "content": str(v), + "usage_metadata": usage_metadata, + "file_list": file_list, + }, + } if isinstance(v, dict): v["usage_metadata"] = usage_metadata v["file_list"] = file_list 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 8336f8f51f8..76d4c99caf7 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 @@ -521,7 +521,9 @@ def test_update_run_error(trace_instance): trace_instance.update_run(update_data) -def test_workflow_trace_usage_extraction_error(trace_instance, monkeypatch: pytest.MonkeyPatch, caplog): +def test_workflow_trace_usage_extraction_error( + trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): workflow_data = MagicMock() workflow_data.created_at = _dt() workflow_data.finished_at = _dt() + timedelta(seconds=1) @@ -654,7 +656,7 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance): trace_instance.add_run = MagicMock() -def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch): +def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.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", @@ -675,7 +677,7 @@ def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypa assert trace_info.metadata.get("external_trace_id") == "external-999" -def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch): +def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.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, 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 3f1fef2c7dc..ab20a783748 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 @@ -615,7 +615,9 @@ def test_get_project_url_error(trace_instance): trace_instance.get_project_url() -def test_workflow_trace_usage_extraction_error_fixed(trace_instance, monkeypatch: pytest.MonkeyPatch, caplog): +def test_workflow_trace_usage_extraction_error_fixed( + trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): trace_info = WorkflowTraceInfo( workflow_id="86a52565-4a6b-4a1b-9bfd-98e4595e70de", tenant_id="66e8e918-472e-4b69-8051-12502c34fc07", diff --git a/api/providers/trace/trace-weave/src/dify_trace_weave/entities/weave_trace_entity.py b/api/providers/trace/trace-weave/src/dify_trace_weave/entities/weave_trace_entity.py index ed6a7dabbb0..98180c80a2c 100644 --- a/api/providers/trace/trace-weave/src/dify_trace_weave/entities/weave_trace_entity.py +++ b/api/providers/trace/trace-weave/src/dify_trace_weave/entities/weave_trace_entity.py @@ -40,41 +40,19 @@ class WeaveTraceModel(WeaveTokenUsage, WeaveMultiModel): "total_tokens": values.get("total_tokens", 0), } 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) + match v: + case str(): if field_name == "inputs": - data = { - "messages": [ - dict(msg, **{"usage_metadata": usage_metadata, "file_list": file_list}) # type: ignore - for msg in v - ] - if isinstance(v, list) - else v, + return { + "messages": { + "role": "user", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, } elif field_name == "outputs": - data = { + return { "choices": { "role": "ai", "content": v, @@ -82,16 +60,39 @@ class WeaveTraceModel(WeaveTokenUsage, WeaveMultiModel): "file_list": file_list, }, } - return data - else: - return { - "choices": { - "role": "ai" if field_name == "outputs" else "user", - "content": str(v), - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } + case 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": [ + dict(msg, **{"usage_metadata": usage_metadata, "file_list": file_list}) # type: ignore + for msg in v + ] + if isinstance(v, list) + else v, + } + elif field_name == "outputs": + data = { + "choices": { + "role": "ai", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, + } + return data + else: + return { + "choices": { + "role": "ai" if field_name == "outputs" else "user", + "content": str(v), + "usage_metadata": usage_metadata, + "file_list": file_list, + }, + } if isinstance(v, dict): v["usage_metadata"] = usage_metadata v["file_list"] = file_list diff --git a/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py b/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py index 6231b9a9fad..6e80c6efa2c 100644 --- a/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py +++ b/api/providers/vdb/vdb-clickzetta/src/dify_vdb_clickzetta/clickzetta_vector.py @@ -361,12 +361,13 @@ class ClickzettaVector(BaseVector): first_pass = json.loads(raw_metadata) # Handle double-encoded JSON - if isinstance(first_pass, str): - metadata = parse_metadata_json(first_pass) - elif isinstance(first_pass, dict): - metadata = first_pass - else: - metadata = {} + match first_pass: + case str(): + metadata = parse_metadata_json(first_pass) + case dict(): + metadata = first_pass + case _: + metadata = {} else: metadata = {} except (json.JSONDecodeError, ValueError, TypeError): @@ -942,12 +943,13 @@ class ClickzettaVector(BaseVector): # First parse may yield a string (double-encoded JSON) first_pass = json.loads(row[2]) - if isinstance(first_pass, str): - metadata = parse_metadata_json(first_pass) - elif isinstance(first_pass, dict): - metadata = first_pass - else: - metadata = {} + match first_pass: + case str(): + metadata = parse_metadata_json(first_pass) + case dict(): + metadata = first_pass + case _: + metadata = {} else: metadata = {} except (json.JSONDecodeError, ValueError, TypeError): diff --git a/api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py b/api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py index d28ded01873..290eda67e94 100644 --- a/api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py +++ b/api/providers/vdb/vdb-hologres/tests/integration_tests/conftest.py @@ -98,14 +98,15 @@ def _extract_identifiers_and_literals(query) -> list[Any]: values: list[Any] = [] if isinstance(query, psql.Composed): for part in query: - if isinstance(part, psql.Identifier): - values.append(("ident", part._obj[0] if part._obj else "")) - elif isinstance(part, psql.Literal): - values.append(("literal", part._obj)) - elif isinstance(part, psql.Composed): - for sub in part: - if isinstance(sub, psql.Literal): - values.append(("literal", sub._obj)) + match part: + case psql.Identifier(): + values.append(("ident", part._obj[0] if part._obj else "")) + case psql.Literal(): + values.append(("literal", part._obj)) + case psql.Composed(): + for sub in part: + if isinstance(sub, psql.Literal): + values.append(("literal", sub._obj)) return values diff --git a/api/pyproject.toml b/api/pyproject.toml index 8e4ebe4112e..17efcef9db0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "resend>=2.27.0,<3.0.0", # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]==0.7.0", - "graphon==0.5.1", + "graphon==0.5.2", "httpx-sse==0.4.3", "json-repair==0.59.4", ] diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py index 72b38e79068..2659e550552 100644 --- a/api/repositories/api_workflow_run_repository.py +++ b/api/repositories/api_workflow_run_repository.py @@ -35,6 +35,7 @@ Example: """ from collections.abc import Callable, Sequence +from dataclasses import dataclass from datetime import datetime from typing import Protocol, TypedDict @@ -65,6 +66,21 @@ class RunsWithRelatedCountsDict(TypedDict): pause_reasons: int +@dataclass(frozen=True) +class WorkflowRunCleanupRef: + """ + Lightweight workflow run reference for retention cleanup scans. + + Cleanup jobs use this DTO when they only need cursor, tenant eligibility, and run-id deletion data. Keeping the + query shape explicit prevents free-plan cleanup from hydrating full WorkflowRun models for rows that may be skipped + after billing checks. + """ + + id: str + tenant_id: str + created_at: datetime + + class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ Protocol for service-layer WorkflowRun repository operations. @@ -286,6 +302,36 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def get_cleanup_refs_batch_by_time_range( + self, + start_from: datetime | None, + end_before: datetime, + last_seen: tuple[datetime, str] | None, + batch_size: int, + run_types: Sequence[WorkflowType] | None = None, + tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, + upper_bound: tuple[datetime, str] | None = None, + ) -> Sequence[WorkflowRunCleanupRef]: + """ + Fetch lightweight ended workflow run refs in a time window for cleanup batching. + + Args: + start_from: Optional inclusive lower time boundary. + end_before: Exclusive upper time boundary. + last_seen: Optional exclusive `(created_at, id)` cursor lower bound. + batch_size: Maximum number of refs to return. + run_types: Optional workflow type filter. + tenant_ids: Optional tenant filter. + workflow_ids: Optional workflow ID filter. + upper_bound: Optional inclusive `(created_at, id)` cursor upper bound. Cleanup uses this for a second, + tenant-filtered target query that must stay within the candidate page high-water cursor. + + Returns: + Ordered lightweight cleanup refs containing only id, tenant_id, and created_at. + """ + ... + def get_archived_run_ids( self, session: Session, @@ -370,6 +416,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def delete_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + """ + Delete workflow runs and cleanup-owned related records by workflow run IDs. + + This mirrors delete_runs_with_related() for cleanup callers that do not need full WorkflowRun models. + """ + ... + def get_app_logs_by_run_id( self, session: Session, @@ -417,6 +476,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol): """ ... + def count_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + """ + Count workflow runs and cleanup-owned related records by workflow run IDs. + + This mirrors count_runs_with_related() for dry-run cleanup callers that do not need full WorkflowRun models. + """ + ... + def create_workflow_pause( self, workflow_run_id: str, diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index cbc9d03e5eb..b40eb4bdd8a 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -44,7 +44,11 @@ from libs.time_parser import get_time_threshold from models.enums import WorkflowRunTriggeredFrom from models.human_input import HumanInputForm, HumanInputFormRecipient from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun -from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict +from repositories.api_workflow_run_repository import ( + APIWorkflowRunRepository, + RunsWithRelatedCountsDict, + WorkflowRunCleanupRef, +) from repositories.entities.workflow_pause import WorkflowPauseEntity from repositories.types import ( AverageInteractionStats, @@ -420,6 +424,71 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): return session.scalars(stmt).all() + @override + def get_cleanup_refs_batch_by_time_range( + self, + start_from: datetime | None, + end_before: datetime, + last_seen: tuple[datetime, str] | None, + batch_size: int, + run_types: Sequence[WorkflowType] | None = None, + tenant_ids: Sequence[str] | None = None, + workflow_ids: Sequence[str] | None = None, + upper_bound: tuple[datetime, str] | None = None, + ) -> Sequence[WorkflowRunCleanupRef]: + """ + Fetch lightweight ended workflow run refs in a time window for cleanup batching. + + The optional upper_bound is inclusive and is paired with last_seen by free-plan cleanup so a second, + tenant-filtered target query stays within the candidate page already checked against billing. + """ + with self._session_maker() as session: + stmt = ( + select(WorkflowRun.id, WorkflowRun.tenant_id, WorkflowRun.created_at) + .where( + WorkflowRun.created_at < end_before, + WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()), + ) + .order_by(WorkflowRun.created_at.asc(), WorkflowRun.id.asc()) + .limit(batch_size) + ) + if run_types is not None: + if not run_types: + return [] + stmt = stmt.where(WorkflowRun.type.in_(run_types)) + + if start_from: + stmt = stmt.where(WorkflowRun.created_at >= start_from) + + if tenant_ids: + stmt = stmt.where(WorkflowRun.tenant_id.in_(tenant_ids)) + + if workflow_ids: + stmt = stmt.where(WorkflowRun.workflow_id.in_(workflow_ids)) + + if last_seen: + stmt = stmt.where( + tuple_(WorkflowRun.created_at, WorkflowRun.id) + > tuple_( + sa.literal(last_seen[0], type_=sa.DateTime()), + sa.literal(last_seen[1], type_=WorkflowRun.id.type), + ) + ) + + if upper_bound: + stmt = stmt.where( + tuple_(WorkflowRun.created_at, WorkflowRun.id) + <= tuple_( + sa.literal(upper_bound[0], type_=sa.DateTime()), + sa.literal(upper_bound[1], type_=WorkflowRun.id.type), + ) + ) + + return [ + WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) + for run_id, tenant_id, created_at in session.execute(stmt).all() + ] + @override def get_archived_run_ids( self, @@ -530,6 +599,56 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): "pause_reasons": pause_reasons_deleted, } + @override + def delete_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + delete_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + delete_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + if not run_ids: + return self._empty_runs_with_related_counts() + + run_ids = list(run_ids) + with self._session_maker() as session: + if delete_node_executions: + node_executions_deleted, offloads_deleted = delete_node_executions(session, run_ids) + else: + node_executions_deleted, offloads_deleted = 0, 0 + + app_logs_result = session.execute(delete(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids))) + app_logs_deleted = cast(CursorResult, app_logs_result).rowcount or 0 + + pause_stmt = select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids)) + pause_ids = session.scalars(pause_stmt).all() + pause_reasons_deleted = 0 + pauses_deleted = 0 + + if pause_ids: + pause_reasons_result = session.execute( + delete(WorkflowPauseReason).where(WorkflowPauseReason.pause_id.in_(pause_ids)) + ) + pause_reasons_deleted = cast(CursorResult, pause_reasons_result).rowcount or 0 + pauses_result = session.execute(delete(WorkflowPause).where(WorkflowPause.id.in_(pause_ids))) + pauses_deleted = cast(CursorResult, pauses_result).rowcount or 0 + + trigger_logs_deleted = delete_trigger_logs(session, run_ids) if delete_trigger_logs else 0 + + runs_result = session.execute(delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) + runs_deleted = cast(CursorResult, runs_result).rowcount or 0 + + session.commit() + + return { + "runs": runs_deleted, + "node_executions": node_executions_deleted, + "offloads": offloads_deleted, + "app_logs": app_logs_deleted, + "trigger_logs": trigger_logs_deleted, + "pauses": pauses_deleted, + "pause_reasons": pause_reasons_deleted, + } + @override def get_app_logs_by_run_id( self, @@ -711,6 +830,72 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): "pause_reasons": int(pause_reasons_count), } + @override + def count_runs_with_related_by_ids( + self, + run_ids: Sequence[str], + count_node_executions: Callable[[Session, Sequence[str]], tuple[int, int]] | None = None, + count_trigger_logs: Callable[[Session, Sequence[str]], int] | None = None, + ) -> RunsWithRelatedCountsDict: + if not run_ids: + return self._empty_runs_with_related_counts() + + run_ids = list(run_ids) + with self._session_maker() as session: + if count_node_executions: + node_executions_count, offloads_count = count_node_executions(session, run_ids) + else: + node_executions_count, offloads_count = 0, 0 + + runs_count = ( + session.scalar(select(func.count()).select_from(WorkflowRun).where(WorkflowRun.id.in_(run_ids))) or 0 + ) + app_logs_count = ( + session.scalar( + select(func.count()).select_from(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id.in_(run_ids)) + ) + or 0 + ) + + pause_ids = session.scalars( + select(WorkflowPause.id).where(WorkflowPause.workflow_run_id.in_(run_ids)) + ).all() + pauses_count = len(pause_ids) + pause_reasons_count = 0 + if pause_ids: + pause_reasons_count = ( + session.scalar( + select(func.count()) + .select_from(WorkflowPauseReason) + .where(WorkflowPauseReason.pause_id.in_(pause_ids)) + ) + or 0 + ) + + trigger_logs_count = count_trigger_logs(session, run_ids) if count_trigger_logs else 0 + + return { + "runs": int(runs_count), + "node_executions": node_executions_count, + "offloads": offloads_count, + "app_logs": int(app_logs_count), + "trigger_logs": trigger_logs_count, + "pauses": pauses_count, + "pause_reasons": int(pause_reasons_count), + } + + @staticmethod + def _empty_runs_with_related_counts() -> RunsWithRelatedCountsDict: + return { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + @override def create_workflow_pause( self, @@ -772,21 +957,22 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): ) pause_reason_models = [] for reason in pause_reasons: - if isinstance(reason, HumanInputRequired): - # TODO(QuantumGhost): record node_id for `WorkflowPauseReason` - pause_reason_model = WorkflowPauseReason( - pause_id=pause_model.id, - type_=reason.TYPE, - form_id=reason.form_id, - ) - elif isinstance(reason, SchedulingPause): - pause_reason_model = WorkflowPauseReason( - pause_id=pause_model.id, - type_=reason.TYPE, - message=reason.message, - ) - else: - raise AssertionError(f"unkown reason type: {type(reason)}") + match reason: + case HumanInputRequired(): + # TODO(QuantumGhost): record node_id for `WorkflowPauseReason` + pause_reason_model = WorkflowPauseReason( + pause_id=pause_model.id, + type_=reason.TYPE, + form_id=reason.form_id, + ) + case SchedulingPause(): + pause_reason_model = WorkflowPauseReason( + pause_id=pause_model.id, + type_=reason.TYPE, + message=reason.message, + ) + case _: + raise AssertionError(f"unknown reason type: {type(reason)}") pause_reason_models.append(pause_reason_model) diff --git a/api/services/account_service.py b/api/services/account_service.py index e39d13a3929..21b5f1eedba 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -5,26 +5,16 @@ import secrets import uuid from datetime import UTC, datetime, timedelta from hashlib import sha256 -from typing import Any, TypedDict, cast +from typing import Any, NotRequired, TypedDict, cast from pydantic import BaseModel, TypeAdapter, ValidationError from sqlalchemy import Row, delete, func, select, update from sqlalchemy.orm import Session, scoped_session - -from core.db.session_factory import session_factory - - -class InvitationData(TypedDict): - account_id: str - email: str - workspace_id: str - - -_invitation_adapter: TypeAdapter[InvitationData] = TypeAdapter(InvitationData) from werkzeug.exceptions import Unauthorized from configs import dify_config from constants.languages import get_valid_language, language_timezone_mapping +from core.db.session_factory import session_factory from events.tenant_event import tenant_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client, redis_fallback @@ -45,8 +35,10 @@ from models.account import ( TenantPluginAutoUpgradeStrategy, TenantStatus, ) -from models.model import DifySetup +from models.dataset import Dataset +from models.model import App, DifySetup from services.billing_service import BillingService +from services.enterprise.rbac_service import ListOption, RBACService from services.entities.auth_entities import ( ChangeEmailNewEmailToken, ChangeEmailOldEmailToken, @@ -90,6 +82,17 @@ from tasks.mail_reset_password_task import ( send_reset_password_mail_task_when_account_not_exist, ) + +class InvitationData(TypedDict): + account_id: str + email: str + workspace_id: str + role: NotRequired[str] + requires_setup: NotRequired[bool] + + +_invitation_adapter: TypeAdapter[InvitationData] = TypeAdapter(InvitationData) + logger = logging.getLogger(__name__) _change_email_token_adapter: TypeAdapter[ChangeEmailTokenData] = TypeAdapter(ChangeEmailTokenData) @@ -147,6 +150,67 @@ class AccountService: OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 + @staticmethod + def _resolve_legacy_role_id(tenant_id: str, account_id: str, role: TenantAccountRole) -> str: + """Resolve a legacy workspace role to the corresponding RBAC role id. + + Looks up the builtin RBAC role whose tag matches the legacy role name + (e.g. ``TenantAccountRole.ADMIN`` → builtin role with tag ``"admin"``). + """ + options = ListOption(page_number=1, results_per_page=100) + roles = RBACService.Roles.list(tenant_id, account_id, options=options).data + + expected_tag = { + TenantAccountRole.OWNER: "owner", + TenantAccountRole.ADMIN: "admin", + TenantAccountRole.EDITOR: "editor", + TenantAccountRole.NORMAL: "normal", + TenantAccountRole.DATASET_OPERATOR: "dataset_operator", + }[role] + for rbac_role in roles: + if ( + rbac_role.is_builtin + and rbac_role.category == "global_system_default" + and rbac_role.role_tag == expected_tag + ): + return str(rbac_role.id) + + raise ValueError(f"Builtin RBAC role not found for {role.value} in tenant {tenant_id}") + + @staticmethod + def get_workspace_permission_keys(tenant_id: str, account_id: str) -> set[str]: + permissions = RBACService.MyPermissions.get(tenant_id, account_id) + return set(getattr(getattr(permissions, "workspace", None), "permission_keys", []) or []) + + @staticmethod + def get_rbac_workspace_owner_account_id(tenant_id: str, actor_account_id: str) -> str: + """Return the account id bound to the workspace owner RBAC role.""" + owner_role_id = AccountService._resolve_legacy_role_id( + tenant_id=tenant_id, + account_id=actor_account_id, + role=TenantAccountRole.OWNER, + ) + owner_members = RBACService.Roles.members( + tenant_id=tenant_id, + account_id=actor_account_id, + role_id=owner_role_id, + options=ListOption(page_number=1, results_per_page=1), + ).data + if not owner_members: + raise ValueError(f"Workspace RBAC owner not found for tenant {tenant_id}.") + return owner_members[0].account_id + + @staticmethod + def is_rbac_workspace_owner(tenant_id: str, actor_account_id: str, member_account_id: str) -> bool: + roles = RBACService.MemberRoles.get( + tenant_id=tenant_id, + account_id=actor_account_id, + member_account_id=member_account_id, + ).roles + return any( + role.is_builtin and role.category == "global_system_default" and role.role_tag == "owner" for role in roles + ) + @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: return f"{REFRESH_TOKEN_PREFIX}{refresh_token}" @@ -1216,20 +1280,30 @@ class TenantService: tenant = TenantService.create_tenant(name=name, is_setup=is_setup) else: tenant = TenantService.create_tenant(name=f"{account.name}'s Workspace", is_setup=is_setup) - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db.session, role="owner") + if dify_config.RBAC_ENABLED: + owner_role_id = AccountService._resolve_legacy_role_id(str(tenant.id), account.id, TenantAccountRole.OWNER) + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=account.id, + member_account_id=account.id, + role_ids=[owner_role_id], + ) account.current_tenant = tenant db.session.commit() tenant_was_created.send(tenant) @staticmethod - def create_tenant_member(tenant: Tenant, account: Account, role: str = "normal") -> TenantAccountJoin: + def create_tenant_member( + tenant: Tenant, account: Account, session: scoped_session, role: str = "normal" + ) -> TenantAccountJoin: """Create tenant member""" if role == TenantAccountRole.OWNER: if TenantService.has_roles(tenant, [TenantAccountRole.OWNER]): logger.error("Tenant %s has already an owner.", tenant.id) raise Exception("Tenant already has an owner.") - ta = db.session.scalar( + ta = session.scalar( select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id) .limit(1) @@ -1238,9 +1312,9 @@ class TenantService: ta.role = TenantAccountRole(role) else: ta = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role=TenantAccountRole(role)) - db.session.add(ta) + session.add(ta) - db.session.commit() + session.commit() if dify_config.BILLING_ENABLED: BillingService.clean_billing_info_cache(tenant.id) return ta @@ -1345,6 +1419,7 @@ class TenantService: """ if not account_id: return None + role = session.execute( select(TenantAccountJoin.role).where( TenantAccountJoin.tenant_id == tenant_id, @@ -1531,11 +1606,6 @@ class TenantService: @staticmethod def check_member_permission(tenant: Tenant, operator: Account, member: Account | None, action: str): """Check member permission""" - perms = { - "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - } if action not in {"add", "remove", "update"}: raise InvalidActionError("Invalid action.") @@ -1543,6 +1613,31 @@ class TenantService: if operator.id == member.id: raise CannotOperateSelfError("Cannot operate self.") + if dify_config.RBAC_ENABLED: + workspace_permission_keys = AccountService.get_workspace_permission_keys( + str(tenant.id), + str(operator.id), + ) + required_permission_key = ( + "workspace.member.manage" if action in {"add", "remove"} else "workspace.role.manage" + ) + if required_permission_key not in workspace_permission_keys: + raise NoPermissionError(f"No permission to {action} member.") + + if ( + action == "remove" + and member + and AccountService.is_rbac_workspace_owner(str(tenant.id), str(operator.id), str(member.id)) + ): + raise NoPermissionError(f"No permission to {action} member.") + return + + perms = { + "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + } + ta_operator = db.session.scalar( select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == operator.id) @@ -1565,6 +1660,8 @@ class TenantService: def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account): """Remove member from tenant. + Apps and datasets maintained by the removed member are reassigned to + the workspace owner without changing their immutable creator records. If the removed member has ``AccountStatus.PENDING`` (invited but never activated) and no remaining workspace memberships, the orphaned account record is deleted as well. @@ -1587,6 +1684,37 @@ class TenantService: account_id = account.id account_email = account.email + owner_id: str | None + if dify_config.RBAC_ENABLED: + owner_id = AccountService.get_rbac_workspace_owner_account_id(str(tenant.id), str(operator.id)) + else: + owner_id = db.session.scalar( + select(TenantAccountJoin.account_id) + .where( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.role == TenantAccountRole.OWNER, + ) + .limit(1) + ) + if owner_id is None: + raise ValueError(f"Workspace owner not found for tenant {tenant.id}.") + + db.session.execute( + update(App) + .where( + App.tenant_id == tenant.id, + App.maintainer == account_id, + ) + .values(maintainer=owner_id) + ) + db.session.execute( + update(Dataset) + .where( + Dataset.tenant_id == tenant.id, + Dataset.maintainer == account_id, + ) + .values(maintainer=owner_id) + ) db.session.delete(ta) # Clean up orphaned pending accounts (invited but never activated) @@ -1658,11 +1786,37 @@ class TenantService: .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") .limit(1) ) - if current_owner_join: - current_owner_join.role = TenantAccountRole.ADMIN + if not dify_config.RBAC_ENABLED: + if current_owner_join: + current_owner_join.role = TenantAccountRole.ADMIN + elif current_owner_join: + admin_role_id = AccountService._resolve_legacy_role_id( + tenant_id=str(tenant.id), + account_id=operator.id, + role=TenantAccountRole.ADMIN, + ) + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=operator.id, + member_account_id=str(current_owner_join.account_id), + role_ids=[admin_role_id], + ) # Update the role of the target member - target_member_join.role = new_tenant_role + if dify_config.RBAC_ENABLED: + resolved_role_id = AccountService._resolve_legacy_role_id( + tenant_id=str(tenant.id), + account_id=operator.id, + role=TenantAccountRole.OWNER, + ) + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=operator.id, + member_account_id=member.id, + role_ids=[resolved_role_id], + ) + else: + target_member_join.role = new_tenant_role db.session.commit() @staticmethod @@ -1763,7 +1917,7 @@ class RegisterService: ): try: tenant = TenantService.create_tenant(f"{account.name}'s Workspace") - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db.session, role="owner") account.current_tenant = tenant tenant_was_created.send(tenant) except Exception: @@ -1796,6 +1950,7 @@ class RegisterService: raise ValueError("Inviter is required") normalized_email = email.lower() + tenant_join_role = TenantAccountRole.NORMAL.value if dify_config.RBAC_ENABLED else role """Invite new member""" # Check workspace permission for member invitations @@ -1805,6 +1960,7 @@ class RegisterService: account = AccountService.get_account_by_email_with_case_fallback(email) + requires_setup = False if not account: TenantService.check_member_permission(tenant, inviter, None, "add") name = normalized_email.split("@")[0] @@ -1816,9 +1972,9 @@ class RegisterService: status=AccountStatus.PENDING, is_setup=True, ) - # Create new tenant member for invited tenant - TenantService.create_tenant_member(tenant, account, role) + TenantService.create_tenant_member(tenant, account, db.session, tenant_join_role) TenantService.switch_tenant(account, tenant.id) + requires_setup = True else: TenantService.check_member_permission(tenant, inviter, account, "add") ta = db.session.scalar( @@ -1826,15 +1982,33 @@ class RegisterService: .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id) .limit(1) ) + requires_setup = account.status == AccountStatus.PENDING - if not ta: - TenantService.create_tenant_member(tenant, account, role) + if not ta and (account.status == AccountStatus.PENDING or dify_config.RBAC_ENABLED): + TenantService.create_tenant_member(tenant, account, db.session, tenant_join_role) # Support resend invitation email when the account is pending status if account.status != AccountStatus.PENDING: - raise AccountAlreadyInTenantError("Account already in tenant.") + if dify_config.RBAC_ENABLED and not ta: + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=inviter.id, + member_account_id=account.id, + role_ids=[role], + ) + if ta or dify_config.RBAC_ENABLED: + raise AccountAlreadyInTenantError("Account already in tenant.") - token = cls.generate_invite_token(tenant, account) + # Assign RBAC role if RBAC is enabled + if dify_config.RBAC_ENABLED: + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=inviter.id, + member_account_id=account.id, + role_ids=[role], + ) + + token = cls.generate_invite_token(tenant, account, role, requires_setup=requires_setup) language = account.interface_language or "en-US" # send email @@ -1849,12 +2023,16 @@ class RegisterService: return token @classmethod - def generate_invite_token(cls, tenant: Tenant, account: Account) -> str: + def generate_invite_token( + cls, tenant: Tenant, account: Account, role: str = "normal", *, requires_setup: bool = False + ) -> str: token = str(uuid.uuid4()) invitation_data = { "account_id": account.id, "email": account.email, "workspace_id": tenant.id, + "role": str(role), + "requires_setup": requires_setup, } expiry_hours = dify_config.INVITE_EXPIRY_HOURS redis_client.setex(cls._get_invitation_token_key(token), expiry_hours * 60 * 60, json.dumps(invitation_data)) @@ -1889,16 +2067,7 @@ class RegisterService: if not tenant: return None - tenant_account = db.session.execute( - select(Account, TenantAccountJoin.role) - .join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id) - .where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id) - ).first() - - if not tenant_account: - return None - - account = tenant_account[0] + account = db.session.scalar(select(Account).where(Account.email == invitation_data["email"]).limit(1)) if not account: return None diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 3f544e9438b..16ab3627929 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -115,7 +115,15 @@ class AgentComposerService: and binding is not None and binding.agent_id and payload.save_strategy - in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION) + in ( + ComposerSaveStrategy.NODE_JOB_ONLY, + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + ) + and ( + payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT + ) ): cls._require_drive_refs_resolved( tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul @@ -823,6 +831,26 @@ class AgentComposerService: node_job = payload.node_job or WorkflowNodeJobConfig() if binding: binding.node_job_config = node_job + if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT: + current_snapshot = cls._require_version( + tenant_id=tenant_id, + agent_id=binding.agent_id, + version_id=binding.current_snapshot_id, + ) + version = cls._update_current_version( + current_snapshot=current_snapshot, + account_id=account_id, + agent_soul=payload.agent_soul, + operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, + version_note=payload.version_note, + ) + agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id) + if agent.scope != AgentScope.WORKFLOW_ONLY: + raise ValueError("Inline workflow agent binding must point to a workflow-only agent") + agent.active_config_snapshot_id = version.id + agent.active_config_has_model = agent_soul_has_model(payload.agent_soul) + agent.updated_by = account_id + binding.current_snapshot_id = version.id binding.updated_by = account_id return binding diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 47c255aae2d..b9519272c4a 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -18,6 +18,7 @@ from services.agent.prompt_mentions import ( from services.entities.agent_entities import ( AgentSoulConfig, ComposerSavePayload, + ComposerSaveStrategy, ComposerVariant, WorkflowNodeJobConfig, ) @@ -50,7 +51,12 @@ _DANGEROUS_ACK_KEYS = ( class ComposerConfigValidator: @classmethod def validate_save_payload(cls, payload: ComposerSavePayload) -> None: - if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None: + if ( + payload.variant == ComposerVariant.WORKFLOW + and payload.soul_lock.locked + and payload.agent_soul is not None + and payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY + ): raise AgentSoulLockedError() if payload.agent_soul is not None: @@ -328,16 +334,17 @@ class ComposerConfigValidator: @classmethod def _reject_plaintext_secrets(cls, value: Any, *, path: str) -> None: - if isinstance(value, dict): - for key, nested in value.items(): - normalized_key = key.lower().replace("-", "_") - nested_path = f"{path}.{key}" - if normalized_key in _PLAINTEXT_SECRET_KEYS and isinstance(nested, str) and nested: - raise PlaintextSecretNotAllowedError(f"Plaintext secret is not allowed at {nested_path}") - cls._reject_plaintext_secrets(nested, path=nested_path) - elif isinstance(value, list): - for index, nested in enumerate(value): - cls._reject_plaintext_secrets(nested, path=f"{path}[{index}]") + match value: + case dict(): + for key, nested in value.items(): + normalized_key = key.lower().replace("-", "_") + nested_path = f"{path}.{key}" + if normalized_key in _PLAINTEXT_SECRET_KEYS and isinstance(nested, str) and nested: + raise PlaintextSecretNotAllowedError(f"Plaintext secret is not allowed at {nested_path}") + cls._reject_plaintext_secrets(nested, path=nested_path) + case list(): + for index, nested in enumerate(value): + cls._reject_plaintext_secrets(nested, path=f"{path}[{index}]") @classmethod def _has_install_command(cls, entry: dict[str, Any]) -> bool: diff --git a/api/services/agent/observability_service.py b/api/services/agent/observability_service.py new file mode 100644 index 00000000000..76b36abed2a --- /dev/null +++ b/api/services/agent/observability_service.py @@ -0,0 +1,739 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import aliased + +from core.app.entities.app_invoke_entities import InvokeFrom +from libs.helper import convert_datetime_to_date, escape_like_pattern, to_timestamp +from models.agent import WorkflowAgentNodeBinding +from models.enums import MessageStatus +from models.model import App, Conversation, Message +from models.workflow import WorkflowNodeExecutionModel, WorkflowRun + + +@dataclass(frozen=True) +class AgentLogQueryParams: + page: int = 1 + limit: int = 20 + keyword: str | None = None + statuses: tuple[str, ...] = () + sources: tuple[str, ...] = () + sort_by: str = "updated_at" + sort_order: str = "desc" + start: datetime | None = None + end: datetime | None = None + + +@dataclass(frozen=True) +class AgentStatisticsQueryParams: + source: str | None = None + start: datetime | None = None + end: datetime | None = None + timezone: str = "UTC" + + +@dataclass(frozen=True) +class AgentSourceFilter: + kind: str + app_id: str | None = None + workflow_id: str | None = None + workflow_version: str | None = None + node_id: str | None = None + invoke_from: InvokeFrom | None = None + + +class AgentObservabilityService: + _SOURCE_ALIASES: dict[str, InvokeFrom] = { + "api": InvokeFrom.SERVICE_API, + "service-api": InvokeFrom.SERVICE_API, + "service_api": InvokeFrom.SERVICE_API, + "console": InvokeFrom.EXPLORE, + "explore": InvokeFrom.EXPLORE, + "explore-app": InvokeFrom.EXPLORE, + "explore_app": InvokeFrom.EXPLORE, + "web": InvokeFrom.WEB_APP, + "web-app": InvokeFrom.WEB_APP, + "web_app": InvokeFrom.WEB_APP, + "debugger": InvokeFrom.DEBUGGER, + "dev": InvokeFrom.DEBUGGER, + "openapi": InvokeFrom.OPENAPI, + "trigger": InvokeFrom.TRIGGER, + } + + def __init__(self, session: Any): + self._session = session + + @classmethod + def resolve_source(cls, source: str | None) -> InvokeFrom | None: + if not source or source == "all": + return None + normalized = source.strip().lower() + if not normalized or normalized == "all": + return None + try: + return cls._SOURCE_ALIASES[normalized] + except KeyError as exc: + raise ValueError(f"Unsupported source: {source}") from exc + + @classmethod + def resolve_source_filter(cls, source: str | None) -> AgentSourceFilter: + if not source or source.strip().lower() == "all": + return AgentSourceFilter(kind="all") + normalized = source.strip() + lowered = normalized.lower() + if lowered == "webapp": + return AgentSourceFilter(kind="webapp") + if lowered.startswith("webapp:"): + return AgentSourceFilter(kind="webapp", app_id=normalized.split(":", 1)[1] or None) + if lowered == "workflow": + return AgentSourceFilter(kind="workflow") + if lowered.startswith("workflow:"): + parts = normalized.split(":", 4) + if len(parts) != 5 or not all(parts[1:]): + raise ValueError(f"Unsupported source: {source}") + return AgentSourceFilter( + kind="workflow", + app_id=parts[1], + workflow_id=parts[2], + workflow_version=parts[3], + node_id=parts[4], + ) + return AgentSourceFilter(kind="webapp", invoke_from=cls.resolve_source(source)) + + @classmethod + def resolve_source_filters(cls, sources: tuple[str, ...]) -> list[AgentSourceFilter]: + if not sources: + return [AgentSourceFilter(kind="all")] + filters: list[AgentSourceFilter] = [] + for source in sources: + source_filter = cls.resolve_source_filter(source) + if source_filter.kind == "all": + return [source_filter] + filters.append(source_filter) + return filters + + @staticmethod + def _message_status(message: Message) -> str: + if message.error or message.status == MessageStatus.ERROR: + return "failed" + if message.status == MessageStatus.PAUSED: + return "paused" + return "success" + + @staticmethod + def _total_tokens(message: Message) -> int: + return int(message.message_tokens or 0) + int(message.answer_tokens or 0) + + @classmethod + def serialize_log_message(cls, message: Message, conversation: Conversation | None = None) -> dict[str, Any]: + invoke_from = message.invoke_from.value if message.invoke_from else None + return { + "id": message.id, + "message_id": message.id, + "conversation_id": message.conversation_id, + "conversation_name": conversation.name if conversation else None, + "query": message.query, + "answer": message.answer, + "status": cls._message_status(message), + "error": message.error, + "source": invoke_from, + "from_source": message.from_source.value if message.from_source else None, + "from_end_user_id": message.from_end_user_id, + "from_account_id": message.from_account_id, + "message_tokens": int(message.message_tokens or 0), + "answer_tokens": int(message.answer_tokens or 0), + "total_tokens": cls._total_tokens(message), + "total_price": str(message.total_price or Decimal(0)), + "currency": message.currency, + "latency": float(message.provider_response_latency or 0), + "created_at": to_timestamp(message.created_at), + "updated_at": to_timestamp(message.updated_at), + } + + def list_logs(self, *, app: App, agent_id: str, params: AgentLogQueryParams) -> dict[str, Any]: + source_filters = self.resolve_source_filters(params.sources) + rows: list[dict[str, Any]] = [] + for source_filter in source_filters: + if source_filter.kind in {"all", "webapp"}: + rows.extend(self._list_webapp_conversation_logs(app=app, params=params, source_filter=source_filter)) + if source_filter.kind in {"all", "workflow"}: + rows.extend( + self._list_workflow_conversation_logs( + app=app, + agent_id=agent_id, + params=params, + source_filter=source_filter, + ) + ) + rows_by_scope = {(row["id"], row["source"]["id"] if row.get("source") else ""): row for row in rows} + rows = list(rows_by_scope.values()) + sort_by = "created_at" if params.sort_by == "created_at" else "updated_at" + rows.sort(key=lambda row: (row[sort_by] or 0, row["id"]), reverse=params.sort_order != "asc") + + total = len(rows) + start = (params.page - 1) * params.limit + end = start + params.limit + return { + "data": rows[start:end], + "page": params.page, + "limit": params.limit, + "total": total, + "has_more": end < total, + } + + def list_log_messages( + self, *, app: App, agent_id: str, conversation_id: str, params: AgentLogQueryParams + ) -> dict[str, Any]: + source_filters = self.resolve_source_filters(params.sources) + rows: list[Message] = [] + for source_filter in source_filters: + if source_filter.kind in {"all", "webapp"}: + rows.extend( + self._list_webapp_messages( + app=app, + conversation_id=conversation_id, + params=params, + source_filter=source_filter, + ) + ) + if source_filter.kind in {"all", "workflow"}: + rows.extend( + self._list_workflow_messages( + app=app, + agent_id=agent_id, + conversation_id=conversation_id, + params=params, + source_filter=source_filter, + ) + ) + + deduped = {message.id: message for message in rows} + sort_column = Message.created_at if params.sort_by == "created_at" else Message.updated_at + sorted_rows = sorted( + deduped.values(), + key=lambda message: (getattr(message, sort_column.key), message.id), + reverse=params.sort_order != "asc", + ) + total = len(sorted_rows) + start = (params.page - 1) * params.limit + end = start + params.limit + return { + "data": [self.serialize_log_message(message) for message in sorted_rows[start:end]], + "page": params.page, + "limit": params.limit, + "total": total, + "has_more": end < total, + } + + def list_log_sources(self, *, app: App, agent_id: str) -> dict[str, Any]: + webapp_source = self._serialize_webapp_source(app) + workflow_sources = self._list_workflow_sources(app=app, agent_id=agent_id) + return { + "data": [webapp_source, *workflow_sources], + "groups": [ + {"type": "webapp", "label": "WEBAPP", "sources": [webapp_source]}, + {"type": "workflow", "label": "WORKFLOW", "sources": workflow_sources}, + ], + } + + def _list_webapp_conversation_logs( + self, *, app: App, params: AgentLogQueryParams, source_filter: AgentSourceFilter + ) -> list[dict[str, Any]]: + stmt = ( + select( + Conversation, + func.count(Message.id).label("message_count"), + func.max(Message.created_at).label("created_at"), + func.max(Message.updated_at).label("updated_at"), + func.sum(sa.case((Message.status == MessageStatus.PAUSED, 1), else_=0)).label("paused_count"), + func.sum( + sa.case((or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR), 1), else_=0) + ).label("failed_count"), + ) + .join(Message, Message.conversation_id == Conversation.id) + .where(Message.app_id == app.id, Conversation.app_id == app.id) + .group_by(Conversation.id) + ) + stmt = self._apply_observability_filters(stmt, params=params, source_filter=source_filter) + rows = list(self._session.execute(stmt).all()) + return [ + self._serialize_conversation_log( + conversation=row[0], + message_count=row.message_count, + paused_count=row.paused_count, + failed_count=row.failed_count, + source=self._serialize_webapp_source(app), + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in rows + ] + + def _list_workflow_conversation_logs( + self, *, app: App, agent_id: str, params: AgentLogQueryParams, source_filter: AgentSourceFilter + ) -> list[dict[str, Any]]: + workflow_app = aliased(App) + stmt = ( + select( + Conversation, + workflow_app, + WorkflowAgentNodeBinding.workflow_id, + WorkflowAgentNodeBinding.workflow_version, + WorkflowAgentNodeBinding.node_id, + func.count(sa.distinct(Message.id)).label("message_count"), + func.max(Message.created_at).label("created_at"), + func.max(Message.updated_at).label("updated_at"), + func.sum(sa.case((Message.status == MessageStatus.PAUSED, 1), else_=0)).label("paused_count"), + func.sum( + sa.case((or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR), 1), else_=0) + ).label("failed_count"), + ) + .select_from(Message) + .join(Conversation, Conversation.id == Message.conversation_id) + .join(WorkflowRun, WorkflowRun.id == Message.workflow_run_id) + .join( + WorkflowAgentNodeBinding, + and_( + WorkflowAgentNodeBinding.tenant_id == app.tenant_id, + WorkflowAgentNodeBinding.agent_id == agent_id, + WorkflowAgentNodeBinding.app_id == WorkflowRun.app_id, + WorkflowAgentNodeBinding.workflow_id == WorkflowRun.workflow_id, + WorkflowAgentNodeBinding.workflow_version == WorkflowRun.version, + ), + ) + .join( + WorkflowNodeExecutionModel, + and_( + WorkflowNodeExecutionModel.workflow_run_id == WorkflowRun.id, + WorkflowNodeExecutionModel.node_id == WorkflowAgentNodeBinding.node_id, + ), + ) + .join(workflow_app, workflow_app.id == WorkflowAgentNodeBinding.app_id) + .where(Message.workflow_run_id.is_not(None), Conversation.app_id == WorkflowAgentNodeBinding.app_id) + .group_by( + Conversation.id, + workflow_app.id, + WorkflowAgentNodeBinding.workflow_id, + WorkflowAgentNodeBinding.workflow_version, + WorkflowAgentNodeBinding.node_id, + ) + ) + stmt = self._apply_observability_filters(stmt, params=params, source_filter=source_filter) + stmt = self._apply_workflow_source_filter(stmt, source_filter) + rows = list(self._session.execute(stmt).all()) + return [ + self._serialize_conversation_log( + conversation=row[0], + message_count=row.message_count, + paused_count=row.paused_count, + failed_count=row.failed_count, + source=self._serialize_workflow_source( + app=row[1], + workflow_id=row.workflow_id, + workflow_version=row.workflow_version, + node_id=row.node_id, + ), + created_at=row.created_at, + updated_at=row.updated_at, + ) + for row in rows + ] + + def _list_webapp_messages( + self, *, app: App, conversation_id: str, params: AgentLogQueryParams, source_filter: AgentSourceFilter + ) -> list[Message]: + stmt = select(Message).where(Message.app_id == app.id, Message.conversation_id == conversation_id) + stmt = self._apply_message_filters(stmt, params=params, source_filter=source_filter) + return list(self._session.scalars(stmt.order_by(Message.created_at.desc(), Message.id.desc())).all()) + + def _list_workflow_messages( + self, + *, + app: App, + agent_id: str, + conversation_id: str, + params: AgentLogQueryParams, + source_filter: AgentSourceFilter, + ) -> list[Message]: + stmt = ( + select(Message) + .join(WorkflowRun, WorkflowRun.id == Message.workflow_run_id) + .join( + WorkflowAgentNodeBinding, + and_( + WorkflowAgentNodeBinding.tenant_id == app.tenant_id, + WorkflowAgentNodeBinding.agent_id == agent_id, + WorkflowAgentNodeBinding.app_id == WorkflowRun.app_id, + WorkflowAgentNodeBinding.workflow_id == WorkflowRun.workflow_id, + WorkflowAgentNodeBinding.workflow_version == WorkflowRun.version, + ), + ) + .join( + WorkflowNodeExecutionModel, + and_( + WorkflowNodeExecutionModel.workflow_run_id == WorkflowRun.id, + WorkflowNodeExecutionModel.node_id == WorkflowAgentNodeBinding.node_id, + ), + ) + .where(Message.conversation_id == conversation_id) + ) + stmt = self._apply_message_filters(stmt, params=params, source_filter=source_filter) + stmt = self._apply_workflow_source_filter(stmt, source_filter) + return list(self._session.scalars(stmt.order_by(Message.created_at.desc(), Message.id.desc())).all()) + + def _list_workflow_sources(self, *, app: App, agent_id: str) -> list[dict[str, Any]]: + workflow_app = aliased(App) + stmt = ( + select( + workflow_app, + WorkflowAgentNodeBinding.workflow_id, + WorkflowAgentNodeBinding.workflow_version, + WorkflowAgentNodeBinding.node_id, + ) + .join(workflow_app, workflow_app.id == WorkflowAgentNodeBinding.app_id) + .where(WorkflowAgentNodeBinding.tenant_id == app.tenant_id, WorkflowAgentNodeBinding.agent_id == agent_id) + .order_by(workflow_app.name.asc(), WorkflowAgentNodeBinding.node_id.asc()) + ) + rows = self._session.execute(stmt).all() + deduped: dict[str, dict[str, Any]] = {} + for row in rows: + source = self._serialize_workflow_source( + app=row[0], + workflow_id=row.workflow_id, + workflow_version=row.workflow_version, + node_id=row.node_id, + ) + deduped[source["id"]] = source + return list(deduped.values()) + + @classmethod + def _apply_observability_filters(cls, stmt, *, params: AgentLogQueryParams, source_filter: AgentSourceFilter): + stmt = cls._apply_message_filters(stmt, params=params, source_filter=source_filter, include_keyword=False) + if params.keyword: + escaped_keyword = escape_like_pattern(params.keyword) + pattern = f"%{escaped_keyword}%" + stmt = stmt.where( + or_( + Message.query.ilike(pattern, escape="\\"), + Message.answer.ilike(pattern, escape="\\"), + Conversation.name.ilike(pattern, escape="\\"), + ) + ) + return stmt + + @classmethod + def _apply_message_filters( + cls, stmt, *, params: AgentLogQueryParams, source_filter: AgentSourceFilter, include_keyword: bool = True + ): + stmt = cls._apply_source_filter(stmt, source_filter.invoke_from) + if params.start: + stmt = stmt.where(Message.created_at >= params.start) + if params.end: + stmt = stmt.where(Message.created_at < params.end) + if include_keyword and params.keyword: + escaped_keyword = escape_like_pattern(params.keyword) + pattern = f"%{escaped_keyword}%" + stmt = stmt.where( + or_( + Message.query.ilike(pattern, escape="\\"), + Message.answer.ilike(pattern, escape="\\"), + ) + ) + if params.statuses: + stmt = cls._apply_status_filter(stmt, params.statuses) + return stmt + + @staticmethod + def _apply_workflow_source_filter(stmt, source_filter: AgentSourceFilter): + if source_filter.app_id: + stmt = stmt.where(WorkflowAgentNodeBinding.app_id == source_filter.app_id) + if source_filter.workflow_id: + stmt = stmt.where(WorkflowAgentNodeBinding.workflow_id == source_filter.workflow_id) + if source_filter.workflow_version: + stmt = stmt.where(WorkflowAgentNodeBinding.workflow_version == source_filter.workflow_version) + if source_filter.node_id: + stmt = stmt.where(WorkflowAgentNodeBinding.node_id == source_filter.node_id) + return stmt + + @classmethod + def _apply_source_filter(cls, stmt, source: InvokeFrom | None): + if source is None: + return stmt.where(Message.invoke_from != InvokeFrom.DEBUGGER) + return stmt.where(Message.invoke_from == source) + + @staticmethod + def _apply_status_filter(stmt, statuses: tuple[str, ...]): + conditions = [] + for status in statuses: + normalized = status.strip().lower() + if normalized in {"success", "normal"}: + conditions.append(and_(Message.error.is_(None), Message.status == MessageStatus.NORMAL)) + elif normalized in {"failed", "error"}: + conditions.append(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR)) + elif normalized == "paused": + conditions.append(Message.status == MessageStatus.PAUSED) + else: + raise ValueError(f"Unsupported status: {status}") + if not conditions: + return stmt + return stmt.where(or_(*conditions)) + + @classmethod + def _serialize_conversation_log( + cls, + *, + conversation: Conversation, + message_count: int, + paused_count: int, + failed_count: int, + source: dict[str, Any], + created_at: datetime | None, + updated_at: datetime | None, + ) -> dict[str, Any]: + return { + "id": conversation.id, + "conversation_id": conversation.id, + "title": conversation.name, + "end_user_id": conversation.from_end_user_id, + "message_count": int(message_count or 0), + "user_rate": None, + "operation_rate": None, + "unread": conversation.read_at is None, + "source": source, + "status": cls._conversation_status(paused_count=paused_count, failed_count=failed_count), + "created_at": to_timestamp(created_at or conversation.created_at), + "updated_at": to_timestamp(updated_at or conversation.updated_at), + } + + @staticmethod + def _conversation_status(*, paused_count: int, failed_count: int) -> str: + if paused_count: + return "paused" + if failed_count: + return "failed" + return "success" + + @staticmethod + def _serialize_webapp_source(app: App) -> dict[str, Any]: + icon_type = app.icon_type.value if app.icon_type else None + return { + "id": f"webapp:{app.id}", + "type": "webapp", + "app_id": app.id, + "app_name": app.name, + "app_icon_type": icon_type, + "app_icon": app.icon, + "app_icon_background": app.icon_background, + "workflow_id": None, + "workflow_version": None, + "node_id": None, + } + + @staticmethod + def _serialize_workflow_source( + *, + app: App, + workflow_id: str, + workflow_version: str, + node_id: str, + ) -> dict[str, Any]: + icon_type = app.icon_type.value if app.icon_type else None + return { + "id": f"workflow:{app.id}:{workflow_id}:{workflow_version}:{node_id}", + "type": "workflow", + "app_id": app.id, + "app_name": app.name, + "app_icon_type": icon_type, + "app_icon": app.icon, + "app_icon_background": app.icon_background, + "workflow_id": workflow_id, + "workflow_version": workflow_version, + "node_id": node_id, + } + + def get_statistics_summary(self, *, app: App, agent_id: str, params: AgentStatisticsQueryParams) -> dict[str, Any]: + source_filter = self.resolve_source_filter(params.source) + rows = self._load_daily_statistics(app=app, agent_id=agent_id, params=params, source_filter=source_filter) + charts = self._build_charts(rows) + summary = self._build_summary(rows) + return { + "source": params.source or "all", + "summary": summary, + "charts": charts, + } + + def _load_daily_statistics( + self, *, app: App, agent_id: str, params: AgentStatisticsQueryParams, source_filter: AgentSourceFilter + ) -> list[dict[str, Any]]: + converted_created_at = convert_datetime_to_date("m.created_at") + message_scope = self._statistics_message_scope_sql(source_filter) + sql_query = f"""SELECT + {converted_created_at} AS date, + COUNT(m.id) AS message_count, + COUNT(DISTINCT m.conversation_id) AS conversation_count, + COUNT(DISTINCT m.from_end_user_id) AS end_user_count, + COALESCE(SUM(COALESCE(m.message_tokens, 0) + COALESCE(m.answer_tokens, 0)), 0) AS token_count, + COALESCE(SUM(COALESCE(m.total_price, 0)), 0) AS total_price, + COALESCE(AVG(m.provider_response_latency), 0) AS avg_latency, + COALESCE(SUM(m.provider_response_latency), 0) AS latency_sum, + COALESCE(SUM(m.answer_tokens), 0) AS answer_tokens, + COUNT(mf.id) AS like_count +FROM messages m +LEFT JOIN message_feedbacks mf + ON mf.message_id = m.id AND mf.rating = 'like' +WHERE + {message_scope}""" + args: dict[str, Any] = { + "tz": params.timezone, + "app_id": app.id, + "tenant_id": app.tenant_id, + "agent_id": agent_id, + "debugger": InvokeFrom.DEBUGGER, + } + if source_filter.invoke_from is not None: + args["source"] = source_filter.invoke_from + if source_filter.app_id: + args["source_app_id"] = source_filter.app_id + if source_filter.workflow_id: + args["workflow_id"] = source_filter.workflow_id + if source_filter.workflow_version: + args["workflow_version"] = source_filter.workflow_version + if source_filter.node_id: + args["node_id"] = source_filter.node_id + if params.start: + sql_query += " AND m.created_at >= :start" + args["start"] = params.start + if params.end: + sql_query += " AND m.created_at < :end" + args["end"] = params.end + sql_query += " GROUP BY date ORDER BY date" + + return [dict(row._mapping) for row in self._session.execute(sa.text(sql_query), args).all()] + + @staticmethod + def _statistics_message_scope_sql(source_filter: AgentSourceFilter) -> str: + app_scope = "m.app_id = :app_id" + if source_filter.invoke_from is None: + app_scope += " AND m.invoke_from != :debugger" + else: + app_scope += " AND m.invoke_from = :source" + workflow_binding_filters = [] + if source_filter.app_id: + workflow_binding_filters.append("wanb.app_id = :source_app_id") + if source_filter.workflow_id: + workflow_binding_filters.append("wanb.workflow_id = :workflow_id") + if source_filter.workflow_version: + workflow_binding_filters.append("wanb.workflow_version = :workflow_version") + if source_filter.node_id: + workflow_binding_filters.append("wanb.node_id = :node_id") + extra_workflow_filters = f"AND {' AND '.join(workflow_binding_filters)}" if workflow_binding_filters else "" + workflow_scope = f"""m.workflow_run_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM workflow_runs wr + JOIN workflow_agent_node_bindings wanb + ON wanb.tenant_id = :tenant_id + AND wanb.agent_id = :agent_id + AND wanb.app_id = wr.app_id + AND wanb.workflow_id = wr.workflow_id + AND wanb.workflow_version = wr.version + {extra_workflow_filters} + JOIN workflow_node_executions wne + ON wne.workflow_run_id = wr.id + AND wne.node_id = wanb.node_id + WHERE wr.id = m.workflow_run_id + )""" + if source_filter.kind == "webapp": + return app_scope + if source_filter.kind == "workflow": + return workflow_scope + return f"(({app_scope}) OR ({workflow_scope}))" + + @staticmethod + def _build_charts(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + messages = [] + conversations = [] + end_users = [] + token_usage = [] + average_session_interactions = [] + average_response_time = [] + tokens_per_second = [] + user_satisfaction_rate = [] + + for row in rows: + date = str(row["date"]) + message_count = int(row["message_count"] or 0) + conversation_count = int(row["conversation_count"] or 0) + token_count = int(row["token_count"] or 0) + total_price = row["total_price"] or Decimal(0) + avg_latency = float(row["avg_latency"] or 0) + latency_sum = float(row["latency_sum"] or 0) + answer_tokens = int(row["answer_tokens"] or 0) + like_count = int(row["like_count"] or 0) + + messages.append({"date": date, "message_count": message_count}) + conversations.append({"date": date, "conversation_count": conversation_count}) + end_users.append({"date": date, "terminal_count": int(row["end_user_count"] or 0)}) + token_usage.append( + { + "date": date, + "token_count": token_count, + "total_price": str(total_price), + "currency": "USD", + } + ) + average_session_interactions.append( + { + "date": date, + "interactions": round(message_count / conversation_count, 2) if conversation_count else 0, + } + ) + average_response_time.append({"date": date, "latency": round(avg_latency * 1000, 4)}) + tokens_per_second.append({"date": date, "tps": round(answer_tokens / latency_sum, 4) if latency_sum else 0}) + user_satisfaction_rate.append( + {"date": date, "rate": round(like_count * 100 / message_count, 2) if message_count else 0} + ) + + return { + "daily_messages": messages, + "daily_conversations": conversations, + "daily_end_users": end_users, + "token_usage": token_usage, + "average_session_interactions": average_session_interactions, + "average_response_time": average_response_time, + "tokens_per_second": tokens_per_second, + "user_satisfaction_rate": user_satisfaction_rate, + } + + @staticmethod + def _build_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + total_messages = sum(int(row["message_count"] or 0) for row in rows) + total_conversations = sum(int(row["conversation_count"] or 0) for row in rows) + total_end_users = sum(int(row["end_user_count"] or 0) for row in rows) + total_tokens = sum(int(row["token_count"] or 0) for row in rows) + total_price = sum(Decimal(str(row["total_price"] or 0)) for row in rows) + total_answer_tokens = sum(int(row["answer_tokens"] or 0) for row in rows) + total_latency = sum(float(row["latency_sum"] or 0) for row in rows) + weighted_latency = sum(float(row["avg_latency"] or 0) * int(row["message_count"] or 0) for row in rows) + total_likes = sum(int(row["like_count"] or 0) for row in rows) + + return { + "total_messages": total_messages, + "total_conversations": total_conversations, + "total_end_users": total_end_users, + "total_tokens": total_tokens, + "total_price": str(total_price), + "currency": "USD", + "average_session_interactions": round(total_messages / total_conversations, 2) + if total_conversations + else 0, + "average_response_time": round((weighted_latency / total_messages) * 1000, 4) if total_messages else 0, + "tokens_per_second": round(total_answer_tokens / total_latency, 4) if total_latency else 0, + "user_satisfaction_rate": round(total_likes * 100 / total_messages, 2) if total_messages else 0, + } diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 21641b2965e..ca8428b4f7c 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -1,6 +1,6 @@ from typing import Any, TypedDict -from sqlalchemy import func, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from libs.datetime_utils import naive_utc_now @@ -19,7 +19,7 @@ from models.agent import ( ) from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus -from models.model import App, AppMode +from models.model import App, AppMode, IconType from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -29,7 +29,10 @@ from services.agent.errors import ( AgentNotFoundError, AgentVersionNotFoundError, ) +from services.app_service import AppService, CreateAppParams +from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload +from services.feature_service import FeatureService class AgentReferencingWorkflow(TypedDict): @@ -48,6 +51,28 @@ class AgentReferencingWorkflow(TypedDict): class AgentRosterService: + _APP_MODEL_CONFIG_COPY_FIELDS = ( + "opening_statement", + "suggested_questions", + "suggested_questions_after_answer", + "speech_to_text", + "text_to_speech", + "more_like_this", + "model", + "user_input_form", + "dataset_query_variable", + "pre_prompt", + "agent_mode", + "sensitive_word_avoidance", + "retriever_resource", + "prompt_type", + "chat_prompt_config", + "completion_prompt_config", + "dataset_configs", + "external_data_tools", + "file_upload", + ) + def __init__(self, session: Any): self._session = session @@ -56,6 +81,7 @@ class AgentRosterService: agent: Agent, active_version: AgentConfigSnapshot | None = None, published_references: list[AgentReferencingWorkflow] | None = None, + active_config_is_published: bool = False, ) -> dict[str, Any]: published_references = published_references or [] return { @@ -74,6 +100,7 @@ class AgentRosterService: "workflow_node_id": agent.workflow_node_id, "active_config_snapshot_id": agent.active_config_snapshot_id, "active_config_snapshot": AgentRosterService.serialize_version(active_version) if active_version else None, + "active_config_is_published": active_config_is_published, "status": agent.status.value, "created_by": agent.created_by, "updated_by": agent.updated_by, @@ -94,12 +121,27 @@ class AgentRosterService: "id": version.id, "agent_id": version.agent_id, "version": version.version, + "display_version": version.version, + "snapshot_version": version.version, "summary": version.summary, "version_note": version.version_note, "created_by": version.created_by, "created_at": to_timestamp(version.created_at), } + @classmethod + def _serialize_visible_version( + cls, + version: AgentConfigSnapshot, + *, + display_version: int, + ) -> dict[str, Any]: + payload = cls.serialize_version(version) or {} + payload["version"] = display_version + payload["display_version"] = display_version + payload["snapshot_version"] = version.version + return payload + @staticmethod def _build_roster_agents_stmt(*, tenant_id: str, keyword: str | None = None): stmt = select(Agent).where( @@ -128,6 +170,10 @@ class AgentRosterService: tenant_id=tenant_id, agent_ids=[agent.id for agent in agents], ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=agents, + ) data = [] for agent in agents: @@ -139,6 +185,7 @@ class AgentRosterService: agent, active_version, published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), ) ) @@ -165,11 +212,16 @@ class AgentRosterService: tenant_id=tenant_id, agent_ids=[agent.id for agent in agents], ) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=agents, + ) data = [ self.serialize_agent( agent, versions_by_id.get(agent.active_config_snapshot_id) if agent.active_config_snapshot_id else None, published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), ) for agent in agents ] @@ -406,6 +458,142 @@ class AgentRosterService: raise AgentNotFoundError() return app + def duplicate_agent_app( + self, + *, + tenant_id: str, + agent_id: str, + account: Any, + name: str | None = None, + description: str | None = None, + icon_type: Any = None, + icon: str | None = None, + icon_background: str | None = None, + ) -> App: + source_app = self.get_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) + source_agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=source_app.id) + if source_agent is None: + raise AgentNotFoundError() + + copied_name = name or self._next_duplicate_agent_name(tenant_id=tenant_id, base_name=source_app.name) + copied_description = description if description is not None else source_app.description + copied_icon_type = icon_type if icon_type is not None else source_app.icon_type + copied_icon = icon if icon is not None else source_app.icon + copied_icon_background = icon_background if icon_background is not None else source_app.icon_background + + target_app = AppService().create_app( + tenant_id, + CreateAppParams( + name=copied_name, + description=copied_description, + mode="agent", + agent_role=source_agent.role or "", + icon_type=self._normalize_app_icon_type(copied_icon_type), + icon=copied_icon, + icon_background=copied_icon_background, + api_rph=source_app.api_rph or 0, + api_rpm=source_app.api_rpm or 0, + max_active_requests=source_app.max_active_requests, + ), + account, + ) + + target_app.enable_site = source_app.enable_site + target_app.enable_api = source_app.enable_api + target_app.use_icon_as_answer_icon = source_app.use_icon_as_answer_icon + target_app.tracing = source_app.tracing + + self._copy_app_model_config(source_app=source_app, target_app=target_app, account_id=account.id) + self._copy_agent_active_snapshot( + tenant_id=tenant_id, + source_agent=source_agent, + target_app_id=target_app.id, + account_id=account.id, + ) + self._session.commit() + + if FeatureService.get_system_features().webapp_auth.enabled: + try: + original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id) + access_mode = original_settings.access_mode + except Exception: + access_mode = "public" + EnterpriseService.WebAppAuth.update_app_access_mode(target_app.id, access_mode) + + return target_app + + @staticmethod + def _normalize_app_icon_type(icon_type: IconType | str | None) -> str | None: + if icon_type is None: + return None + if isinstance(icon_type, IconType): + return icon_type.value + return icon_type + + def _copy_app_model_config(self, *, source_app: App, target_app: App, account_id: str) -> None: + source_config = source_app.app_model_config + target_config = target_app.app_model_config + if source_config is None or target_config is None: + return + + for field_name in self._APP_MODEL_CONFIG_COPY_FIELDS: + setattr(target_config, field_name, getattr(source_config, field_name)) + target_config.updated_by = account_id + + def _copy_agent_active_snapshot( + self, + *, + tenant_id: str, + source_agent: Agent, + target_app_id: str, + account_id: str, + ) -> None: + target_agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=target_app_id) + if target_agent is None: + raise AgentNotFoundError() + + source_version = self._get_version( + tenant_id=tenant_id, + agent_id=source_agent.id, + version_id=source_agent.active_config_snapshot_id, + ) + target_version = self._get_version( + tenant_id=tenant_id, + agent_id=target_agent.id, + version_id=target_agent.active_config_snapshot_id, + ) + + target_version.config_snapshot = AgentSoulConfig.model_validate(source_version.config_snapshot_dict) + target_version.summary = source_version.summary + target_version.version_note = source_version.version_note + target_version.created_by = account_id + target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot) + target_agent.updated_by = account_id + + def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str: + suffix = " copy" + max_base_len = 255 - len(suffix) + first_candidate = f"{base_name[:max_base_len]}{suffix}" + candidates = [first_candidate] + for index in range(2, 100): + numbered_suffix = f" copy {index}" + candidates.append(f"{base_name[: 255 - len(numbered_suffix)]}{numbered_suffix}") + + existing_names = set( + self._session.scalars( + select(Agent.name).where( + Agent.tenant_id == tenant_id, + Agent.scope == AgentScope.ROSTER, + Agent.status == AgentStatus.ACTIVE, + Agent.name.in_(candidates), + ) + ).all() + ) + for candidate in candidates: + if candidate not in existing_names: + return candidate + return f"{base_name[:245]} copy {int(naive_utc_now().timestamp())}" + def list_workflows_referencing_app_agent(self, *, tenant_id: str, app_id: str) -> list[AgentReferencingWorkflow]: """List the workflow apps that reference this Agent App's bound Agent. @@ -420,6 +608,12 @@ class AgentRosterService: return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=[agent.id]).get(agent.id, []) + def load_published_references_by_agent_id( + self, *, tenant_id: str, agent_ids: list[str] + ) -> dict[str, list[AgentReferencingWorkflow]]: + """Return published workflow references grouped by roster Agent id.""" + return self._load_published_references_by_agent_id(tenant_id=tenant_id, agent_ids=agent_ids) + def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) active_version = self._get_version( @@ -429,7 +623,16 @@ class AgentRosterService: tenant_id=tenant_id, agent_ids=[agent.id], ) - return self.serialize_agent(agent, active_version, published_references_by_agent_id.get(agent.id, [])) + active_config_is_published_by_agent_id = self.load_active_config_is_published_by_agent_id( + tenant_id=tenant_id, + agents=[agent], + ) + return self.serialize_agent( + agent, + active_version, + published_references_by_agent_id.get(agent.id, []), + active_config_is_published_by_agent_id.get(agent.id, False), + ) def update_roster_agent( self, *, tenant_id: str, agent_id: str, account_id: str, payload: RosterAgentUpdatePayload @@ -471,17 +674,21 @@ class AgentRosterService: AgentConfigRevisionOperation.SAVE_TO_ROSTER, } + def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool: + """Return whether the Agent's current active snapshot is a visible published version.""" + return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get( + agent.id, + False, + ) + + def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]: + """Return publish-state flags for the active config snapshots of the given Agents.""" + published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents) + return {agent.id: agent.id in published_agent_ids for agent in agents} + def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) - visible_version_ids = ( - select(AgentConfigRevision.current_snapshot_id) - .where( - AgentConfigRevision.tenant_id == tenant_id, - AgentConfigRevision.agent_id == agent_id, - AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), - ) - .subquery() - ) + visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent) versions = list( self._session.scalars( select(AgentConfigSnapshot) @@ -493,25 +700,39 @@ class AgentRosterService: .order_by(AgentConfigSnapshot.version.desc()) ).all() ) + total = len(versions) return [ - serialized_version - for version in versions - if (serialized_version := self.serialize_version(version)) is not None + self._serialize_visible_version(version, display_version=total - index) + for index, version in enumerate(versions) ] - def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: - agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) - visible_revision_id = self._session.scalar( - select(AgentConfigRevision.id) + def _visible_version_ids_stmt(self, *, tenant_id: str, agent_id: str, agent: Agent): + return ( + select(AgentConfigRevision.current_snapshot_id) .where( AgentConfigRevision.tenant_id == tenant_id, AgentConfigRevision.agent_id == agent_id, - AgentConfigRevision.current_snapshot_id == version_id, AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), ) - .limit(1) + .subquery() ) - if not visible_revision_id: + + def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent) + visible_versions = list( + self._session.scalars( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)), + ) + .order_by(AgentConfigSnapshot.version.asc()) + ).all() + ) + display_versions_by_id = {version.id: index for index, version in enumerate(visible_versions, start=1)} + if version_id not in display_versions_by_id: raise AgentVersionNotFoundError() version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) revisions = list( @@ -525,7 +746,7 @@ class AgentRosterService: .order_by(AgentConfigRevision.revision.desc()) ).all() ) - result = self.serialize_version(version) or {} + result = self._serialize_visible_version(version, display_version=display_versions_by_id[version_id]) result["config_snapshot"] = version.config_snapshot_dict result["revisions"] = [ { @@ -568,6 +789,29 @@ class AgentRosterService: raise AgentVersionNotFoundError() return version + def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]: + predicates = [ + and_( + AgentConfigRevision.agent_id == agent.id, + AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id, + AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), + ) + for agent in agents + if agent.active_config_snapshot_id + ] + if not predicates: + return set() + + agent_ids = self._session.scalars( + select(AgentConfigRevision.agent_id) + .where( + AgentConfigRevision.tenant_id == tenant_id, + or_(*predicates), + ) + .distinct() + ).all() + return set(agent_ids) + def _load_published_references_by_agent_id( self, *, tenant_id: str, agent_ids: list[str] ) -> dict[str, list[AgentReferencingWorkflow]]: diff --git a/api/services/agent/skill_standardize_service.py b/api/services/agent/skill_standardize_service.py index 71bb8daded9..b83004f3c4b 100644 --- a/api/services/agent/skill_standardize_service.py +++ b/api/services/agent/skill_standardize_service.py @@ -10,7 +10,9 @@ to the agent drive (Agent Files §5.4 / §4): Both are stored as ``ToolFile`` records and bound via ``AgentDriveService.commit`` with ``value_owned_by_drive=True`` (the drive owns their lifecycle). The returned skill ref records the stable drive paths + file ids (not just the raw upload id), -so the Composer can reload the bound skill list. +so the Composer can reload the bound skill list. The console ``/skills/upload`` +endpoints delegate to this service so "upload" now always means drive-backed skill +normalization. """ from __future__ import annotations @@ -34,7 +36,7 @@ def slugify_skill_name(name: str) -> str: class SkillStandardizeService: - """Validate + standardize a Skill package into a per-agent drive.""" + """Validate + standardize a Skill package into a per-agent drive upload result.""" def __init__( self, diff --git a/api/services/agent_app_feature_service.py b/api/services/agent_app_feature_service.py index cc0fe67802d..b8e98653c8e 100644 --- a/api/services/agent_app_feature_service.py +++ b/api/services/agent_app_feature_service.py @@ -13,6 +13,8 @@ from __future__ import annotations from typing import Any, cast +from sqlalchemy.orm import scoped_session + from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager from core.app.app_config.features.opening_statement.manager import OpeningStatementConfigManager from core.app.app_config.features.retrieval_resource.manager import RetrievalResourceConfigManager @@ -21,7 +23,6 @@ from core.app.app_config.features.suggested_questions_after_answer.manager impor SuggestedQuestionsAfterAnswerConfigManager, ) from core.app.app_config.features.text_to_speech.manager import TextToSpeechConfigManager -from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from models.account import Account from models.model import App, AppModelConfig, AppModelConfigDict @@ -67,7 +68,9 @@ class AgentAppFeatureConfigService: return cast(AppModelConfigDict, filtered) @classmethod - def update_features(cls, *, app_model: App, account: Account, config: dict[str, Any]) -> AppModelConfig: + def update_features( + cls, *, app_model: App, account: Account, config: dict[str, Any], session: scoped_session + ) -> AppModelConfig: """Persist the presentation features as a new app_model_config version. Returns the new ``AppModelConfig`` row (now referenced by the app); the @@ -82,13 +85,13 @@ class AgentAppFeatureConfigService: updated_by=account.id, ).from_model_config_dict(validated) - db.session.add(new_config) - db.session.flush() + session.add(new_config) + session.flush() app_model.app_model_config_id = new_config.id app_model.updated_by = account.id app_model.updated_at = naive_utc_now() - db.session.commit() + session.commit() return new_config diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index 276f6339b8f..bb3f8ca69e3 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -19,6 +19,7 @@ from __future__ import annotations import logging import re +import urllib.parse from typing import Any, Literal from pydantic import BaseModel @@ -386,7 +387,12 @@ class AgentDriveService: @staticmethod def _resolve_download_url( - *, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False + *, + tenant_id: str, + file_kind: AgentDriveFileKind, + file_id: str, + for_external: bool = False, + as_attachment: bool = False, ) -> str | None: """Signed URL for a drive value. ``for_external`` selects the audience: the inner manifest hands agents *internal* URLs, while the console @@ -398,10 +404,22 @@ class AgentDriveService: controller = DatabaseFileAccessController() runtime = DifyWorkflowFileRuntime(file_access_controller=controller) try: + if file_kind == AgentDriveFileKind.UPLOAD_FILE: + return runtime.resolve_upload_file_url( + upload_file_id=file_id, + for_external=for_external, + as_attachment=as_attachment, + ) # No FileAccessScope bound -> drive-owned: the builders still filter by # tenant_id, so resolution is tenant-scoped without user-level checks. file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller) - return runtime.resolve_file_url(file=file, for_external=for_external) + url = runtime.resolve_file_url(file=file, for_external=for_external) + if as_attachment and url: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query.append(("as_attachment", "true")) + return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) + return url except ValueError: return None @@ -475,7 +493,11 @@ class AgentDriveService: self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) url = self._resolve_download_url( - tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True + tenant_id=tenant_id, + file_kind=row.file_kind, + file_id=row.file_id, + for_external=True, + as_attachment=True, ) if url is None: raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404) diff --git a/api/services/api_based_extension_service.py b/api/services/api_based_extension_service.py index fdb377694bb..25f554b6bdc 100644 --- a/api/services/api_based_extension_service.py +++ b/api/services/api_based_extension_service.py @@ -1,16 +1,16 @@ from sqlalchemy import select +from sqlalchemy.orm import Session from core.extension.api_based_extension_requestor import APIBasedExtensionRequestor from core.helper.encrypter import decrypt_token, encrypt_token -from extensions.ext_database import db from models.api_based_extension import APIBasedExtension, APIBasedExtensionPoint class APIBasedExtensionService: @staticmethod - def get_all_by_tenant_id(tenant_id: str) -> list[APIBasedExtension]: + def get_all_by_tenant_id(session: Session, tenant_id: str) -> list[APIBasedExtension]: extension_list = list( - db.session.scalars( + session.scalars( select(APIBasedExtension) .where(APIBasedExtension.tenant_id == tenant_id) .order_by(APIBasedExtension.created_at.desc()) @@ -23,23 +23,23 @@ class APIBasedExtensionService: return extension_list @classmethod - def save(cls, extension_data: APIBasedExtension) -> APIBasedExtension: - cls._validation(extension_data) + def save(cls, session: Session, extension_data: APIBasedExtension) -> APIBasedExtension: + cls._validation(session, extension_data) extension_data.api_key = encrypt_token(extension_data.tenant_id, extension_data.api_key) - db.session.add(extension_data) - db.session.commit() + session.add(extension_data) + session.commit() return extension_data @staticmethod - def delete(extension_data: APIBasedExtension): - db.session.delete(extension_data) - db.session.commit() + def delete(session: Session, extension_data: APIBasedExtension): + session.delete(extension_data) + session.commit() @staticmethod - def get_with_tenant_id(tenant_id: str, api_based_extension_id: str) -> APIBasedExtension: - extension = db.session.scalar( + def get_with_tenant_id(session: Session, tenant_id: str, api_based_extension_id: str) -> APIBasedExtension: + extension = session.scalar( select(APIBasedExtension) .where(APIBasedExtension.tenant_id == tenant_id, APIBasedExtension.id == api_based_extension_id) .limit(1) @@ -53,14 +53,14 @@ class APIBasedExtensionService: return extension @classmethod - def _validation(cls, extension_data: APIBasedExtension): + def _validation(cls, session: Session, extension_data: APIBasedExtension): # name if not extension_data.name: raise ValueError("name must not be empty") if not extension_data.id: # case one: check new data, name must be unique - is_name_existed = db.session.scalar( + is_name_existed = session.scalar( select(APIBasedExtension) .where( APIBasedExtension.tenant_id == extension_data.tenant_id, @@ -73,7 +73,7 @@ class APIBasedExtensionService: raise ValueError("name must be unique, it is already existed") else: # case two: check existing data, name must be unique - is_name_existed = db.session.scalar( + is_name_existed = session.scalar( select(APIBasedExtension) .where( APIBasedExtension.tenant_id == extension_data.tenant_id, diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index e69cff6a294..52e936bf1ee 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -11,7 +11,7 @@ import yaml from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from packaging.version import parse as parse_version -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session @@ -60,6 +60,7 @@ class Import(BaseModel): status: ImportStatus app_id: str | None = None app_mode: str | None = None + permission_keys: list[str] = Field(default_factory=list) current_dsl_version: str = CURRENT_DSL_VERSION imported_dsl_version: str = "" error: str = "" @@ -433,6 +434,7 @@ class AppDslService: app.enable_api = True app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False) app.created_by = account.id + app.maintainer = account.id app.updated_by = account.id self._session.add(app) diff --git a/api/services/app_service.py b/api/services/app_service.py index c435a672520..941855b8321 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -7,7 +7,8 @@ from typing import Any, Literal, NotRequired, TypedDict, cast, override import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import ColumnElement, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, scoped_session from configs import dify_config @@ -27,7 +28,9 @@ from models import Account, AppStar from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentStatus from models.model import App, AppMode, AppModelConfig, IconType, Site from models.tools import ApiToolProvider +from services.agent.errors import AgentNameConflictError from services.billing_service import BillingService +from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService from services.openapi.visibility import apply_openapi_gate, is_openapi_visible @@ -48,6 +51,8 @@ class AppListBaseParams(BaseModel): tag_ids: list[str] | None = None creator_ids: list[str] | None = None is_created_by_me: bool | None = None + accessible_app_ids: list[str] | None = None + include_own_apps: bool = False class AppListParams(AppListBaseParams): @@ -75,7 +80,7 @@ class CreateAppParams(BaseModel): class AppService: @staticmethod def _build_app_list_filters( - user_id: str, tenant_id: str, params: AppListBaseParams + user_id: str, tenant_id: str, params: AppListBaseParams, session: scoped_session ) -> list[sa.ColumnElement[bool]]: filters = [App.tenant_id == tenant_id, App.is_universal == False] @@ -106,6 +111,11 @@ class AppService: if params.is_created_by_me: filters.append(App.created_by == user_id) + elif params.accessible_app_ids is not None: + accessible_filter: ColumnElement[bool] = App.id.in_(params.accessible_app_ids) + if params.include_own_apps: + accessible_filter = sa.or_(App.maintainer == user_id, accessible_filter) + filters.append(accessible_filter) if params.creator_ids: filters.append(App.created_by.in_(params.creator_ids)) if params.name: @@ -115,7 +125,7 @@ class AppService: escaped_name = escape_like_pattern(name) filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) 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, match_all=True) + target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, session, match_all=True) if target_ids and len(target_ids) > 0: filters.append(App.id.in_(target_ids)) else: @@ -197,7 +207,9 @@ class AppService: ).scalars() ) - def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: + def get_paginate_apps( + self, user_id: str, tenant_id: str, params: AppListParams, session: scoped_session + ) -> Pagination | None: """ Get app list with pagination, filters, and explicit sort order. :param user_id: user id @@ -205,7 +217,7 @@ class AppService: :param params: query parameters :return: """ - filters = self._build_app_list_filters(user_id, tenant_id, params) + filters = self._build_app_list_filters(user_id, tenant_id, params, session) if not filters: return None @@ -231,12 +243,12 @@ class AppService: return app_models def get_paginate_starred_apps( - self, user_id: str, tenant_id: str, params: StarredAppListParams + self, user_id: str, tenant_id: str, params: StarredAppListParams, session: scoped_session ) -> Pagination | None: """ Get apps starred by the current account with pagination, filters, and explicit sort order. """ - filters = self._build_app_list_filters(user_id, tenant_id, params) + filters = self._build_app_list_filters(user_id, tenant_id, params, session) if not filters: return None @@ -373,6 +385,7 @@ class AppService: app.api_rpm = params.api_rpm app.max_active_requests = params.max_active_requests app.created_by = account.id + app.maintainer = account.id app.updated_by = account.id db.session.add(app) @@ -424,6 +437,12 @@ class AppService: db.session.commit() app_was_created.send(app, account=account) + enterprise_rbac_service.try_sync_creator_access_policy_member_bindings( + tenant_id, + account.id, + enterprise_rbac_service.RBACResourceType.APP, + app.id, + ) if FeatureService.get_system_features().webapp_auth.enabled: # update web app setting as private @@ -540,17 +559,21 @@ class AppService: *, name: str | None = None, description: str | None = None, + role: str | None = None, icon_type: IconType | str | None = None, icon: str | None = None, icon_background: str | None = None, - role: str | None = None, account_id: str | None = None, updated_at: datetime | None = None, ) -> None: """Keep the Roster identity aligned with its Agent App shell. Agent Soul remains versioned through Composer. This helper only mirrors - user-facing identity fields so Roster and Agent Console do not drift. + user-facing identity fields, including the roster role/persona label, + so Roster and Agent Console do not drift. + + Role omission is intentional: ``role=None`` preserves the backing + Agent's current role, while ``role=""`` explicitly clears it. """ agent = self._get_backing_agent_for_update(app) if agent is None: @@ -560,18 +583,28 @@ class AppService: agent.name = name if description is not None: agent.description = description + if role is not None: + agent.role = role if icon_type is not None: agent.icon_type = self._to_agent_icon_type(icon_type) if icon is not None: agent.icon = icon if icon_background is not None: agent.icon_background = icon_background - if role is not None: - agent.role = role agent.updated_by = account_id if updated_at is not None: agent.updated_at = updated_at + @staticmethod + def _commit_app_identity_update(app: App) -> None: + try: + db.session.commit() + except IntegrityError as exc: + db.session.rollback() + if app.mode == AppMode.AGENT: + raise AgentNameConflictError() from exc + raise + def update_app(self, app: App, args: ArgsDict) -> App: """ Update app @@ -599,14 +632,16 @@ class AppService: app, name=app.name, description=app.description, + # Omitted role must stay omitted here: None means "preserve current + # backing-agent role", while an empty string is an explicit clear. + role=args.get("role"), icon_type=app.icon_type, icon=app.icon, icon_background=app.icon_background, - role=args.get("role"), account_id=current_user.id, updated_at=app.updated_at, ) - db.session.commit() + self._commit_app_identity_update(app) app_was_updated.send(app) @@ -629,7 +664,7 @@ class AppService: account_id=current_user.id, updated_at=app.updated_at, ) - db.session.commit() + self._commit_app_identity_update(app) app_was_updated.send(app) diff --git a/api/services/attachment_service.py b/api/services/attachment_service.py index dad7163739f..4129613b2fe 100644 --- a/api/services/attachment_service.py +++ b/api/services/attachment_service.py @@ -14,12 +14,13 @@ class AttachmentService: _session_maker: sessionmaker def __init__(self, session_factory: sessionmaker | Engine | None = None): - if isinstance(session_factory, Engine): - self._session_maker = sessionmaker(bind=session_factory) - elif isinstance(session_factory, sessionmaker): - self._session_maker = session_factory - else: - raise AssertionError("must be a sessionmaker or an Engine.") + match session_factory: + case Engine(): + self._session_maker = sessionmaker(bind=session_factory) + case sessionmaker(): + self._session_maker = session_factory + case _: + raise AssertionError("must be a sessionmaker or an Engine.") def get_file_base64(self, file_id: str) -> str: with self._session_maker(expire_on_commit=False) as session: diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index af50a0e3180..a8f341fdd04 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -12,8 +12,8 @@ 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 +from sqlalchemy import ColumnElement, delete, exists, func, select, update +from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config @@ -67,6 +67,7 @@ from models.source import DataSourceOauthBinding from models.workflow import Workflow from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import DuplicateDocumentIndexingTaskProxy +from services.enterprise import rbac_service as enterprise_rbac_service from services.entities.knowledge_entities.knowledge_entities import ( ChildChunkUpdateArgs, KnowledgeConfig, @@ -235,9 +236,36 @@ class _EstimateArgs(BaseModel): class DatasetService: @staticmethod - def get_datasets(page, per_page, tenant_id=None, user=None, search=None, tag_ids=None, include_all=False): + def _can_manage_all_datasets(tenant_id: str, account_id: str) -> bool: + if not dify_config.RBAC_ENABLED: + return False + + permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id) + workspace_permission_keys = getattr(getattr(permissions, "workspace", None), "permission_keys", []) or [] + return "dataset.create_and_management" in workspace_permission_keys + + @staticmethod + def get_datasets( + page, + per_page, + session: scoped_session | Session | None = None, + tenant_id=None, + user=None, + search=None, + tag_ids=None, + include_all=False, + accessible_dataset_ids: list[str] | None = None, + include_own_datasets: bool = False, + ): + session = session or db.session query = select(Dataset).where(Dataset.tenant_id == tenant_id).order_by(Dataset.created_at.desc(), Dataset.id) + if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None: + accessible_filter: ColumnElement[bool] = Dataset.id.in_(accessible_dataset_ids) + if include_own_datasets and user: + accessible_filter = sa.or_(Dataset.maintainer == user.id, accessible_filter) + query = query.where(accessible_filter) + if user: # get permitted dataset ids dataset_permission = db.session.scalars( @@ -246,8 +274,7 @@ class DatasetService: ) ).all() permitted_dataset_ids = {dp.dataset_id for dp in dataset_permission} if dataset_permission else None - - if user.current_role == TenantAccountRole.DATASET_OPERATOR: + if not dify_config.RBAC_ENABLED and user.current_role == TenantAccountRole.DATASET_OPERATOR: # only show datasets that the user has permission to access # Check if permitted_dataset_ids is not empty to avoid WHERE false condition if permitted_dataset_ids and len(permitted_dataset_ids) > 0: @@ -255,34 +282,50 @@ class DatasetService: else: return [], 0 else: - if user.current_role != TenantAccountRole.OWNER or not include_all: - # show all datasets that the user has permission to access - # Check if permitted_dataset_ids is not empty to avoid WHERE false condition - if permitted_dataset_ids and len(permitted_dataset_ids) > 0: - query = query.where( - sa.or_( - Dataset.permission == DatasetPermissionEnum.ALL_TEAM, - sa.and_( - Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id - ), - sa.and_( - Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM, - Dataset.id.in_(permitted_dataset_ids), - ), - ) - ) + if dify_config.RBAC_ENABLED: + can_manage_all_datasets = DatasetService._can_manage_all_datasets(str(tenant_id), str(user.id)) + should_show_all_datasets = include_all and can_manage_all_datasets + else: + should_show_all_datasets = user.current_role == TenantAccountRole.OWNER and include_all + + if not should_show_all_datasets: + if dify_config.RBAC_ENABLED: + # RBAC mode: show all datasets. Permission control is enforced + # via permission_keys on each item and @rbac_permission_required decorators. + pass else: - query = query.where( - sa.or_( - Dataset.permission == DatasetPermissionEnum.ALL_TEAM, - sa.and_( - Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id - ), + # Keep legacy visibility rules when RBAC is disabled. + if permitted_dataset_ids and len(permitted_dataset_ids) > 0: + query = query.where( + sa.or_( + Dataset.permission == DatasetPermissionEnum.ALL_TEAM, + sa.and_( + Dataset.permission == DatasetPermissionEnum.ONLY_ME, + Dataset.maintainer == user.id, + ), + sa.and_( + Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM, + Dataset.id.in_(permitted_dataset_ids), + ), + ) + ) + else: + query = query.where( + sa.or_( + Dataset.permission == DatasetPermissionEnum.ALL_TEAM, + sa.and_( + Dataset.permission == DatasetPermissionEnum.ONLY_ME, + Dataset.maintainer == user.id, + ), + ) ) - ) else: - # if no user, only show datasets that are shared with all team members - query = query.where(Dataset.permission == DatasetPermissionEnum.ALL_TEAM) + if dify_config.RBAC_ENABLED: + # Without an account we cannot resolve RBAC resource visibility. + query = query.where(sa.false()) + else: + # if no user, only show datasets that are shared with all team members + query = query.where(Dataset.permission == DatasetPermissionEnum.ALL_TEAM) if search: escaped_search = helper.escape_like_pattern(search) @@ -295,6 +338,7 @@ class DatasetService: "knowledge", tenant_id, tag_ids, + session, match_all=True, ) else: @@ -326,12 +370,28 @@ class DatasetService: return {"mode": mode, "rules": rules} @staticmethod - def get_datasets_by_ids(ids, tenant_id): + def get_datasets_by_ids( + ids, + tenant_id, + user=None, + accessible_dataset_ids: list[str] | None = None, + include_own_datasets: bool = False, + ): # Check if ids is not empty to avoid WHERE false condition if not ids or len(ids) == 0: return [], 0 stmt = select(Dataset).where(Dataset.id.in_(ids), Dataset.tenant_id == tenant_id) + if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None: + requested_dataset_ids = set(ids) + accessible_dataset_ids = [ + dataset_id for dataset_id in accessible_dataset_ids if dataset_id in requested_dataset_ids + ] + accessible_filter: ColumnElement[bool] = Dataset.id.in_(accessible_dataset_ids) + if include_own_datasets and user: + accessible_filter = sa.or_(Dataset.maintainer == user.id, accessible_filter) + stmt = stmt.where(accessible_filter) + datasets = db.paginate(select=stmt, page=1, per_page=len(ids), max_per_page=len(ids), error_out=False) return datasets.items, datasets.total @@ -389,6 +449,7 @@ class DatasetService: # dataset = Dataset(name=name, provider=provider, config=config) dataset.description = description dataset.created_by = account.id + dataset.maintainer = account.id dataset.updated_by = account.id dataset.tenant_id = tenant_id dataset.embedding_model_provider = embedding_model.provider if embedding_model else None @@ -419,6 +480,12 @@ class DatasetService: db.session.add(external_knowledge_binding) db.session.commit() + enterprise_rbac_service.try_sync_creator_access_policy_member_bindings( + tenant_id, + account.id, + enterprise_rbac_service.RBACResourceType.DATASET, + dataset.id, + ) return dataset @staticmethod @@ -464,6 +531,7 @@ class DatasetService: runtime_mode=DatasetRuntimeMode.RAG_PIPELINE, icon_info=rag_pipeline_dataset_create_entity.icon_info.model_dump(), created_by=current_user.id, + maintainer=current_user.id, pipeline_id=pipeline.id, ) db.session.add(dataset) @@ -1262,12 +1330,12 @@ class DatasetService: logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id) raise NoPermissionError("You do not have permission to access this dataset.") if user.current_role != TenantAccountRole.OWNER: - if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id: + if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.maintainer != user.id: logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id) raise NoPermissionError("You do not have permission to access this dataset.") if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM: - # For partial team permission, user needs explicit permission or be the creator - if dataset.created_by != user.id: + # For partial team permission, user needs explicit permission or be the maintainer. + if dataset.maintainer != user.id: user_permission = db.session.scalar( select(DatasetPermission) .where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id) @@ -1287,7 +1355,7 @@ class DatasetService: if user.current_role != TenantAccountRole.OWNER: if dataset.permission == DatasetPermissionEnum.ONLY_ME: - if dataset.created_by != user.id: + if dataset.maintainer != user.id: raise NoPermissionError("You do not have permission to access this dataset.") elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM: @@ -1710,7 +1778,7 @@ class DocumentService: invalid_source_message="Document does not have an uploaded file to download.", missing_file_message="Uploaded file not found.", ) - upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id]) + upload_files_by_id = FileService.get_upload_files_by_ids(db.session(), document.tenant_id, [upload_file_id]) upload_file = upload_files_by_id.get(upload_file_id) if not upload_file: raise NotFound("Uploaded file not found.") @@ -1749,7 +1817,7 @@ class DocumentService: upload_file_ids.append(upload_file_id) upload_file_ids_by_document_id[document_id] = upload_file_id - upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids) + upload_files_by_id = FileService.get_upload_files_by_ids(db.session(), tenant_id, upload_file_ids) missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys()) if missing_upload_file_ids: raise NotFound("Only uploaded-file documents can be downloaded as ZIP.") @@ -2032,14 +2100,7 @@ class DocumentService: website_info = knowledge_config.data_source.info_list.website_info_list assert website_info count = len(website_info.urls) - batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT) - - if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1: - raise ValueError("Your current plan does not support batch upload, please upgrade your plan.") - if count > batch_upload_limit: - raise ValueError(f"You have reached the batch upload limit of {batch_upload_limit}.") - - DocumentService.check_documents_upload_quota(count, features) + DocumentService.check_document_creation_limits(count, features) # if dataset is empty, update dataset data_source_type if not dataset.data_source_type and knowledge_config.data_source: @@ -2603,6 +2664,21 @@ class DocumentService: f"You have reached the limit of your subscription. Only {can_upload_size} documents can be uploaded." ) + @staticmethod + def check_document_creation_limits(count: int, features: FeatureModel): + """Validate billing-backed document creation limits before document rows are created.""" + if not features.billing.enabled: + return + + if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1: + raise ValueError("Your current plan does not support batch upload, please upgrade your plan.") + + batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT) + if count > batch_upload_limit: + raise ValueError(f"You have reached the batch upload limit of {batch_upload_limit}.") + + DocumentService.check_documents_upload_quota(count, features) + @staticmethod def build_document( dataset: Dataset, @@ -2824,13 +2900,7 @@ class DocumentService: website_info = knowledge_config.data_source.info_list.website_info_list if website_info: count = len(website_info.urls) - if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1: - raise ValueError("Your current plan does not support batch upload, please upgrade your plan.") - batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT) - if count > batch_upload_limit: - raise ValueError(f"You have reached the batch upload limit of {batch_upload_limit}.") - - DocumentService.check_documents_upload_quota(count, features) + DocumentService.check_document_creation_limits(count, features) dataset_collection_binding_id = None retrieval_model = None @@ -2859,6 +2929,7 @@ class DocumentService: data_source_type=knowledge_config.data_source.info_list.data_source_type, indexing_technique=IndexTechniqueType(knowledge_config.indexing_technique), created_by=account.id, + maintainer=account.id, embedding_model=knowledge_config.embedding_model, embedding_model_provider=knowledge_config.embedding_model_provider, collection_binding_id=dataset_collection_binding_id, diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index 749d8dbc30e..c15e9949abb 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -4,8 +4,8 @@ from collections.abc import Mapping from sqlalchemy import case, select from sqlalchemy.orm import sessionmaker -from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db +from models.enums import EndUserType from models.model import App, DefaultEndUserSessionID, EndUser logger = logging.getLogger(__name__) @@ -41,11 +41,11 @@ class EndUserService: Get or create an end user for a given app. """ - return cls.get_or_create_end_user_by_type(InvokeFrom.SERVICE_API, app_model.tenant_id, app_model.id, user_id) + return cls.get_or_create_end_user_by_type(EndUserType.SERVICE_API, app_model.tenant_id, app_model.id, user_id) @classmethod def get_or_create_end_user_by_type( - cls, type: InvokeFrom, tenant_id: str, app_id: str, user_id: str | None = None + cls, type: EndUserType, tenant_id: str, app_id: str, user_id: str | None = None ) -> EndUser: """ Get or create an end user for a given app and type. @@ -98,7 +98,7 @@ class EndUserService: @classmethod def create_end_user_batch( - cls, type: InvokeFrom, tenant_id: str, app_ids: list[str], user_id: str + cls, type: EndUserType, tenant_id: str, app_ids: list[str], user_id: str ) -> Mapping[str, EndUser]: """Create end users in batch. diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index c1637847e25..71deec752e5 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -36,6 +36,11 @@ class MCPIdentityRefreshError(MCPTokenError): logger = logging.getLogger(__name__) +# Headers recognised by dify-enterprise's /inner/api/rbac/* endpoints. +# Keep in sync with pkg/enterprise/service/rbac_inner_handlers.go. +INNER_TENANT_ID_HEADER = "X-Inner-Tenant-Id" +INNER_ACCOUNT_ID_HEADER = "X-Inner-Account-Id" + class BaseRequest: proxies: Mapping[str, str] | None = { @@ -69,8 +74,16 @@ class BaseRequest: *, timeout: float | httpx.Timeout | None = None, raise_for_status: bool = False, + extra_headers: Mapping[str, str] | None = None, ) -> Any: headers = {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key} + if extra_headers: + # Explicitly ignore empty values so callers can pass optional + # headers (e.g. `X-Inner-Account-Id`) without having to branch. + for key, value in extra_headers.items(): + if value is None or value == "": + continue + headers[key] = value url = f"{cls.base_url}{endpoint}" mounts = cls._build_mounts() @@ -139,9 +152,60 @@ class BaseRequest: class EnterpriseRequest(BaseRequest): base_url = os.environ.get("ENTERPRISE_API_URL", "ENTERPRISE_API_URL") + rbac_base_url = os.environ.get("ENTERPRISE_RBAC_API_URL", base_url) secret_key = os.environ.get("ENTERPRISE_API_SECRET_KEY", "ENTERPRISE_API_SECRET_KEY") secret_key_header = "Enterprise-Api-Secret-Key" + @classmethod + def send_inner_rbac_request( + cls, + method: str, + endpoint: str, + *, + tenant_id: str, + account_id: str | None = None, + json: Any | None = None, + params: Mapping[str, Any] | None = None, + timeout: float | httpx.Timeout | None = None, + ) -> Any: + """Call an /inner/api/rbac/* endpoint on dify-enterprise. + + Inner RBAC endpoints require three headers on top of the standard + Enterprise-Api-Secret-Key: the tenant the call targets and (optionally) + the account acting on behalf of the workspace. This helper centralises + both the assertions and the header wiring so callers only have to + supply business payload. + """ + if not tenant_id: + raise ValueError("tenant_id must be provided for inner RBAC requests") + + inner_headers: dict[str, str] = {INNER_TENANT_ID_HEADER: tenant_id} + if account_id: + inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id + url = f"{cls.rbac_base_url}{endpoint}" + mounts = cls._build_mounts() + + try: + traceparent = generate_traceparent_header() + if traceparent: + inner_headers = dict(inner_headers) + inner_headers["traceparent"] = traceparent + except Exception: + logger.debug("Failed to generate traceparent header", exc_info=True) + + with httpx.Client(mounts=mounts) as client: + request_kwargs: dict[str, Any] = { + "json": json, + "params": params, + "headers": {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key, **inner_headers}, + } + if timeout is not None: + request_kwargs["timeout"] = timeout + response = client.request(method, url, **request_kwargs) + if not response.is_success: + cls._handle_error_response(response) + return response.json() + class EnterprisePluginManagerRequest(BaseRequest): base_url = os.environ.get("ENTERPRISE_PLUGIN_MANAGER_API_URL", "ENTERPRISE_PLUGIN_MANAGER_API_URL") diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py new file mode 100644 index 00000000000..39a3a61a781 --- /dev/null +++ b/api/services/enterprise/rbac_service.py @@ -0,0 +1,1713 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence +from enum import StrEnum +from typing import Any, TypeVar + +from flask import has_request_context, request +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from configs import dify_config +from core.db.session_factory import session_factory +from models import TenantAccountJoin, TenantAccountRole +from services.enterprise.base import EnterpriseRequest + +T = TypeVar("T") +logger = logging.getLogger(__name__) + + +class _RBACModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class Pagination(_RBACModel): + total_count: int = 0 + per_page: int = 0 + current_page: int = 0 + total_pages: int = 0 + + +class Paginated[T](_RBACModel): + data: list[T] = Field(default_factory=list) + pagination: Pagination | None = None + + +class MembersInRole(_RBACModel): + account_id: str = "" + account_name: str = "" + + +class RBACResourceType(StrEnum): + """Resource types understood by access policies.""" + + APP = "app" + DATASET = "dataset" + + +class RBACRoleType(StrEnum): + """The only concrete role type after the access-policy refactor.""" + + WORKSPACE = "workspace" + + +class PermissionCatalogItem(_RBACModel): + key: str + name: str + description: str = "" + + +class PermissionCatalogGroup(_RBACModel): + group_key: str + group_name: str + description: str = "" + permissions: list[PermissionCatalogItem] = Field(default_factory=list) + + +class PermissionCatalogResponse(_RBACModel): + groups: list[PermissionCatalogGroup] = Field(default_factory=list) + + +class RBACRole(_RBACModel): + id: str + tenant_id: str | None = None + type: str + category: str = "" + name: str + description: str = "" + is_builtin: bool = False + permission_keys: list[str] = Field(default_factory=list) + role_tag: str = "" + + @field_validator("permission_keys", mode="before") + @classmethod + def _coerce_permission_keys(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class RBACRoleAccount(_RBACModel): + account_id: str + account_name: str = "" + email: str = "" + avatar: str = "" + + +class MemberRoleSummary(_RBACModel): + id: str + name: str + + +class ResourcePermissionKeys(_RBACModel): + resource_id: str + permission_keys: list[str] = Field(default_factory=list) + + +class ResourcePermissionKeysBatchResponse(_RBACModel): + data: list[ResourcePermissionKeys] = Field(default_factory=list) + + +class AccessPolicy(_RBACModel): + id: str + tenant_id: str = "" + resource_type: str + policy_key: str = "" + name: str + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + is_builtin: bool = False + category: str = "" + created_at: int = 0 + updated_at: int = 0 + + @field_validator("permission_keys", mode="before") + @classmethod + def _coerce_permission_keys(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class AccessPolicyRoleBinding(_RBACModel): + id: str + tenant_id: str = "" + access_policy_id: str + resource_type: str + resource_id: str = "" + role_id: str + role_name: str = "" + created_at: int = 0 + + +class AccessPolicyMemberBinding(_RBACModel): + id: str + tenant_id: str = "" + access_policy_id: str + resource_type: str + resource_id: str = "" + account_id: str + account_name: str = "" + created_at: int = 0 + + +class AccessPolicyBindingState(_RBACModel): + binding_id: str + is_locked: bool = False + + +class AccessPolicyRole(BaseModel): + role_id: str + role_name: str + binding_id: str + is_locked: bool = False + role_tag: str = "" + + +class AccessPolicyAccount(BaseModel): + account_id: str + account_name: str + binding_id: str + is_locked: bool = False + avatar: str = "" + email: str = "" + + +class AccessMatrixItem(_RBACModel): + policy: AccessPolicy | None = None + roles: list[AccessPolicyRole] = Field(default_factory=list) + accounts: list[AccessPolicyAccount] = Field(default_factory=list) + + @field_validator("roles", "accounts", mode="before") + @classmethod + def _coerce_empty_lists(cls, value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + return value + + +class AppAccessMatrix(_RBACModel): + app_id: str = Field(default="", validation_alias=AliasChoices("app_id", "resource_id")) + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class DatasetAccessMatrix(_RBACModel): + dataset_id: str = Field(default="", validation_alias=AliasChoices("dataset_id", "resource_id")) + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class WorkspaceAccessMatrix(_RBACModel): + items: list[AccessMatrixItem] = Field(default_factory=list) + pagination: Pagination | None = None + + +class RoleBindingsResponse(_RBACModel): + data: list[AccessPolicyRoleBinding] = Field(default_factory=list) + + +class MemberBindingsResponse(_RBACModel): + data: list[AccessPolicyMemberBinding] = Field(default_factory=list) + + +class ResourceWhitelist(_RBACModel): + account_ids: list[str] = Field(default_factory=list) + + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ResourceWhitelistResources(_RBACModel): + unrestricted: bool = False + resource_ids: list[str] = Field(default_factory=list) + + @field_validator("resource_ids", mode="before") + @classmethod + def _coerce_resource_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ResourceUserAccessPolicies(_RBACModel): + account: RBACRoleAccount + roles: list[RBACRole] = Field(default_factory=list) + access_policies: list[AccessPolicy] = Field(default_factory=list) + + @field_validator("access_policies", "roles", mode="before") + @classmethod + def _coerce_none_to_list(cls, value: Any) -> Any: + if value is None: + return [] + return value + + +class ResourceUserAccessPoliciesResponse(_RBACModel): + scope: str + data: list[ResourceUserAccessPolicies] = Field(default_factory=list) + + +class ReplaceUserAccessPolicies(_RBACModel): + access_policy_ids: list[str] = Field(default_factory=list) + + @field_validator("access_policy_ids", mode="before") + @classmethod + def _coerce_access_policy_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ReplaceUserAccessPoliciesResponse(_RBACModel): + access_policies: list[AccessPolicy] = Field(default_factory=list) + + +class MemberRolesResponse(_RBACModel): + account_id: str + roles: list[RBACRole] = Field(default_factory=list) + + +class MemberRolesBatchResponse(_RBACModel): + data: list[MemberRolesResponse] = Field(default_factory=list) + + +class WorkspacePermissionSnapshot(_RBACModel): + permission_keys: list[str] = Field(default_factory=list) + + +class ResourcePermissionSnapshot(_RBACModel): + default_permission_keys: list[str] = Field(default_factory=list) + overrides: list[ResourcePermissionKeys] = Field(default_factory=list) + + def permission_keys_by_resource_ids(self, resource_ids: list[str]) -> dict[str, list[str]]: + result = {str(resource_id): list(self.default_permission_keys) for resource_id in resource_ids} + for override in self.overrides: + resource_id = str(override.resource_id) + if resource_id in result: + result[resource_id] = list(override.permission_keys) + return result + + +class MyPermissionsResponse(_RBACModel): + workspace: WorkspacePermissionSnapshot = Field(default_factory=WorkspacePermissionSnapshot) + app: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot) + dataset: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot) + + +# Fallback permission snapshots for legacy Dify tenant roles when external RBAC is disabled. +# Keep these keys aligned with langgenius/rbac's built-in workspace roles and access policies. +_LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ + "workspace.member.manage", + "workspace.role.manage", + "data_source.manage", + "api_extension.manage", + "customization.manage", + "plugin.install", + "plugin.plugin_preferences", + "plugin.manage", + "plugin.debug", + "credential.use", + "credential.manage", + "app_library.access", + "app.create_and_management", + "app.tag.manage", + "dataset.create_and_management", + "dataset.tag.manage", + "dataset.external.connect", + "tool.manage", + "mcp.manage", +] + +_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ + "workspace.member.manage", + "workspace.role.manage", + "data_source.manage", + "api_extension.manage", + "customization.manage", + "plugin.install", + "plugin.plugin_preferences", + "plugin.manage", + "plugin.debug", + "credential.use", + "credential.manage", + "app_library.access", + "app.create_and_management", + "app.tag.manage", + "dataset.create_and_management", + "dataset.tag.manage", + "dataset.external.connect", + "tool.manage", + "mcp.manage", +] + +_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ + "workspace.member.manage", + "api_extension.manage", + "plugin.install", + "credential.use", + "app_library.access", + "app.create_and_management", + "app.tag.manage", + "dataset.create_and_management", + "dataset.tag.manage", + "dataset.external.connect", + "tool.manage", +] + +_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ + "api_extension.manage", + "plugin.install", + "credential.use", + "app_library.access", +] + +_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ + "plugin.install", + "dataset.create_and_management", + "dataset.external.connect", +] + +_LEGACY_APP_OWNER_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.edit", + "app.acl.import_export_dsl", + "app.acl.delete", + "app.acl.release_and_version", + "app.acl.monitor", + "app.acl.access_config", +] + +_LEGACY_APP_ADMIN_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.edit", + "app.acl.import_export_dsl", + "app.acl.delete", + "app.acl.release_and_version", + "app.acl.monitor", + "app.acl.access_config", +] + +_LEGACY_APP_EDITOR_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.edit", + "app.acl.import_export_dsl", + "app.acl.delete", + "app.acl.release_and_version", + "app.acl.monitor", + "app.acl.access_config", +] + +_LEGACY_APP_NORMAL_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.monitor", +] + +_LEGACY_DATASET_OWNER_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", + "dataset.acl.delete", + "dataset.acl.access_config", + "dataset.api_key.manage", +] + +_LEGACY_DATASET_ADMIN_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", + "dataset.acl.delete", + "dataset.acl.access_config", + "dataset.api_key.manage", +] + +_LEGACY_DATASET_EDITOR_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", +] + +_LEGACY_DATASET_DATASET_OPERATOR_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", +] + +_LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = { + TenantAccountRole.OWNER: { + "workspace": _LEGACY_WORKSPACE_OWNER_KEYS, + "app": _LEGACY_APP_OWNER_KEYS, + "dataset": _LEGACY_DATASET_OWNER_KEYS, + }, + TenantAccountRole.ADMIN: { + "workspace": _LEGACY_WORKSPACE_ADMIN_KEYS, + "app": _LEGACY_APP_ADMIN_KEYS, + "dataset": _LEGACY_DATASET_ADMIN_KEYS, + }, + TenantAccountRole.EDITOR: { + "workspace": _LEGACY_WORKSPACE_EDITOR_KEYS, + "app": _LEGACY_APP_EDITOR_KEYS, + "dataset": _LEGACY_DATASET_EDITOR_KEYS, + }, + TenantAccountRole.NORMAL: { + "workspace": _LEGACY_WORKSPACE_NORMAL_KEYS, + "app": _LEGACY_APP_NORMAL_KEYS, + }, + TenantAccountRole.DATASET_OPERATOR: { + "workspace": _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + "dataset": _LEGACY_DATASET_DATASET_OPERATOR_KEYS, + }, +} + + +def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse: + if not account_id: + return MyPermissionsResponse() + + try: + with session_factory.create_session() as session: + role = session.scalar( + select(TenantAccountJoin.role).where( + TenantAccountJoin.tenant_id == tenant_id, + TenantAccountJoin.account_id == account_id, + ) + ) + if not role: + return MyPermissionsResponse() + + try: + tenant_role = TenantAccountRole(role) + except ValueError: + return MyPermissionsResponse() + except SQLAlchemyError: + return MyPermissionsResponse() + + permissions = _LEGACY_MY_PERMISSIONS.get(tenant_role, {}) + return MyPermissionsResponse( + workspace=WorkspacePermissionSnapshot(permission_keys=list(permissions.get("workspace", []))), + app=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("app", []))), + dataset=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("dataset", []))), + ) + + +def _legacy_resource_permission_keys_batch( + tenant_id: str, + account_id: str | None, + resource_ids: list[str], + resource_type: RBACResourceType, +) -> dict[str, list[str]]: + snapshot = _legacy_my_permissions(tenant_id, account_id) + if resource_type == RBACResourceType.APP: + permission_keys = snapshot.app.default_permission_keys + else: + permission_keys = snapshot.dataset.default_permission_keys + return {str(resource_id): list(permission_keys) for resource_id in resource_ids} + + +# ---------- Mutation request models ---------- + + +class RoleMutation(_RBACModel): + """Payload shared by role create & update. + + ``type`` defaults to ``workspace`` because that is the only concrete role + type supported by the enterprise backend today (see biz.RBACRoleType). + """ + + name: str + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + type: RBACRoleType = RBACRoleType.WORKSPACE + + +class AccessPolicyCreate(_RBACModel): + name: str + resource_type: RBACResourceType + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + + +class AccessPolicyUpdate(_RBACModel): + name: str + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + + +class ReplaceRoleBindings(_RBACModel): + role_ids: list[str] = Field(default_factory=list) + + @field_validator("role_ids", mode="before") + @classmethod + def _coerce_role_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ReplaceMemberBindings(_RBACModel): + scope: str = "specific" + + @field_validator("scope") + @classmethod + def _normalize_scope(cls, value: Any) -> str: + scope = str(value or "").strip().lower() + if scope in {"", "specific"}: + return "specific" + if scope in {"all", "only_me"}: + return scope + raise ValueError(f"invalid scope: {value}") + + +class DeleteMemberBindings(_RBACModel): + account_ids: list[str] = Field(default_factory=list) + + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ReplaceBindings(_RBACModel): + role_ids: list[str] = Field(default_factory=list) + account_ids: list[str] = Field(default_factory=list) + + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_bindings(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ListOption(_RBACModel): + page_number: int | None = None + results_per_page: int | None = None + reverse: bool | None = None + + def to_params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: + params: dict[str, Any] = {} + if self.page_number is not None: + params["page_number"] = self.page_number + if self.results_per_page is not None: + params["results_per_page"] = self.results_per_page + if self.reverse is not None: + # httpx renders `True` as the string "True"; we want the inner + # handler to match on the lowercase form it compares against. + params["reverse"] = "true" if self.reverse else "false" + if extra: + params.update({k: v for k, v in extra.items() if v is not None}) + return params + + +_INNER_PREFIX = "/rbac" + + +def _request_language_param() -> str | None: + if not has_request_context(): + return None + language = (request.args.get("language") or "").strip().lower() + if language in {"en", "ja", "zh"}: + return language + return None + + +def _inner_call( + method: str, + endpoint: str, + *, + tenant_id: str, + account_id: str | None = None, + json: Any | None = None, + params: dict[str, Any] | None = None, +) -> Any: + """Thin wrapper around `EnterpriseRequest.send_inner_rbac_request`. + + Kept as a module-level helper (rather than a nested-class method) so that + unit tests can monkey-patch this single entry point instead of every + individual `Roles.*`, `AccessPolicies.*`, … method. + """ + language = _request_language_param() + if language and (not params or "language" not in params): + params = dict(params or {}) + params["language"] = language + return EnterpriseRequest.send_inner_rbac_request( + method, + endpoint, + tenant_id=tenant_id, + account_id=account_id, + json=json, + params=params, + ) + + +def _resource_id_params(resource_type: RBACResourceType | str, resource_id: str) -> dict[str, str]: + resource_type_value = resource_type.value if isinstance(resource_type, RBACResourceType) else str(resource_type) + resource_id = resource_id.strip() + if resource_type_value == RBACResourceType.APP.value: + return {"resource_type": resource_type_value, "app_id": resource_id} + if resource_type_value == RBACResourceType.DATASET.value: + return {"resource_type": resource_type_value, "dataset_id": resource_id} + raise ValueError(f"unsupported resource_type: {resource_type_value}") + + +def try_sync_creator_access_policy_member_bindings( + tenant_id: str, + account_id: str, + resource_type: RBACResourceType | str, + resource_id: str, +) -> None: + if not dify_config.RBAC_ENABLED: + return + try: + RBACService.AccessPolicies.sync_creator_access_policy_member_bindings( + tenant_id, + account_id, + resource_type=resource_type, + resource_id=resource_id, + ) + except Exception: + logger.warning( + "Failed to sync creator access policy member binding for " + "tenant_id=%s resource_type=%s resource_id=%s account_id=%s", + tenant_id, + resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type, + resource_id, + account_id, + exc_info=True, + ) + + +class RBACService: + """Single entry point grouping every inner RBAC call by feature area. + + Each nested class keeps the classmethods tightly scoped to one URL family + so call sites read naturally (e.g. ``RBACService.Roles.create(tenant_id, + account_id, payload)``). + """ + + # ------------------------------------------------------------------ + # Permission catalog (screenshot 3: 新增/编辑角色 弹窗内的权限列表). + # ------------------------------------------------------------------ + class Catalog: + @staticmethod + def workspace(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/role-permissions/catalog", + tenant_id=tenant_id, + account_id=account_id, + ) + return PermissionCatalogResponse.model_validate(data or {}) + + @staticmethod + def app(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/role-permissions/catalog/app", + tenant_id=tenant_id, + account_id=account_id, + ) + return PermissionCatalogResponse.model_validate(data or {}) + + @staticmethod + def dataset(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/role-permissions/catalog/dataset", + tenant_id=tenant_id, + account_id=account_id, + ) + return PermissionCatalogResponse.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Role CRUD (Settings > Permissions). + # ------------------------------------------------------------------ + class Roles: + @staticmethod + def list( + tenant_id: str, + account_id: str | None = None, + include_owner: int | None = None, + *, + options: ListOption | None = None, + ) -> Paginated[RBACRole]: + params = (options or ListOption()).to_params({"include_owner": include_owner}) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles", + tenant_id=tenant_id, + account_id=account_id, + params=params or None, + ) + data = data or {} + return Paginated[RBACRole]( + data=[RBACRole.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + @staticmethod + def list_members_by_role( + tenant_id: str, + role_id: str | None = None, + *, + options: ListOption | None = None, + ) -> Paginated[MembersInRole]: + params = (options or ListOption()).to_params({"role_id": role_id}) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles/members", + tenant_id=tenant_id, + params=params or None, + ) + data = data or {} + return Paginated[MembersInRole]( + data=[MembersInRole.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + @staticmethod + def get(tenant_id: str, account_id: str | None, role_id: str) -> RBACRole: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": role_id}, + ) + return RBACRole.model_validate(data or {}) + + @staticmethod + def create(tenant_id: str, account_id: str | None, payload: RoleMutation) -> RBACRole: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/roles", + tenant_id=tenant_id, + account_id=account_id, + json=payload.model_dump(mode="json"), + ) + return RBACRole.model_validate(data or {}) + + @staticmethod + def update(tenant_id: str, account_id: str | None, role_id: str, payload: RoleMutation) -> RBACRole: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/roles/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": role_id}, + json=payload.model_dump(mode="json"), + ) + return RBACRole.model_validate(data or {}) + + @staticmethod + def delete(tenant_id: str, account_id: str | None, role_id: str) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/roles/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": role_id}, + ) + + @staticmethod + def copy(tenant_id: str, account_id: str | None, role_id: str, copy_member: bool = True) -> RBACRole: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/roles/copy", + tenant_id=tenant_id, + account_id=account_id, + json={"copy_member": copy_member}, + params={"id": role_id}, + ) + + return RBACRole.model_validate(data or {}) + + @staticmethod + def members( + tenant_id: str, + account_id: str | None, + role_id: str, + *, + options: ListOption | None = None, + ) -> Paginated[RBACRoleAccount]: + params = (options or ListOption()).to_params({"role_id": role_id}) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles/members", + tenant_id=tenant_id, + account_id=account_id, + params=params, + ) + data = data or {} + return Paginated[RBACRoleAccount]( + data=[RBACRoleAccount.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + # ------------------------------------------------------------------ + # Access policies (Settings > Access Rules: create/edit permission sets). + # ------------------------------------------------------------------ + class AccessPolicies: + @staticmethod + def list( + tenant_id: str, + account_id: str | None = None, + *, + resource_type: RBACResourceType | str | None = None, + options: ListOption | None = None, + ) -> Paginated[AccessPolicy]: + extra: dict[str, Any] = {} + if resource_type is not None: + extra["resource_type"] = ( + resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type + ) + params = (options or ListOption()).to_params(extra) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/access-policies", + tenant_id=tenant_id, + account_id=account_id, + params=params or None, + ) + data = data or {} + return Paginated[AccessPolicy]( + data=[AccessPolicy.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + @staticmethod + def get(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/access-policies/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def create(tenant_id: str, account_id: str | None, payload: AccessPolicyCreate) -> AccessPolicy: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/access-policies", + tenant_id=tenant_id, + account_id=account_id, + json=payload.model_dump(mode="json"), + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def update( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: AccessPolicyUpdate, + ) -> AccessPolicy: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policies/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def copy(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/access-policies/copy", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def delete(tenant_id: str, account_id: str | None, policy_id: str) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/access-policies/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + ) + + @staticmethod + def sync_creator_access_policy_member_bindings( + tenant_id: str, + account_id: str | None, + *, + resource_type: RBACResourceType | str, + resource_id: str, + ) -> Sequence[AccessPolicyMemberBinding]: + params = _resource_id_params(resource_type, resource_id) + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policies/creator-member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params=params, + ) + items: list[Any] = [] + if isinstance(data, dict): + items = data.get("data") or [] + return [AccessPolicyMemberBinding.model_validate(item) for item in items] + + # ------------------------------------------------------------------ + # Access-policy bindings (lock / unlock a single binding). + # ------------------------------------------------------------------ + class AccessPolicyBindings: + @staticmethod + def lock(tenant_id: str, account_id: str | None, binding_id: str) -> AccessPolicyBindingState: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policy-bindings/lock", + tenant_id=tenant_id, + account_id=account_id, + json={"binding_id": binding_id}, + ) + return AccessPolicyBindingState.model_validate(data or {}) + + @staticmethod + def unlock(tenant_id: str, account_id: str | None, binding_id: str) -> AccessPolicyBindingState: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policy-bindings/unlock", + tenant_id=tenant_id, + account_id=account_id, + json={"binding_id": binding_id}, + ) + return AccessPolicyBindingState.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Per-app access (screenshot 1: App Access Config). + # ------------------------------------------------------------------ + class AppAccess: + @staticmethod + def whitelist_resources(tenant_id: str, account_id: str | None) -> ResourceWhitelistResources: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/whitelist/resources", + tenant_id=tenant_id, + account_id=account_id, + ) + return ResourceWhitelistResources.model_validate(data or {}) + + @staticmethod + def user_access_policies( + tenant_id: str, account_id: str | None, app_id: str + ) -> ResourceUserAccessPoliciesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + ) + return ResourceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def replace_user_access_policies( + tenant_id: str, + account_id: str | None, + app_id: str, + target_account_id: str, + payload: ReplaceUserAccessPolicies, + ) -> ReplaceUserAccessPoliciesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "account_id": target_account_id}, + json=payload.model_dump(mode="json"), + ) + return ReplaceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def whitelist(tenant_id: str, account_id: str | None, app_id: str) -> ResourceWhitelist: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def replace_whitelist( + tenant_id: str, + account_id: str | None, + app_id: str, + payload: ReplaceMemberBindings, + ) -> ResourceWhitelist: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + json=payload.model_dump(mode="json"), + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def matrix(tenant_id: str, account_id: str | None, app_id: str) -> AppAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + ) + return AppAccessMatrix.model_validate(data or {}) + + @staticmethod + def list_role_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_role_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_member_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def delete_member_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: DeleteMemberBindings, + ) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + + @staticmethod + def replace_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Per-dataset access (screenshot 1: Knowledge Base Access Config). + # ------------------------------------------------------------------ + class DatasetAccess: + @staticmethod + def whitelist_resources(tenant_id: str, account_id: str | None) -> ResourceWhitelistResources: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/whitelist/resources", + tenant_id=tenant_id, + account_id=account_id, + ) + return ResourceWhitelistResources.model_validate(data or {}) + + @staticmethod + def user_access_policies( + tenant_id: str, account_id: str | None, dataset_id: str + ) -> ResourceUserAccessPoliciesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + ) + return ResourceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def replace_user_access_policies( + tenant_id: str, + account_id: str | None, + dataset_id: str, + target_account_id: str, + payload: ReplaceUserAccessPolicies, + ) -> ReplaceUserAccessPoliciesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "account_id": target_account_id}, + json=payload.model_dump(mode="json"), + ) + return ReplaceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def whitelist(tenant_id: str, account_id: str | None, dataset_id: str) -> ResourceWhitelist: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def replace_whitelist( + tenant_id: str, + account_id: str | None, + dataset_id: str, + payload: ReplaceMemberBindings, + ) -> ResourceWhitelist: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + json=payload.model_dump(mode="json"), + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def matrix(tenant_id: str, account_id: str | None, dataset_id: str) -> DatasetAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + ) + return DatasetAccessMatrix.model_validate(data or {}) + + @staticmethod + def list_role_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_role_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_member_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def delete_member_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: DeleteMemberBindings, + ) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + + @staticmethod + def replace_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Workspace-level access (screenshot 2: Settings > Access Rules). + # ------------------------------------------------------------------ + class WorkspaceAccess: + @staticmethod + def app_matrix( + tenant_id: str, + account_id: str | None = None, + *, + options: ListOption | None = None, + ) -> WorkspaceAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params=(options or ListOption()).to_params() or None, + ) + return WorkspaceAccessMatrix.model_validate(data or {}) + + @staticmethod + def dataset_matrix( + tenant_id: str, + account_id: str | None = None, + *, + options: ListOption | None = None, + ) -> WorkspaceAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params=(options or ListOption()).to_params() or None, + ) + return WorkspaceAccessMatrix.model_validate(data or {}) + + @staticmethod + def list_app_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_app_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_app_member_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_app_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/apps/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + @staticmethod + def list_dataset_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_dataset_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_dataset_member_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_dataset_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles). + # ------------------------------------------------------------------ + class MemberRoles: + @staticmethod + def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + ) + rst = MemberRolesResponse.model_validate(data or {}) + return rst + + @staticmethod + def batch_get( + tenant_id: str, + account_id: str | None, + member_account_ids: list[str], + ) -> list[MemberRolesResponse]: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/members/rbac-roles/batch", + tenant_id=tenant_id, + account_id=account_id, + json={"member_ids": member_account_ids}, + ) + items = [] + if isinstance(data, dict): + items = [{"account_id": account_id, "roles": roles} for account_id, roles in data.items()] + rst = [] + for item in items: + tmp = MemberRolesResponse.model_validate(item) + rst.append(tmp) + return rst + + @staticmethod + def replace( + tenant_id: str, + account_id: str | None, + member_account_id: str, + role_ids: list[str], + ) -> MemberRolesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + json={"role_ids": role_ids}, + ) + return MemberRolesResponse.model_validate(data or {}) + + class CheckAccess: + """Call the ``/inner/api/rbac/check-access`` endpoint.""" + + @staticmethod + def check( + tenant_id: str, + account_id: str | None, + *, + scene: str, + resource_type: str | None = None, + resource_id: str | None = None, + ) -> bool: + """Return ``True`` if the account is allowed, ``False`` otherwise.""" + if not dify_config.RBAC_ENABLED: + return True + + payload: dict[str, Any] = { + "account_id": account_id or "", + "tenant_id": tenant_id, + "scene": scene, + } + if resource_type: + payload["resource_type"] = resource_type + if resource_id: + payload["resource_id"] = resource_id + + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/check-access", + tenant_id=tenant_id, + account_id=account_id, + json=payload, + ) + return bool(data.get("allowed", False)) + + class AppPermissions: + @staticmethod + def batch_get( + tenant_id: str, + account_id: str | None, + app_ids: list[str], + ) -> dict[str, list[str]]: + if not app_ids: + return {} + if not dify_config.RBAC_ENABLED: + return _legacy_resource_permission_keys_batch(tenant_id, account_id, app_ids, RBACResourceType.APP) + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/apps/permission-keys/batch", + tenant_id=tenant_id, + account_id=account_id, + json={"app_ids": app_ids}, + ) + return _parse_resource_permission_keys_batch(data, resource_id_key="app_id") + + class DatasetPermissions: + @staticmethod + def batch_get( + tenant_id: str, + account_id: str | None, + dataset_ids: list[str], + ) -> dict[str, list[str]]: + if not dataset_ids: + return {} + if not dify_config.RBAC_ENABLED: + return _legacy_resource_permission_keys_batch( + tenant_id, account_id, dataset_ids, RBACResourceType.DATASET + ) + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/datasets/permission-keys/batch", + tenant_id=tenant_id, + account_id=account_id, + json={"dataset_ids": dataset_ids}, + ) + return _parse_resource_permission_keys_batch(data, resource_id_key="dataset_id") + + class MyPermissions: + @staticmethod + def get( + tenant_id: str, + account_id: str | None, + *, + app_id: str | None = None, + dataset_id: str | None = None, + ) -> MyPermissionsResponse: + if not dify_config.RBAC_ENABLED: + return _legacy_my_permissions(tenant_id, account_id) + + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/my-permissions", + tenant_id=tenant_id, + account_id=account_id, + params={ + k: v + for k, v in { + "app_id": app_id, + "dataset_id": dataset_id, + }.items() + if v is not None + } + or None, + ) + return MyPermissionsResponse.model_validate(data or {}) + + +def _parse_resource_permission_keys_batch(data: Any, *, resource_id_key: str) -> dict[str, list[str]]: + if not data: + return {} + + if isinstance(data, dict): + permissions = data.get("permissions") + if isinstance(permissions, dict): + return {str(key): [str(item) for item in (value or [])] for key, value in permissions.items()} + + items = data.get("data") + if items is None: + items = data.get("items") + if items is None: + items = data.get("apps") if resource_id_key == "app_id" else data.get("datasets") + if isinstance(items, dict): + items = [{"resource_id": key, "permission_keys": value} for key, value in items.items()] + elif isinstance(data, list): + items = data + else: + items = [] + + result: dict[str, list[str]] = {} + for item in items or []: + if not isinstance(item, dict): + continue + resource_id = item.get("resource_id") or item.get(resource_id_key) + if not resource_id: + continue + permission_keys = item.get("permission_keys") or [] + result[str(resource_id)] = [str(permission_key) for permission_key in permission_keys] + return result diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py index 63ae101533d..e7b5cbd7c6d 100644 --- a/api/services/entities/agent_entities.py +++ b/api/services/entities/agent_entities.py @@ -60,6 +60,7 @@ class ComposerSavePayload(BaseModel): class RosterAgentCreatePayload(BaseModel): name: str = Field(min_length=1, max_length=255) + mode: Literal["agent"] = "agent" description: str = "" role: str = Field(default="", max_length=255) icon_type: AgentIconType | None = None diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py index 1b233076927..8cd99c4a98d 100644 --- a/api/services/entities/knowledge_entities/knowledge_entities.py +++ b/api/services/entities/knowledge_entities/knowledge_entities.py @@ -1,6 +1,6 @@ -from typing import Any, Literal +from typing import Annotated, Any, Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, WithJsonSchema, field_validator from core.rag.entities import Rule from core.rag.entities.metadata_entities import MetadataFilteringCondition @@ -8,10 +8,79 @@ from core.rag.index_processor.constant.index_type import IndexStructureType from core.rag.retrieval.retrieval_methods import RetrievalMethod from models.enums import ProcessRuleMode +DocForm = Annotated[ + str, + WithJsonSchema({"enum": ["text_model", "hierarchical_model", "qa_model"], "type": "string"}), +] +IndexingTechnique = Annotated[ + str | None, + WithJsonSchema({"anyOf": [{"enum": ["high_quality", "economy"], "type": "string"}, {"type": "null"}]}), +] +KnowledgeProvider = Annotated[ + str, + WithJsonSchema({"enum": ["vendor", "external"], "type": "string"}), +] +RerankingMode = Annotated[ + str | None, + WithJsonSchema({"anyOf": [{"enum": ["reranking_model", "weighted_score"], "type": "string"}, {"type": "null"}]}), +] +SummaryIndexSetting = Annotated[ + dict[str, Any] | None, + WithJsonSchema( + { + "anyOf": [ + { + "properties": { + "enable": {"description": "Whether to enable summary indexing.", "type": "boolean"}, + "model_name": { + "description": "Name of the model used for generating summaries.", + "type": "string", + }, + "model_provider_name": { + "description": "Provider of the summary generation model.", + "type": "string", + }, + "summary_prompt": { + "description": "Custom prompt template for summary generation.", + "type": "string", + }, + }, + "type": "object", + }, + {"type": "null"}, + ] + } + ), +] +ExternalRetrievalModel = Annotated[ + dict[str, Any] | None, + WithJsonSchema( + { + "anyOf": [ + { + "properties": { + "top_k": {"description": "Maximum number of results to return.", "type": "integer"}, + "score_threshold": { + "description": "Minimum similarity score threshold for filtering results.", + "type": "number", + }, + "score_threshold_enabled": { + "description": "Whether score threshold filtering is enabled.", + "type": "boolean", + }, + }, + "type": "object", + }, + {"type": "null"}, + ] + } + ), +] + class RerankingModel(BaseModel): - reranking_provider_name: str | None = None - reranking_model_name: str | None = None + reranking_provider_name: str | None = Field(default=None, description="Provider name of the reranking model.") + reranking_model_name: str | None = Field(default=None, description="Name of the reranking model.") class NotionIcon(BaseModel): @@ -56,36 +125,56 @@ class DataSource(BaseModel): class ProcessRule(BaseModel): - mode: ProcessRuleMode - rules: Rule | None = None + mode: ProcessRuleMode = Field( + description=( + "Processing mode. `automatic` uses built-in rules, `custom` allows manual configuration, and " + "`hierarchical` enables parent-child chunk structure for `doc_form: hierarchical_model`." + ) + ) + rules: Rule | None = Field(default=None, description="Custom processing rules.") class WeightVectorSetting(BaseModel): - vector_weight: float - embedding_provider_name: str - embedding_model_name: str + vector_weight: float = Field(description="Weight assigned to semantic vector search results.") + embedding_provider_name: str = Field(description="Provider of the embedding model used for vector search.") + embedding_model_name: str = Field(description="Name of the embedding model used for vector search.") class WeightKeywordSetting(BaseModel): - keyword_weight: float + keyword_weight: float = Field(description="Weight assigned to keyword search results.") class WeightModel(BaseModel): - weight_type: Literal["semantic_first", "keyword_first", "customized"] | None = None - vector_setting: WeightVectorSetting | None = None - keyword_setting: WeightKeywordSetting | None = None + weight_type: Literal["semantic_first", "keyword_first", "customized"] | None = Field( + default=None, + description="Strategy for balancing semantic and keyword search weights.", + ) + vector_setting: WeightVectorSetting | None = Field(default=None, description="Semantic search weight settings.") + keyword_setting: WeightKeywordSetting | None = Field(default=None, description="Keyword search weight settings.") class RetrievalModel(BaseModel): - search_method: RetrievalMethod - reranking_enable: bool - reranking_model: RerankingModel | None = None - reranking_mode: str | None = None - top_k: int - score_threshold_enabled: bool - score_threshold: float | None = None - weights: WeightModel | None = None - metadata_filtering_conditions: MetadataFilteringCondition | None = None + search_method: RetrievalMethod = Field(description="Search method used for retrieval.") + reranking_enable: bool = Field(description="Whether reranking is enabled.") + reranking_model: RerankingModel | None = Field(default=None, description="Reranking model configuration.") + reranking_mode: RerankingMode = Field( + default=None, + description="Reranking mode. Required when `reranking_enable` is `true`.", + ) + top_k: int = Field(description="Maximum number of results to return.") + score_threshold_enabled: bool = Field(description="Whether score threshold filtering is enabled.") + score_threshold: float | None = Field( + default=None, + description="Minimum similarity score for results. Only effective when score threshold filtering is enabled.", + ) + weights: WeightModel | None = Field(default=None, description="Weight configuration for hybrid search.") + metadata_filtering_conditions: MetadataFilteringCondition | None = Field( + default=None, + description=( + "Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are " + "evaluated server-side against document metadata fields." + ), + ) class MetaDataConfig(BaseModel): @@ -94,19 +183,51 @@ class MetaDataConfig(BaseModel): class KnowledgeConfig(BaseModel): - original_document_id: str | None = None - duplicate: bool = True - indexing_technique: Literal["high_quality", "economy"] - data_source: DataSource | None = None - process_rule: ProcessRule | None = None - retrieval_model: RetrievalModel | None = None - summary_index_setting: dict[str, Any] | None = Field(default=None) - doc_form: str = "text_model" - doc_language: str = "English" - embedding_model: str | None = None - embedding_model_provider: str | None = None - name: str | None = None - is_multimodal: bool = False + original_document_id: str | None = Field(default=None, description="Original document ID for replacement updates.") + duplicate: bool = Field(default=True, description="Whether duplicate document content is allowed.") + indexing_technique: Literal["high_quality", "economy"] = Field( + description=( + "`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. " + "Required when adding the first document to a knowledge base; subsequent documents inherit the " + "knowledge base's indexing technique if omitted." + ) + ) + data_source: DataSource | None = Field(default=None, description="Document data source configuration.") + process_rule: ProcessRule | None = Field(default=None, description="Processing rules for chunking.") + retrieval_model: RetrievalModel | None = Field( + default=None, + description=( + "Retrieval model configuration. Controls how chunks are searched and ranked in this knowledge base." + ), + ) + summary_index_setting: SummaryIndexSetting = Field( + default=None, + description="Summary index configuration.", + ) + doc_form: DocForm = Field( + default="text_model", + description=( + "`text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, " + "`qa_model` for question-answer pair extraction." + ), + ) + doc_language: str = Field(default="English", description="Language of the document for processing optimization.") + embedding_model: str | None = Field( + default=None, + description=( + "Embedding model name. Use the `model` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + embedding_model_provider: str | None = Field( + default=None, + description=( + "Embedding model provider. Use the `provider` field from " + "[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`." + ), + ) + name: str | None = Field(default=None, description="Document name.") + is_multimodal: bool = Field(default=False, description="Whether the document uses multimodal indexing.") @field_validator("doc_form") @classmethod @@ -122,47 +243,61 @@ class KnowledgeConfig(BaseModel): class SegmentCreateArgs(BaseModel): - content: str | None = None - answer: str | None = None - keywords: list[str] | None = None - attachment_ids: list[str] | None = None + content: str | None = Field(default=None, description="Chunk text content.") + answer: str | None = Field(default=None, description="Answer content for QA mode.") + keywords: list[str] | None = Field(default=None, description="Keywords for the chunk.") + attachment_ids: list[str] | None = Field(default=None, description="Attachment file IDs.") class SegmentUpdateArgs(BaseModel): - content: str | None = None - answer: str | None = None - keywords: list[str] | None = None - regenerate_child_chunks: bool = False - enabled: bool | None = None - attachment_ids: list[str] | None = None - summary: str | None = None # Summary content for summary index + content: str | None = Field(default=None, description="Updated chunk text content.") + answer: str | None = Field(default=None, description="Updated answer content for QA mode.") + keywords: list[str] | None = Field(default=None, description="Updated keywords for the chunk.") + regenerate_child_chunks: bool = Field( + default=False, + description="Whether to regenerate child chunks after updating a parent chunk.", + ) + enabled: bool | None = Field(default=None, description="Whether the chunk is enabled.") + attachment_ids: list[str] | None = Field(default=None, description="Attachment file IDs.") + summary: str | None = Field(default=None, description="Summary content for summary index.") class ChildChunkUpdateArgs(BaseModel): - id: str | None = None - content: str + id: str | None = Field(default=None, description="Existing child chunk ID. Omit to create a new child chunk.") + content: str = Field(description="Child chunk text content.") class MetadataArgs(BaseModel): - type: Literal["string", "number", "time"] - name: str + type: Literal["string", "number", "time"] = Field( + description="`string` for text values, `number` for numeric values, `time` for date/time values." + ) + name: str = Field(description="Metadata field name.") class MetadataUpdateArgs(BaseModel): - name: str - value: str | int | float | None = None + name: str = Field(description="Metadata field name.") + value: str | int | float | None = Field( + default=None, + description="Metadata value. Can be a string, number, or `null`.", + ) class MetadataDetail(BaseModel): - id: str - name: str - value: str | int | float | None = None + id: str = Field(description="Metadata field ID.") + name: str = Field(description="Metadata field name.") + value: str | int | float | None = Field( + default=None, + description="Metadata value. Can be a string, number, or `null`.", + ) class DocumentMetadataOperation(BaseModel): - document_id: str - metadata_list: list[MetadataDetail] - partial_update: bool = False + document_id: str = Field(description="Document ID whose metadata should be updated.") + metadata_list: list[MetadataDetail] = Field(description="Metadata fields to update.") + partial_update: bool = Field( + default=False, + description="Whether to partially update metadata, keeping existing values for unspecified fields.", + ) class MetadataOperationData(BaseModel): @@ -170,4 +305,8 @@ class MetadataOperationData(BaseModel): Metadata operation data """ - operation_data: list[DocumentMetadataOperation] + operation_data: list[DocumentMetadataOperation] = Field( + description=( + "Array of document metadata update operations. Each entry maps a document ID to its metadata values." + ) + ) diff --git a/api/services/entities/knowledge_retrieval_inner.py b/api/services/entities/knowledge_retrieval_inner.py new file mode 100644 index 00000000000..86276b80177 --- /dev/null +++ b/api/services/entities/knowledge_retrieval_inner.py @@ -0,0 +1,210 @@ +"""DTOs for the inner knowledge retrieval API. + +These models define the stable HTTP contract for trusted internal callers and +the response shape returned by the workflow knowledge retrieval stack. + +Key cross-field invariants live here because callers cannot infer them from +scalar field types alone: ``dataset_ids`` must be non-empty, either ``query`` +or ``attachment_ids`` is required, ``single`` retrieval requires both ``query`` +and ``retrieval.model``, ``automatic`` metadata filtering requires +``model_config``, and ``manual`` metadata filtering requires conditions. The +response reuses workflow ``Source`` items plus serialized ``llm_usage``. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from core.rag.data_post_processor.data_post_processor import WeightsDict +from core.rag.entities.metadata_entities import SupportedComparisonOperator +from core.workflow.nodes.knowledge_retrieval.retrieval import Source +from fields.base import ResponseModel + +type JsonScalar = str | int | float | bool | None +type JsonValue = JsonScalar | list[JsonScalar] | dict[str, JsonScalar] +type MetadataValue = str | list[str] | int | float | None + + +class InnerKnowledgeRetrieveCaller(BaseModel): + """Execution context provided by the trusted internal caller.""" + + model_config = ConfigDict(extra="forbid") + + tenant_id: str = Field(min_length=1) + user_id: str = Field(min_length=1) + app_id: str = Field(min_length=1) + user_from: Literal["account", "end-user"] + invoke_from: str = Field(min_length=1) + + +class InnerKnowledgeRetrieveModelConfig(BaseModel): + """Model configuration used by single-retrieval or metadata filtering.""" + + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1) + name: str = Field(min_length=1) + mode: str = Field(min_length=1) + completion_params: dict[str, JsonValue] = Field(default_factory=dict) + + +class InnerKnowledgeRetrieveRerankingModelConfig(BaseModel): + """Reranking model configuration for multiple retrieval mode.""" + + model_config = ConfigDict(extra="forbid") + + provider: str = Field(min_length=1) + model: str = Field(min_length=1) + + +class InnerKnowledgeRetrieveRetrievalConfig(BaseModel): + """Retrieval strategy and its mode-specific configuration.""" + + model_config = ConfigDict(extra="forbid") + + mode: Literal["multiple", "single"] + top_k: int | None = Field(default=None, ge=1) + score_threshold: float = 0.0 + reranking_mode: str = "reranking_model" + reranking_enable: bool = True + reranking_model: InnerKnowledgeRetrieveRerankingModelConfig | None = None + weights: WeightsDict | None = None + model: InnerKnowledgeRetrieveModelConfig | None = None + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> InnerKnowledgeRetrieveRetrievalConfig: + if self.mode == "single" and self.model is None: + raise ValueError("retrieval.model is required for single mode") + if self.mode == "multiple" and self.top_k is None: + raise ValueError("retrieval.top_k is required for multiple mode") + return self + + +class InnerKnowledgeRetrieveMetadataCondition(BaseModel): + """Single metadata filter condition.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1) + comparison_operator: SupportedComparisonOperator + value: MetadataValue = None + + +class InnerKnowledgeRetrieveMetadataConditions(BaseModel): + """Boolean composition for metadata filter conditions.""" + + model_config = ConfigDict(extra="forbid") + + logical_operator: Literal["and", "or"] | None = "and" + conditions: list[InnerKnowledgeRetrieveMetadataCondition] | None = None + + +class InnerKnowledgeRetrieveMetadataFilteringConfig(BaseModel): + """Metadata filtering configuration forwarded to workflow retrieval. + + ``automatic`` mode requires ``model_config`` so downstream metadata model + planning has the necessary LLM settings. ``manual`` mode requires + non-empty conditions because workflow retrieval expects explicit filters + instead of a bare mode switch. + """ + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + mode: Literal["disabled", "automatic", "manual"] = "disabled" + metadata_model_config: InnerKnowledgeRetrieveModelConfig | None = Field(default=None, alias="model_config") + conditions: InnerKnowledgeRetrieveMetadataConditions | None = None + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> InnerKnowledgeRetrieveMetadataFilteringConfig: + if self.mode == "automatic" and self.metadata_model_config is None: + raise ValueError("metadata_filtering.model_config is required for automatic mode") + if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): + raise ValueError("metadata_filtering.conditions is required for manual mode") + return self + + +class InnerKnowledgeRetrieveRequest(BaseModel): + """Top-level request payload for the inner knowledge retrieval endpoint. + + Request validation enforces the endpoint's behavioral contract: callers + must provide at least one dataset ID, at least one of ``query`` or + ``attachment_ids``, and a text query for ``single`` retrieval mode. + """ + + model_config = ConfigDict(extra="forbid") + + caller: InnerKnowledgeRetrieveCaller + dataset_ids: list[str] + query: str | None = None + retrieval: InnerKnowledgeRetrieveRetrievalConfig + metadata_filtering: InnerKnowledgeRetrieveMetadataFilteringConfig = Field( + default_factory=InnerKnowledgeRetrieveMetadataFilteringConfig + ) + attachment_ids: list[str] = Field(default_factory=list) + + @field_validator("dataset_ids", "attachment_ids") + @classmethod + def validate_non_empty_items(cls, value: list[str]) -> list[str]: + if any(not item.strip() for item in value): + raise ValueError("list items must not be empty") + return value + + @field_validator("query") + @classmethod + def normalize_query(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + @model_validator(mode="after") + def validate_request(self) -> InnerKnowledgeRetrieveRequest: + if not self.dataset_ids: + raise ValueError("dataset_ids must contain at least one item") + if not self.query and not self.attachment_ids: + raise ValueError("query or attachment_ids is required") + if self.retrieval.mode == "single" and not self.query: + raise ValueError("query is required for single mode") + return self + + +class InnerKnowledgeRetrieveUsage(ResponseModel): + """Serialized LLM usage payload returned by dataset retrieval.""" + + model_config = ConfigDict( + from_attributes=True, + extra="forbid", + populate_by_name=True, + serialize_by_alias=True, + protected_namespaces=(), + ) + + prompt_tokens: int + completion_tokens: int + total_tokens: int + prompt_unit_price: str + completion_unit_price: str + prompt_price_unit: str + completion_price_unit: str + prompt_price: str + completion_price: str + total_price: str + currency: str | None = None + latency: float | int + + +class InnerKnowledgeRetrieveResponse(ResponseModel): + """Workflow-style retrieval results plus accumulated usage.""" + + model_config = ConfigDict( + from_attributes=True, + extra="forbid", + populate_by_name=True, + serialize_by_alias=True, + protected_namespaces=(), + ) + + results: list[Source] + usage: InnerKnowledgeRetrieveUsage diff --git a/api/services/errors/knowledge_retrieval.py b/api/services/errors/knowledge_retrieval.py new file mode 100644 index 00000000000..4e00641e340 --- /dev/null +++ b/api/services/errors/knowledge_retrieval.py @@ -0,0 +1,49 @@ +"""Service errors for the inner knowledge retrieval API.""" + +from services.errors.base import BaseServiceError + + +class InnerKnowledgeRetrievalServiceError(BaseServiceError): + """Base service error with a stable HTTP mapping contract.""" + + error_code = "knowledge_retrieve_failed" + status_code = 500 + default_description = "Knowledge retrieval failed." + + def __init__(self, description: str | None = None): + self.description = description or self.default_description + ValueError.__init__(self, self.description) + + +class InnerKnowledgeRetrieveAppNotFoundError(InnerKnowledgeRetrievalServiceError): + error_code = "app_not_found" + status_code = 404 + default_description = "App not found." + + +class InnerKnowledgeRetrieveAppTenantMismatchError(InnerKnowledgeRetrievalServiceError): + error_code = "app_tenant_mismatch" + status_code = 403 + default_description = "App does not belong to caller tenant." + + +class InnerKnowledgeRetrieveDatasetNotFoundError(InnerKnowledgeRetrievalServiceError): + error_code = "dataset_not_found" + status_code = 404 + default_description = "Dataset not found." + + +class InnerKnowledgeRetrieveDatasetTenantMismatchError(InnerKnowledgeRetrievalServiceError): + error_code = "dataset_tenant_mismatch" + status_code = 403 + default_description = "Dataset does not belong to caller tenant." + + +class ExternalKnowledgeRetrievalError(ValueError): + """Raised when an external dataset retrieval dependency fails. + + This stays a ``ValueError`` subclass for compatibility with existing callers + that already treat external retrieval failures as generic retrieval errors, + while still giving inner API controllers a dedicated error type to map to + ``502 external_knowledge_failed``. + """ diff --git a/api/services/external_knowledge_service.py b/api/services/external_knowledge_service.py index 60b457ecd03..c0aedfaceea 100644 --- a/api/services/external_knowledge_service.py +++ b/api/services/external_knowledge_service.py @@ -22,6 +22,7 @@ from services.entities.external_knowledge_entities.external_knowledge_entities i ExternalKnowledgeApiSetting, ) from services.errors.dataset import DatasetNameDuplicateError +from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError class ExternalDatasetService: @@ -279,6 +280,7 @@ class ExternalDatasetService: provider="external", retrieval_model=args.get("external_retrieval_model"), created_by=user_id, + maintainer=user_id, ) db.session.add(dataset) @@ -309,13 +311,22 @@ class ExternalDatasetService: external_retrieval_parameters: dict[str, Any], metadata_condition: MetadataFilteringCondition | None = None, ): + """Fetch retrieval records from an external knowledge provider. + + Success requires a tenant-scoped binding plus API template and a ``200`` + response body shaped like ``{"records": [...]}``. All dependency + failures, non-200 responses, and malformed success payloads must be + normalized to ``ExternalKnowledgeRetrievalError`` so callers—especially + the inner knowledge retrieval API—can consistently expose + ``502 external_knowledge_failed``. + """ external_knowledge_binding = db.session.scalar( select(ExternalKnowledgeBindings) .where(ExternalKnowledgeBindings.dataset_id == dataset_id, ExternalKnowledgeBindings.tenant_id == tenant_id) .limit(1) ) if not external_knowledge_binding: - raise ValueError("external knowledge binding not found") + raise ExternalKnowledgeRetrievalError("external knowledge binding not found") external_knowledge_api = db.session.scalar( select(ExternalKnowledgeApis) @@ -326,7 +337,7 @@ class ExternalDatasetService: .limit(1) ) if external_knowledge_api is None or external_knowledge_api.settings is None: - raise ValueError("external api template not found") + raise ExternalKnowledgeRetrievalError("external api template not found") settings = json.loads(external_knowledge_api.settings) headers = {"Content-Type": "application/json"} @@ -344,16 +355,34 @@ class ExternalDatasetService: "metadata_condition": metadata_condition.model_dump() if metadata_condition else None, } - response = ExternalDatasetService.process_external_api( - ExternalKnowledgeApiSetting( - url=f"{settings.get('endpoint')}/retrieval", - request_method="post", - headers=headers, - params=request_params, - ), - None, - ) + try: + response = ExternalDatasetService.process_external_api( + ExternalKnowledgeApiSetting( + url=f"{settings.get('endpoint')}/retrieval", + request_method="post", + headers=headers, + params=request_params, + ), + None, + ) + except ExternalKnowledgeRetrievalError: + raise + except Exception as exc: + raise ExternalKnowledgeRetrievalError(str(exc)) from exc + if response.status_code == 200: - return cast(list[Any], response.json().get("records", [])) + try: + response_payload = response.json() + except Exception as exc: + raise ExternalKnowledgeRetrievalError("invalid external knowledge response") from exc + + if not isinstance(response_payload, dict): + raise ExternalKnowledgeRetrievalError("invalid external knowledge response") + + records = response_payload.get("records", []) + if not isinstance(records, list): + raise ExternalKnowledgeRetrievalError("invalid external knowledge response") + + return cast(list[Any], records) else: - raise ValueError(response.text) + raise ExternalKnowledgeRetrievalError(response.text) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 4d460c288ac..2ae0c63ff75 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -160,6 +160,7 @@ class PluginManagerModel(FeatureResponseModel): class SystemFeatureModel(FeatureResponseModel): + enable_app_deploy: bool = False sso_enforced_for_signin: bool = False sso_enforced_for_signin_protocol: str = "" enable_marketplace: bool = False @@ -180,6 +181,7 @@ class SystemFeatureModel(FeatureResponseModel): enable_creators_platform: bool = False enable_trial_app: bool = False enable_explore_banner: bool = False + rbac_enabled: bool = False class FeatureService: @@ -246,6 +248,7 @@ class FeatureService: @classmethod def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: system_features = SystemFeatureModel() + system_features.rbac_enabled = dify_config.RBAC_ENABLED cls._fulfill_system_params_from_env(system_features) @@ -419,6 +422,9 @@ class FeatureService: if "IsAllowCreateWorkspace" in enterprise_info: features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"] + if "EnableAppDeploy" in enterprise_info: + features.enable_app_deploy = enterprise_info["EnableAppDeploy"] + if "Branding" in enterprise_info: features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "") features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "") diff --git a/api/services/feedback_service.py b/api/services/feedback_service.py index d6c338a830d..24cfb8aa852 100644 --- a/api/services/feedback_service.py +++ b/api/services/feedback_service.py @@ -5,8 +5,8 @@ from datetime import datetime from flask import Response from sqlalchemy import or_, select +from sqlalchemy.orm import Session -from extensions.ext_database import db from models.enums import FeedbackRating from models.model import Account, App, Conversation, Message, MessageFeedback @@ -14,6 +14,7 @@ from models.model import Account, App, Conversation, Message, MessageFeedback class FeedbackService: @staticmethod def export_feedbacks( + session: Session, app_id: str, from_source: str | None = None, rating: str | None = None, @@ -81,7 +82,7 @@ class FeedbackService: stmt = stmt.order_by(MessageFeedback.created_at.desc()) # Execute query - results = db.session.execute(stmt).all() + results = session.execute(stmt).all() # Prepare data for export export_data = [] diff --git a/api/services/file_service.py b/api/services/file_service.py index 4d3afcc9ad4..e41d74ad3eb 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -20,7 +20,6 @@ from constants import ( VIDEO_EXTENSIONS, ) from core.rag.extractor.extract_processor import ExtractProcessor -from extensions.ext_database import db from extensions.ext_storage import storage from extensions.storage.storage_type import StorageType from graphon.file import helpers as file_helpers @@ -39,12 +38,13 @@ class FileService: _session_maker: sessionmaker[Session] def __init__(self, session_factory: sessionmaker | Engine | None = None): - if isinstance(session_factory, Engine): - self._session_maker = sessionmaker(bind=session_factory) - elif isinstance(session_factory, sessionmaker): - self._session_maker = session_factory - else: - raise AssertionError("must be a sessionmaker or an Engine.") + match session_factory: + case Engine(): + self._session_maker = sessionmaker(bind=session_factory) + case sessionmaker(): + self._session_maker = session_factory + case _: + raise AssertionError("must be a sessionmaker or an Engine.") def upload_file( self, @@ -267,7 +267,9 @@ class FileService: session.delete(upload_file) @staticmethod - def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]: + def get_upload_files_by_ids( + session: Session, tenant_id: str, upload_file_ids: Sequence[str] + ) -> dict[str, UploadFile]: """ Fetch `UploadFile` rows for a tenant in a single batch query. @@ -281,7 +283,7 @@ class FileService: unique_upload_file_ids: list[str] = list(set(upload_file_id_list)) # Fetch upload files in one query for efficient batch access. - upload_files: Sequence[UploadFile] = db.session.scalars( + upload_files: Sequence[UploadFile] = session.scalars( select(UploadFile).where( UploadFile.tenant_id == tenant_id, UploadFile.id.in_(unique_upload_file_ids), diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py index c266d4f9586..995cb94c633 100644 --- a/api/services/human_input_delivery_test_service.py +++ b/api/services/human_input_delivery_test_service.py @@ -119,10 +119,11 @@ class HumanInputDeliveryTestService: class EmailDeliveryTestHandler: def __init__(self, session_factory: sessionmaker | Engine | None = None) -> None: - if session_factory is None: - session_factory = sessionmaker(bind=db.engine) - elif isinstance(session_factory, Engine): - session_factory = sessionmaker(bind=session_factory) + match session_factory: + case None: + session_factory = sessionmaker(bind=db.engine) + case Engine(): + session_factory = sessionmaker(bind=session_factory) self._session_factory = session_factory def supports(self, method: DeliveryChannelConfig) -> bool: @@ -179,11 +180,12 @@ class EmailDeliveryTestHandler: emails: list[str] = [] bound_reference_ids: list[str] = [] for recipient in recipients.items: - if isinstance(recipient, MemberRecipient): - bound_reference_ids.append(recipient.reference_id) - elif isinstance(recipient, ExternalRecipient): - if recipient.email: - emails.append(recipient.email) + match recipient: + case MemberRecipient(): + bound_reference_ids.append(recipient.reference_id) + case ExternalRecipient(): + if recipient.email: + emails.append(recipient.email) if recipients.include_bound_group: bound_reference_ids = [] diff --git a/api/services/knowledge_retrieval_inner_service.py b/api/services/knowledge_retrieval_inner_service.py new file mode 100644 index 00000000000..fccc81c4a29 --- /dev/null +++ b/api/services/knowledge_retrieval_inner_service.py @@ -0,0 +1,145 @@ +"""Service wrapper for the inner knowledge retrieval API. + +This service keeps the internal HTTP contract small while reusing the workflow +retrieval stack in ``core.rag.retrieval.dataset_retrieval.DatasetRetrieval``. +The only authorization enforced here is tenant ownership of the caller app and +requested datasets. + +It intentionally does not check ``dataset.enable_api`` or user-level dataset +permissions. After the caller app and requested datasets pass tenant-scoped +prechecks, dataset availability and "no usable document" cases are delegated to +``DatasetRetrieval`` and may legitimately produce an empty result list instead +of a separate validation error. +""" + +from sqlalchemy import select + +from core.rag.entities.metadata_entities import Condition, MetadataFilteringCondition +from core.rag.retrieval.dataset_retrieval import DatasetRetrieval +from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest +from extensions.ext_database import db +from graphon.model_runtime.utils.encoders import jsonable_encoder +from graphon.nodes.llm.entities import ModelConfig +from models.dataset import Dataset +from models.model import App +from services.entities.knowledge_retrieval_inner import ( + InnerKnowledgeRetrieveRequest, + InnerKnowledgeRetrieveResponse, + InnerKnowledgeRetrieveUsage, +) +from services.errors.knowledge_retrieval import ( + InnerKnowledgeRetrieveAppNotFoundError, + InnerKnowledgeRetrieveAppTenantMismatchError, + InnerKnowledgeRetrieveDatasetNotFoundError, + InnerKnowledgeRetrieveDatasetTenantMismatchError, +) + + +class InnerKnowledgeRetrievalService: + """Validate inner caller scope and delegate to workflow dataset retrieval.""" + + def retrieve(self, request: InnerKnowledgeRetrieveRequest) -> InnerKnowledgeRetrieveResponse: + """Run tenant-scoped retrieval for a trusted internal caller. + + This method only rejects caller app existence/tenant mismatches and + requested dataset existence/tenant mismatches. It deliberately leaves + ``dataset.enable_api``, user-level dataset permissions, and + availability/no-usable-document handling to ``DatasetRetrieval`` so the + inner API stays aligned with workflow retrieval semantics, including + returning ``[]`` when datasets are present but yield no retrievable + content. + + Raises: + InnerKnowledgeRetrieveAppNotFoundError: The caller app does not exist. + InnerKnowledgeRetrieveAppTenantMismatchError: The caller app is outside the caller tenant. + InnerKnowledgeRetrieveDatasetNotFoundError: At least one requested dataset does not exist. + InnerKnowledgeRetrieveDatasetTenantMismatchError: + At least one requested dataset is outside the caller tenant. + """ + self._validate_caller_app(tenant_id=request.caller.tenant_id, app_id=request.caller.app_id) + self._validate_datasets(tenant_id=request.caller.tenant_id, dataset_ids=request.dataset_ids) + + rag = DatasetRetrieval() + results = rag.knowledge_retrieval(request=self._to_rag_request(request)) + return InnerKnowledgeRetrieveResponse( + results=results, + usage=InnerKnowledgeRetrieveUsage.model_validate(jsonable_encoder(rag.llm_usage)), + ) + + def _validate_caller_app(self, *, tenant_id: str, app_id: str) -> None: + app = db.session.scalar(select(App).where(App.id == app_id).limit(1)) + if app is None: + raise InnerKnowledgeRetrieveAppNotFoundError(f"App '{app_id}' not found") + if app.tenant_id != tenant_id: + raise InnerKnowledgeRetrieveAppTenantMismatchError( + f"App '{app_id}' does not belong to tenant '{tenant_id}'" + ) + + def _validate_datasets(self, *, tenant_id: str, dataset_ids: list[str]) -> None: + datasets = db.session.scalars(select(Dataset).where(Dataset.id.in_(dataset_ids))).all() + + found_ids = {dataset.id for dataset in datasets} + missing_ids = sorted(set(dataset_ids) - found_ids) + if missing_ids: + raise InnerKnowledgeRetrieveDatasetNotFoundError(f"Datasets not found: {', '.join(missing_ids)}") + + mismatched_ids = sorted(dataset.id for dataset in datasets if dataset.tenant_id != tenant_id) + if mismatched_ids: + raise InnerKnowledgeRetrieveDatasetTenantMismatchError( + f"Datasets do not belong to tenant '{tenant_id}': {', '.join(mismatched_ids)}" + ) + + def _to_rag_request(self, request: InnerKnowledgeRetrieveRequest) -> KnowledgeRetrievalRequest: + metadata_model_config = request.metadata_filtering.metadata_model_config + metadata_conditions = request.metadata_filtering.conditions + + return KnowledgeRetrievalRequest( + tenant_id=request.caller.tenant_id, + user_id=request.caller.user_id, + app_id=request.caller.app_id, + user_from=request.caller.user_from, + dataset_ids=request.dataset_ids, + query=request.query, + retrieval_mode=request.retrieval.mode, + model_provider=request.retrieval.model.provider if request.retrieval.model else None, + completion_params=request.retrieval.model.completion_params if request.retrieval.model else None, + model_mode=request.retrieval.model.mode if request.retrieval.model else None, + model_name=request.retrieval.model.name if request.retrieval.model else None, + metadata_model_config=ModelConfig.model_validate(metadata_model_config.model_dump(mode="python")) + if metadata_model_config + else None, + metadata_filtering_conditions=( + MetadataFilteringCondition( + logical_operator=metadata_conditions.logical_operator, + conditions=( + [ + Condition( + name=condition.name, + comparison_operator=condition.comparison_operator, + value=condition.value, + ) + for condition in metadata_conditions.conditions + ] + if metadata_conditions.conditions is not None + else None + ), + ) + if metadata_conditions is not None + else None + ), + metadata_filtering_mode=request.metadata_filtering.mode, + top_k=request.retrieval.top_k or 0, + score_threshold=request.retrieval.score_threshold, + reranking_mode=request.retrieval.reranking_mode, + reranking_model=( + { + "reranking_provider_name": request.retrieval.reranking_model.provider, + "reranking_model_name": request.retrieval.reranking_model.model, + } + if request.retrieval.reranking_model is not None + else None + ), + weights=request.retrieval.weights, + reranking_enable=request.retrieval.reranking_enable, + attachment_ids=request.attachment_ids or None, + ) diff --git a/api/services/rag_pipeline/entity/pipeline_service_api_entities.py b/api/services/rag_pipeline/entity/pipeline_service_api_entities.py index ec25adac8bf..c80b7604099 100644 --- a/api/services/rag_pipeline/entity/pipeline_service_api_entities.py +++ b/api/services/rag_pipeline/entity/pipeline_service_api_entities.py @@ -1,22 +1,142 @@ from collections.abc import Mapping -from typing import Any +from typing import Annotated, Any -from pydantic import BaseModel +from pydantic import BaseModel, Field, WithJsonSchema + +DatasourceType = Annotated[ + str, + WithJsonSchema({"enum": ["local_file", "online_document", "website_crawl", "online_drive"], "type": "string"}), +] +PipelineResponseMode = Annotated[ + str, + WithJsonSchema({"enum": ["streaming", "blocking"], "type": "string"}), +] +DatasourceInfoList = Annotated[ + list[Mapping[str, Any]], + WithJsonSchema( + { + "items": { + "oneOf": [ + { + "properties": { + "reference": { + "description": ( + "Use the `id` returned by the " + "[Upload Pipeline File](/api-reference/knowledge-pipeline/upload-pipeline-file) " + "endpoint. `related_id` is accepted as an alias." + ), + "type": "string", + }, + "name": {"description": "Document title. Defaults to `untitled`.", "type": "string"}, + }, + "required": ["reference"], + "title": "Local File", + "type": "object", + }, + { + "properties": { + "workspace_id": { + "description": "ID of the workspace or database in the external platform.", + "type": "string", + }, + "page": { + "description": "Page details.", + "properties": { + "page_id": {"description": "Page identifier.", "type": "string"}, + "type": { + "description": "Page type defined by the datasource plugin.", + "type": "string", + }, + "page_name": { + "description": "Display name. Defaults to `untitled`.", + "type": "string", + }, + }, + "required": ["page_id", "type"], + "type": "object", + }, + "credential_id": { + "description": ( + "Credential for authenticating with the external platform. If omitted, the " + "provider's default credential is used." + ), + "type": "string", + }, + }, + "required": ["workspace_id", "page"], + "title": "Online Document", + "type": "object", + }, + { + "properties": { + "url": {"description": "URL to crawl.", "type": "string"}, + "title": { + "description": "Used as the document name. Defaults to `untitled`.", + "type": "string", + }, + }, + "required": ["url"], + "title": "Website Crawl", + "type": "object", + }, + { + "properties": { + "id": {"description": "File or folder ID.", "type": "string"}, + "type": { + "description": "Whether this entry is a single file or a folder to expand.", + "enum": ["file", "folder"], + "type": "string", + }, + "bucket": { + "description": ( + "Storage bucket name. Required by some drive providers, such as S3-compatible " + "stores; omit if the provider does not use buckets." + ), + "type": "string", + }, + "name": {"description": "File name. Defaults to `untitled`.", "type": "string"}, + }, + "required": ["id", "type"], + "title": "Online Drive", + "type": "object", + }, + ] + }, + "type": "array", + } + ), +] class DatasourceNodeRunApiEntity(BaseModel): pipeline_id: str node_id: str inputs: dict[str, Any] - datasource_type: str + datasource_type: DatasourceType credential_id: str | None = None is_published: bool class PipelineRunApiEntity(BaseModel): - inputs: Mapping[str, Any] - datasource_type: str - datasource_info_list: list[Mapping[str, Any]] - start_node_id: str - is_published: bool - response_mode: str + inputs: Mapping[str, Any] = Field( + description=( + "Key-value pairs for pipeline input variables defined in the workflow. Pass `{}` if the pipeline has " + "no input variables." + ) + ) + datasource_type: DatasourceType = Field( + description="Type of the datasource. Determines which fields are expected in `datasource_info_list` items." + ) + datasource_info_list: DatasourceInfoList = Field( + description="List of datasource objects to process. The expected item structure depends on `datasource_type`." + ) + start_node_id: str = Field(description="ID of the datasource node where the run starts.") + is_published: bool = Field( + description=( + "Whether to run the published or draft version of the pipeline. `true` runs the latest published " + "version; `false` runs the current draft (useful for testing unpublished changes)." + ) + ) + response_mode: PipelineResponseMode = Field( + description="Response mode. Use `streaming` for SSE or `blocking` for JSON." + ) diff --git a/api/services/rag_pipeline/rag_pipeline_dsl_service.py b/api/services/rag_pipeline/rag_pipeline_dsl_service.py index 52fe9108907..bdbf3e080e9 100644 --- a/api/services/rag_pipeline/rag_pipeline_dsl_service.py +++ b/api/services/rag_pipeline/rag_pipeline_dsl_service.py @@ -283,6 +283,7 @@ class RagPipelineDslService: }, indexing_technique=IndexTechniqueType(knowledge_configuration.indexing_technique), created_by=account.id, + maintainer=account.id, retrieval_model=knowledge_configuration.retrieval_model.model_dump(), runtime_mode=DatasetRuntimeMode.RAG_PIPELINE, chunk_structure=knowledge_configuration.chunk_structure, @@ -415,6 +416,7 @@ class RagPipelineDslService: }, indexing_technique=IndexTechniqueType(knowledge_configuration.indexing_technique), created_by=account.id, + maintainer=account.id, retrieval_model=knowledge_configuration.retrieval_model.model_dump(), runtime_mode=DatasetRuntimeMode.RAG_PIPELINE, chunk_structure=knowledge_configuration.chunk_structure, diff --git a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py index 58e8ac57a8f..3652997f8af 100644 --- a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py +++ b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py @@ -1,3 +1,10 @@ +"""Cleanup expired workflow run logs for free-plan tenants. + +The cleanup service owns billing eligibility decisions while repositories own database-efficient batch selection and +deletion. Free-plan cleanup intentionally scans lightweight workflow run references first, then re-queries the same +candidate cursor slice with eligible tenant IDs so paid tenants are skipped without hydrating full WorkflowRun models. +""" + import datetime import logging import random @@ -11,8 +18,11 @@ from sqlalchemy.orm import Session, sessionmaker from configs import dify_config from enums.cloud_plan import CloudPlan from extensions.ext_database import db -from models.workflow import WorkflowRun -from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict +from repositories.api_workflow_run_repository import ( + APIWorkflowRunRepository, + RunsWithRelatedCountsDict, + WorkflowRunCleanupRef, +) from repositories.factory import DifyAPIRepositoryFactory from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from services.billing_service import BillingService, SubscriptionPlan @@ -186,6 +196,13 @@ _RELATED_RECORD_KEYS = ("node_executions", "offloads", "app_logs", "trigger_logs class WorkflowRunCleanup: + """ + Coordinates free-plan workflow run retention cleanup. + + The cleanup cursor advances by candidate refs, not target refs. This keeps pagination stable + when billing filters out paid or unknown tenants before the repository performs the target lookup. + """ + def __init__( self, days: int, @@ -254,26 +271,28 @@ class WorkflowRunCleanup: batch_start = time.monotonic() fetch_start = time.monotonic() - run_rows = self.workflow_run_repo.get_runs_batch_by_time_range( + candidate_last_seen = last_seen + candidate_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range( start_from=self.window_start, end_before=self.window_end, - last_seen=last_seen, + last_seen=candidate_last_seen, batch_size=self.batch_size, ) - if not run_rows: + if not candidate_refs: logger.info("workflow_run_cleanup (batch #%s): no more rows to process", batch_index + 1) break batch_index += 1 - last_seen = (run_rows[-1].created_at, run_rows[-1].id) + candidate_high_water = self._cursor_from_ref(candidate_refs[-1]) + last_seen = candidate_high_water logger.info( - "workflow_run_cleanup (batch #%s): fetched %s rows in %sms", + "workflow_run_cleanup (batch #%s): fetched %s candidate refs in %sms", batch_index, - len(run_rows), + len(candidate_refs), int((time.monotonic() - fetch_start) * 1000), ) - tenant_ids = {row.tenant_id for row in run_rows} + tenant_ids = {ref.tenant_id for ref in candidate_refs} filter_start = time.monotonic() free_tenants = self._filter_free_tenants(tenant_ids) @@ -285,10 +304,28 @@ class WorkflowRunCleanup: int((time.monotonic() - filter_start) * 1000), ) - free_runs = [row for row in run_rows if row.tenant_id in free_tenants] - paid_or_skipped = len(run_rows) - len(free_runs) + target_refs: Sequence[WorkflowRunCleanupRef] = [] + if free_tenants: + target_fetch_start = time.monotonic() + target_refs = self.workflow_run_repo.get_cleanup_refs_batch_by_time_range( + start_from=self.window_start, + end_before=self.window_end, + last_seen=candidate_last_seen, + batch_size=self.batch_size, + tenant_ids=sorted(free_tenants), + upper_bound=candidate_high_water, + ) + logger.info( + "workflow_run_cleanup (batch #%s): fetched %s target refs in %sms", + batch_index, + len(target_refs), + int((time.monotonic() - target_fetch_start) * 1000), + ) - if not free_runs: + target_run_ids = [ref.id for ref in target_refs] + paid_or_skipped = max(len(candidate_refs) - len(target_run_ids), 0) + + if not target_run_ids: skipped_message = ( f"[batch #{batch_index}] skipped (no sandbox runs in batch, {paid_or_skipped} paid/unknown)" ) @@ -299,7 +336,7 @@ class WorkflowRunCleanup: ) ) self._metrics.record_batch( - batch_rows=len(run_rows), + batch_rows=len(candidate_refs), targeted_runs=0, skipped_runs=paid_or_skipped, deleted_runs=0, @@ -309,13 +346,13 @@ class WorkflowRunCleanup: ) continue - total_runs_targeted += len(free_runs) + total_runs_targeted += len(target_run_ids) if self.dry_run: count_start = time.monotonic() - batch_counts = self.workflow_run_repo.count_runs_with_related( - free_runs, - count_node_executions=self._count_node_executions, + batch_counts = self.workflow_run_repo.count_runs_with_related_by_ids( + target_run_ids, + count_node_executions=self._count_node_executions_by_run_ids, count_trigger_logs=self._count_trigger_logs, ) logger.info( @@ -325,10 +362,10 @@ class WorkflowRunCleanup: ) if related_totals is not None: self._accumulate_related_counts(related_totals, batch_counts) - sample_ids = ", ".join(run.id for run in free_runs[:5]) + sample_ids = ", ".join(target_run_ids[:5]) click.echo( click.style( - f"[batch #{batch_index}] would delete {len(free_runs)} runs " + f"[batch #{batch_index}] would delete {len(target_run_ids)} runs " f"(sample ids: {sample_ids}) and skip {paid_or_skipped} paid/unknown", fg="yellow", ) @@ -339,8 +376,8 @@ class WorkflowRunCleanup: int((time.monotonic() - batch_start) * 1000), ) self._metrics.record_batch( - batch_rows=len(run_rows), - targeted_runs=len(free_runs), + batch_rows=len(candidate_refs), + targeted_runs=len(target_run_ids), skipped_runs=paid_or_skipped, deleted_runs=0, related_counts={ @@ -354,14 +391,14 @@ class WorkflowRunCleanup: try: delete_start = time.monotonic() - counts = self.workflow_run_repo.delete_runs_with_related( - free_runs, - delete_node_executions=self._delete_node_executions, + counts = self.workflow_run_repo.delete_runs_with_related_by_ids( + target_run_ids, + delete_node_executions=self._delete_node_executions_by_run_ids, delete_trigger_logs=self._delete_trigger_logs, ) delete_ms = int((time.monotonic() - delete_start) * 1000) except Exception: - logger.exception("Failed to delete workflow runs batch ending at %s", last_seen[0]) + logger.exception("Failed to delete workflow runs batch ending at %s", candidate_high_water[0]) raise total_runs_deleted += counts["runs"] @@ -382,8 +419,8 @@ class WorkflowRunCleanup: int((time.monotonic() - batch_start) * 1000), ) self._metrics.record_batch( - batch_rows=len(run_rows), - targeted_runs=len(free_runs), + batch_rows=len(candidate_refs), + targeted_runs=len(target_run_ids), skipped_runs=paid_or_skipped, deleted_runs=counts["runs"], related_counts={ @@ -439,7 +476,7 @@ class WorkflowRunCleanup: ) def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]: - tenant_id_list = list(tenant_ids) + tenant_id_list = sorted(set(tenant_ids)) if not dify_config.BILLING_ENABLED: return set(tenant_id_list) @@ -553,15 +590,17 @@ class WorkflowRunCleanup: totals["pauses"] += batch.get("pauses", 0) totals["pause_reasons"] += batch.get("pause_reasons", 0) - def _count_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: - run_ids = [run.id for run in runs] + @staticmethod + def _cursor_from_ref(ref: WorkflowRunCleanupRef) -> tuple[datetime.datetime, str]: + return ref.created_at, ref.id + + def _count_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]: repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False) ) return repo.count_by_runs(session, run_ids) - def _delete_node_executions(self, session: Session, runs: Sequence[WorkflowRun]) -> tuple[int, int]: - run_ids = [run.id for run in runs] + def _delete_node_executions_by_run_ids(self, session: Session, run_ids: Sequence[str]) -> tuple[int, int]: repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( session_maker=sessionmaker(bind=session.get_bind(), expire_on_commit=False) ) diff --git a/api/services/saved_message_service.py b/api/services/saved_message_service.py index 90f01377123..9a65429748e 100644 --- a/api/services/saved_message_service.py +++ b/api/services/saved_message_service.py @@ -1,6 +1,6 @@ from sqlalchemy import select +from sqlalchemy.orm import Session -from extensions.ext_database import db from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account from models.enums import CreatorUserRole @@ -12,11 +12,11 @@ from services.message_service import MessageService class SavedMessageService: @classmethod def pagination_by_last_id( - cls, app_model: App, user: Account | EndUser | None, last_id: str | None, limit: int + cls, session: Session, app_model: App, user: Account | EndUser | None, last_id: str | None, limit: int ) -> InfiniteScrollPagination: if not user: raise ValueError("User is required") - saved_messages = db.session.scalars( + saved_messages = session.scalars( select(SavedMessage) .where( SavedMessage.app_id == app_model.id, @@ -32,10 +32,10 @@ class SavedMessageService: ) @classmethod - def save(cls, app_model: App, user: Account | EndUser | None, message_id: str): + def save(cls, session: Session, app_model: App, user: Account | EndUser | None, message_id: str): if not user: return - saved_message = db.session.scalar( + saved_message = session.scalar( select(SavedMessage) .where( SavedMessage.app_id == app_model.id, @@ -58,14 +58,14 @@ class SavedMessageService: created_by=user.id, ) - db.session.add(saved_message) - db.session.commit() + session.add(saved_message) + session.commit() @classmethod - def delete(cls, app_model: App, user: Account | EndUser | None, message_id: str): + def delete(cls, session: Session, app_model: App, user: Account | EndUser | None, message_id: str): if not user: return - saved_message = db.session.scalar( + saved_message = session.scalar( select(SavedMessage) .where( SavedMessage.app_id == app_model.id, @@ -79,5 +79,5 @@ class SavedMessageService: if not saved_message: return - db.session.delete(saved_message) - db.session.commit() + session.delete(saved_message) + session.commit() diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index 9f16d412040..75282db9d4c 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from typing import Any from sqlalchemy import delete, func, select -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session, scoped_session, sessionmaker from core.db import session_factory from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING @@ -192,6 +192,7 @@ class SnippetService: self, *, tenant_id: str, + session: scoped_session, page: int = 1, limit: int = 20, keyword: str | None = None, @@ -229,20 +230,19 @@ class SnippetService: stmt = stmt.where(CustomizedSnippet.created_by.in_(creators)) if tag_ids: - target_ids = TagService.get_target_ids_by_tag_ids("snippet", tenant_id, tag_ids, match_all=True) + target_ids = TagService.get_target_ids_by_tag_ids("snippet", tenant_id, tag_ids, session, match_all=True) if target_ids: stmt = stmt.where(CustomizedSnippet.id.in_(target_ids)) else: return [], 0, False - with self._session_scope() as session: - # Get total count - count_stmt = select(func.count()).select_from(stmt.subquery()) - total = session.scalar(count_stmt) or 0 + # Get total count + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = session.scalar(count_stmt) or 0 - # Apply pagination - stmt = stmt.limit(limit + 1).offset((page - 1) * limit) - snippets = list(session.scalars(stmt).all()) + # Apply pagination + stmt = stmt.limit(limit + 1).offset((page - 1) * limit) + snippets = list(session.scalars(stmt).all()) has_more = len(snippets) > limit if has_more: diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 20f9a2c73d5..b144c15e85e 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -6,10 +6,9 @@ from flask_login import current_user from pydantic import BaseModel, Field from sqlalchemy import delete, func, select from sqlalchemy.engine import CursorResult -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, scoped_session from werkzeug.exceptions import NotFound -from extensions.ext_database import db from models.dataset import Dataset from models.enums import TagType from models.model import App, Tag, TagBinding @@ -56,7 +55,12 @@ class TagService: @staticmethod def get_target_ids_by_tag_ids( - tag_type: str, current_tenant_id: str, tag_ids: list[str], *, match_all: bool = False + tag_type: str, + current_tenant_id: str, + tag_ids: list[str], + session: scoped_session | Session, + *, + match_all: bool = False, ): """ Return target IDs bound to tags for the given tenant and resource type. @@ -70,7 +74,7 @@ class TagService: return [] # Deduplicate repeated query params so match_all counts each requested tag once. requested_tag_ids = list(dict.fromkeys(tag_ids)) - tags = db.session.scalars( + tags = session.scalars( select(Tag.id).where( Tag.id.in_(requested_tag_ids), Tag.tenant_id == current_tenant_id, @@ -86,13 +90,13 @@ class TagService: if match_all: if len(tag_ids) != len(requested_tag_ids): return [] - return db.session.scalars( + return session.scalars( select(TagBinding.target_id) .where(TagBinding.tag_id.in_(tag_ids), TagBinding.tenant_id == current_tenant_id) .group_by(TagBinding.target_id) .having(func.count(sa.distinct(TagBinding.tag_id)) == len(tag_ids)) ).all() - tag_bindings = db.session.scalars( + tag_bindings = session.scalars( select(TagBinding.target_id).where( TagBinding.tag_id.in_(tag_ids), TagBinding.tenant_id == current_tenant_id ) @@ -100,11 +104,11 @@ class TagService: return tag_bindings @staticmethod - def get_tag_by_tag_name(tag_type: str, current_tenant_id: str, tag_name: str): + def get_tag_by_tag_name(tag_type: str, current_tenant_id: str, tag_name: str, session: scoped_session): if not tag_type or not tag_name: return [] tags = list( - db.session.scalars( + session.scalars( select(Tag).where(Tag.name == tag_name, Tag.tenant_id == current_tenant_id, Tag.type == tag_type) ).all() ) @@ -113,8 +117,8 @@ class TagService: return tags @staticmethod - def get_tags_by_target_id(tag_type: str, current_tenant_id: str, target_id: str): - tags = db.session.scalars( + def get_tags_by_target_id(tag_type: str, current_tenant_id: str, target_id: str, session: scoped_session): + tags = session.scalars( select(Tag) .join(TagBinding, Tag.id == TagBinding.tag_id) .where( @@ -128,8 +132,8 @@ class TagService: return tags or [] @staticmethod - def save_tags(payload: SaveTagPayload) -> Tag: - if TagService.get_tag_by_tag_name(payload.type, current_user.current_tenant_id, payload.name): + def save_tags(payload: SaveTagPayload, session: scoped_session) -> Tag: + if TagService.get_tag_by_tag_name(payload.type, current_user.current_tenant_id, payload.name, session): raise ValueError("Tag name already exists") tag = Tag( name=payload.name, @@ -138,17 +142,17 @@ class TagService: tenant_id=current_user.current_tenant_id, ) tag.id = str(uuid.uuid4()) - db.session.add(tag) - db.session.commit() + session.add(tag) + session.commit() return tag @staticmethod - def update_tags(payload: UpdateTagPayload, tag_id: str) -> Tag: - tag = db.session.scalar(select(Tag).where(Tag.id == tag_id).limit(1)) + def update_tags(payload: UpdateTagPayload, tag_id: str, session: scoped_session) -> Tag: + tag = session.scalar(select(Tag).where(Tag.id == tag_id).limit(1)) if not tag: raise NotFound("Tag not found") if payload.name != tag.name: - existing = db.session.scalar( + existing = session.scalar( select(Tag) .where( Tag.name == payload.name, @@ -161,31 +165,31 @@ class TagService: if existing: raise ValueError("Tag name already exists") tag.name = payload.name - db.session.commit() + session.commit() return tag @staticmethod - def get_tag_binding_count(tag_id: str) -> int: - count = db.session.scalar(select(func.count(TagBinding.id)).where(TagBinding.tag_id == tag_id)) or 0 + def get_tag_binding_count(tag_id: str, session: scoped_session) -> int: + count = session.scalar(select(func.count(TagBinding.id)).where(TagBinding.tag_id == tag_id)) or 0 return count @staticmethod - def delete_tag(tag_id: str): - tag = db.session.scalar(select(Tag).where(Tag.id == tag_id).limit(1)) + def delete_tag(tag_id: str, session: scoped_session): + tag = session.scalar(select(Tag).where(Tag.id == tag_id).limit(1)) if not tag: raise NotFound("Tag not found") - db.session.delete(tag) + session.delete(tag) # delete tag binding - tag_bindings = db.session.scalars(select(TagBinding).where(TagBinding.tag_id == tag_id)).all() + tag_bindings = session.scalars(select(TagBinding).where(TagBinding.tag_id == tag_id)).all() if tag_bindings: for tag_binding in tag_bindings: - db.session.delete(tag_binding) - db.session.commit() + session.delete(tag_binding) + session.commit() @staticmethod - def save_tag_binding(payload: TagBindingCreatePayload): - TagService.check_target_exists(payload.type, payload.target_id) - valid_tag_ids = db.session.scalars( + def save_tag_binding(payload: TagBindingCreatePayload, session: scoped_session): + TagService.check_target_exists(payload.type, payload.target_id, session) + valid_tag_ids = session.scalars( select(Tag.id).where( Tag.id.in_(payload.tag_ids), Tag.tenant_id == current_user.current_tenant_id, @@ -193,7 +197,7 @@ class TagService: ) ).all() for tag_id in valid_tag_ids: - tag_binding = db.session.scalar( + tag_binding = session.scalar( select(TagBinding) .where(TagBinding.tag_id == tag_id, TagBinding.target_id == payload.target_id) .limit(1) @@ -206,15 +210,15 @@ class TagService: tenant_id=current_user.current_tenant_id, created_by=current_user.id, ) - db.session.add(new_tag_binding) - db.session.commit() + session.add(new_tag_binding) + session.commit() @staticmethod - def delete_tag_binding(payload: TagBindingDeletePayload): - TagService.check_target_exists(payload.type, payload.target_id) + def delete_tag_binding(payload: TagBindingDeletePayload, session: scoped_session): + TagService.check_target_exists(payload.type, payload.target_id, session) result = cast( CursorResult, - db.session.execute( + session.execute( delete(TagBinding).where( TagBinding.target_id == payload.target_id, TagBinding.tag_id.in_(payload.tag_ids), @@ -230,12 +234,12 @@ class TagService: ) if result.rowcount: - db.session.commit() + session.commit() @staticmethod - def check_target_exists(type: str, target_id: str): + def check_target_exists(type: str, target_id: str, session: scoped_session): if type == "knowledge": - dataset = db.session.scalar( + dataset = session.scalar( select(Dataset) .where(Dataset.tenant_id == current_user.current_tenant_id, Dataset.id == target_id) .limit(1) @@ -243,13 +247,13 @@ class TagService: if not dataset: raise NotFound("Dataset not found") elif type == "app": - app = db.session.scalar( + app = session.scalar( select(App).where(App.tenant_id == current_user.current_tenant_id, App.id == target_id).limit(1) ) if not app: raise NotFound("App not found") elif type == "snippet": - snippet = db.session.scalar( + snippet = session.scalar( select(CustomizedSnippet) .where(CustomizedSnippet.tenant_id == current_user.current_tenant_id, CustomizedSnippet.id == target_id) .limit(1) diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index d5d258aeabc..b0a3de1cee8 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -475,7 +475,7 @@ class TriggerProviderService: tenant_id=tenant_id, provider_id=provider_id ) # Create encrypter - encrypter, cache = create_provider_encrypter( + encrypter, _ = create_provider_encrypter( tenant_id=tenant_id, config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()], cache=NoOpProviderCredentialCache(), @@ -506,14 +506,20 @@ class TriggerProviderService: subscription.credentials = dict(encrypter.encrypt(dict(refreshed_credentials.credentials))) subscription.credential_expires_at = refreshed_credentials.expires_at - # Clear cache - cache.delete() - - return { + provider_id_value = subscription.provider_id + result = { "result": "success", "expires_at": refreshed_credentials.expires_at, } + # Clear the trigger runtime credential cache after the DB commit so dispatch uses the refreshed token. + delete_cache_for_subscription( + tenant_id=tenant_id, + provider_id=provider_id_value, + subscription_id=subscription_id, + ) + return result + @classmethod def refresh_subscription( cls, diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 592f6784217..ac7f6a468eb 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -14,7 +14,6 @@ from werkzeug.datastructures import FileStorage from werkzeug.exceptions import RequestEntityTooLarge from configs import dify_config -from core.app.entities.app_invoke_entities import InvokeFrom from core.app.file_access import DatabaseFileAccessController from core.tools.tool_file_manager import ToolFileManager from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE @@ -31,7 +30,7 @@ from factories import file_factory from graphon.entities.graph_config import NodeConfigDict from graphon.file import FileTransferMethod from graphon.variables.types import ArrayValidation, SegmentType -from models.enums import AppTriggerStatus, AppTriggerType +from models.enums import AppTriggerStatus, AppTriggerType, EndUserType from models.model import App from models.trigger import AppTrigger, WorkflowWebhookTrigger from models.workflow import Workflow @@ -810,7 +809,7 @@ class WebhookService: ) end_user = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.TRIGGER, + type=EndUserType.TRIGGER, tenant_id=webhook_trigger.tenant_id, app_id=webhook_trigger.app_id, user_id=None, diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index 834d78011ac..2b63d9171e9 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -12,6 +12,7 @@ from libs.helper import TokenManager from libs.passport import PassportService from libs.password import compare_password from models import Account, AccountStatus +from models.enums import EndUserType from models.model import App, EndUser, Site from services.account_service import AccountService from services.app_service import AppService @@ -102,7 +103,7 @@ class WebAppAuthService: end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=False, session_id=email, name="enterpriseuser", diff --git a/api/services/workflow/workflow_converter.py b/api/services/workflow/workflow_converter.py index 5dedb9e3729..e279f1daaa3 100644 --- a/api/services/workflow/workflow_converter.py +++ b/api/services/workflow/workflow_converter.py @@ -95,6 +95,7 @@ class WorkflowConverter: new_app.is_demo = False new_app.is_public = app_model.is_public new_app.created_by = account.id + new_app.maintainer = account.id new_app.updated_by = account.id db.session.add(new_app) db.session.flush() diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 7c9bd489bda..9c30f32a7ad 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -125,10 +125,11 @@ class DraftVarLoader(VariableLoader): # can be safely accessed before any offloading logic is applied. for draft_var in draft_vars: value = draft_var.get_value() - if isinstance(value, FileSegment): - files.append(value.value) - elif isinstance(value, ArrayFileSegment): - files.extend(value.value) + match value: + case FileSegment(): + files.append(value.value) + case ArrayFileSegment(): + files.extend(value.value) with Session(bind=self._engine) as session: storage_key_loader = StorageKeyLoader( session, diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py index 29b9e72a009..2499e6cc094 100644 --- a/api/services/workflow_run_service.py +++ b/api/services/workflow_run_service.py @@ -34,10 +34,11 @@ class WorkflowRunService: def __init__(self, session_factory: Engine | sessionmaker | None = None): """Initialize WorkflowRunService with repository dependencies.""" - if session_factory is None: - session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) - elif isinstance(session_factory, Engine): - session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) + match session_factory: + case None: + session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) + case Engine(): + session_factory = sessionmaker(bind=session_factory, expire_on_commit=False) self._session_factory = session_factory self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py index 58092689929..9f6dfc93f4c 100644 --- a/api/tasks/async_workflow_tasks.py +++ b/api/tasks/async_workflow_tasks.py @@ -237,14 +237,12 @@ def resume_workflow_execution(task_data_dict: dict[str, Any]) -> None: workflow = session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id)) if workflow is None: raise WorkflowNotFoundError( - "Workflow not found: workflow_run_id=%s, workflow_id=%s", workflow_run.id, workflow_run.workflow_id + f"Workflow not found: workflow_run_id={workflow_run.id}, workflow_id={workflow_run.workflow_id}" ) user = _get_user(session, workflow_run) app_model = session.scalar(select(App).where(App.id == workflow_run.app_id)) if app_model is None: - raise _AppNotFoundError( - "App not found: app_id=%s, workflow_run_id=%s", workflow_run.app_id, workflow_run.id - ) + raise _AppNotFoundError(f"App not found: app_id={workflow_run.app_id}, workflow_run_id={workflow_run.id}") workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository( session_factory=session_factory, diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index 8505375b6a6..4bd45a42ead 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -15,7 +15,6 @@ from celery import shared_task from sqlalchemy import func, select from sqlalchemy.orm import Session -from core.app.entities.app_invoke_entities import InvokeFrom from core.db.session_factory import session_factory from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.entities.request import TriggerInvokeEventResponse @@ -32,6 +31,7 @@ from graphon.enums import WorkflowExecutionStatus from models.enums import ( AppTriggerType, CreatorUserRole, + EndUserType, WorkflowRunTriggeredFrom, WorkflowTriggerStatus, ) @@ -265,7 +265,7 @@ def dispatch_triggered_workflow( workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers) end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch( - type=InvokeFrom.TRIGGER, + type=EndUserType.TRIGGER, tenant_id=subscription.tenant_id, app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers], user_id=user_id, diff --git a/api/tests/helpers/legacy_model_type_migration.py b/api/tests/helpers/legacy_model_type_migration.py index 12f092a0fe3..4140119f326 100644 --- a/api/tests/helpers/legacy_model_type_migration.py +++ b/api/tests/helpers/legacy_model_type_migration.py @@ -131,10 +131,11 @@ def fetch_table_rows( for row in rows: normalized = dict(row) for key, value in normalized.items(): - if isinstance(value, datetime): - normalized[key] = value.isoformat() - elif isinstance(value, uuid.UUID): - normalized[key] = str(value) + match value: + case datetime(): + normalized[key] = value.isoformat() + case uuid.UUID(): + normalized[key] = str(value) result.append(normalized) return result 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 93310ad3805..f30abb4ed05 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 @@ -289,6 +289,7 @@ class TestFeedbackExportApi: # Verify service was called with correct parameters mock_export_feedbacks.assert_called_once_with( + mock.ANY, app_id=mock_app_model.id, from_source=FeedbackFromSource.USER, rating=FeedbackRating.DISLIKE, diff --git a/api/tests/integration_tests/controllers/openapi/test_app_run.py b/api/tests/integration_tests/controllers/openapi/test_app_run.py index 92e2e993dbf..b4f383a7cea 100644 --- a/api/tests/integration_tests/controllers/openapi/test_app_run.py +++ b/api/tests/integration_tests/controllers/openapi/test_app_run.py @@ -13,7 +13,9 @@ from extensions.ext_database import db from models import App -def test_run_chat_dispatches_to_chat_handler(flask_app, account_token, app_in_workspace, monkeypatch): +def test_run_chat_dispatches_to_chat_handler( + flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch +): captured = {} def _fake_generate(*, app_model, user, args, invoke_from, streaming): @@ -78,7 +80,9 @@ def app_with_mode(flask_app: Flask, workspace_account): db.session.commit() -def test_run_chat_without_query_returns_422(flask_app, account_token, app_in_workspace, monkeypatch): +def test_run_chat_without_query_returns_422( + flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch +): client = flask_app.test_client() res = client.post( f"/openapi/v1/apps/{app_in_workspace.id}/run", @@ -89,7 +93,9 @@ def test_run_chat_without_query_returns_422(flask_app, account_token, app_in_wor assert b"query_required_for_chat" in res.data -def test_run_completion_dispatches_to_completion_handler(flask_app, account_token, app_with_mode, monkeypatch): +def test_run_completion_dispatches_to_completion_handler( + flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch +): app = app_with_mode("completion") captured: dict = {} @@ -119,7 +125,9 @@ def test_run_completion_dispatches_to_completion_handler(flask_app, account_toke assert captured["mode"] == "completion" -def test_run_workflow_with_query_returns_422(flask_app, account_token, app_with_mode, monkeypatch): +def test_run_workflow_with_query_returns_422( + flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch +): app = app_with_mode("workflow") client = flask_app.test_client() res = client.post( @@ -131,7 +139,9 @@ def test_run_workflow_with_query_returns_422(flask_app, account_token, app_with_ assert b"query_not_supported_for_workflow" in res.data -def test_run_workflow_no_query_dispatches_to_workflow_handler(flask_app, account_token, app_with_mode, monkeypatch): +def test_run_workflow_no_query_dispatches_to_workflow_handler( + flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch +): app = app_with_mode("workflow") def _fake_generate(*, app_model, user, args, invoke_from, streaming): @@ -154,7 +164,9 @@ def test_run_workflow_no_query_dispatches_to_workflow_handler(flask_app, account assert body["workflow_run_id"] == "wfr" -def test_run_unsupported_mode_returns_422(flask_app, account_token, app_with_mode, monkeypatch): +def test_run_unsupported_mode_returns_422( + flask_app: Flask, account_token, app_with_mode, monkeypatch: pytest.MonkeyPatch +): app = app_with_mode("channel") client = flask_app.test_client() res = client.post( @@ -166,7 +178,7 @@ def test_run_unsupported_mode_returns_422(flask_app, account_token, app_with_mod assert b"mode_not_runnable" in res.data -def test_run_without_bearer_returns_401(flask_app, app_in_workspace): +def test_run_without_bearer_returns_401(flask_app: Flask, app_in_workspace): client = flask_app.test_client() res = client.post( f"/openapi/v1/apps/{app_in_workspace.id}/run", @@ -175,7 +187,9 @@ def test_run_without_bearer_returns_401(flask_app, app_in_workspace): assert res.status_code == 401 -def test_run_with_insufficient_scope_returns_403(flask_app, account_token, app_in_workspace, monkeypatch): +def test_run_with_insufficient_scope_returns_403( + flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch +): """Stub the authenticator to return an AuthContext with empty scopes.""" from libs import oauth_bearer @@ -198,7 +212,7 @@ def test_run_with_insufficient_scope_returns_403(flask_app, account_token, app_i assert res.status_code == 403 -def test_run_with_unknown_app_returns_404(flask_app, account_token): +def test_run_with_unknown_app_returns_404(flask_app: Flask, account_token): client = flask_app.test_client() res = client.post( f"/openapi/v1/apps/{uuid.uuid4()}/run", @@ -208,7 +222,9 @@ def test_run_with_unknown_app_returns_404(flask_app, account_token): assert res.status_code == 404 -def test_run_streaming_returns_event_stream(flask_app, account_token, app_in_workspace, monkeypatch): +def test_run_streaming_returns_event_stream( + flask_app: Flask, account_token, app_in_workspace, monkeypatch: pytest.MonkeyPatch +): def _stream() -> Generator[str, None, None]: yield 'event: message\ndata: {"x": 1}\n\n' @@ -228,7 +244,7 @@ def test_run_streaming_returns_event_stream(flask_app, account_token, app_in_wor assert b"event: message" in res.data -def test_run_without_inputs_returns_422(flask_app, account_token, app_in_workspace): +def test_run_without_inputs_returns_422(flask_app: Flask, account_token, app_in_workspace): client = flask_app.test_client() res = client.post( f"/openapi/v1/apps/{app_in_workspace.id}/run", diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index 5b7790f6f44..d8a0a713f12 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -4,6 +4,8 @@ import uuid from collections.abc import Generator from unittest.mock import MagicMock, patch +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.llm_generator.output_parser.structured_output import _parse_structured_output from core.model_manager import ModelInstance @@ -91,7 +93,7 @@ def init_llm_node(config: dict) -> LLMNode: return node -def _mock_db_session_close(monkeypatch) -> None: +def _mock_db_session_close(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(db.session, "close", MagicMock()) 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 fc230a2a68d..44708285037 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -3,6 +3,8 @@ import time import uuid from unittest.mock import MagicMock +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.model_manager import ModelInstance from core.workflow.node_runtime import DifyPromptMessageSerializer @@ -83,11 +85,11 @@ def init_parameter_extractor_node(config: dict, memory=None): return node -def _mock_db_session_close(monkeypatch) -> None: +def _mock_db_session_close(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(db.session, "close", MagicMock()) -def test_function_calling_parameter_extractor(setup_model_mock, monkeypatch): +def test_function_calling_parameter_extractor(setup_model_mock, monkeypatch: pytest.MonkeyPatch): """ Test function calling for parameter extractor. """ @@ -128,7 +130,7 @@ def test_function_calling_parameter_extractor(setup_model_mock, monkeypatch): assert result.outputs.get("__reason") == None -def test_instructions(setup_model_mock, monkeypatch): +def test_instructions(setup_model_mock, monkeypatch: pytest.MonkeyPatch): """ Test chat parameter extractor. """ @@ -178,7 +180,7 @@ def test_instructions(setup_model_mock, monkeypatch): assert "what's the weather in SF" in prompt.get("text") -def test_chat_parameter_extractor(setup_model_mock, monkeypatch): +def test_chat_parameter_extractor(setup_model_mock, monkeypatch: pytest.MonkeyPatch): """ Test chat parameter extractor. """ @@ -229,7 +231,7 @@ def test_chat_parameter_extractor(setup_model_mock, monkeypatch): assert '\n{"type": "object"' in prompt.get("text") -def test_completion_parameter_extractor(setup_model_mock, monkeypatch): +def test_completion_parameter_extractor(setup_model_mock, monkeypatch: pytest.MonkeyPatch): """ Test completion parameter extractor. """ @@ -354,7 +356,7 @@ def test_extract_json_from_tool_call(): assert result["location"] == "kawaii" -def test_chat_parameter_extractor_with_memory(setup_model_mock, monkeypatch): +def test_chat_parameter_extractor_with_memory(setup_model_mock, monkeypatch: pytest.MonkeyPatch): """ Test chat parameter extractor with memory. """ diff --git a/api/tests/test_containers_integration_tests/.ruff.toml b/api/tests/test_containers_integration_tests/.ruff.toml index 250cf103ab9..68e3f9af4bd 100644 --- a/api/tests/test_containers_integration_tests/.ruff.toml +++ b/api/tests/test_containers_integration_tests/.ruff.toml @@ -9,7 +9,6 @@ extend-select = ["ANN401", "ARG", "TID251"] "models/test_types_enum_text.py" = ["ANN401", "TID251"] "services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"] "services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"] -"services/test_hit_testing_service.py" = ["ANN401", "TID251"] "trigger/conftest.py" = ["ANN401", "TID251"] "trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"] "controllers/console/app/test_app_apis.py" = ["ARG"] 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 3c496d1fc8c..d043c0d413a 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 @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest from flask import Flask @@ -517,11 +517,11 @@ class TestAccountGeneration: @patch("controllers.console.auth.oauth.TenantService") def test_should_handle_account_generation_scenarios( self, - mock_tenant_service, - mock_account_service, - mock_register_service, - mock_feature_service, - mock_get_account, + mock_tenant_service: MagicMock, + mock_account_service: MagicMock, + mock_register_service: MagicMock, + mock_feature_service: MagicMock, + mock_get_account: MagicMock, app: Flask, user_info: OAuthUserInfo, mock_account, @@ -562,11 +562,11 @@ class TestAccountGeneration: @patch("controllers.console.auth.oauth.TenantService") def test_should_register_with_lowercase_email( self, - mock_tenant_service, - mock_account_service, - mock_register_service, - mock_feature_service, - mock_get_account, + mock_tenant_service: MagicMock, + mock_account_service: MagicMock, + mock_register_service: MagicMock, + mock_feature_service: MagicMock, + mock_get_account: MagicMock, app: Flask, ): user_info = OAuthUserInfo(id="123", name="Test User", email="Upper@Example.com") @@ -593,11 +593,11 @@ class TestAccountGeneration: @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, + mock_tenant_service: MagicMock, + mock_account_service: MagicMock, + mock_register_service: MagicMock, + mock_feature_service: MagicMock, + mock_get_account: MagicMock, app: Flask, user_info: OAuthUserInfo, ): @@ -624,11 +624,11 @@ class TestAccountGeneration: @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, + mock_tenant_service: MagicMock, + mock_account_service: MagicMock, + mock_register_service: MagicMock, + mock_feature_service: MagicMock, + mock_get_account: MagicMock, app: Flask, user_info: OAuthUserInfo, ): @@ -655,11 +655,11 @@ class TestAccountGeneration: @patch("controllers.console.auth.oauth.tenant_was_created") def test_should_create_workspace_for_account_without_tenant( self, - mock_event, - mock_account_service, - mock_feature_service, - mock_tenant_service, - mock_get_account, + mock_event: MagicMock, + mock_account_service: MagicMock, + mock_feature_service: MagicMock, + mock_tenant_service: MagicMock, + mock_get_account: MagicMock, app: Flask, user_info: OAuthUserInfo, mock_account, @@ -678,6 +678,6 @@ class TestAccountGeneration: assert oauth_new_user is False mock_tenant_service.create_tenant.assert_called_once_with("Test User's Workspace") mock_tenant_service.create_tenant_member.assert_called_once_with( - mock_new_tenant, mock_account, role="owner" + mock_new_tenant, mock_account, ANY, role="owner" ) mock_event.send.assert_called_once_with(mock_new_tenant) diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py b/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py index 058f4e5fa34..e60558040a5 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py @@ -97,12 +97,13 @@ def test_list_scopes_api_based_extensions_to_authenticated_tenant( assert account_create_response.status_code == 201 APIBasedExtensionService.save( + db_session_with_containers, APIBasedExtension( tenant_id=foreign_tenant_id, name="Foreign API", api_endpoint="https://foreign.example.com/hook", api_key="foreign-secret-12345", - ) + ), ) response = test_client_with_containers.get( diff --git a/api/tests/test_containers_integration_tests/controllers/mcp/test_mcp.py b/api/tests/test_containers_integration_tests/controllers/mcp/test_mcp.py index 21b395a04c6..c281f071560 100644 --- a/api/tests/test_containers_integration_tests/controllers/mcp/test_mcp.py +++ b/api/tests/test_containers_integration_tests/controllers/mcp/test_mcp.py @@ -3,6 +3,7 @@ from __future__ import annotations import types +from inspect import unwrap from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -13,12 +14,6 @@ from pydantic import ValidationError import controllers.mcp.mcp as module -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture(autouse=True) def mock_mcp_ns(): fake_ns = types.SimpleNamespace() diff --git a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py index d961479f55b..5fe0f787524 100644 --- a/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py +++ b/api/tests/test_containers_integration_tests/controllers/openapi/conftest.py @@ -12,6 +12,7 @@ from flask import Flask from sqlalchemy.orm import Session from controllers.openapi.auth.data import AuthData +from extensions.ext_database import db from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, reset_auth_ctx, set_auth_ctx from models import Account, Tenant from services.account_service import AccountService, TenantService @@ -58,7 +59,7 @@ def add_tenant_for_account(account: Account, *, role: str = "normal", name: str with patch("services.account_service.FeatureService") as mock_feature_service: mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True tenant = TenantService.create_tenant(name=name) - TenantService.create_tenant_member(tenant, account, role=role) + TenantService.create_tenant_member(tenant, account, db.session, role=role) return tenant 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 642dd3ab62d..1717fea789f 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 @@ -16,17 +16,17 @@ since these test controller-level behavior. import uuid from contextlib import ExitStack from datetime import UTC, datetime -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import ANY, Mock, PropertyMock, patch import pytest from flask import Flask -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, scoped_session from werkzeug.exceptions import Forbidden, NotFound class SessionMatcher: def __eq__(self, other): - return isinstance(other, Session) + return isinstance(other, (Session, scoped_session)) import services @@ -366,6 +366,7 @@ DATASET_DETAIL_KEYS = { "total_available_documents", "enable_api", "is_multimodal", + "maintainer", } @@ -468,6 +469,7 @@ class TestDatasetListApiGet: mock_dataset_svc.get_datasets.assert_called_once_with( 1, 20, + SessionMatcher(), mock_tenant.id, mock_current_user, None, @@ -1129,7 +1131,7 @@ class TestDatasetTagsApiPatch: assert status == 200 assert response == {"id": "tag-1", "name": "Updated Tag", "type": "knowledge", "binding_count": "5"} mock_tag_svc.update_tags.assert_called_once() - update_payload, tag_id = mock_tag_svc.update_tags.call_args.args + update_payload, tag_id, session = mock_tag_svc.update_tags.call_args.args assert update_payload.name == "Updated Tag" assert tag_id == "tag-1" @@ -1184,7 +1186,7 @@ class TestDatasetTagsApiDelete: result = api.delete(_=None) assert result == ("", 204) - mock_tag_svc.delete_tag.assert_called_once_with("tag-1") + mock_tag_svc.delete_tag.assert_called_once_with("tag-1", ANY) @patch("libs.login.current_user") def test_delete_tag_forbidden(self, mock_current_user, app: Flask): @@ -1233,7 +1235,7 @@ class TestDatasetTagsBindingStatusApi: assert status_code == 200 assert response["data"] == [{"id": "tag_1", "name": "Test Tag"}] assert response["total"] == 1 - mock_tag_svc.get_tags_by_target_id.assert_called_once_with("knowledge", "tenant_123", "dataset_123") + mock_tag_svc.get_tags_by_target_id.assert_called_once_with("knowledge", "tenant_123", "dataset_123", ANY) class TestDatasetTagBindingApiPost: @@ -1266,7 +1268,8 @@ class TestDatasetTagBindingApiPost: from services.tag_service import TagBindingCreatePayload mock_tag_svc.save_tag_binding.assert_called_once_with( - TagBindingCreatePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE) + TagBindingCreatePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE), + ANY, ) @patch("controllers.service_api.dataset.dataset.current_user") @@ -1317,7 +1320,8 @@ class TestDatasetTagUnbindingApiPost: 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=TagType.KNOWLEDGE) + TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE), + ANY, ) @patch("controllers.service_api.dataset.dataset.TagService") @@ -1347,7 +1351,8 @@ class TestDatasetTagUnbindingApiPost: 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=TagType.KNOWLEDGE) + TagBindingDeletePayload(tag_ids=["tag-1"], target_id="ds-1", type=TagType.KNOWLEDGE), + ANY, ) @patch("controllers.service_api.dataset.dataset.current_user") 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 1bc4253cb90..3eab8ccbee5 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 @@ -187,6 +187,7 @@ class TestDecodeJwtToken: return flask_app_with_containers def _create_app_site_enduser(self, db_session: Session, *, enable_site: bool = True): + from models.enums import EndUserType from models.model import App, AppMode, CustomizeTokenStrategy, EndUser, Site tenant_id = str(uuid4()) @@ -215,7 +216,7 @@ class TestDecodeJwtToken: end_user = EndUser( tenant_id=tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, session_id="sess-1", ) db_session.add(end_user) diff --git a/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py b/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py index 14c2263110c..ecf0af43025 100644 --- a/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py +++ b/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py @@ -13,7 +13,7 @@ import pytest from sqlalchemy.orm import Session from models.account import Account -from models.enums import CreatorUserRole +from models.enums import CreatorUserRole, EndUserType from models.model import App, AppMode, EndUser from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom @@ -44,7 +44,7 @@ class TestWorkflowNodeExecutionModelCreatedBy: end_user = EndUser( tenant_id=tenant_id, app_id=app_id, - type="service_api", + type=EndUserType.SERVICE_API, external_user_id=f"ext-{uuid4()}", name="End User", session_id=f"session-{uuid4()}", diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml index 06ea10036f5..92c84320d9a 100644 --- a/api/tests/test_containers_integration_tests/pyrefly.toml +++ b/api/tests/test_containers_integration_tests/pyrefly.toml @@ -100,7 +100,6 @@ project-excludes = [ "services/test_feature_service.py", "services/test_feedback_service.py", "services/test_file_service.py", - "services/test_hit_testing_service.py", "services/test_human_input_delivery_test.py", "services/test_human_input_delivery_test_service.py", "services/test_message_export_service.py", diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py new file mode 100644 index 00000000000..be659fac184 --- /dev/null +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_workflow_run_cleanup_repository.py @@ -0,0 +1,320 @@ +"""Integration tests for workflow run cleanup repository queries.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import override +from uuid import uuid4 + +from sqlalchemy import Engine, select +from sqlalchemy.orm import Session, sessionmaker + +from graphon.entities import WorkflowExecution +from graphon.entities.pause_reason import PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowType +from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun +from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository + + +class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository): + """Concrete repository for tests where save() is not under test.""" + + @override + def save(self, execution: WorkflowExecution) -> None: + return None + + +@dataclass +class _TestScope: + """Per-test identifiers for rows created by cleanup repository tests.""" + + tenant_id: str = field(default_factory=lambda: str(uuid4())) + app_id: str = field(default_factory=lambda: str(uuid4())) + workflow_id: str = field(default_factory=lambda: str(uuid4())) + user_id: str = field(default_factory=lambda: str(uuid4())) + + +def _repository(db_session_with_containers: Session) -> DifyAPISQLAlchemyWorkflowRunRepository: + engine = db_session_with_containers.get_bind() + assert isinstance(engine, Engine) + return _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False)) + + +def _create_workflow_run( + session: Session, + scope: _TestScope, + *, + status: WorkflowExecutionStatus = WorkflowExecutionStatus.SUCCEEDED, + created_at: datetime, + tenant_id: str | None = None, + workflow_id: str | None = None, + workflow_type: str = WorkflowType.WORKFLOW, +) -> WorkflowRun: + workflow_run = WorkflowRun( + id=str(uuid4()), + tenant_id=tenant_id or scope.tenant_id, + app_id=scope.app_id, + workflow_id=workflow_id or scope.workflow_id, + type=workflow_type, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, + version="draft", + graph="{}", + inputs="{}", + status=status, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=scope.user_id, + created_at=created_at, + ) + session.add(workflow_run) + session.commit() + return workflow_run + + +def _add_app_log(session: Session, scope: _TestScope, workflow_run: WorkflowRun) -> None: + session.add( + WorkflowAppLog( + tenant_id=workflow_run.tenant_id, + app_id=scope.app_id, + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + created_from=WorkflowAppLogCreatedFrom.SERVICE_API, + created_by_role=CreatorUserRole.ACCOUNT, + created_by=scope.user_id, + ) + ) + session.commit() + + +def _add_pause_with_reason(session: Session, workflow_run: WorkflowRun) -> WorkflowPause: + pause = WorkflowPause( + workflow_id=workflow_run.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=f"workflow-state-{uuid4()}.json", + ) + pause_reason = WorkflowPauseReason( + pause_id=pause.id, + type_=PauseReasonType.SCHEDULED_PAUSE, + message="scheduled pause", + ) + session.add_all([pause, pause_reason]) + session.commit() + return pause + + +class TestGetCleanupRefsBatchByTimeRange: + def test_applies_cursor_window_and_cleanup_filters(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + base = datetime(2024, 1, 1, 12, 0, 0) + + _create_workflow_run(db_session_with_containers, scope, created_at=base - timedelta(minutes=1)) + cursor_run = _create_workflow_run(db_session_with_containers, scope, created_at=base) + first_target = _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=1)) + second_target = _create_workflow_run( + db_session_with_containers, + scope, + status=WorkflowExecutionStatus.FAILED, + created_at=base + timedelta(minutes=2), + ) + _create_workflow_run( + db_session_with_containers, + scope, + status=WorkflowExecutionStatus.RUNNING, + created_at=base + timedelta(minutes=1), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + tenant_id=str(uuid4()), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + workflow_id=str(uuid4()), + ) + _create_workflow_run( + db_session_with_containers, + scope, + created_at=base + timedelta(minutes=1), + workflow_type=WorkflowType.CHAT, + ) + _create_workflow_run(db_session_with_containers, scope, created_at=base + timedelta(minutes=3)) + + refs = repository.get_cleanup_refs_batch_by_time_range( + start_from=base, + end_before=base + timedelta(minutes=4), + last_seen=(cursor_run.created_at, cursor_run.id), + batch_size=10, + run_types=[WorkflowType.WORKFLOW], + tenant_ids=[scope.tenant_id], + workflow_ids=[scope.workflow_id], + upper_bound=(second_target.created_at, second_target.id), + ) + + assert [(ref.id, ref.tenant_id, ref.created_at) for ref in refs] == [ + (first_target.id, scope.tenant_id, first_target.created_at), + (second_target.id, scope.tenant_id, second_target.created_at), + ] + + def test_returns_empty_when_run_type_filter_is_empty(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + + refs = repository.get_cleanup_refs_batch_by_time_range( + start_from=None, + end_before=datetime(2024, 1, 2), + last_seen=None, + batch_size=10, + run_types=[], + ) + + assert refs == [] + + +class TestCountRunsWithRelatedByIds: + def test_counts_existing_runs_and_related_rows(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + missing_run_id = str(uuid4()) + _add_app_log(db_session_with_containers, scope, workflow_run) + _add_pause_with_reason(db_session_with_containers, workflow_run) + counted_node_run_ids: list[str] = [] + counted_trigger_run_ids: list[str] = [] + + counts = repository.count_runs_with_related_by_ids( + [workflow_run.id, missing_run_id], + count_node_executions=lambda _session, run_ids: counted_node_run_ids.extend(run_ids) or (2, 1), + count_trigger_logs=lambda _session, run_ids: counted_trigger_run_ids.extend(run_ids) or 3, + ) + + assert counted_node_run_ids == [workflow_run.id, missing_run_id] + assert counted_trigger_run_ids == [workflow_run.id, missing_run_id] + assert counts == { + "runs": 1, + "node_executions": 2, + "offloads": 1, + "app_logs": 1, + "trigger_logs": 3, + "pauses": 1, + "pause_reasons": 1, + } + + def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + + counts = repository.count_runs_with_related_by_ids([workflow_run.id]) + + assert counts == { + "runs": 1, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + + +class TestDeleteRunsWithRelatedByIds: + def test_deletes_runs_and_related_rows(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + _add_app_log(db_session_with_containers, scope, workflow_run) + pause = _add_pause_with_reason(db_session_with_containers, workflow_run) + pause_id = pause.id + deleted_node_run_ids: list[str] = [] + deleted_trigger_run_ids: list[str] = [] + + counts = repository.delete_runs_with_related_by_ids( + [workflow_run.id], + delete_node_executions=lambda _session, run_ids: deleted_node_run_ids.extend(run_ids) or (2, 1), + delete_trigger_logs=lambda _session, run_ids: deleted_trigger_run_ids.extend(run_ids) or 3, + ) + + assert deleted_node_run_ids == [workflow_run.id] + assert deleted_trigger_run_ids == [workflow_run.id] + assert counts == { + "runs": 1, + "node_executions": 2, + "offloads": 1, + "app_logs": 1, + "trigger_logs": 3, + "pauses": 1, + "pause_reasons": 1, + } + verification_session = Session(bind=db_session_with_containers.get_bind()) + with verification_session: + assert verification_session.get(WorkflowRun, workflow_run.id) is None + assert verification_session.get(WorkflowPause, pause_id) is None + assert ( + verification_session.scalar( + select(WorkflowAppLog).where(WorkflowAppLog.workflow_run_id == workflow_run.id) + ) + is None + ) + assert ( + verification_session.scalar(select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id)) + is None + ) + + def test_defaults_optional_related_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + scope = _TestScope() + workflow_run = _create_workflow_run( + db_session_with_containers, + scope, + created_at=datetime(2024, 1, 1, 12, 0, 0), + ) + + counts = repository.delete_runs_with_related_by_ids([workflow_run.id]) + + assert counts == { + "runs": 1, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + + def test_empty_ids_return_empty_counts(self, db_session_with_containers: Session) -> None: + repository = _repository(db_session_with_containers) + + assert repository.count_runs_with_related_by_ids([]) == { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } + assert repository.delete_runs_with_related_by_ids([]) == { + "runs": 0, + "node_executions": 0, + "offloads": 0, + "app_logs": 0, + "trigger_logs": 0, + "pauses": 0, + "pause_reasons": 0, + } 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 9d588d4c73f..c93e61b2bfb 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 @@ -1,7 +1,7 @@ from __future__ import annotations import json -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch from uuid import uuid4 import pytest @@ -83,8 +83,8 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.encrypter") def test_create_provider_auth_success( self, - mock_encrypter, - mock_factory, + mock_encrypter: MagicMock, + mock_factory: MagicMock, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, @@ -107,7 +107,12 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") def test_create_provider_auth_validation_failed( - self, mock_factory, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, mock_args + self, + mock_factory: MagicMock, + 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 @@ -123,8 +128,8 @@ class TestApiKeyAuthService: @patch("services.auth.api_key_auth_service.encrypter") def test_create_provider_auth_encrypts_api_key( self, - mock_encrypter, - mock_factory, + mock_encrypter: MagicMock, + mock_factory: MagicMock, flask_app_with_containers: Flask, db_session_with_containers: Session, tenant_id, @@ -289,7 +294,7 @@ class TestApiKeyAuthService: ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) @patch("services.auth.api_key_auth_service.ApiKeyAuthFactory") - def test_create_provider_auth_factory_exception(self, mock_factory, tenant_id, mock_args): + def test_create_provider_auth_factory_exception(self, mock_factory: MagicMock, tenant_id, mock_args): mock_factory.side_effect = Exception("Factory error") with pytest.raises(Exception, match="Factory error"): ApiKeyAuthService.create_provider_auth(tenant_id, mock_args) 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 075f43d63a3..646a0592630 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 @@ -45,7 +45,7 @@ class TestQueueTask: assert "task_id" in task_data assert "created_at" in task_data - def test_queue_task_redis_error(self, caplog): + def test_queue_task_redis_error(self, caplog: pytest.LogCaptureFixture): with patch("services.enterprise.account_deletion_sync.redis_client") as mock_redis: mock_redis.lpush.side_effect = RedisError("Connection failed") @@ -54,7 +54,7 @@ class TestQueueTask: assert result is False assert "Failed to queue account deletion sync" in caplog.text - def test_queue_task_type_error(self, caplog): + def test_queue_task_type_error(self, caplog: pytest.LogCaptureFixture): with patch("services.enterprise.account_deletion_sync.redis_client") as mock_redis: mock_redis.lpush.side_effect = TypeError("Cannot serialize") 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 48830c0f43b..398c5979ef2 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 @@ -47,8 +47,8 @@ class TestGetDynamicSelectOptionsTool: @patch("services.plugin.plugin_parameter_service.ToolManager") def test_fetches_credentials_with_credential_id( self, - mock_tool_mgr, - mock_encrypter_fn, + mock_tool_mgr: MagicMock, + mock_encrypter_fn: MagicMock, mock_client_cls, flask_app_with_containers: Flask, db_session_with_containers: Session, @@ -90,8 +90,8 @@ class TestGetDynamicSelectOptionsTool: @patch("services.plugin.plugin_parameter_service.ToolManager") def test_raises_when_tool_provider_not_found( self, - mock_tool_mgr, - mock_encrypter_fn, + mock_tool_mgr: MagicMock, + mock_encrypter_fn: MagicMock, flask_app_with_containers: Flask, db_session_with_containers: Session, ): 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 66ff24f3741..5d2f4f709f1 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 @@ -258,7 +258,7 @@ class TestUpgradePluginWithMarketplace: class TestUpgradePluginWithGithub: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") - def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls, mock_fs): + def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_fs.get_system_features.return_value = _make_features() installer = mock_installer_cls.return_value installer.upgrade_plugin.return_value = MagicMock() @@ -273,7 +273,7 @@ class TestUpgradePluginWithGithub: class TestUploadPkg: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") - def test_runs_permission_and_scope_checks(self, mock_installer_cls, mock_fs): + def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_fs.get_system_features.return_value = _make_features() upload_resp = MagicMock() upload_resp.verification = None @@ -318,7 +318,7 @@ class TestInstallFromMarketplacePkg: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") @patch("core.plugin.plugin_service.dify_config") - def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls, mock_fs): + def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_config.MARKETPLACE_ENABLED = True mock_fs.get_system_features.return_value = _make_features() installer = mock_installer_cls.return_value diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 9a53ff087c5..a2f5370cb76 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import Unauthorized from configs import dify_config from controllers.console.error import AccountNotFound, NotAllowedCreateWorkspace -from models import AccountStatus, TenantAccountJoin, TenantStatus +from models import AccountStatus, App, Dataset, TenantAccountJoin, TenantStatus from services.account_service import AccountService, RegisterService, TenantService, TokenPair from services.errors.account import ( AccountAlreadyInTenantError, @@ -1276,7 +1276,7 @@ class TestTenantService: ) # Create tenant member - tenant_member = TenantService.create_tenant_member(tenant, account, role="admin") + tenant_member = TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="admin") assert tenant_member.tenant_id == tenant.id assert tenant_member.account_id == account.id @@ -1317,11 +1317,11 @@ class TestTenantService: ) # Create first owner - TenantService.create_tenant_member(tenant, account1, role="owner") + TenantService.create_tenant_member(tenant, account1, db_session_with_containers, role="owner") # Try to create second owner (should fail) with pytest.raises(Exception, match="Tenant already has an owner"): - TenantService.create_tenant_member(tenant, account2, role="owner") + TenantService.create_tenant_member(tenant, account2, db_session_with_containers, role="owner") def test_create_tenant_member_existing_member( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1349,11 +1349,11 @@ class TestTenantService: ) # Create member with initial role - tenant_member1 = TenantService.create_tenant_member(tenant, account, role="normal") + tenant_member1 = TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="normal") assert tenant_member1.role == "normal" # Update member role - tenant_member2 = TenantService.create_tenant_member(tenant, account, role="editor") + tenant_member2 = TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="editor") assert tenant_member2.tenant_id == tenant_member1.tenant_id assert tenant_member2.account_id == tenant_member1.account_id assert tenant_member2.role == "editor" @@ -1384,8 +1384,8 @@ class TestTenantService: tenant2 = TenantService.create_tenant(name=tenant2_name) # Add account to both tenants - TenantService.create_tenant_member(tenant1, account, role="normal") - TenantService.create_tenant_member(tenant2, account, role="admin") + TenantService.create_tenant_member(tenant1, account, db_session_with_containers, role="normal") + TenantService.create_tenant_member(tenant2, account, db_session_with_containers, role="admin") # Get join tenants join_tenants = TenantService.get_join_tenants(account) @@ -1421,7 +1421,7 @@ class TestTenantService: tenant = TenantService.create_tenant(name=tenant_name) # Add account to tenant and set as current - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="owner") account.current_tenant = tenant db_session_with_containers.commit() @@ -1486,8 +1486,8 @@ class TestTenantService: tenant2 = TenantService.create_tenant(name=tenant2_name) # Add account to both tenants - TenantService.create_tenant_member(tenant1, account, role="owner") - TenantService.create_tenant_member(tenant2, account, role="admin") + TenantService.create_tenant_member(tenant1, account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant2, account, db_session_with_containers, role="admin") # Set initial current tenant account.current_tenant = tenant1 @@ -1588,8 +1588,8 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, admin_account, role="admin") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, admin_account, db_session_with_containers, role="admin") # Check if tenant has owner role from models.account import TenantAccountRole @@ -1648,7 +1648,7 @@ class TestTenantService: ) # Add account to tenant with specific role - TenantService.create_tenant_member(tenant, account, role="editor") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="editor") # Get user role user_role = TenantService.get_user_role(account, tenant) @@ -1690,8 +1690,8 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, member_account, role="normal") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, member_account, db_session_with_containers, role="normal") # Check owner permission to add member (should succeed) TenantService.check_member_permission(tenant, owner_account, member_account, "add") @@ -1723,7 +1723,7 @@ class TestTenantService: ) # Add account to tenant - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="owner") # Try to check permission with invalid action with pytest.raises(Exception, match="Invalid action"): @@ -1755,7 +1755,7 @@ class TestTenantService: ) # Add account to tenant - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="owner") # Try to check permission to operate self with pytest.raises(Exception, match="Cannot operate self"): @@ -1796,11 +1796,35 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, member_account, role="normal") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, member_account, db_session_with_containers, role="normal") + + app = App( + tenant_id=tenant.id, + name="Member app", + mode="chat", + enable_site=True, + enable_api=True, + created_by=member_account.id, + maintainer=member_account.id, + ) + dataset = Dataset( + tenant_id=tenant.id, + name="Member dataset", + created_by=member_account.id, + maintainer=member_account.id, + ) + db_session_with_containers.add_all([app, dataset]) + db_session_with_containers.commit() # Remove member - with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: + with ( + patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, + patch( + "services.account_service.AccountService.get_rbac_workspace_owner_account_id", + return_value=owner_account.id, + ), + ): mock_sync.return_value = True TenantService.remove_member_from_tenant(tenant, member_account, owner_account) @@ -1819,6 +1843,12 @@ class TestTenantService: .first() ) assert member_join is None + db_session_with_containers.refresh(app) + db_session_with_containers.refresh(dataset) + assert app.created_by == member_account.id + assert app.maintainer == owner_account.id + assert dataset.created_by == member_account.id + assert dataset.maintainer == owner_account.id def test_remove_member_from_tenant_operate_self( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1846,7 +1876,7 @@ class TestTenantService: ) # Add account to tenant - TenantService.create_tenant_member(tenant, account, role="owner") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="owner") # Try to remove self with pytest.raises(Exception, match="Cannot operate self"): @@ -1887,7 +1917,7 @@ class TestTenantService: ) # Add only owner to tenant - TenantService.create_tenant_member(tenant, owner_account, role="owner") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") # Try to remove non-member with pytest.raises(Exception, match="Member not in tenant"): @@ -1926,8 +1956,8 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, member_account, role="normal") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, member_account, db_session_with_containers, role="normal") # Update member role TenantService.update_member_role(tenant, member_account, "admin", owner_account) @@ -1975,8 +2005,8 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, member_account, role="admin") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, member_account, db_session_with_containers, role="admin") # Update member role to owner TenantService.update_member_role(tenant, member_account, "owner", owner_account) @@ -2032,8 +2062,8 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, member_account, role="admin") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, member_account, db_session_with_containers, role="admin") # Try to update member role to already assigned role with pytest.raises(Exception, match="The provided role is already assigned to the member"): @@ -2130,7 +2160,7 @@ class TestTenantService: password=password, ) existing_tenant = TenantService.create_tenant(name=existing_tenant_name) - TenantService.create_tenant_member(existing_tenant, account, role="owner") + TenantService.create_tenant_member(existing_tenant, account, db_session_with_containers, role="owner") account.current_tenant = existing_tenant db_session_with_containers.commit() @@ -2213,9 +2243,9 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, admin_account, role="admin") - TenantService.create_tenant_member(tenant, normal_account, role="normal") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member(tenant, admin_account, db_session_with_containers, role="admin") + TenantService.create_tenant_member(tenant, normal_account, db_session_with_containers, role="normal") # Get tenant members members = TenantService.get_tenant_members(tenant) @@ -2279,9 +2309,11 @@ class TestTenantService: ) # Add members with different roles - TenantService.create_tenant_member(tenant, owner_account, role="owner") - TenantService.create_tenant_member(tenant, dataset_operator_account, role="dataset_operator") - TenantService.create_tenant_member(tenant, normal_account, role="normal") + TenantService.create_tenant_member(tenant, owner_account, db_session_with_containers, role="owner") + TenantService.create_tenant_member( + tenant, dataset_operator_account, db_session_with_containers, role="dataset_operator" + ) + TenantService.create_tenant_member(tenant, normal_account, db_session_with_containers, role="normal") # Get dataset operator members dataset_operators = TenantService.get_dataset_operator_members(tenant) @@ -2712,7 +2744,7 @@ class TestRegisterService: interface_language="en-US", password=inviter_password, ) - TenantService.create_tenant_member(tenant, inviter, role="owner") + TenantService.create_tenant_member(tenant, inviter, db_session_with_containers, role="owner") # Mock the email task with patch("services.account_service.send_invite_member_mail_task") as mock_send_mail: @@ -2755,7 +2787,7 @@ class TestRegisterService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test inviting an existing member who is not in the tenant yet. + Test inviting an existing active account who is not in the tenant yet. """ fake = Faker() tenant_name = fake.company() @@ -2778,7 +2810,7 @@ class TestRegisterService: interface_language="en-US", password=inviter_password, ) - TenantService.create_tenant_member(tenant, inviter, role="owner") + TenantService.create_tenant_member(tenant, inviter, db_session_with_containers, role="owner") # Create existing account existing_account = AccountService.create_account( @@ -2791,20 +2823,20 @@ class TestRegisterService: # Mock the email task with patch("services.account_service.send_invite_member_mail_task") as mock_send_mail: mock_send_mail.delay.return_value = None - with pytest.raises(AccountAlreadyInTenantError, match="Account already in tenant."): - # Execute invitation - token = RegisterService.invite_new_member( - tenant=tenant, - email=existing_member_email, - language=language, - role="admin", - inviter=inviter, - ) - # Verify email task was not called - mock_send_mail.delay.assert_not_called() + token = RegisterService.invite_new_member( + tenant=tenant, + email=existing_member_email, + language=language, + role="admin", + inviter=inviter, + ) - # Verify tenant member was created for existing account + assert token is not None + assert len(token) > 0 + mock_send_mail.delay.assert_called_once() + + # Existing active accounts must accept the invite before becoming workspace members. from models.account import TenantAccountJoin tenant_join = ( @@ -2812,8 +2844,13 @@ class TestRegisterService: .filter_by(tenant_id=tenant.id, account_id=existing_account.id) .first() ) - assert tenant_join is not None - assert tenant_join.role == "admin" + assert tenant_join is None + + invitation = RegisterService.get_invitation_if_token_valid(None, None, token) + assert invitation is not None + assert invitation["account"].id == existing_account.id + assert invitation["data"]["role"] == "admin" + assert invitation["data"]["requires_setup"] is False def test_invite_new_member_existing_member( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -2842,7 +2879,7 @@ class TestRegisterService: interface_language="en-US", password=inviter_password, ) - TenantService.create_tenant_member(tenant, inviter, role="owner") + TenantService.create_tenant_member(tenant, inviter, db_session_with_containers, role="owner") # Create existing account with pending status existing_account = AccountService.create_account( @@ -2856,7 +2893,7 @@ class TestRegisterService: db_session_with_containers.commit() # Add existing account to tenant - TenantService.create_tenant_member(tenant, existing_account, role="normal") + TenantService.create_tenant_member(tenant, existing_account, db_session_with_containers, role="normal") # Mock the email task with patch("services.account_service.send_invite_member_mail_task") as mock_send_mail: @@ -2932,7 +2969,7 @@ class TestRegisterService: interface_language="en-US", password=inviter_password, ) - TenantService.create_tenant_member(tenant, inviter, role="owner") + TenantService.create_tenant_member(tenant, inviter, db_session_with_containers, role="owner") # Create existing account with active status existing_account = AccountService.create_account( @@ -2946,7 +2983,7 @@ class TestRegisterService: db_session_with_containers.commit() # Add existing account to tenant - TenantService.create_tenant_member(tenant, existing_account, role="normal") + TenantService.create_tenant_member(tenant, existing_account, db_session_with_containers, role="normal") # Execute invitation (should fail for active member) with pytest.raises(AccountAlreadyInTenantError, match="Account already in tenant."): @@ -3158,7 +3195,7 @@ class TestRegisterService: interface_language="en-US", password=password, ) - TenantService.create_tenant_member(tenant, account, role="normal") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="normal") # Generate a real token token = RegisterService.generate_invite_token(tenant, account) @@ -3278,7 +3315,7 @@ class TestRegisterService: interface_language="en-US", password=password, ) - TenantService.create_tenant_member(tenant, account, role="normal") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="normal") # Create a real token but with mismatched account ID from extensions.ext_redis import redis_client @@ -3328,7 +3365,7 @@ class TestRegisterService: interface_language="en-US", password=password, ) - TenantService.create_tenant_member(tenant, account, role="normal") + TenantService.create_tenant_member(tenant, account, db_session_with_containers, role="normal") # Change tenant status to non-normal tenant.status = TenantStatus.ARCHIVE 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 27e793915ab..21a768e3446 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 @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from core.plugin.impl.exc import PluginDaemonClientSideError from models import Account, AppMode, CreatorUserRole -from models.enums import ConversationFromSource, MessageFileBelongsTo +from models.enums import ConversationFromSource, EndUserType, MessageFileBelongsTo from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought from services.account_service import AccountService, TenantService from services.agent_service import AgentService @@ -388,7 +388,7 @@ class TestAgentService: id=fake.uuid4(), tenant_id=app.tenant_id, app_id=app.id, - type="web_app", + type=EndUserType.BROWSER, is_anonymous=False, session_id=fake.uuid4(), name=fake.name(), diff --git a/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py b/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py index b8e022503fd..8bd4069639f 100644 --- a/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py +++ b/api/tests/test_containers_integration_tests/services/test_api_based_extension_service.py @@ -81,7 +81,7 @@ class TestAPIBasedExtensionService: ) # Save extension - saved_extension = APIBasedExtensionService.save(extension_data) + saved_extension = APIBasedExtensionService.save(db_session_with_containers, extension_data) # Verify extension was saved correctly assert saved_extension.id is not None @@ -119,21 +119,21 @@ class TestAPIBasedExtensionService: ) with pytest.raises(ValueError, match="name must not be empty"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) # Test empty api_endpoint extension_data.name = fake.company() extension_data.api_endpoint = "" with pytest.raises(ValueError, match="api_endpoint must not be empty"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) # Test empty api_key extension_data.api_endpoint = f"https://{fake.domain_name()}/api" extension_data.api_key = "" with pytest.raises(ValueError, match="api_key must not be empty"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_get_all_by_tenant_id_success( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -157,11 +157,11 @@ class TestAPIBasedExtensionService: api_key=fake.password(length=20), ) - saved_extension = APIBasedExtensionService.save(extension_data) + saved_extension = APIBasedExtensionService.save(db_session_with_containers, extension_data) extensions.append(saved_extension) # Get all extensions for tenant - extension_list = APIBasedExtensionService.get_all_by_tenant_id(tenant.id) + extension_list = APIBasedExtensionService.get_all_by_tenant_id(db_session_with_containers, tenant.id) # Verify results assert len(extension_list) == 3 @@ -191,10 +191,12 @@ class TestAPIBasedExtensionService: api_key=fake.password(length=20), ) - created_extension = APIBasedExtensionService.save(extension_data) + created_extension = APIBasedExtensionService.save(db_session_with_containers, extension_data) # Get extension by ID - retrieved_extension = APIBasedExtensionService.get_with_tenant_id(tenant.id, created_extension.id) + retrieved_extension = APIBasedExtensionService.get_with_tenant_id( + db_session_with_containers, tenant.id, created_extension.id + ) # Verify extension was retrieved correctly assert retrieved_extension is not None @@ -219,7 +221,9 @@ class TestAPIBasedExtensionService: # Try to get non-existent extension with pytest.raises(ValueError, match="API based extension is not found"): - APIBasedExtensionService.get_with_tenant_id(tenant.id, non_existent_extension_id) + APIBasedExtensionService.get_with_tenant_id( + db_session_with_containers, tenant.id, non_existent_extension_id + ) def test_delete_extension_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ @@ -238,11 +242,11 @@ class TestAPIBasedExtensionService: api_key=fake.password(length=20), ) - created_extension = APIBasedExtensionService.save(extension_data) + created_extension = APIBasedExtensionService.save(db_session_with_containers, extension_data) extension_id = created_extension.id # Delete the extension - APIBasedExtensionService.delete(created_extension) + APIBasedExtensionService.delete(db_session_with_containers, created_extension) # Verify extension was deleted @@ -270,7 +274,7 @@ class TestAPIBasedExtensionService: api_key=fake.password(length=20), ) - APIBasedExtensionService.save(extension_data1) + APIBasedExtensionService.save(db_session_with_containers, extension_data1) # Try to create second extension with same name extension_data2 = APIBasedExtension( tenant_id=tenant.id, @@ -280,7 +284,7 @@ class TestAPIBasedExtensionService: ) with pytest.raises(ValueError, match="name must be unique, it is already existed"): - APIBasedExtensionService.save(extension_data2) + APIBasedExtensionService.save(db_session_with_containers, extension_data2) def test_save_extension_update_existing( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -301,7 +305,7 @@ class TestAPIBasedExtensionService: api_key=fake.password(length=20), ) - created_extension = APIBasedExtensionService.save(extension_data) + created_extension = APIBasedExtensionService.save(db_session_with_containers, extension_data) # Save original values for later comparison original_name = created_extension.name @@ -320,7 +324,7 @@ class TestAPIBasedExtensionService: created_extension.api_endpoint = new_endpoint created_extension.api_key = new_api_key - updated_extension = APIBasedExtensionService.save(created_extension) + updated_extension = APIBasedExtensionService.save(db_session_with_containers, created_extension) # Verify extension was updated correctly assert updated_extension.id == created_extension.id @@ -336,7 +340,9 @@ class TestAPIBasedExtensionService: assert mock_external_service_dependencies["requestor_instance"].request.call_count == 2 # Verify the update by retrieving the extension again - retrieved_extension = APIBasedExtensionService.get_with_tenant_id(tenant.id, created_extension.id) + retrieved_extension = APIBasedExtensionService.get_with_tenant_id( + db_session_with_containers, tenant.id, created_extension.id + ) assert retrieved_extension.name == new_name assert retrieved_extension.api_endpoint == new_endpoint assert retrieved_extension.api_key == new_api_key # Should be decrypted when retrieved @@ -367,7 +373,7 @@ class TestAPIBasedExtensionService: # Try to save extension with connection error with pytest.raises(ValueError, match="connection error: request timeout"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_save_extension_invalid_api_key_length( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -390,7 +396,7 @@ class TestAPIBasedExtensionService: # Try to save extension with short API key with pytest.raises(ValueError, match="api_key must be at least 5 characters"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_save_extension_empty_fields(self, db_session_with_containers: Session, mock_external_service_dependencies): """ @@ -410,21 +416,21 @@ class TestAPIBasedExtensionService: ) with pytest.raises(ValueError, match="name must not be empty"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) # Test with None api_endpoint extension_data.name = fake.company() extension_data.api_endpoint = None with pytest.raises(ValueError, match="api_endpoint must not be empty"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) # Test with None api_key extension_data.api_endpoint = f"https://{fake.domain_name()}/api" extension_data.api_key = None with pytest.raises(ValueError, match="api_key must not be empty"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_get_all_by_tenant_id_empty_list( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -438,7 +444,7 @@ class TestAPIBasedExtensionService: ) # Get all extensions for tenant (none exist) - extension_list = APIBasedExtensionService.get_all_by_tenant_id(tenant.id) + extension_list = APIBasedExtensionService.get_all_by_tenant_id(db_session_with_containers, tenant.id) # Verify empty list is returned assert len(extension_list) == 0 @@ -468,7 +474,7 @@ class TestAPIBasedExtensionService: # Try to save extension with invalid ping response with pytest.raises(ValueError, match="{'result': 'invalid'}"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_save_extension_missing_ping_result( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -494,7 +500,7 @@ class TestAPIBasedExtensionService: # Try to save extension with missing ping result with pytest.raises(ValueError, match="{'status': 'ok'}"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_get_with_tenant_id_wrong_tenant( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -520,11 +526,11 @@ class TestAPIBasedExtensionService: api_key=fake.password(length=20), ) - created_extension = APIBasedExtensionService.save(extension_data) + created_extension = APIBasedExtensionService.save(db_session_with_containers, extension_data) # Try to get extension with wrong tenant ID with pytest.raises(ValueError, match="API based extension is not found"): - APIBasedExtensionService.get_with_tenant_id(tenant2.id, created_extension.id) + APIBasedExtensionService.get_with_tenant_id(db_session_with_containers, tenant2.id, created_extension.id) def test_save_extension_api_key_exactly_four_chars_rejected( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -544,7 +550,7 @@ class TestAPIBasedExtensionService: ) with pytest.raises(ValueError, match="api_key must be at least 5 characters"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_save_extension_api_key_exactly_five_chars_accepted( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -563,7 +569,7 @@ class TestAPIBasedExtensionService: api_key="12345", ) - saved = APIBasedExtensionService.save(extension_data) + saved = APIBasedExtensionService.save(db_session_with_containers, extension_data) assert saved.id is not None def test_save_extension_requestor_constructor_error( @@ -586,7 +592,7 @@ class TestAPIBasedExtensionService: ) with pytest.raises(ValueError, match="connection error: bad config"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_save_extension_network_exception( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -610,7 +616,7 @@ class TestAPIBasedExtensionService: ) with pytest.raises(ValueError, match="connection error: network failure"): - APIBasedExtensionService.save(extension_data) + APIBasedExtensionService.save(db_session_with_containers, extension_data) def test_save_extension_update_duplicate_name_rejected( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -623,26 +629,28 @@ class TestAPIBasedExtensionService: assert tenant is not None ext1 = APIBasedExtensionService.save( + db_session_with_containers, APIBasedExtension( tenant_id=tenant.id, name="Extension Alpha", api_endpoint=f"https://{fake.domain_name()}/api", api_key=fake.password(length=20), - ) + ), ) ext2 = APIBasedExtensionService.save( + db_session_with_containers, APIBasedExtension( tenant_id=tenant.id, name="Extension Beta", api_endpoint=f"https://{fake.domain_name()}/api", api_key=fake.password(length=20), - ) + ), ) # Try to rename ext2 to ext1's name ext2.name = "Extension Alpha" with pytest.raises(ValueError, match="name must be unique, it is already existed"): - APIBasedExtensionService.save(ext2) + APIBasedExtensionService.save(db_session_with_containers, ext2) def test_get_all_returns_empty_for_different_tenant( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -658,14 +666,15 @@ class TestAPIBasedExtensionService: assert tenant1 is not None APIBasedExtensionService.save( + db_session_with_containers, APIBasedExtension( tenant_id=tenant1.id, name=fake.company(), api_endpoint=f"https://{fake.domain_name()}/api", api_key=fake.password(length=20), - ) + ), ) assert tenant2 is not None - result = APIBasedExtensionService.get_all_by_tenant_id(tenant2.id) + result = APIBasedExtensionService.get_all_by_tenant_id(db_session_with_containers, tenant2.id) assert result == [] 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 1bc3e559651..f8482f99c00 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 @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom from models import App +from models.enums import EndUserType from models.model import EndUser from models.workflow import Workflow from services.app_generate_service import AppGenerateService @@ -446,7 +447,7 @@ class TestAppGenerateService: end_user = EndUser( tenant_id=account.current_tenant.id, app_id=app.id, - type="normal", + type=EndUserType.BROWSER, external_user_id=fake.uuid4(), name=fake.name(), is_anonymous=False, @@ -831,7 +832,7 @@ class TestAppGenerateService: end_user = EndUser( tenant_id=account.current_tenant.id, app_id=app.id, - type="normal", + type=EndUserType.BROWSER, external_user_id=fake.uuid4(), name=fake.name(), is_anonymous=False, 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 384f83fce3e..0f5cd184430 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 @@ -96,6 +96,7 @@ class TestAppService: assert app.api_rph == app_params.api_rph assert app.api_rpm == app_params.api_rpm assert app.created_by == account.id + assert app.maintainer == account.id assert app.updated_by == account.id assert app.status == "normal" assert app.enable_site is True @@ -234,7 +235,7 @@ class TestAppService: # Get paginated apps params = AppListParams(page=1, limit=10, mode="chat") - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params, db_session_with_containers) # Verify pagination results assert paginated_apps is not None @@ -295,7 +296,7 @@ class TestAppService: db_session_with_containers.commit() last_modified_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat"), db_session_with_containers ) assert last_modified_apps is not None assert [app.name for app in last_modified_apps.items] == [ @@ -305,7 +306,10 @@ class TestAppService: ] recently_created_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="recently_created") + account.id, + tenant.id, + AppListParams(page=1, limit=10, mode="chat", sort_by="recently_created"), + db_session_with_containers, ) assert recently_created_apps is not None assert [app.name for app in recently_created_apps.items] == [ @@ -315,7 +319,10 @@ class TestAppService: ] earliest_created_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created") + account.id, + tenant.id, + AppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created"), + db_session_with_containers, ) assert earliest_created_apps is not None assert [app.name for app in earliest_created_apps.items] == [ @@ -366,7 +373,7 @@ class TestAppService: assert star_count == 1 paginated_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat"), db_session_with_containers ) assert paginated_apps is not None starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items} @@ -377,7 +384,7 @@ class TestAppService: db_session_with_containers.commit() paginated_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat") + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat"), db_session_with_containers ) assert paginated_apps is not None starred_by_app_id = {app.id: app.is_starred for app in paginated_apps.items} @@ -442,7 +449,7 @@ class TestAppService: db_session_with_containers.commit() last_modified_apps = app_service.get_paginate_starred_apps( - account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat") + account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat"), db_session_with_containers ) assert last_modified_apps is not None assert [app.name for app in last_modified_apps.items] == [ @@ -457,6 +464,7 @@ class TestAppService: account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat", sort_by="recently_created"), + db_session_with_containers, ) assert recently_created_apps is not None assert [app.name for app in recently_created_apps.items] == [ @@ -469,6 +477,7 @@ class TestAppService: account.id, tenant.id, StarredAppListParams(page=1, limit=10, mode="chat", sort_by="earliest_created"), + db_session_with_containers, ) assert earliest_created_apps is not None assert [app.name for app in earliest_created_apps.items] == [ @@ -522,20 +531,25 @@ class TestAppService: completion_app = app_service.create_app(tenant.id, completion_app_params, account) # Test filter by mode - chat_apps = app_service.get_paginate_apps(account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")) + chat_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat"), db_session_with_containers + ) assert len(chat_apps.items) == 1 assert chat_apps.items[0].mode == "chat" # Test filter by name filtered_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", name="Chat") + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", name="Chat"), db_session_with_containers ) assert len(filtered_apps.items) == 1 assert "Chat" in filtered_apps.items[0].name # Test filter by created_by_me my_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(page=1, limit=10, mode="completion", is_created_by_me=True) + account.id, + tenant.id, + AppListParams(page=1, limit=10, mode="completion", is_created_by_me=True), + db_session_with_containers, ) assert len(my_apps.items) == 1 @@ -588,6 +602,7 @@ class TestAppService: first_account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", creator_ids=[second_account.id]), + db_session_with_containers, ) assert filtered_apps is not None @@ -635,10 +650,12 @@ class TestAppService: # Test with tag filter params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["tag1", "tag2"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params, db_session_with_containers) # Verify tag service was called - mock_tag_service.assert_called_once_with("app", tenant.id, ["tag1", "tag2"], match_all=True) + mock_tag_service.assert_called_once_with( + "app", tenant.id, ["tag1", "tag2"], db_session_with_containers, match_all=True + ) # Verify results assert paginated_apps is not None @@ -651,7 +668,7 @@ class TestAppService: params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["nonexistent_tag"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params, db_session_with_containers) # Should return None when no apps match tag filter assert paginated_apps is None @@ -1467,7 +1484,7 @@ class TestAppService: # Test 1: Search with % character paginated_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10), db_session_with_containers ) assert paginated_apps is not None assert paginated_apps.total == 1 @@ -1476,7 +1493,10 @@ class TestAppService: # Test 2: Search with _ character paginated_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(name="test_data", mode="chat", page=1, limit=10) + account.id, + tenant.id, + AppListParams(name="test_data", mode="chat", page=1, limit=10), + db_session_with_containers, ) assert paginated_apps is not None assert paginated_apps.total == 1 @@ -1485,7 +1505,10 @@ class TestAppService: # Test 3: Search with \ character paginated_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(name="path\\to\\app", mode="chat", page=1, limit=10) + account.id, + tenant.id, + AppListParams(name="path\\to\\app", mode="chat", page=1, limit=10), + db_session_with_containers, ) assert paginated_apps is not None assert paginated_apps.total == 1 @@ -1494,7 +1517,7 @@ class TestAppService: # Test 4: Search with % should NOT match 100% (verifies escaping works) paginated_apps = app_service.get_paginate_apps( - account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10), db_session_with_containers ) assert paginated_apps is not None assert paginated_apps.total == 1 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 5f3914eb19d..b19b6b9c984 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 @@ -12,7 +12,7 @@ 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.enums import ConversationFromSource, EndUserType from models.model import App, Conversation, EndUser, Message, MessageAnnotation from services.annotation_service import AppAnnotationService from services.conversation_service import ConversationService @@ -76,7 +76,7 @@ class ConversationServiceIntegrationTestDataFactory: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, external_user_id=f"external-{uuid4()}", name="End User", is_anonymous=False, 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 853630ad65c..33d4563904e 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 @@ -12,7 +12,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from graphon.variables import FloatVariable, IntegerVariable, StringVariable from models.account import Account, Tenant, TenantAccountJoin -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import App, Conversation, EndUser from models.workflow import ConversationVariable from services.conversation_service import ConversationService @@ -78,7 +78,7 @@ class ConversationServiceVariableIntegrationFactory: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type=InvokeFrom.SERVICE_API.value, + type=EndUserType.SERVICE_API, external_user_id=f"external-{uuid4()}", name=f"End User {uuid4()}", is_anonymous=False, 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 f9898e2cfac..1ea1b10a15d 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 @@ -73,6 +73,7 @@ class DatasetPermissionTestDataFactory: data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique=IndexTechniqueType.HIGH_QUALITY, created_by=created_by, + maintainer=created_by, permission=permission, provider="vendor", retrieval_model={"top_k": 2}, 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 e6ee896a525..201b65b30d0 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 @@ -441,6 +441,7 @@ class TestDatasetServiceCreateRagPipelineDataset: assert created_dataset.name == entity.name assert created_dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE assert created_dataset.created_by == account.id + assert created_dataset.maintainer == account.id assert created_dataset.permission == DatasetPermissionEnum.ONLY_ME assert created_pipeline is not None assert created_pipeline.name == entity.name 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 0c089e506bf..946ac661940 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 @@ -30,11 +30,13 @@ class DocumentServiceIntegrationFactory: created_by: str | None = None, name: str | None = None, ) -> Dataset: + resolved_created_by = created_by or str(uuid4()) dataset = Dataset( tenant_id=tenant_id or str(uuid4()), name=name or f"dataset-{uuid4()}", data_source_type=DataSourceType.UPLOAD_FILE, - created_by=created_by or str(uuid4()), + created_by=resolved_created_by, + maintainer=resolved_created_by, ) db_session_with_containers.add(dataset) db_session_with_containers.commit() 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 0603a1e27f1..d97c16668fd 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 @@ -100,6 +100,7 @@ class DatasetPermissionIntegrationFactory: data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique=indexing_technique, created_by=created_by, + maintainer=created_by, provider="vendor", permission=permission, retrieval_model={"top_k": 2}, 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 4e2bf9fc103..05632b1ec2a 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 @@ -106,6 +106,7 @@ class DatasetRetrievalTestDataFactory: data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique=IndexTechniqueType.HIGH_QUALITY, created_by=created_by, + maintainer=created_by, permission=permission, provider="vendor", retrieval_model={"top_k": 2}, @@ -227,7 +228,7 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id) + datasets, total = DatasetService.get_datasets(page, per_page, db_session_with_containers, tenant_id=tenant.id) # Assert assert len(datasets) == 5 @@ -257,7 +258,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, search=search) + datasets, total = DatasetService.get_datasets( + page, per_page, db_session_with_containers, tenant_id=tenant.id, search=search + ) # Assert assert len(datasets) == 1 @@ -301,7 +304,9 @@ class TestDatasetServiceGetDatasets: tag_ids = [tag_1.id, tag_2.id] # Act - datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, tag_ids=tag_ids) + datasets, total = DatasetService.get_datasets( + page, per_page, db_session_with_containers, tenant_id=tenant.id, tag_ids=tag_ids + ) # Assert assert len(datasets) == 1 @@ -326,7 +331,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, tag_ids=tag_ids) + datasets, total = DatasetService.get_datasets( + page, per_page, db_session_with_containers, tenant_id=tenant.id, tag_ids=tag_ids + ) # Assert # When tag_ids is empty, tag filtering is skipped, so normal query results are returned @@ -356,7 +363,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page, per_page, tenant_id=tenant.id, user=None) + datasets, total = DatasetService.get_datasets( + page, per_page, db_session_with_containers, tenant_id=tenant.id, user=None + ) # Assert assert len(datasets) == 1 @@ -384,6 +393,7 @@ class TestDatasetServiceGetDatasets: datasets, total = DatasetService.get_datasets( page=1, per_page=20, + session=db_session_with_containers, tenant_id=tenant.id, user=owner, include_all=True, @@ -408,7 +418,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=user) + datasets, total = DatasetService.get_datasets( + page=1, per_page=20, session=db_session_with_containers, tenant_id=tenant.id, user=user + ) # Assert assert len(datasets) == 1 @@ -432,7 +444,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=user) + datasets, total = DatasetService.get_datasets( + page=1, per_page=20, session=db_session_with_containers, tenant_id=tenant.id, user=user + ) # Assert assert len(datasets) == 1 @@ -459,7 +473,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=user) + datasets, total = DatasetService.get_datasets( + page=1, per_page=20, session=db_session_with_containers, tenant_id=tenant.id, user=user + ) # Assert assert len(datasets) == 1 @@ -486,7 +502,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=operator) + datasets, total = DatasetService.get_datasets( + page=1, per_page=20, session=db_session_with_containers, tenant_id=tenant.id, user=operator + ) # Assert assert len(datasets) == 1 @@ -509,7 +527,9 @@ class TestDatasetServiceGetDatasets: ) # Act - datasets, total = DatasetService.get_datasets(page=1, per_page=20, tenant_id=tenant.id, user=operator) + datasets, total = DatasetService.get_datasets( + page=1, per_page=20, session=db_session_with_containers, tenant_id=tenant.id, user=operator + ) # Assert assert datasets == [] 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 998b3378e2c..b6104f94f21 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 @@ -1,15 +1,14 @@ from __future__ import annotations import logging -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.enums import EndUserType from models.model import App, DefaultEndUserSessionID, EndUser from services.end_user_service import EndUserService @@ -72,7 +71,7 @@ class TestEndUserServiceFactory: tenant_id: str, app_id: str, session_id: str, - invoke_type: InvokeFrom, + invoke_type: EndUserType, is_anonymous: bool = False, ): end_user = EndUser( @@ -120,7 +119,7 @@ class TestEndUserServiceGetOrCreateEndUser: assert result.tenant_id == app.tenant_id assert result.app_id == app.id assert result.session_id == user_id - assert result.type == InvokeFrom.SERVICE_API + assert result.type == EndUserType.SERVICE_API assert result.is_anonymous is False def test_get_or_create_end_user_without_user_id( @@ -148,7 +147,7 @@ class TestEndUserServiceGetOrCreateEndUser: tenant_id=app.tenant_id, app_id=app.id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) # Act @@ -163,7 +162,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: Unit tests for EndUserService.get_or_create_end_user_by_type method. This test suite covers: - - Creating end users with different InvokeFrom types + - Creating end users with different EndUserType values - Type migration for legacy users - Query ordering and prioritization - Session management @@ -186,22 +185,22 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, ) # Assert - assert result.type == InvokeFrom.SERVICE_API + assert result.type == EndUserType.SERVICE_API assert result.tenant_id == tenant_id assert result.app_id == app_id assert result.session_id == user_id - def test_create_end_user_web_app_type( + def test_create_end_user_browser_type( self, db_session_with_containers: Session, factory: TestEndUserServiceFactory ): - """Test creating new end user with WEB_APP type.""" + """Test creating new end user with BROWSER type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) tenant_id = app.tenant_id @@ -210,14 +209,14 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.WEB_APP, + type=EndUserType.BROWSER, tenant_id=tenant_id, app_id=app_id, user_id=user_id, ) # Assert - assert result.type == InvokeFrom.WEB_APP + assert result.type == EndUserType.BROWSER def test_upgrade_legacy_end_user_type( self, caplog: pytest.LogCaptureFixture, db_session_with_containers: Session, factory: TestEndUserServiceFactory @@ -235,12 +234,12 @@ class TestEndUserServiceGetOrCreateEndUserByType: tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) with caplog.at_level(logging.INFO, logger="services.end_user_service"): # Act - Request with different type result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.WEB_APP, + type=EndUserType.BROWSER, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -248,7 +247,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Assert assert result.id == existing_user.id - assert result.type == InvokeFrom.WEB_APP # Type should be updated + assert result.type == EndUserType.BROWSER # Type should be updated matching_logs = [ record for record in caplog.records @@ -259,9 +258,8 @@ class TestEndUserServiceGetOrCreateEndUserByType: assert len(matching_logs) == 1 - @patch("services.end_user_service.logger") def test_get_existing_end_user_matching_type( - self, mock_logger, db_session_with_containers: Session, factory: TestEndUserServiceFactory + self, db_session_with_containers: Session, factory: TestEndUserServiceFactory, caplog ): """Test retrieving existing end user with matching type.""" # Arrange @@ -275,21 +273,23 @@ class TestEndUserServiceGetOrCreateEndUserByType: tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) # Act - Request with same type - result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, - tenant_id=tenant_id, - app_id=app_id, - user_id=user_id, - ) + with caplog.at_level(logging.INFO, logger="services.end_user_service"): + result = EndUserService.get_or_create_end_user_by_type( + type=EndUserType.SERVICE_API, + tenant_id=tenant_id, + app_id=app_id, + user_id=user_id, + ) # Assert assert result.id == existing_user.id - assert result.type == InvokeFrom.SERVICE_API - mock_logger.info.assert_not_called() + assert result.type == EndUserType.SERVICE_API + # No legacy-upgrade log should be emitted when the existing user's type already matches. + assert [record for record in caplog.records if record.levelno == logging.INFO] == [] def test_create_anonymous_user_with_default_session( self, db_session_with_containers: Session, factory: TestEndUserServiceFactory @@ -302,7 +302,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=None, @@ -329,19 +329,19 @@ class TestEndUserServiceGetOrCreateEndUserByType: tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.WEB_APP, + invoke_type=EndUserType.BROWSER, ) matching = factory.create_end_user( db_session_with_containers, tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -363,7 +363,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -376,16 +376,16 @@ class TestEndUserServiceGetOrCreateEndUserByType: @pytest.mark.parametrize( "invoke_type", [ - InvokeFrom.SERVICE_API, - InvokeFrom.WEB_APP, - InvokeFrom.EXPLORE, - InvokeFrom.DEBUGGER, + EndUserType.SERVICE_API, + EndUserType.BROWSER, + EndUserType.OPENAPI, + EndUserType.TRIGGER, ], ) def test_create_end_user_with_different_invoke_types( - self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory + self, db_session_with_containers: Session, invoke_type: EndUserType, factory: TestEndUserServiceFactory ): - """Test creating end users with different InvokeFrom types.""" + """Test creating end users with different EndUserType values.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) tenant_id = app.tenant_id @@ -421,7 +421,7 @@ class TestEndUserServiceGetEndUserById: tenant_id=app.tenant_id, app_id=app.id, session_id=f"session-{uuid4()}", - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) result = EndUserService.get_end_user_by_id( @@ -487,7 +487,7 @@ class TestEndUserServiceCreateBatch: 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" + type=EndUserType.SERVICE_API, tenant_id=str(uuid4()), app_ids=[], user_id="user-1" ) assert result == {} @@ -499,14 +499,14 @@ class TestEndUserServiceCreateBatch: user_id = f"user-{uuid4()}" result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) assert len(result) == 3 for app_id in app_ids: assert app_id in result assert result[app_id].session_id == user_id - assert result[app_id].type == InvokeFrom.SERVICE_API + assert result[app_id].type == EndUserType.SERVICE_API def test_create_batch_default_session_id( self, db_session_with_containers: Session, factory: TestEndUserServiceFactory @@ -515,7 +515,7 @@ class TestEndUserServiceCreateBatch: app_ids = [a.id for a in apps] result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id="" + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id="" ) assert len(result) == 2 @@ -531,7 +531,7 @@ class TestEndUserServiceCreateBatch: user_id = f"user-{uuid4()}" result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) assert len(result) == 2 @@ -545,12 +545,12 @@ class TestEndUserServiceCreateBatch: # Create batch first time first_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) # Create batch second time — should return existing users second_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) assert len(second_result) == 2 @@ -565,7 +565,7 @@ class TestEndUserServiceCreateBatch: # Create for first 2 apps first_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=[apps[0].id, apps[1].id], user_id=user_id, @@ -573,7 +573,7 @@ class TestEndUserServiceCreateBatch: # Create for all 3 apps — should reuse first 2, create 3rd all_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=[a.id for a in apps], user_id=user_id, @@ -586,10 +586,10 @@ class TestEndUserServiceCreateBatch: @pytest.mark.parametrize( "invoke_type", - [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER], + [EndUserType.SERVICE_API, EndUserType.BROWSER, EndUserType.OPENAPI, EndUserType.TRIGGER], ) def test_create_batch_all_invoke_types( - self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory + self, db_session_with_containers: Session, invoke_type: EndUserType, factory: TestEndUserServiceFactory ): 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 a678e37b41d..4e5bfc6ac1d 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 @@ -61,6 +61,7 @@ class TestFeatureService: }, "WebAppAuth": {"allowSso": True, "allowEmailCodeLogin": True, "allowEmailPasswordLogin": False}, "SSOEnforcedForWebProtocol": "oidc", + "EnableAppDeploy": True, "License": { "status": "active", "expiredAt": "2025-12-31", @@ -291,6 +292,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify enterprise features + assert result.enable_app_deploy is True assert result.branding.enabled is True assert result.webapp_auth.enabled is True assert result.enable_change_email is False @@ -377,6 +379,7 @@ class TestFeatureService: # Ensure that data required for frontend rendering remains accessible. # Branding should match the mock data + assert result.enable_app_deploy is True assert result.branding.enabled is True assert result.branding.application_title == "Test Enterprise" assert result.branding.login_page_logo == "https://example.com/logo.png" @@ -424,6 +427,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify basic configuration + assert result.enable_app_deploy is False assert result.branding.enabled is False assert result.webapp_auth.enabled is False assert result.enable_change_email is True @@ -625,6 +629,7 @@ class TestFeatureService: assert isinstance(result, SystemFeatureModel) # Verify enterprise features are disabled + assert result.enable_app_deploy is False assert result.branding.enabled is False assert result.webapp_auth.enabled is False assert result.enable_change_email is True 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 a4663450d49..e4fd81b53e7 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 @@ -7,7 +7,6 @@ from unittest import mock import pytest -from extensions.ext_database import db from models.enums import FeedbackFromSource, FeedbackRating from models.model import App, Conversation, Message from services.feedback_service import FeedbackService @@ -23,11 +22,9 @@ class TestFeedbackService: """Test FeedbackService methods.""" @pytest.fixture - def mock_db_session(self, monkeypatch: pytest.MonkeyPatch): - """Mock database session.""" - mock_session = mock.Mock() - monkeypatch.setattr(db, "session", mock_session) - return mock_session + def mock_db_session(self): + """Mock database session passed explicitly to the service.""" + return mock.Mock() @pytest.fixture def sample_data(self): @@ -100,7 +97,7 @@ class TestFeedbackService: ) # Test CSV export - result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") + result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="csv") # Verify response structure assert hasattr(result, "headers") @@ -131,7 +128,7 @@ class TestFeedbackService: ) # Test JSON export - result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") + result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="json") # Verify response structure assert hasattr(result, "headers") @@ -161,6 +158,7 @@ class TestFeedbackService: # Test with filters result = FeedbackService.export_feedbacks( + mock_db_session, app_id=sample_data["app"].id, from_source=FeedbackFromSource.ADMIN, rating=FeedbackRating.DISLIKE, @@ -177,7 +175,7 @@ class TestFeedbackService: """Test exporting feedback when no data exists.""" mock_db_session.execute.return_value = _execute_result([]) - result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") + result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="csv") # Should return an empty CSV with headers only assert hasattr(result, "headers") @@ -195,17 +193,22 @@ class TestFeedbackService: # Test with invalid start_date with pytest.raises(ValueError, match="Invalid start_date format"): - FeedbackService.export_feedbacks(app_id=sample_data["app"].id, start_date="invalid-date-format") + FeedbackService.export_feedbacks( + mock_db_session, app_id=sample_data["app"].id, start_date="invalid-date-format" + ) # Test with invalid end_date with pytest.raises(ValueError, match="Invalid end_date format"): - FeedbackService.export_feedbacks(app_id=sample_data["app"].id, end_date="invalid-date-format") + FeedbackService.export_feedbacks( + mock_db_session, app_id=sample_data["app"].id, end_date="invalid-date-format" + ) def test_export_feedbacks_invalid_format(self, mock_db_session, sample_data): """Test exporting feedback with unsupported format.""" with pytest.raises(ValueError, match="Unsupported format"): FeedbackService.export_feedbacks( + mock_db_session, app_id=sample_data["app"].id, format_type="xml", # Unsupported format ) @@ -236,7 +239,7 @@ class TestFeedbackService: ) # Test export - result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") + result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="json") # Check JSON content json_content = json.loads(result.get_data(as_text=True)) @@ -287,7 +290,7 @@ class TestFeedbackService: ) # Test export - result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") + result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="csv") # Check that unicode content is preserved csv_content = result.get_data(as_text=True) @@ -317,7 +320,7 @@ class TestFeedbackService: ) # Test export - result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") + result = FeedbackService.export_feedbacks(mock_db_session, app_id=sample_data["app"].id, format_type="json") # Check JSON content for emoji ratings json_content = json.loads(result.get_data(as_text=True)) 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 4532005836a..deb0d9d7d08 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 @@ -11,7 +11,7 @@ from werkzeug.exceptions import NotFound from configs import dify_config from extensions.storage.storage_type import StorageType from models import Account, Tenant -from models.enums import CreatorUserRole +from models.enums import CreatorUserRole, EndUserType from models.model import EndUser, UploadFile from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError from services.file_service import FileService @@ -112,7 +112,7 @@ class TestFileService: end_user = EndUser( tenant_id=str(fake.uuid4()), - type="web", + type=EndUserType.BROWSER, name=fake.name(), is_anonymous=False, session_id=fake.uuid4(), 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 1101d834a0d..5eb84f805aa 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 @@ -69,7 +69,7 @@ def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch 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()), []) == {} + assert FileService.get_upload_files_by_ids(db_session_with_containers, str(uuid4()), []) == {} def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_containers: Session) -> None: @@ -78,7 +78,7 @@ def test_get_upload_files_by_ids_returns_id_keyed_mapping(db_session_with_contai file1 = _create_upload_file(db_session_with_containers, tenant_id=tenant_id, key="k1", name="file1.txt") file2 = _create_upload_file(db_session_with_containers, tenant_id=tenant_id, key="k2", name="file2.txt") - result = FileService.get_upload_files_by_ids(tenant_id, [file1.id, file1.id, file2.id]) + result = FileService.get_upload_files_by_ids(db_session_with_containers, tenant_id, [file1.id, file1.id, file2.id]) assert set(result.keys()) == {file1.id, file2.id} assert result[file1.id].id == file1.id @@ -92,6 +92,6 @@ def test_get_upload_files_by_ids_filters_by_tenant(db_session_with_containers: S file_a = _create_upload_file(db_session_with_containers, tenant_id=tenant_a, key="ka", name="a.txt") _create_upload_file(db_session_with_containers, tenant_id=tenant_b, key="kb", name="b.txt") - result = FileService.get_upload_files_by_ids(tenant_a, [file_a.id]) + result = FileService.get_upload_files_by_ids(db_session_with_containers, tenant_a, [file_a.id]) assert set(result.keys()) == {file_a.id} diff --git a/api/tests/test_containers_integration_tests/services/test_hit_testing_service.py b/api/tests/test_containers_integration_tests/services/test_hit_testing_service.py index f332ba05ec2..2d23ae8f68f 100644 --- a/api/tests/test_containers_integration_tests/services/test_hit_testing_service.py +++ b/api/tests/test_containers_integration_tests/services/test_hit_testing_service.py @@ -1,26 +1,79 @@ from __future__ import annotations import json -from typing import Any, cast -from unittest.mock import ANY, MagicMock, patch +from datetime import datetime +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from pydantic import BaseModel, ConfigDict, TypeAdapter from sqlalchemy import func, select from sqlalchemy.orm import Session +from core.rag.embedding.retrieval import RetrievalSegments from core.rag.models.document import Document -from models.dataset import Dataset, DatasetQuery +from core.rag.retrieval.retrieval_methods import RetrievalMethod +from models.dataset import Dataset, DatasetQuery, DocumentSegment +from models.dataset import Document as DatasetDocument +from models.enums import DataSourceType, DocumentCreatedFrom, SegmentStatus from services.hit_testing_service import HitTestingService -def _create_dataset(db_session: Session, *, provider: str = "vendor", **kwargs: Any) -> Dataset: - tenant_id = str(uuid4()) - created_by = str(uuid4()) +class _QueryResponse(BaseModel): + content: str + + +class _RetrieveRecordResponse(BaseModel): + content: str | None = None + title: str | None = None + + model_config = ConfigDict(extra="allow") + + +class _RetrieveResponse(BaseModel): + query: _QueryResponse + records: list[_RetrieveRecordResponse] + + +class _DumpedDocumentResponse(BaseModel): + id: str + data_source_type: str + name: str + doc_type: str | None + doc_metadata: dict[str, object] | None + + +class _DumpedSegmentResponse(BaseModel): + id: str + document_id: str + created_at: datetime | None = None + document: _DumpedDocumentResponse | None = None + + model_config = ConfigDict(extra="allow") + + +class _DumpedRetrievalRecordResponse(BaseModel): + segment: _DumpedSegmentResponse + score: float + + model_config = ConfigDict(extra="allow") + + +_DUMPED_RETRIEVAL_RECORDS = TypeAdapter(list[_DumpedRetrievalRecordResponse]) + + +def _create_dataset( + db_session: Session, + *, + provider: str = "vendor", + tenant_id: str | None = None, + created_by: str | None = None, + name: str = "test-dataset", +) -> Dataset: ds = Dataset( - tenant_id=kwargs.get("tenant_id", tenant_id), - name=kwargs.get("name", "test-dataset"), - created_by=kwargs.get("created_by", created_by), + tenant_id=tenant_id or str(uuid4()), + name=name, + created_by=created_by or str(uuid4()), provider=provider, ) db_session.add(ds) @@ -29,36 +82,106 @@ def _create_dataset(db_session: Session, *, provider: str = "vendor", **kwargs: return ds +def _create_dataset_document( + db_session: Session, + *, + name: str = "guide.md", + data_source_type: str = DataSourceType.UPLOAD_FILE, + doc_type: str | None = None, + doc_metadata: dict[str, object] | None = None, +) -> DatasetDocument: + tenant_id = str(uuid4()) + created_by = str(uuid4()) + dataset = Dataset( + tenant_id=tenant_id, + name=f"dataset-{uuid4()}", + data_source_type=DataSourceType.UPLOAD_FILE, + created_by=created_by, + ) + db_session.add(dataset) + db_session.flush() + + document = DatasetDocument( + tenant_id=tenant_id, + dataset_id=dataset.id, + position=1, + data_source_type=data_source_type, + batch=f"batch-{uuid4()}", + name=name, + created_from=DocumentCreatedFrom.WEB, + created_by=created_by, + doc_type=doc_type, + doc_metadata=doc_metadata, + ) + db_session.add(document) + db_session.commit() + db_session.refresh(document) + return document + + +def _build_segment( + *, + document_id: str, + tenant_id: str | None = None, + dataset_id: str | None = None, + created_by: str | None = None, +) -> DocumentSegment: + return DocumentSegment( + tenant_id=tenant_id or str(uuid4()), + dataset_id=dataset_id or str(uuid4()), + document_id=document_id, + created_by=created_by or str(uuid4()), + position=1, + content="segment content", + word_count=2, + tokens=2, + status=SegmentStatus.COMPLETED, + ) + + +def _create_segment(db_session: Session, *, document: DatasetDocument | None = None) -> DocumentSegment: + segment = _build_segment( + tenant_id=document.tenant_id if document else None, + dataset_id=document.dataset_id if document else None, + document_id=document.id if document else str(uuid4()), + created_by=document.created_by if document else None, + ) + db_session.add(segment) + db_session.commit() + db_session.refresh(segment) + return segment + + class TestHitTestingService: # ── Utility methods (pure logic, no DB) ──────────────────────────── - def test_escape_query_for_search_should_escape_double_quotes(self): + def test_escape_query_for_search_should_escape_double_quotes(self) -> None: query = 'test "query" with quotes' result = HitTestingService.escape_query_for_search(query) assert result == 'test \\"query\\" with quotes' - def test_hit_testing_args_check_should_pass_with_valid_query(self): + def test_hit_testing_args_check_should_pass_with_valid_query(self) -> None: HitTestingService.hit_testing_args_check({"query": "valid query"}) - def test_hit_testing_args_check_should_pass_with_valid_attachments(self): + def test_hit_testing_args_check_should_pass_with_valid_attachments(self) -> None: HitTestingService.hit_testing_args_check({"attachment_ids": ["id1", "id2"]}) - def test_hit_testing_args_check_should_raise_error_when_no_query_or_attachments(self): + def test_hit_testing_args_check_should_raise_error_when_no_query_or_attachments(self) -> None: with pytest.raises(ValueError, match="Query or attachment_ids is required"): HitTestingService.hit_testing_args_check({}) - def test_hit_testing_args_check_should_raise_error_when_query_too_long(self): + def test_hit_testing_args_check_should_raise_error_when_query_too_long(self) -> None: with pytest.raises(ValueError, match="Query cannot exceed 250 characters"): HitTestingService.hit_testing_args_check({"query": "a" * 251}) - def test_hit_testing_args_check_should_raise_error_when_attachments_not_list(self): + def test_hit_testing_args_check_should_raise_error_when_attachments_not_list(self) -> None: with pytest.raises(ValueError, match="Attachment_ids must be a list"): HitTestingService.hit_testing_args_check({"attachment_ids": "not a list"}) # ── Response formatting ──────────────────────────────────────────── @patch("core.rag.datasource.retrieval_service.RetrievalService.format_retrieval_documents") - def test_compact_retrieve_response_should_format_correctly(self, mock_format): + def test_compact_retrieve_response_should_format_correctly(self, mock_format: MagicMock) -> None: query = "test query" mock_doc = MagicMock(spec=Document) @@ -66,50 +189,49 @@ class TestHitTestingService: mock_record.model_dump.return_value = {"content": "formatted content"} mock_format.return_value = [mock_record] - result = cast(dict[str, Any], HitTestingService.compact_retrieve_response(query, [mock_doc])) + response = _RetrieveResponse.model_validate(HitTestingService.compact_retrieve_response(query, [mock_doc])) - assert cast(dict[str, Any], result["query"])["content"] == query - assert len(result["records"]) == 1 - assert cast(dict[str, Any], result["records"][0])["content"] == "formatted content" + assert response.query.content == query + assert len(response.records) == 1 + assert response.records[0].content == "formatted content" mock_format.assert_called_once_with([mock_doc]) def test_compact_external_retrieve_response_should_return_records_for_external_provider( self, db_session_with_containers: Session - ): + ) -> None: dataset = _create_dataset(db_session_with_containers, provider="external") documents = [ {"content": "c1", "title": "t1", "score": 0.9, "metadata": {"m1": "v1"}}, {"content": "c2", "title": "t2", "score": 0.8, "metadata": {"m2": "v2"}}, ] - result = cast( - dict[str, Any], HitTestingService.compact_external_retrieve_response(dataset, "test query", documents) + response = _RetrieveResponse.model_validate( + HitTestingService.compact_external_retrieve_response(dataset, "test query", documents) ) - assert cast(dict[str, Any], result["query"])["content"] == "test query" - assert len(result["records"]) == 2 - assert cast(dict[str, Any], result["records"][0])["content"] == "c1" - assert cast(dict[str, Any], result["records"][1])["title"] == "t2" + assert response.query.content == "test query" + assert len(response.records) == 2 + assert response.records[0].content == "c1" + assert response.records[1].title == "t2" def test_compact_external_retrieve_response_should_return_empty_for_non_external_provider( self, db_session_with_containers: Session - ): + ) -> None: dataset = _create_dataset(db_session_with_containers, provider="vendor") - result = cast( - dict[str, Any], - HitTestingService.compact_external_retrieve_response(dataset, "test query", [{"content": "c1"}]), + response = _RetrieveResponse.model_validate( + HitTestingService.compact_external_retrieve_response(dataset, "test query", [{"content": "c1"}]) ) - assert cast(dict[str, Any], result["query"])["content"] == "test query" - assert result["records"] == [] + assert response.query.content == "test query" + assert response.records == [] # ── External retrieve (real DB) ──────────────────────────────────── @patch("core.rag.datasource.retrieval_service.RetrievalService.external_retrieve") def test_external_retrieve_should_succeed_for_external_provider( - self, mock_ext_retrieve, db_session_with_containers: Session - ): + self, mock_ext_retrieve: MagicMock, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers, provider="external") account_id = str(uuid4()) account = MagicMock() @@ -118,19 +240,18 @@ class TestHitTestingService: before_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 - result = cast( - dict[str, Any], + response = _RetrieveResponse.model_validate( HitTestingService.external_retrieve( dataset=dataset, query='test "query"', account=account, external_retrieval_model={"model": "test"}, metadata_filtering_conditions={"key": "val"}, - ), + ) ) - assert cast(dict[str, Any], result["query"])["content"] == 'test "query"' - assert cast(dict[str, Any], result["records"][0])["content"] == "ext content" + assert response.query.content == 'test "query"' + assert response.records[0].content == "ext content" mock_ext_retrieve.assert_called_once_with( dataset_id=dataset.id, query='test \\"query\\"', @@ -142,37 +263,44 @@ class TestHitTestingService: after_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 assert after_count == before_count + 1 - def test_external_retrieve_should_return_empty_for_non_external_provider(self, db_session_with_containers: Session): + def test_external_retrieve_should_return_empty_for_non_external_provider( + self, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers, provider="vendor") account = MagicMock() - result = cast(dict[str, Any], HitTestingService.external_retrieve(dataset, "test query", account)) + response = _RetrieveResponse.model_validate(HitTestingService.external_retrieve(dataset, "test query", account)) - assert cast(dict[str, Any], result["query"])["content"] == "test query" - assert result["records"] == [] + assert response.query.content == "test query" + assert response.records == [] # ── Retrieve (real DB) ───────────────────────────────────────────── @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") def test_retrieve_should_use_default_model_when_none_provided( - self, mock_retrieve, db_session_with_containers: Session - ): + self, mock_retrieve: MagicMock, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers) dataset.retrieval_model = None account = MagicMock() account.id = str(uuid4()) - mock_retrieve.return_value = [] + retrieved_documents: list[Document] = [] + mock_retrieve.return_value = retrieved_documents + external_retrieval_model: dict[str, object] = {} before_count = db_session_with_containers.scalar(select(func.count()).select_from(DatasetQuery)) or 0 - result = cast( - dict[str, Any], + response = _RetrieveResponse.model_validate( HitTestingService.retrieve( - dataset=dataset, query="test query", account=account, retrieval_model=None, external_retrieval_model={} - ), + dataset=dataset, + query="test query", + account=account, + retrieval_model=None, + external_retrieval_model=external_retrieval_model, + ) ) - assert cast(dict[str, Any], result["query"])["content"] == "test query" + assert response.query.content == "test query" mock_retrieve.assert_called_once() assert mock_retrieve.call_args.kwargs["top_k"] == 4 @@ -183,11 +311,12 @@ class TestHitTestingService: @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") @patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval.get_metadata_filter_condition") def test_retrieve_should_handle_metadata_filtering( - self, mock_get_meta, mock_retrieve, db_session_with_containers: Session - ): + self, mock_get_meta: MagicMock, mock_retrieve: MagicMock, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers) account = MagicMock() account.id = str(uuid4()) + external_retrieval_model: dict[str, object] = {} retrieval_model = { "search_method": "semantic_search", @@ -197,14 +326,15 @@ class TestHitTestingService: "score_threshold_enabled": False, } mock_get_meta.return_value = ({dataset.id: ["doc_id1"]}, "condition_string") - mock_retrieve.return_value = [] + retrieved_documents: list[Document] = [] + mock_retrieve.return_value = retrieved_documents HitTestingService.retrieve( dataset=dataset, query="test query", account=account, retrieval_model=retrieval_model, - external_retrieval_model={}, + external_retrieval_model=external_retrieval_model, ) mock_get_meta.assert_called_once() @@ -214,10 +344,11 @@ class TestHitTestingService: @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") @patch("core.rag.retrieval.dataset_retrieval.DatasetRetrieval.get_metadata_filter_condition") def test_retrieve_should_return_empty_if_metadata_filtering_fails( - self, mock_get_meta, mock_retrieve, db_session_with_containers: Session - ): + self, mock_get_meta: MagicMock, mock_retrieve: MagicMock, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers) account = MagicMock() + external_retrieval_model: dict[str, object] = {} retrieval_model = { "search_method": "semantic_search", @@ -226,28 +357,31 @@ class TestHitTestingService: "reranking_enable": False, "score_threshold_enabled": False, } - mock_get_meta.return_value = ({}, "condition_string") + empty_document_ids: dict[str, list[str]] = {} + mock_get_meta.return_value = (empty_document_ids, "condition_string") - result = cast( - dict[str, Any], + response = _RetrieveResponse.model_validate( HitTestingService.retrieve( dataset=dataset, query="test query", account=account, retrieval_model=retrieval_model, - external_retrieval_model={}, - ), + external_retrieval_model=external_retrieval_model, + ) ) - assert result["records"] == [] + assert response.records == [] mock_retrieve.assert_not_called() @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") - def test_retrieve_should_handle_attachments(self, mock_retrieve, db_session_with_containers: Session): + def test_retrieve_should_handle_attachments( + self, mock_retrieve: MagicMock, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers) account = MagicMock() account.id = str(uuid4()) attachment_ids = ["att1", "att2"] + external_retrieval_model: dict[str, object] = {} retrieval_model = { "search_method": "semantic_search", @@ -255,19 +389,20 @@ class TestHitTestingService: "reranking_enable": False, "score_threshold_enabled": False, } - mock_retrieve.return_value = [] + retrieved_documents: list[Document] = [] + mock_retrieve.return_value = retrieved_documents HitTestingService.retrieve( dataset=dataset, query="test query", account=account, retrieval_model=retrieval_model, - external_retrieval_model={}, + external_retrieval_model=external_retrieval_model, attachment_ids=attachment_ids, ) mock_retrieve.assert_called_once_with( - retrieval_method=ANY, + retrieval_method=RetrievalMethod.SEMANTIC_SEARCH, dataset_id=dataset.id, query="test query", attachment_ids=attachment_ids, @@ -295,10 +430,13 @@ class TestHitTestingService: assert query_content[1]["content"] == "att1" @patch("core.rag.datasource.retrieval_service.RetrievalService.retrieve") - def test_retrieve_should_handle_reranking_and_threshold(self, mock_retrieve, db_session_with_containers: Session): + def test_retrieve_should_handle_reranking_and_threshold( + self, mock_retrieve: MagicMock, db_session_with_containers: Session + ) -> None: dataset = _create_dataset(db_session_with_containers) account = MagicMock() account.id = str(uuid4()) + external_retrieval_model: dict[str, object] = {} retrieval_model = { "search_method": "hybrid_search", @@ -310,14 +448,15 @@ class TestHitTestingService: "score_threshold": 0.5, "weights": {"vector": 0.5, "keyword": 0.5}, } - mock_retrieve.return_value = [] + retrieved_documents: list[Document] = [] + mock_retrieve.return_value = retrieved_documents HitTestingService.retrieve( dataset=dataset, query="test query", account=account, retrieval_model=retrieval_model, - external_retrieval_model={}, + external_retrieval_model=external_retrieval_model, ) mock_retrieve.assert_called_once() @@ -326,3 +465,57 @@ class TestHitTestingService: assert kwargs["reranking_model"] == {"provider": "test"} assert kwargs["reranking_mode"] == "weighted_sum" assert kwargs["weights"] == {"vector": 0.5, "keyword": 0.5} + + def test_dump_dataset_document_returns_frontend_required_fields(self, db_session_with_containers: Session) -> None: + document = _create_dataset_document(db_session_with_containers, doc_metadata={"source": "manual"}) + + assert HitTestingService._dump_dataset_document(document) == { + "id": document.id, + "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) -> None: + segment = _build_segment(document_id="") + record = RetrievalSegments.model_validate({"segment": segment, "score": 0.95}) + + records = _DUMPED_RETRIEVAL_RECORDS.validate_python(HitTestingService._dump_retrieval_records([record])) + + assert len(records) == 1 + assert records[0].segment.id == segment.id + assert records[0].segment.document_id == "" + assert records[0].score == 0.95 + + def test_dump_retrieval_records_injects_documents(self, db_session_with_containers: Session) -> None: + document = _create_dataset_document(db_session_with_containers) + segment = _create_segment(db_session_with_containers, document=document) + record = RetrievalSegments.model_validate({"segment": segment, "score": 0.9}) + + records = _DUMPED_RETRIEVAL_RECORDS.validate_python(HitTestingService._dump_retrieval_records([record])) + + assert len(records) == 1 + dumped_segment = records[0].segment + assert dumped_segment.id == segment.id + assert dumped_segment.document_id == document.id + assert dumped_segment.created_at == segment.created_at + assert dumped_segment.document == _DumpedDocumentResponse( + id=document.id, + data_source_type="upload_file", + name="guide.md", + doc_type=None, + doc_metadata=None, + ) + assert records[0].score == 0.9 + + def test_dump_retrieval_records_skips_records_with_missing_documents( + self, db_session_with_containers: Session, caplog: pytest.LogCaptureFixture + ) -> None: + segment = _create_segment(db_session_with_containers) + record = RetrievalSegments.model_validate({"segment": segment, "score": 0.95}) + + result = HitTestingService._dump_retrieval_records([record]) + + assert result == [] + assert "Skipping hit-testing records with missing documents" in caplog.text 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 7368ad42493..ad85ac67bc5 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 @@ -5,7 +5,7 @@ from faker import Faker from sqlalchemy.orm import Session from models import App, CreatorUserRole -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import EndUser, Message from models.web import SavedMessage from services.app_service import AppService, CreateAppParams @@ -107,7 +107,7 @@ class TestSavedMessageService: app_id=app.id, external_user_id=fake.uuid4(), name=fake.name(), - type="normal", + type=EndUserType.BROWSER, session_id=fake.uuid4(), is_anonymous=False, ) @@ -220,7 +220,9 @@ class TestSavedMessageService: mock_external_service_dependencies["message_service"].pagination_by_last_id.return_value = mock_pagination # Act: Execute the method under test - result = SavedMessageService.pagination_by_last_id(app_model=app, user=account, last_id=None, limit=10) + result = SavedMessageService.pagination_by_last_id( + db_session_with_containers, app_model=app, user=account, last_id=None, limit=10 + ) # Assert: Verify the expected outcomes assert result is not None @@ -294,7 +296,7 @@ class TestSavedMessageService: # Act: Execute the method under test result = SavedMessageService.pagination_by_last_id( - app_model=app, user=end_user, last_id="test_last_id", limit=5 + db_session_with_containers, app_model=app, user=end_user, last_id="test_last_id", limit=5 ) # Assert: Verify the expected outcomes @@ -344,7 +346,7 @@ class TestSavedMessageService: mock_external_service_dependencies["message_service"].get_message.return_value = message # Act: Execute the method under test - SavedMessageService.save(app_model=app, user=account, message_id=message.id) + SavedMessageService.save(db_session_with_containers, app_model=app, user=account, message_id=message.id) # Assert: Verify the expected outcomes # Check if saved message was created in database @@ -393,7 +395,9 @@ class TestSavedMessageService: # Act & Assert: Verify proper error handling with pytest.raises(ValueError) as exc_info: - SavedMessageService.pagination_by_last_id(app_model=app, user=None, last_id=None, limit=10) + SavedMessageService.pagination_by_last_id( + db_session_with_containers, app_model=app, user=None, last_id=None, limit=10 + ) assert "User is required" in str(exc_info.value) @@ -412,7 +416,7 @@ class TestSavedMessageService: message = self._create_test_message(db_session_with_containers, app, account) # Act: Execute the method under test with None user - result = SavedMessageService.save(app_model=app, user=None, message_id=message.id) + result = SavedMessageService.save(db_session_with_containers, app_model=app, user=None, message_id=message.id) # Assert: Verify the expected outcomes assert result is None @@ -471,7 +475,7 @@ class TestSavedMessageService: ) # Act: Execute the method under test - SavedMessageService.delete(app_model=app, user=account, message_id=message.id) + SavedMessageService.delete(db_session_with_containers, app_model=app, user=account, message_id=message.id) # Assert: Verify the expected outcomes # Check if saved message was deleted from database @@ -501,7 +505,7 @@ class TestSavedMessageService: mock_external_service_dependencies["message_service"].get_message.return_value = message - SavedMessageService.save(app_model=app, user=end_user, message_id=message.id) + SavedMessageService.save(db_session_with_containers, app_model=app, user=end_user, message_id=message.id) saved = ( db_session_with_containers.query(SavedMessage) @@ -522,9 +526,9 @@ class TestSavedMessageService: mock_external_service_dependencies["message_service"].get_message.return_value = message # Save once - SavedMessageService.save(app_model=app, user=account, message_id=message.id) + SavedMessageService.save(db_session_with_containers, app_model=app, user=account, message_id=message.id) # Save again - SavedMessageService.save(app_model=app, user=account, message_id=message.id) + SavedMessageService.save(db_session_with_containers, app_model=app, user=account, message_id=message.id) count = ( db_session_with_containers.query(SavedMessage) @@ -547,7 +551,7 @@ class TestSavedMessageService: db_session_with_containers.add(saved) db_session_with_containers.commit() - SavedMessageService.delete(app_model=app, user=None, message_id=message.id) + SavedMessageService.delete(db_session_with_containers, app_model=app, user=None, message_id=message.id) # Should still exist assert ( @@ -566,7 +570,7 @@ class TestSavedMessageService: # Should not raise — use a valid UUID that doesn't exist in DB from uuid import uuid4 - SavedMessageService.delete(app_model=app, user=account, message_id=str(uuid4())) + SavedMessageService.delete(db_session_with_containers, app_model=app, user=account, message_id=str(uuid4())) def test_delete_for_end_user(self, db_session_with_containers: Session, mock_external_service_dependencies): """Test deleting a saved message for an EndUser.""" @@ -580,7 +584,7 @@ class TestSavedMessageService: db_session_with_containers.add(saved) db_session_with_containers.commit() - SavedMessageService.delete(app_model=app, user=end_user, message_id=message.id) + SavedMessageService.delete(db_session_with_containers, app_model=app, user=end_user, message_id=message.id) assert ( db_session_with_containers.query(SavedMessage) @@ -610,7 +614,7 @@ class TestSavedMessageService: db_session_with_containers.commit() # Delete only account1's saved message - SavedMessageService.delete(app_model=app, user=account1, message_id=message.id) + SavedMessageService.delete(db_session_with_containers, app_model=app, user=account1, message_id=message.id) # Account's saved message should be gone assert ( 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 517d5d2ed4c..197415ee6bd 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 @@ -449,7 +449,7 @@ class TestTagService: # Act: Execute the method under test tag_ids = [tag.id for tag in tags] - result = TagService.get_target_ids_by_tag_ids("knowledge", tenant.id, tag_ids) + result = TagService.get_target_ids_by_tag_ids("knowledge", tenant.id, tag_ids, db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -485,7 +485,7 @@ class TestTagService: ) # Act: Execute the method under test with empty tag IDs - result = TagService.get_target_ids_by_tag_ids("knowledge", tenant.id, []) + result = TagService.get_target_ids_by_tag_ids("knowledge", tenant.id, [], db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -533,13 +533,19 @@ class TestTagService: # Act: Execute the method under test tag_ids = [tag.id for tag in tags] - result = TagService.get_target_ids_by_tag_ids("knowledge", tenant.id, tag_ids, match_all=True) + result = TagService.get_target_ids_by_tag_ids( + "knowledge", tenant.id, tag_ids, db_session_with_containers, match_all=True + ) # Assert: Verify the expected outcomes assert result == [dataset_with_all_tags.id] missing_tag_result = TagService.get_target_ids_by_tag_ids( - "knowledge", tenant.id, [tags[0].id, str(uuid.uuid4())], match_all=True + "knowledge", + tenant.id, + [tags[0].id, str(uuid.uuid4())], + db_session_with_containers, + match_all=True, ) assert missing_tag_result == [] @@ -565,7 +571,9 @@ class TestTagService: non_existent_tag_ids = [str(uuid.uuid4()), str(uuid.uuid4())] # Act: Execute the method under test - result = TagService.get_target_ids_by_tag_ids("knowledge", tenant.id, non_existent_tag_ids) + result = TagService.get_target_ids_by_tag_ids( + "knowledge", tenant.id, non_existent_tag_ids, db_session_with_containers + ) # Assert: Verify the expected outcomes assert result is not None @@ -599,7 +607,7 @@ class TestTagService: db_session_with_containers.commit() # Act: Execute the method under test - result = TagService.get_tag_by_tag_name("app", tenant.id, "python_tag") + result = TagService.get_tag_by_tag_name("app", tenant.id, "python_tag", db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -625,7 +633,7 @@ class TestTagService: ) # Act: Execute the method under test with non-existent tag name - result = TagService.get_tag_by_tag_name("knowledge", tenant.id, "nonexistent_tag") + result = TagService.get_tag_by_tag_name("knowledge", tenant.id, "nonexistent_tag", db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -650,8 +658,8 @@ class TestTagService: ) # Act: Execute the method under test with empty parameters - result_empty_type = TagService.get_tag_by_tag_name("", tenant.id, "test_tag") - result_empty_name = TagService.get_tag_by_tag_name("knowledge", tenant.id, "") + result_empty_type = TagService.get_tag_by_tag_name("", tenant.id, "test_tag", db_session_with_containers) + result_empty_name = TagService.get_tag_by_tag_name("knowledge", tenant.id, "", db_session_with_containers) # Assert: Verify the expected outcomes assert result_empty_type is not None @@ -688,7 +696,7 @@ class TestTagService: ) # Act: Execute the method under test - result = TagService.get_tags_by_target_id("app", tenant.id, app.id) + result = TagService.get_tags_by_target_id("app", tenant.id, app.id, db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -720,7 +728,7 @@ class TestTagService: app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, tenant.id) # Act: Execute the method under test - result = TagService.get_tags_by_target_id("app", tenant.id, app.id) + result = TagService.get_tags_by_target_id("app", tenant.id, app.id, db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -745,7 +753,7 @@ class TestTagService: tag_args = SaveTagPayload(name="test_tag_name", type="knowledge") # Act: Execute the method under test - result = TagService.save_tags(tag_args) + result = TagService.save_tags(tag_args, db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -783,11 +791,11 @@ class TestTagService: # Create first tag tag_args = SaveTagPayload(name="duplicate_tag", type="app") - TagService.save_tags(tag_args) + TagService.save_tags(tag_args, db_session_with_containers) # Act & Assert: Verify proper error handling with pytest.raises(ValueError) as exc_info: - TagService.save_tags(tag_args) + TagService.save_tags(tag_args, db_session_with_containers) assert "Tag name already exists" in str(exc_info.value) def test_update_tags_success(self, db_session_with_containers: Session, mock_external_service_dependencies): @@ -807,13 +815,13 @@ class TestTagService: # Create a tag to update tag_args = SaveTagPayload(name="original_name", type="knowledge") - tag = TagService.save_tags(tag_args) + tag = TagService.save_tags(tag_args, db_session_with_containers) # Update args update_args = UpdateTagPayload(name="updated_name") # Act: Execute the method under test - result = TagService.update_tags(update_args, tag.id) + result = TagService.update_tags(update_args, tag.id, db_session_with_containers) # Assert: Verify the expected outcomes assert result is not None @@ -854,7 +862,7 @@ class TestTagService: # Act & Assert: Verify proper error handling with pytest.raises(NotFound) as exc_info: - TagService.update_tags(update_args, non_existent_tag_id) + TagService.update_tags(update_args, non_existent_tag_id, db_session_with_containers) assert "Tag not found" in str(exc_info.value) def test_update_tags_duplicate_name_error( @@ -875,17 +883,17 @@ class TestTagService: # Create two tags tag1_args = SaveTagPayload(name="first_tag", type="app") - tag1 = TagService.save_tags(tag1_args) + tag1 = TagService.save_tags(tag1_args, db_session_with_containers) tag2_args = SaveTagPayload(name="second_tag", type="app") - tag2 = TagService.save_tags(tag2_args) + tag2 = TagService.save_tags(tag2_args, db_session_with_containers) # Try to update second tag with first tag's name update_args = UpdateTagPayload(name="first_tag") # Act & Assert: Verify proper error handling with pytest.raises(ValueError) as exc_info: - TagService.update_tags(update_args, tag2.id) + TagService.update_tags(update_args, tag2.id, db_session_with_containers) assert "Tag name already exists" in str(exc_info.value) def test_get_tag_binding_count_success( @@ -917,8 +925,8 @@ class TestTagService: ) # Act: Execute the method under test - result_tag_with_bindings = TagService.get_tag_binding_count(tags[0].id) - result_tag_without_bindings = TagService.get_tag_binding_count(tags[1].id) + result_tag_with_bindings = TagService.get_tag_binding_count(tags[0].id, db_session_with_containers) + result_tag_without_bindings = TagService.get_tag_binding_count(tags[1].id, db_session_with_containers) # Assert: Verify the expected outcomes assert result_tag_with_bindings == 1 @@ -946,7 +954,7 @@ class TestTagService: non_existent_tag_id = str(uuid.uuid4()) # Act: Execute the method under test - result = TagService.get_tag_binding_count(non_existent_tag_id) + result = TagService.get_tag_binding_count(non_existent_tag_id, db_session_with_containers) # Assert: Verify the expected outcomes assert result == 0 @@ -986,7 +994,7 @@ class TestTagService: assert binding_before is not None # Act: Execute the method under test - TagService.delete_tag(tag.id) + TagService.delete_tag(tag.id, db_session_with_containers) # Assert: Verify the expected outcomes # Verify tag was deleted @@ -1018,7 +1026,7 @@ class TestTagService: # Act & Assert: Verify proper error handling with pytest.raises(NotFound) as exc_info: - TagService.delete_tag(non_existent_tag_id) + TagService.delete_tag(non_existent_tag_id, db_session_with_containers) assert "Tag not found" in str(exc_info.value) def test_save_tag_binding_success(self, db_session_with_containers: Session, mock_external_service_dependencies): @@ -1048,7 +1056,7 @@ class TestTagService: binding_payload = TagBindingCreatePayload( type="knowledge", target_id=dataset.id, tag_ids=[tag.id for tag in tags] ) - TagService.save_tag_binding(binding_payload) + TagService.save_tag_binding(binding_payload, db_session_with_containers) # Assert: Verify the expected outcomes @@ -1090,10 +1098,10 @@ class TestTagService: # Create first binding binding_payload = TagBindingCreatePayload(type="app", target_id=app.id, tag_ids=[tag.id]) - TagService.save_tag_binding(binding_payload) + TagService.save_tag_binding(binding_payload, db_session_with_containers) # Act: Try to create duplicate binding - TagService.save_tag_binding(binding_payload) + TagService.save_tag_binding(binding_payload, db_session_with_containers) # Assert: Verify the expected outcomes @@ -1173,7 +1181,7 @@ class TestTagService: delete_payload = TagBindingDeletePayload( type="knowledge", target_id=dataset.id, tag_ids=[tag.id for tag in tags] ) - TagService.delete_tag_binding(delete_payload) + TagService.delete_tag_binding(delete_payload, db_session_with_containers) # Assert: Verify the expected outcomes # Verify tag bindings were deleted @@ -1209,7 +1217,7 @@ class TestTagService: # Act: Try to delete non-existent binding delete_payload = TagBindingDeletePayload(type="app", target_id=app.id, tag_ids=[tag.id]) - TagService.delete_tag_binding(delete_payload) + TagService.delete_tag_binding(delete_payload, db_session_with_containers) # Assert: Verify the expected outcomes # No error should be raised, and database state should remain unchanged @@ -1240,7 +1248,7 @@ class TestTagService: dataset = self._create_test_dataset(db_session_with_containers, mock_external_service_dependencies, tenant.id) # Act: Execute the method under test - TagService.check_target_exists("knowledge", dataset.id) + TagService.check_target_exists("knowledge", dataset.id, db_session_with_containers) # Assert: Verify the expected outcomes # No exception should be raised for existing dataset @@ -1268,7 +1276,7 @@ class TestTagService: # Act & Assert: Verify proper error handling with pytest.raises(NotFound) as exc_info: - TagService.check_target_exists("knowledge", non_existent_dataset_id) + TagService.check_target_exists("knowledge", non_existent_dataset_id, db_session_with_containers) assert "Dataset not found" in str(exc_info.value) def test_check_target_exists_app_success( @@ -1292,7 +1300,7 @@ class TestTagService: app = self._create_test_app(db_session_with_containers, mock_external_service_dependencies, tenant.id) # Act: Execute the method under test - TagService.check_target_exists("app", app.id) + TagService.check_target_exists("app", app.id, db_session_with_containers) # Assert: Verify the expected outcomes # No exception should be raised for existing app @@ -1320,7 +1328,7 @@ class TestTagService: # Act & Assert: Verify proper error handling with pytest.raises(NotFound) as exc_info: - TagService.check_target_exists("app", non_existent_app_id) + TagService.check_target_exists("app", non_existent_app_id, db_session_with_containers) assert "App not found" in str(exc_info.value) def test_check_target_exists_invalid_type( @@ -1346,5 +1354,5 @@ class TestTagService: # Act & Assert: Verify proper error handling with pytest.raises(NotFound) as exc_info: - TagService.check_target_exists("invalid_type", non_existent_target_id) + TagService.check_target_exists("invalid_type", non_existent_target_id, db_session_with_containers) assert "Invalid binding type" in str(exc_info.value) 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 8e53a2d6cd7..8651636616c 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 @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom from models import Account, App -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import Conversation, EndUser from models.web import PinnedConversation from services.account_service import AccountService, TenantService @@ -109,7 +109,7 @@ class TestWebConversationService: end_user = EndUser( session_id=fake.uuid4(), app_id=app.id, - type="normal", + type=EndUserType.BROWSER, is_anonymous=False, tenant_id=app.tenant_id, ) 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 07a49130d06..fbbf255c581 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 @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from graphon.enums import WorkflowExecutionStatus from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun -from models.enums import AppTriggerType, CreatorUserRole, WorkflowRunTriggeredFrom +from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.workflow import WorkflowAppLogCreatedFrom from services.account_service import AccountService, TenantService @@ -821,7 +821,7 @@ class TestWorkflowAppService: id=str(uuid.uuid4()), tenant_id=app.tenant_id, app_id=app.id, - type="web", + type=EndUserType.BROWSER, is_anonymous=False, session_id="test_session_123", created_at=datetime.now(UTC), @@ -1567,7 +1567,7 @@ class TestWorkflowAppService: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=False, session_id="session-1", ) 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 09fe1570bcf..e065e5df1c3 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 @@ -7,7 +7,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session -from models.enums import ConversationFromSource, CreatorUserRole +from models.enums import ConversationFromSource, CreatorUserRole, EndUserType from models.model import ( Message, ) @@ -684,7 +684,7 @@ class TestWorkflowRunService: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type="web_app", + type=EndUserType.BROWSER, is_anonymous=False, session_id=str(uuid.uuid4()), external_user_id=str(uuid.uuid4()), 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 204f5339785..3e9a7859030 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 @@ -1,5 +1,6 @@ +import logging import uuid -from unittest.mock import ANY, call, patch +from unittest.mock import call, patch import pytest from sqlalchemy import delete, func, select @@ -146,9 +147,8 @@ class TestDeleteDraftVariablesBatch: assert db_session_with_containers.scalar(select(func.count()).select_from(WorkflowDraftVariable)) == 0 @patch("tasks.remove_app_and_related_data_task._delete_draft_variable_offload_data") - @patch("tasks.remove_app_and_related_data_task.logger") def test_delete_draft_variables_batch_logs_progress( - self, mock_logger, mock_offload_cleanup, db_session_with_containers + self, mock_offload_cleanup, db_session_with_containers, caplog: pytest.LogCaptureFixture ): """Test that batch deletion logs progress correctly.""" tenant, app = _create_tenant_and_app(db_session_with_containers) @@ -163,14 +163,15 @@ class TestDeleteDraftVariablesBatch: mock_offload_cleanup.return_value = len(file_id_by_index) - result = delete_draft_variables_batch(app.id, 50) + with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): + result = delete_draft_variables_batch(app.id, 50) assert result == 30 mock_offload_cleanup.assert_called_once() _, called_file_ids = mock_offload_cleanup.call_args.args assert {str(file_id) for file_id in called_file_ids} == {str(file_id) for file_id in file_id_by_index.values()} - assert mock_logger.info.call_count == 2 - mock_logger.info.assert_any_call(ANY) + info_records = [record for record in caplog.records if record.levelno == logging.INFO] + assert len(info_records) == 2 class TestDeleteDraftVariableOffloadData: @@ -204,9 +205,8 @@ class TestDeleteDraftVariableOffloadData: assert remaining_upload_files_count == 0 @patch("extensions.ext_storage.storage") - @patch("tasks.remove_app_and_related_data_task.logging") def test_delete_draft_variable_offload_data_storage_failure( - self, mock_logging, mock_storage, db_session_with_containers + self, mock_storage, db_session_with_containers, caplog: pytest.LogCaptureFixture ): """Test handling of storage deletion failures.""" tenant, app = _create_tenant_and_app(db_session_with_containers) @@ -217,11 +217,12 @@ class TestDeleteDraftVariableOffloadData: mock_storage.delete.side_effect = [Exception("Storage error"), None] - with session_factory.create_session() as session, session.begin(): - result = _delete_draft_variable_offload_data(session, file_ids) + with caplog.at_level(logging.ERROR): + with session_factory.create_session() as session, session.begin(): + result = _delete_draft_variable_offload_data(session, file_ids) assert result == 1 - mock_logging.exception.assert_called_once_with("Failed to delete storage object %s", storage_keys[0]) + assert f"Failed to delete storage object {storage_keys[0]}" in caplog.text remaining_var_files_count = db_session_with_containers.scalar( select(func.count()) diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index 0fa4d3261bc..c0b308a5cb5 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -15,6 +15,7 @@ from dify_agent.layers.dify_plugin import ( DifyPluginToolsLayerConfig, ) from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig +from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellEnvVarConfig, DifyShellLayerConfig from dify_agent.protocol import ( @@ -28,6 +29,7 @@ from pydantic import ValidationError from clients.agent_backend import ( AGENT_SOUL_PROMPT_LAYER_ID, DIFY_EXECUTION_CONTEXT_LAYER_ID, + DIFY_KNOWLEDGE_BASE_LAYER_ID, DIFY_PLUGIN_TOOLS_LAYER_ID, WORKFLOW_NODE_JOB_PROMPT_LAYER_ID, WORKFLOW_USER_PROMPT_LAYER_ID, @@ -155,6 +157,25 @@ def test_request_builder_adds_dify_plugin_tools_layer_when_configured(): assert tools_config.tools[0].tool_name == "current_time" +def test_request_builder_adds_knowledge_layer_when_configured(): + run_input = _run_input() + run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ) + + request = AgentBackendRunRequestBuilder().build_for_workflow_node(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert DIFY_KNOWLEDGE_BASE_LAYER_ID in layers + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config) + assert knowledge_config.dataset_ids == ["dataset-1"] + + def test_request_builder_can_delete_on_exit_for_cleanup_paths(): run_input = _run_input() run_input.suspend_on_exit = False @@ -329,6 +350,25 @@ def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): assert shell_config.env[0].name == "APP_ENV" +def test_agent_app_request_builder_adds_knowledge_layer_when_configured(): + run_input = _agent_app_input() + run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1", "dataset-2"], + "retrieval": {"mode": "multiple", "top_k": 2}, + } + ) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + assert DIFY_KNOWLEDGE_BASE_LAYER_ID in layers + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID + assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config) + assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"] + + # ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ───── 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 index 8444af741fb..e989d54a965 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -5,6 +5,8 @@ import json import sys from pathlib import Path +import pytest + def _load_generate_swagger_markdown_docs_module(): api_dir = Path(__file__).resolve().parents[3] @@ -20,7 +22,9 @@ def _load_generate_swagger_markdown_docs_module(): return module -def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_console(tmp_path, monkeypatch): +def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_console( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): module = _load_generate_swagger_markdown_docs_module() openapi_dir = tmp_path / "openapi" markdown_dir = tmp_path / "markdown" @@ -69,7 +73,9 @@ def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_con assert "FastOpenAPI Preview" not in (markdown_dir / "service-openapi.md").read_text(encoding="utf-8") -def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagger_dir(tmp_path, monkeypatch): +def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagger_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): module = _load_generate_swagger_markdown_docs_module() swagger_dir = tmp_path / "swagger" markdown_dir = tmp_path / "markdown" @@ -105,7 +111,7 @@ def test_generate_markdown_docs_only_removes_generated_specs_from_separate_swagg assert not list(swagger_dir.glob("*.json")) -def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path): +def test_patch_union_schema_markdown_fills_converter_blank_schema_types(tmp_path: Path): module = _load_generate_swagger_markdown_docs_module() spec_path = tmp_path / "console-openapi.json" spec_path.write_text( @@ -239,7 +245,7 @@ def test_patch_union_schema_markdown_ignores_specs_without_schemas(tmp_path): assert module._patch_union_schema_markdown("unchanged", spec_path) == "unchanged" -def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path): +def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path: Path): module = _load_generate_swagger_markdown_docs_module() spec_path = tmp_path / "console-openapi.json" spec_path.write_text( @@ -266,6 +272,7 @@ def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path): assert module._schema_ref_name(None) is None assert module._schema_markdown_type(None) == "" assert module._schema_markdown_type({"anyOf": [{"type": "null"}]}) == "" + assert module._strip_trailing_line_whitespace("line \ncell\t \n") == "line\ncell\n" assert module._replace_schema_table_type("unchanged", "Definition", "field", "") == "unchanged" assert ( module._replace_schema_table_type( @@ -284,7 +291,7 @@ def test_patch_union_schema_markdown_ignores_unrenderable_shapes(tmp_path): assert module._patch_union_schema_markdown("#### BrokenUnion\n", spec_path) == "#### BrokenUnion\n" -def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path, monkeypatch): +def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): module = _load_generate_swagger_markdown_docs_module() spec_path = tmp_path / "console-openapi.json" output_path = tmp_path / "console-openapi.md" @@ -319,7 +326,10 @@ def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path, monke assert kwargs["check"] is False markdown_path = Path(args[args.index("-o") + 1]) markdown_path.write_text( - """#### FormInputConfig + "Intro line" + + " \n" + + """ +#### FormInputConfig | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | @@ -340,5 +350,7 @@ def test_convert_spec_to_markdown_patches_generated_union_tables(tmp_path, monke module._convert_spec_to_markdown(spec_path, output_path) converted = output_path.read_text(encoding="utf-8") + assert "Intro line \n" not in converted + assert "Intro line\n" in converted 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 03af7643f3c..c30386c9d65 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -8,12 +8,13 @@ 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) + match value: + case dict(): + for child in value.values(): + yield from _walk_values(child) + case list(): + for child in value: + yield from _walk_values(child) def _load_generate_swagger_specs_module(): @@ -106,6 +107,39 @@ def test_generate_specs_writes_get_operations_without_request_bodies(tmp_path): assert all("requestBody" not in operation for operation in _get_operations(payload)) +def test_generate_specs_writes_service_api_reference_descriptions(tmp_path): + module = _load_generate_swagger_specs_module() + + written_paths = module.generate_specs(tmp_path) + service_path = next(path for path in written_paths if path.name == "service-openapi.json") + payload = json.loads(service_path.read_text(encoding="utf-8")) + + chat_operation = payload["paths"]["/chat-messages"]["post"] + assert chat_operation["summary"] == "Send Chat Message" + assert chat_operation["description"] == "Send a request to the chat application." + assert chat_operation["tags"] == ["Chatflows", "Chats"] + + rename_operation = payload["paths"]["/conversations/{c_id}/name"]["post"] + assert rename_operation["summary"] == "Rename Conversation" + + +def test_standalone_inline_model_name_includes_list_constraints(): + module = _load_generate_swagger_specs_module() + + from flask_restx import fields + + cases = ( + ({"min_items": 1}, {"min_items": 2}), + ({"max_items": 1}, {"max_items": 2}), + ({"unique": True}, {"unique": False}), + ) + for first_kwargs, second_kwargs in cases: + first_inline_model = {"items": fields.List(fields.String, **first_kwargs)} + second_inline_model = {"items": fields.List(fields.String, **second_kwargs)} + + assert module._inline_model_name(first_inline_model) != module._inline_model_name(second_inline_model) + + def test_generate_specs_is_idempotent(tmp_path): module = _load_generate_swagger_specs_module() diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index d9bc08920bb..8b77772a36d 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1,4 +1,4 @@ -from inspect import unwrap +from inspect import getsource, unwrap from types import SimpleNamespace from typing import Any, cast @@ -21,10 +21,15 @@ from controllers.console.agent.composer import ( ) from controllers.console.agent.roster import ( AgentAppApi, + AgentAppCopyApi, AgentAppListApi, AgentInviteOptionsApi, + AgentLogMessagesApi, + AgentLogsApi, + AgentLogSourcesApi, AgentRosterVersionDetailApi, AgentRosterVersionsApi, + AgentStatisticsSummaryApi, ) from controllers.console.app import completion as completion_controller from controllers.console.app import message as message_controller @@ -90,6 +95,7 @@ def _agent_app_composer_response() -> dict: def _app_detail_obj(**overrides): data = { "id": "app-1", + "tenant_id": "tenant-1", "name": "Iris", "description": "Agent app", "mode_compatible_with_agent": "agent", @@ -113,7 +119,6 @@ def _app_detail_obj(**overrides): "deleted_tools": [], "site": None, "bound_agent_id": "00000000-0000-0000-0000-000000000001", - "tenant_id": "tenant-1", } data.update(overrides) return SimpleNamespace(**data) @@ -138,6 +143,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//composer/validate", "/agent//composer/candidates", "/agent//features", + "/agent//copy", "/agent//referencing-workflows", "/agent//drive/files", "/agent//sandbox/files", @@ -148,6 +154,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: "/agent//feedbacks", "/agent//chat-messages//suggested-questions", "/agent//messages/", + "/agent//logs", + "/agent//logs//messages", + "/agent//log-sources", + "/agent//statistics/summary", "/agent/invite-options", ): assert route in paths @@ -167,6 +177,11 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None: assert route not in paths +def test_agent_app_write_routes_do_not_reuse_app_billing_quota() -> None: + for route_class in (AgentAppListApi, AgentAppCopyApi): + assert '@cloud_edition_billing_resource_check("apps")' not in getsource(route_class) + + @pytest.fixture def account_id() -> str: return "account-1" @@ -181,7 +196,7 @@ def test_agent_app_list_and_create_use_agent_route( def get_app(self, app_obj: object) -> object: return app_obj - def get_paginate_apps(self, user_id: str, tenant_id: str, params) -> object: + def get_paginate_apps(self, user_id: str, tenant_id: str, params, session) -> object: captured["list"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params} return SimpleNamespace( page=1, @@ -199,12 +214,36 @@ def test_agent_app_list_and_create_use_agent_route( monkeypatch.setattr( roster_controller.AgentRosterService, "load_app_backing_agents_by_app_id", - lambda _self, **kwargs: {"app-list": SimpleNamespace(id="agent-list", role="List role")}, + lambda _self, **kwargs: { + "app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None) + }, ) monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", - lambda _self, **kwargs: SimpleNamespace(id="agent-created", role="Created role"), + lambda _self, **kwargs: SimpleNamespace( + id="agent-created", role="Created role", active_config_snapshot_id=None + ), + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "load_published_references_by_agent_id", + lambda _self, **kwargs: { + "agent-list": [ + { + "app_id": "workflow-app-id", + "app_name": "RFP Review Flow", + "app_icon_type": "emoji", + "app_icon": "A", + "app_icon_background": "#fff", + "app_mode": "workflow", + "app_updated_at": 1781660000, + "workflow_id": "workflow-1", + "workflow_version": "v1", + "node_ids": ["node-1", "node-2"], + } + ] + }, ) monkeypatch.setattr( roster_controller.FeatureService, @@ -221,6 +260,17 @@ def test_agent_app_list_and_create_use_agent_route( assert listed["data"][0]["id"] == "agent-list" assert listed["data"][0]["app_id"] == "app-list" assert listed["data"][0]["role"] == "List role" + assert listed["data"][0]["active_config_is_published"] is False + assert listed["data"][0]["published_reference_count"] == 1 + assert listed["data"][0]["published_references"] == [ + { + "app_id": "workflow-app-id", + "app_name": "RFP Review Flow", + "app_icon_type": "emoji", + "app_icon": "A", + "app_icon_background": "#fff", + } + ] assert "bound_agent_id" not in listed["data"][0] list_call = cast(dict[str, object], captured["list"]) list_params = cast(Any, list_call["params"]) @@ -229,7 +279,13 @@ def test_agent_app_list_and_create_use_agent_route( with app.test_request_context( "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, + json={ + "name": "Iris", + "description": "Agent app", + "role": "Coordinator", + "icon_type": "emoji", + "icon": "robot", + }, ): created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) @@ -237,10 +293,28 @@ def test_agent_app_list_and_create_use_agent_route( assert created["id"] == "agent-created" assert created["app_id"] == "app-created" assert created["role"] == "Created role" + assert created["active_config_is_published"] is False assert "bound_agent_id" not in created create_call = cast(dict[str, object], captured["create"]) create_params = cast(Any, create_call["params"]) assert create_params.mode == "agent" + assert create_params.agent_role == "Coordinator" + + +def test_agent_app_create_requires_role(app: Flask, account_id: str) -> None: + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, + ): + with pytest.raises(ValueError, match="Field required"): + unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) + + with app.test_request_context( + "/console/api/agent", + json={"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"}, + ): + with pytest.raises(ValueError, match="Agent role is required"): + unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) def test_agent_app_detail_update_delete_resolve_app_from_agent_id( @@ -258,7 +332,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", - lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role"), + lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None), ) monkeypatch.setattr( roster_controller.FeatureService, @@ -284,11 +358,12 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert detail["id"] == agent_id assert detail["app_id"] == "app-1" assert detail["role"] == "Resolved role" + assert detail["active_config_is_published"] is False assert "bound_agent_id" not in detail with app.test_request_context( "/console/api/agent/00000000-0000-0000-0000-000000000001", - json={"name": "Renamed", "description": "", "icon_type": "emoji", "icon": "R"}, + json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"}, ): updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) @@ -296,15 +371,102 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert updated["id"] == agent_id assert updated["app_id"] == "app-1" assert updated["role"] == "Resolved role" + assert updated["active_config_is_published"] is False assert "bound_agent_id" not in updated update_call = cast(dict[str, object], captured["update"]) assert update_call["app"] is app_model + assert cast(dict[str, object], update_call["args"])["role"] == "Reviewer" deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), "tenant-1", agent_id) assert (deleted, status) == ("", 204) assert captured["delete"] is app_model +def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + current_user = SimpleNamespace(id=account_id) + copied_app = _app_detail_obj(id="copied-app", bound_agent_id="copied-agent") + captured: dict[str, object] = {} + + class FakeRosterService: + def duplicate_agent_app(self, **kwargs: object) -> object: + captured.update(kwargs) + return copied_app + + monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda: FakeRosterService()) + monkeypatch.setattr( + roster_controller, + "_serialize_agent_app_detail", + lambda app_model: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name}, + ) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/copy", + json={ + "name": "Iris copy", + "description": "Copied", + "icon_type": "emoji", + "icon": "sparkles", + "icon_background": "#fff", + }, + ): + copied, status = unwrap(AgentAppCopyApi.post)(AgentAppCopyApi(), "tenant-1", current_user, agent_id) + + assert status == 201 + assert copied == {"id": "copied-agent", "app_id": "copied-app", "name": "Iris"} + assert captured == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account": current_user, + "name": "Iris copy", + "description": "Copied", + "icon_type": "emoji", + "icon": "sparkles", + "icon_background": "#fff", + } + + +def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id) + captured: dict[str, object] = {} + + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_agent_app_model", + lambda _self, **kwargs: app_model, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_app_backing_agent", + lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="", active_config_snapshot_id=None), + ) + monkeypatch.setattr( + roster_controller.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + class FakeAppService: + def get_app(self, app_obj: object) -> object: + return app_obj + + def update_app(self, app_obj: object, args: dict[str, object]) -> object: + captured["update"] = {"app": app_obj, "args": args} + return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id) + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001", + json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"}, + ): + with pytest.raises(ValueError, match="String should have at least 1 character"): + unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + + def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} @@ -363,6 +525,200 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc assert version_detail["agent_id"] == agent_id +def test_agent_observability_routes_resolve_app_from_agent_id( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + agent_id = "00000000-0000-0000-0000-000000000001" + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + class FakeObservabilityService: + def list_logs(self, *, app, agent_id, params): + captured["logs"] = {"app": app, "agent_id": agent_id, "params": params} + return { + "data": [ + { + "conversation_id": "conversation-1", + "id": "conversation-1", + "title": "Debug", + "end_user_id": "end-user-1", + "message_count": 2, + "user_rate": None, + "operation_rate": None, + "unread": True, + "source": { + "id": "webapp:app-1", + "type": "webapp", + "app_id": "app-1", + "app_name": "Iris", + "app_icon_type": "emoji", + "app_icon": "robot", + "app_icon_background": "#fff", + "workflow_id": None, + "workflow_version": None, + "node_id": None, + }, + "status": "success", + "created_at": 1, + "updated_at": 2, + } + ], + "page": 2, + "limit": 5, + "total": 6, + "has_more": False, + } + + def list_log_messages(self, *, app, agent_id, conversation_id, params): + captured["messages"] = { + "app": app, + "agent_id": agent_id, + "conversation_id": conversation_id, + "params": params, + } + return { + "data": [ + { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "from_end_user_id": None, + "from_account_id": account_id, + "message_tokens": 1, + "answer_tokens": 2, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "latency": 1.2, + "created_at": 1, + "updated_at": 2, + } + ], + "page": 1, + "limit": 20, + "total": 1, + "has_more": False, + } + + def list_log_sources(self, *, app, agent_id): + captured["sources"] = {"app": app, "agent_id": agent_id} + return { + "data": [ + { + "id": "webapp:app-1", + "type": "webapp", + "app_id": "app-1", + "app_name": "Iris", + "app_icon_type": "emoji", + "app_icon": "robot", + "app_icon_background": "#fff", + "workflow_id": None, + "workflow_version": None, + "node_id": None, + } + ], + "groups": [{"type": "webapp", "label": "WEBAPP", "sources": []}], + } + + def get_statistics_summary(self, *, app, agent_id, params): + captured["statistics"] = {"app": app, "agent_id": agent_id, "params": params} + return { + "source": "all", + "summary": { + "total_messages": 1, + "total_conversations": 1, + "total_end_users": 1, + "total_tokens": 3, + "total_price": "0", + "currency": "USD", + "average_session_interactions": 1, + "average_response_time": 1200, + "tokens_per_second": 2, + "user_satisfaction_rate": 100, + }, + "charts": { + "daily_messages": [{"date": "2026-06-17", "message_count": 1}], + "daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}], + "daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}], + "token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}], + "average_session_interactions": [{"date": "2026-06-17", "interactions": 1}], + "average_response_time": [{"date": "2026-06-17", "latency": 1200}], + "tokens_per_second": [{"date": "2026-06-17", "tps": 2}], + "user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}], + }, + } + + monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model) + monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda: FakeObservabilityService()) + + account = SimpleNamespace(id=account_id, timezone="UTC") + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/logs" + "?page=2&limit=5&keyword=hello&statuses[]=success&statuses[]=failed&sources[]=webapp:app-1" + "&sources[]=workflow:app-2:workflow-1:v1:node-1&sort_by=created_at&sort_order=asc" + ): + logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id) + + assert logs["data"][0]["id"] == "conversation-1" + assert logs["data"][0]["source"]["id"] == "webapp:app-1" + logs_call = cast(dict[str, object], captured["logs"]) + assert logs_call["app"] is app_model + assert logs_call["agent_id"] == agent_id + logs_params = cast(Any, logs_call["params"]) + assert logs_params.page == 2 + assert logs_params.limit == 5 + assert logs_params.keyword == "hello" + assert logs_params.statuses == ("success", "failed") + assert logs_params.sources == ("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1") + assert logs_params.sort_by == "created_at" + assert logs_params.sort_order == "asc" + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/logs/00000000-0000-0000-0000-000000000002/messages" + ): + messages = unwrap(AgentLogMessagesApi.get)( + AgentLogMessagesApi(), + "tenant-1", + account, + agent_id, + "00000000-0000-0000-0000-000000000002", + ) + + assert messages["data"][0]["id"] == "message-1" + messages_call = cast(dict[str, object], captured["messages"]) + assert messages_call["app"] is app_model + assert messages_call["agent_id"] == agent_id + assert messages_call["conversation_id"] == "00000000-0000-0000-0000-000000000002" + messages_params = cast(Any, messages_call["params"]) + assert messages_params.sources == () + assert messages_params.statuses == () + + with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/log-sources"): + sources = unwrap(AgentLogSourcesApi.get)(AgentLogSourcesApi(), "tenant-1", account, agent_id) + + assert sources["data"][0]["id"] == "webapp:app-1" + sources_call = cast(dict[str, object], captured["sources"]) + assert sources_call["app"] is app_model + assert sources_call["agent_id"] == agent_id + + with app.test_request_context( + "/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api" + ): + statistics = unwrap(AgentStatisticsSummaryApi.get)(AgentStatisticsSummaryApi(), "tenant-1", account, agent_id) + + assert statistics["summary"]["total_messages"] == 1 + stats_call = cast(dict[str, object], captured["statistics"]) + assert stats_call["app"] is app_model + assert stats_call["agent_id"] == agent_id + stats_params = cast(Any, stats_call["params"]) + assert stats_params.source == "api" + assert stats_params.timezone == "UTC" + + def test_workflow_composer_get_put_validate_candidates_impact_and_save( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py index bcb4aeab462..e94536aa012 100644 --- a/api/tests/unit_tests/controllers/console/app/test_agent_skills.py +++ b/api/tests/unit_tests/controllers/console/app/test_agent_skills.py @@ -10,7 +10,7 @@ from __future__ import annotations import inspect import io from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch from flask import Flask @@ -18,8 +18,6 @@ from controllers.console.app.agent import ( AgentDriveFilesByAgentApi, AgentSkillByAgentApi, AgentSkillInferToolsByAgentApi, - AgentSkillStandardizeApi, - AgentSkillStandardizeByAgentApi, AgentSkillUploadApi, AgentSkillUploadByAgentApi, ) @@ -45,47 +43,39 @@ _APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bou _WORKFLOW_APP = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW, bound_agent_id=None) -def test_upload_validates_and_returns_skill_ref(): +def test_upload_standardizes_into_drive_and_returns_skill_ref(): raw = _raw(AgentSkillUploadApi.post) - manifest = MagicMock() - manifest.to_skill_ref.return_value.model_dump.return_value = {"name": "S", "file_id": "uf-1"} - manifest.model_dump.return_value = {"name": "S"} with _file_ctx(files={"file": b"zip-bytes"}): with ( - patch(f"{_MOD}.SkillPackageService") as pkg, - patch(f"{_MOD}.FileService") as fs, - patch(f"{_MOD}.db"), + patch(f"{_MOD}.SkillStandardizeService") as svc, ): - pkg.return_value.validate_and_extract.return_value = manifest - fs.return_value.upload_file.return_value = SimpleNamespace(id="uf-1") + svc.return_value.standardize.return_value = { + "skill": {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"}, + "manifest": {"name": "Skill A"}, + } body, status = raw(AgentSkillUploadApi(), _USER, _APP) assert status == 201 - assert body["skill"] == {"name": "S", "file_id": "uf-1"} - manifest.to_skill_ref.assert_called_once_with(file_id="uf-1") + assert body["skill"] == {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"} + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" -def test_upload_by_agent_resolves_app_and_returns_skill_ref(): +def test_upload_by_agent_resolves_app_and_standardizes_into_drive(): raw = _raw(AgentSkillUploadByAgentApi.post) - manifest = MagicMock() - manifest.to_skill_ref.return_value.model_dump.return_value = {"name": "S", "file_id": "uf-1"} - manifest.model_dump.return_value = {"name": "S"} with _file_ctx(files={"file": b"zip-bytes"}): with ( patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.SkillPackageService") as pkg, - patch(f"{_MOD}.FileService") as fs, - patch(f"{_MOD}.db"), + patch(f"{_MOD}.SkillStandardizeService") as svc, ): - pkg.return_value.validate_and_extract.return_value = manifest - fs.return_value.upload_file.return_value = SimpleNamespace(id="uf-1") + svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}} body, status = raw(AgentSkillUploadByAgentApi(), "tenant-1", _USER, "agent-1") assert status == 201 - assert body["skill"] == {"name": "S", "file_id": "uf-1"} + assert body["skill"] == {"path": "skill-a"} resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") + assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" def test_upload_no_file_is_400(): @@ -99,8 +89,8 @@ def test_upload_no_file_is_400(): def test_upload_maps_package_error(): raw = _raw(AgentSkillUploadApi.post) with _file_ctx(files={"file": b"bad"}): - with patch(f"{_MOD}.SkillPackageService") as pkg: - pkg.return_value.validate_and_extract.side_effect = SkillPackageError( + with patch(f"{_MOD}.SkillStandardizeService") as svc: + svc.return_value.standardize.side_effect = SkillPackageError( "missing_skill_md", "no SKILL.md", status_code=400 ) body, status = raw(AgentSkillUploadApi(), _USER, _APP) @@ -108,44 +98,17 @@ def test_upload_maps_package_error(): assert body["code"] == "missing_skill_md" -def test_standardize_returns_result(): - raw = _raw(AgentSkillStandardizeApi.post) - with _file_ctx(files={"file": b"zip"}): - with patch(f"{_MOD}.SkillStandardizeService") as svc: - svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} - body, status = raw(AgentSkillStandardizeApi(), _USER, _APP) - assert status == 201 - assert body["skill"] == {"path": "s"} - assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" - - -def test_standardize_by_agent_resolves_app(): - raw = _raw(AgentSkillStandardizeByAgentApi.post) - with _file_ctx(files={"file": b"zip"}): - with ( - patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app, - patch(f"{_MOD}.SkillStandardizeService") as svc, - ): - svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} - body, status = raw(AgentSkillStandardizeByAgentApi(), "tenant-1", _USER, "agent-1") - - assert status == 201 - assert body["skill"] == {"path": "s"} - resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1") - assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1" - - -def test_standardize_no_bound_agent_is_400(): - raw = _raw(AgentSkillStandardizeApi.post) +def test_upload_no_bound_agent_is_400(): + raw = _raw(AgentSkillUploadApi.post) app_without_agent = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT, bound_agent_id=None) with _file_ctx(files={"file": b"zip"}): - body, status = raw(AgentSkillStandardizeApi(), _USER, app_without_agent) + body, status = raw(AgentSkillUploadApi(), _USER, app_without_agent) assert status == 400 assert body["code"] == "agent_not_bound" -def test_standardize_resolves_workflow_node_agent(): - raw = _raw(AgentSkillStandardizeApi.post) +def test_upload_resolves_workflow_node_agent(): + raw = _raw(AgentSkillUploadApi.post) with app.test_request_context( "/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")} ): @@ -155,19 +118,19 @@ def test_standardize_resolves_workflow_node_agent(): ): composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1" svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}} - body, status = raw(AgentSkillStandardizeApi(), _USER, _WORKFLOW_APP) + body, status = raw(AgentSkillUploadApi(), _USER, _WORKFLOW_APP) assert status == 201 assert body["skill"] == {"path": "s"} assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1" -def test_standardize_maps_drive_error(): - raw = _raw(AgentSkillStandardizeApi.post) +def test_upload_maps_drive_error(): + raw = _raw(AgentSkillUploadApi.post) with _file_ctx(files={"file": b"zip"}): with patch(f"{_MOD}.SkillStandardizeService") as svc: svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404) - body, status = raw(AgentSkillStandardizeApi(), _USER, _APP) + body, status = raw(AgentSkillUploadApi(), _USER, _APP) assert status == 404 assert body["code"] == "source_not_found" 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 0cccb34b08d..08273a6e1f7 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 @@ -13,13 +13,28 @@ from controllers.console.app import app_import as app_import_module from services.app_dsl_service import ImportStatus +def _unwrap(func): + bound_self = getattr(func, "__self__", None) + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + if bound_self is not None: + return func.__get__(bound_self, bound_self.__class__) + return func + + class _Result: - def __init__(self, status: ImportStatus, app_id: str | None = "app-1"): + def __init__( + self, + status: ImportStatus, + app_id: str | None = "app-1", + permission_keys: list[str] | None = None, + ): self.status = status self.app_id = app_id + self.permission_keys = permission_keys or [] def model_dump(self, mode: str = "json"): - return {"status": self.status, "app_id": self.app_id} + return {"status": self.status, "app_id": self.app_id, "permission_keys": self.permission_keys} def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: @@ -107,6 +122,72 @@ class TestAppImportApi: assert status == 200 assert response["status"] == ImportStatus.COMPLETED + def test_import_post_attaches_permission_keys_when_creating_new_app_and_rbac_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + _install_features(monkeypatch, enabled=False) + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "import_app", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda tenant_id, account_id, app_id: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}): + response, status = method() + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + def test_import_post_does_not_attach_permission_keys_when_overwriting_existing_app( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + _install_features(monkeypatch, enabled=False) + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "import_app", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda *_args, **_kwargs: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context( + "/console/api/apps/imports", + method="POST", + json={"mode": "yaml-content", "app_id": "existing-app"}, + ): + response, status = method() + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == [] + class TestAppImportConfirmApi: @pytest.fixture @@ -132,3 +213,79 @@ class TestAppImportConfirmApi: session.commit.assert_not_called() assert status == 400 assert response["status"] == ImportStatus.FAILED + + def test_import_confirm_attaches_permission_keys_when_creating_new_app_and_rbac_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr( + app_import_module.redis_client, + "get", + lambda *_args, **_kwargs: ( + b'{"import_mode":"yaml-content","yaml_content":"app: {}","app_id":null,' + b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}' + ), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "confirm_import", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-456"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda tenant_id, account_id, app_id: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"): + response, status = method(import_id="import-1") + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + def test_import_confirm_does_not_attach_permission_keys_when_overwriting_existing_app( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr( + app_import_module.redis_client, + "get", + lambda *_args, **_kwargs: ( + b'{"import_mode":"yaml-content","yaml_content":"app: {}","app_id":"existing-app",' + b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}' + ), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "confirm_import", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-456"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda *_args, **_kwargs: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"): + response, status = method(import_id="import-1") + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == [] 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 ef8f90e5c9c..48a19bb0364 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 @@ -14,6 +14,8 @@ from flask.views import MethodView from pydantic import ValidationError from werkzeug.datastructures import MultiDict +from configs import dify_config + # kombu references MethodView as a global when importing celery/kombu pools. if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @@ -351,6 +353,8 @@ def test_app_partial_serialization_uses_aliases(app_models): create_user_name="Creator", author_name="Author", has_draft_trigger=True, + permission_keys=["app.acl.view_layout"], + role="Should stay agent-only", ) serialized = AppPartial.model_validate(app_obj, from_attributes=True).model_dump(mode="json") @@ -363,6 +367,8 @@ def test_app_partial_serialization_uses_aliases(app_models): assert serialized["model_config"]["model"] == {"provider": "openai", "name": "gpt-4o"} assert serialized["workflow"]["id"] == "wf-1" assert serialized["tags"][0]["name"] == "Utilities" + assert serialized["permission_keys"] == ["app.acl.view_layout"] + assert "role" not in serialized def test_app_detail_with_site_includes_nested_serialization(app_models): @@ -400,11 +406,13 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): updated_at=timestamp, access_mode="public", tags=[SimpleNamespace(id="tag-2", name="Prod", type="app")], + permission_keys=["app.acl.view_layout", "app.acl.edit"], api_base_url="https://api.example.com/v1", max_active_requests=5, deleted_tools=[{"type": "api", "tool_name": "search", "provider_id": "prov"}], site=site, bound_agent_id="agent-1", + role="Should stay agent-only", ) serialized = AppDetailWithSite.model_validate(app_obj, from_attributes=True).model_dump(mode="json") @@ -414,7 +422,9 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): assert serialized["deleted_tools"][0]["tool_name"] == "search" assert serialized["site"]["icon_url"] == "signed:site-icon" assert serialized["site"]["created_at"] == int(timestamp.timestamp()) + assert serialized["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] assert serialized["bound_agent_id"] == "agent-1" + assert "role" not in serialized def test_app_pagination_aliases_per_page_and_has_next(app_models): @@ -428,6 +438,7 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models): icon="first-icon", created_at=_ts(15), updated_at=_ts(15), + permission_keys=["app.acl.edit"], ) item_two = SimpleNamespace( id="app-11", @@ -489,6 +500,20 @@ def test_app_list_uses_injected_session_for_draft_workflows( "FeatureService", SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))), ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.edit"], + ) + ] + ) + ), + ) monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session)) with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"): @@ -498,3 +523,351 @@ def test_app_list_uses_injected_session_for_draft_workflows( assert response["data"][0]["has_draft_trigger"] is True session.execute.assert_called_once() scoped_session.execute.assert_not_called() + assert response["data"][0]["permission_keys"] == ["app.acl.edit"] + + +def test_app_create_api_attaches_permission_keys(app, app_module): + method = app_module.AppListApi.post + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-new", + name="Created App", + description="Summary", + mode_compatible_with_agent="advanced-chat", + enable_site=True, + enable_api=True, + permission_keys=[], + ) + + with app.test_request_context("/apps", method="POST", json={}): + with pytest.MonkeyPatch.context() as monkeypatch: + app_module.console_ns.payload = { + "name": "Created App", + "description": "Summary", + "mode": "advanced-chat", + } + monkeypatch.setattr( + app_module, + "AppService", + lambda: SimpleNamespace(create_app=lambda tenant_id, params, user: app_obj), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppPermissions, + "batch_get", + lambda tenant_id, account_id, app_ids: {"app-new": ["app.acl.view_layout", "app.acl.edit"]}, + ) + + resp, status = method(app_module.AppListApi(), "tenant-1", SimpleNamespace(id="acct-1")) + + assert status == 201 + assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + +def test_app_list_api_attaches_permission_keys(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-1", + name="List App", + desc_or_prompt="Summary", + mode_compatible_with_agent="chat", + mode="chat", + created_at=_ts(15), + updated_at=_ts(15), + permission_keys=[], + ) + pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_obj]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.AppService, + "get_paginate_apps", + get_paginate_apps, + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + default_permission_keys=["app.preview", "app.acl.view_layout"], + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.view_layout", "app.acl.edit"], + ) + ], + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace(unrestricted=True, resource_ids=[]), + ) + + session = MagicMock() + session.execute.return_value.scalars.return_value.all.return_value = [] + resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + assert status == 200 + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids is None + assert params.is_created_by_me is None + assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + +def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + pagination = SimpleNamespace(page=1, per_page=20, total=0, has_next=False, items=[]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps) + monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + workspace=app_module.enterprise_rbac_service.WorkspacePermissionSnapshot( + permission_keys=["app.create_and_management"] + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-shared", "app-not-permitted"]), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + session = MagicMock() + resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + assert status == 200 + assert resp["data"] == [] + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-not-permitted", "app-shared"] + assert params.include_own_apps is True + assert params.is_created_by_me is None + + +def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + pagination = SimpleNamespace(page=1, per_page=20, total=0, has_next=False, items=[]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps) + monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-acl-shared", + permission_keys=["app.acl.preview"], + ), + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-full", + permission_keys=["app.full_access"], + ), + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-shared", + permission_keys=["app.preview"], + ), + ] + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace( + resource_ids=["app-shared", "app-acl-shared", "app-full", "app-whitelist-only"] + ), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + session = MagicMock() + method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-acl-shared", "app-full", "app-shared", "app-whitelist-only"] + assert params.include_own_apps is False + assert params.is_created_by_me is None + + +def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permission(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + pagination = SimpleNamespace(page=1, per_page=20, total=0, has_next=False, items=[]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps) + monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse(), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-not-permitted"]), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + session = MagicMock() + method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-not-permitted"] + assert params.include_own_apps is False + assert params.is_created_by_me is None + + +def test_app_detail_api_attaches_current_user_permission_keys(app, app_module): + method = app_module.AppApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-1", + name="Detail App", + description="Summary", + mode_compatible_with_agent="chat", + enable_site=True, + enable_api=True, + permission_keys=[], + ) + + with app.test_request_context("/apps/app-1"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr(app_module, "AppService", lambda: SimpleNamespace(get_app=lambda app_model: app_obj)) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + get_permissions = MagicMock( + return_value=app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"], + ) + ] + ) + ) + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + get_permissions, + ) + + resp = method(app_module.AppApi(), "tenant-1", SimpleNamespace(id="acct-1"), app_model=app_obj) + + get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1") + assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"] + + +def test_app_copy_api_attaches_permission_keys(app, app_module): + method = app_module.AppCopyApi.post + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-new", + name="Copied App", + description="Summary", + mode_compatible_with_agent="workflow", + enable_site=True, + enable_api=True, + permission_keys=[], + ) + + import_result = SimpleNamespace(status=app_module.ImportStatus.COMPLETED, app_id="app-new") + fake_session = MagicMock() + fake_session.__enter__.return_value = fake_session + fake_session.__exit__.return_value = None + fake_session.scalar.return_value = app_obj + + with app.test_request_context("/apps/app-original/copy", method="POST", json={}): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module, + "AppDslService", + lambda *_args, **_kwargs: SimpleNamespace( + export_dsl=lambda **_kwargs: "dsl", + import_app=lambda **_kwargs: import_result, + ), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + monkeypatch.setattr(app_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + app_module, + "Session", + lambda *_args, **_kwargs: fake_session, + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppPermissions, + "batch_get", + lambda tenant_id, account_id, app_ids: {"app-new": ["app.acl.view_layout", "app.acl.edit"]}, + ) + + resp, status = method( + app_module.AppCopyApi(), + "tenant-1", + SimpleNamespace(id="acct-1"), + app_model=SimpleNamespace(id="app-original"), + ) + + assert status == 201 + assert fake_session.scalar.called + assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] diff --git a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py index c7dba152162..aa248180bca 100644 --- a/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py +++ b/api/tests/unit_tests/controllers/console/app/test_mcp_server_response.py @@ -1,4 +1,5 @@ import datetime +from inspect import unwrap from types import SimpleNamespace from unittest.mock import PropertyMock, patch @@ -8,12 +9,6 @@ from controllers.console import console_ns from controllers.console.app.mcp_server import AppMCPServerController, AppMCPServerResponse -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class _ValidatedResponse: def __init__(self, payload): self._payload = payload diff --git a/api/tests/unit_tests/controllers/console/app/test_message_api.py b/api/tests/unit_tests/controllers/console/app/test_message_api.py index 27bc5e341e9..067edc6fd68 100644 --- a/api/tests/unit_tests/controllers/console/app/test_message_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_message_api.py @@ -3,11 +3,12 @@ from __future__ import annotations from datetime import UTC, datetime import pytest +from flask import Flask from controllers.console.app import message as message_module -def test_chat_messages_query_valid(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_chat_messages_query_valid(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test valid ChatMessagesQuery with all fields.""" query = message_module.ChatMessagesQuery( conversation_id="550e8400-e29b-41d4-a716-446655440000", @@ -17,14 +18,14 @@ def test_chat_messages_query_valid(app, monkeypatch: pytest.MonkeyPatch) -> None assert query.limit == 50 -def test_chat_messages_query_defaults(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_chat_messages_query_defaults(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test ChatMessagesQuery with defaults.""" query = message_module.ChatMessagesQuery(conversation_id="550e8400-e29b-41d4-a716-446655440000") assert query.first_id is None assert query.limit == 20 -def test_chat_messages_query_empty_first_id(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_chat_messages_query_empty_first_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test ChatMessagesQuery converts empty first_id to None.""" query = message_module.ChatMessagesQuery( conversation_id="550e8400-e29b-41d4-a716-446655440000", @@ -33,7 +34,7 @@ def test_chat_messages_query_empty_first_id(app, monkeypatch: pytest.MonkeyPatch assert query.first_id is None -def test_message_feedback_payload_valid_like(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_message_feedback_payload_valid_like(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test MessageFeedbackPayload with like rating.""" payload = message_module.MessageFeedbackPayload( message_id="550e8400-e29b-41d4-a716-446655440000", @@ -44,7 +45,7 @@ def test_message_feedback_payload_valid_like(app, monkeypatch: pytest.MonkeyPatc assert payload.content == "Good answer" -def test_message_feedback_payload_valid_dislike(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_message_feedback_payload_valid_dislike(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test MessageFeedbackPayload with dislike rating.""" payload = message_module.MessageFeedbackPayload( message_id="550e8400-e29b-41d4-a716-446655440000", @@ -53,69 +54,69 @@ def test_message_feedback_payload_valid_dislike(app, monkeypatch: pytest.MonkeyP assert payload.rating == "dislike" -def test_message_feedback_payload_no_rating(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_message_feedback_payload_no_rating(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test MessageFeedbackPayload without rating.""" payload = message_module.MessageFeedbackPayload(message_id="550e8400-e29b-41d4-a716-446655440000") assert payload.rating is None -def test_feedback_export_query_defaults(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_defaults(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with default format.""" query = message_module.FeedbackExportQuery() assert query.format == "csv" assert query.from_source is None -def test_feedback_export_query_json_format(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_json_format(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with JSON format.""" query = message_module.FeedbackExportQuery(format="json") assert query.format == "json" -def test_feedback_export_query_has_comment_true(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_has_comment_true(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with has_comment as true string.""" query = message_module.FeedbackExportQuery(has_comment="true") assert query.has_comment is True -def test_feedback_export_query_has_comment_false(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_has_comment_false(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with has_comment as false string.""" query = message_module.FeedbackExportQuery(has_comment="false") assert query.has_comment is False -def test_feedback_export_query_has_comment_1(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_has_comment_1(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with has_comment as 1.""" query = message_module.FeedbackExportQuery(has_comment="1") assert query.has_comment is True -def test_feedback_export_query_has_comment_0(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_has_comment_0(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with has_comment as 0.""" query = message_module.FeedbackExportQuery(has_comment="0") assert query.has_comment is False -def test_feedback_export_query_rating_filter(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_feedback_export_query_rating_filter(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test FeedbackExportQuery with rating filter.""" query = message_module.FeedbackExportQuery(rating="like") assert query.rating == "like" -def test_annotation_count_response(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_annotation_count_response(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test AnnotationCountResponse creation.""" response = message_module.AnnotationCountResponse(count=10) assert response.count == 10 -def test_suggested_questions_response(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_suggested_questions_response(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test SuggestedQuestionsResponse creation.""" response = message_module.SuggestedQuestionsResponse(data=["What is AI?", "How does ML work?"]) assert len(response.data) == 2 assert response.data[0] == "What is AI?" -def test_message_detail_response_normalizes_aliases_and_timestamp(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_message_detail_response_normalizes_aliases_and_timestamp(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test MessageDetailResponse normalizes alias fields and datetime timestamps.""" created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) response = message_module.MessageDetailResponse.model_validate( diff --git a/api/tests/unit_tests/controllers/console/app/test_statistic_api.py b/api/tests/unit_tests/controllers/console/app/test_statistic_api.py index b31dccd034f..b0506e348fc 100644 --- a/api/tests/unit_tests/controllers/console/app/test_statistic_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_statistic_api.py @@ -5,6 +5,7 @@ from inspect import unwrap from types import SimpleNamespace import pytest +from flask import Flask from werkzeug.exceptions import BadRequest from controllers.console.app import statistic as statistic_module @@ -38,7 +39,7 @@ def _install_common(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(statistic_module, "convert_datetime_to_date", lambda field: field) -def test_daily_message_statistic_returns_rows(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_message_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyMessageStatistic() method = unwrap(api.get) @@ -52,7 +53,7 @@ def test_daily_message_statistic_returns_rows(app, monkeypatch: pytest.MonkeyPat assert response.get_json() == {"data": [{"date": "2024-01-01", "message_count": 3}]} -def test_daily_conversation_statistic_returns_rows(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_conversation_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyConversationStatistic() method = unwrap(api.get) @@ -66,7 +67,7 @@ def test_daily_conversation_statistic_returns_rows(app, monkeypatch: pytest.Monk assert response.get_json() == {"data": [{"date": "2024-01-02", "conversation_count": 5}]} -def test_daily_token_cost_statistic_returns_rows(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_token_cost_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyTokenCostStatistic() method = unwrap(api.get) @@ -84,7 +85,7 @@ def test_daily_token_cost_statistic_returns_rows(app, monkeypatch: pytest.Monkey assert data["data"][0]["total_price"] == 0.25 -def test_daily_terminals_statistic_returns_rows(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_terminals_statistic_returns_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyTerminalsStatistic() method = unwrap(api.get) @@ -98,7 +99,7 @@ def test_daily_terminals_statistic_returns_rows(app, monkeypatch: pytest.MonkeyP assert response.get_json() == {"data": [{"date": "2024-01-04", "terminal_count": 7}]} -def test_average_session_interaction_statistic_requires_chat_mode(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_average_session_interaction_statistic_requires_chat_mode(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: """Test that AverageSessionInteractionStatistic is limited to chat/agent modes.""" # This just verifies the decorator is applied correctly # Actual endpoint testing would require complex JOIN mocking @@ -107,7 +108,7 @@ def test_average_session_interaction_statistic_requires_chat_mode(app, monkeypat assert callable(method) -def test_daily_message_statistic_with_invalid_time_range(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_message_statistic_with_invalid_time_range(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyMessageStatistic() method = unwrap(api.get) @@ -123,7 +124,7 @@ def test_daily_message_statistic_with_invalid_time_range(app, monkeypatch: pytes method(api, SimpleNamespace(timezone="UTC"), app_model=SimpleNamespace(id="app-1")) -def test_daily_message_statistic_multiple_rows(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_message_statistic_multiple_rows(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyMessageStatistic() method = unwrap(api.get) @@ -142,7 +143,7 @@ def test_daily_message_statistic_multiple_rows(app, monkeypatch: pytest.MonkeyPa assert len(data["data"]) == 3 -def test_daily_message_statistic_empty_result(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_message_statistic_empty_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyMessageStatistic() method = unwrap(api.get) @@ -155,7 +156,7 @@ def test_daily_message_statistic_empty_result(app, monkeypatch: pytest.MonkeyPat assert response.get_json() == {"data": []} -def test_daily_conversation_statistic_with_time_range(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_conversation_statistic_with_time_range(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyConversationStatistic() method = unwrap(api.get) @@ -174,7 +175,7 @@ def test_daily_conversation_statistic_with_time_range(app, monkeypatch: pytest.M assert response.get_json() == {"data": [{"date": "2024-01-02", "conversation_count": 5}]} -def test_daily_token_cost_with_multiple_currencies(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_daily_token_cost_with_multiple_currencies(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = statistic_module.DailyTokenCostStatistic() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py new file mode 100644 index 00000000000..dd254a31f63 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py @@ -0,0 +1,55 @@ +"""Unit tests for convert-to-workflow endpoint.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from flask import Flask + +from controllers.console.app import workflow as workflow_module + + +def _unwrap(func): + bound_self = getattr(func, "__self__", None) + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + if bound_self is not None: + return func.__get__(bound_self, bound_self.__class__) + return func + + +class TestConvertToWorkflowApi: + @pytest.fixture + def api(self): + return workflow_module.ConvertToWorkflowApi() + + def test_convert_to_workflow_attaches_permission_keys_when_rbac_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace(convert_to_workflow=lambda **_kwargs: SimpleNamespace(id="new-app-1")), + ) + monkeypatch.setattr( + workflow_module, + "get_app_permission_keys", + lambda tenant_id, account_id, app_id: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context( + "/console/api/apps/app-1/convert-to-workflow", + method="POST", + json={}, + ): + response = method( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id="u1"), + app_model=SimpleNamespace(id="app-1"), + ) + + assert response["new_app_id"] == "new-app-1" + assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] 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 79169cfce7e..6ef7f442591 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 @@ -15,7 +15,7 @@ from flask import Flask from controllers.console.auth.activate import ActivateApi, ActivateCheckApi from controllers.console.error import AccountInFreezeError, AlreadyActivateError -from models.account import AccountStatus +from models.account import AccountStatus, TenantAccountRole class TestActivateCheckApi: @@ -66,6 +66,20 @@ class TestActivateCheckApi: assert response["data"]["workspace_id"] == "workspace-123" assert response["data"]["email"] == "invitee@example.com" + @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") + def test_check_valid_invitation_token_includes_account_status(self, mock_get_invitation, app, mock_invitation): + mock_account = MagicMock() + mock_account.status = AccountStatus.ACTIVE + mock_invitation["account"] = mock_account + mock_get_invitation.return_value = mock_invitation + + with app.test_request_context("/activate/check?email=invitee@example.com&token=valid_token"): + response = ActivateCheckApi().get() + + assert response["is_valid"] is True + assert response["data"]["account_status"] == AccountStatus.ACTIVE + assert response["data"]["requires_setup"] is False + @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") def test_check_invalid_invitation_token(self, mock_get_invitation, app: Flask): """ @@ -177,16 +191,21 @@ class TestActivateApi: "account": mock_account, } + @pytest.fixture(autouse=True) + def mock_switch_tenant(self): + with patch("controllers.console.auth.activate.TenantService.switch_tenant") as mock: + yield mock + @patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid") @patch("controllers.console.auth.activate.RegisterService.revoke_token") @patch("controllers.console.auth.activate.db") def test_successful_account_activation( self, - mock_db, - mock_revoke_token, - mock_get_invitation, + mock_db: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, app: Flask, - mock_invitation, + mock_invitation: MagicMock, mock_account, ): """ @@ -224,7 +243,40 @@ class TestActivateApi: assert mock_account.status == AccountStatus.ACTIVE assert mock_account.initialized_at is not None mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token") - mock_db.session.commit.assert_called_once() + + @patch("controllers.console.auth.activate.TenantService.create_tenant_member") + @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") + @patch("controllers.console.auth.activate.RegisterService.revoke_token") + @patch("controllers.console.auth.activate.db") + def test_activation_rejects_missing_setup_fields_before_consuming_invitation( + self, + mock_db, + mock_revoke_token, + mock_get_invitation, + mock_create_tenant_member, + app: Flask, + mock_invitation, + mock_switch_tenant, + ): + mock_invitation["data"]["requires_setup"] = True + mock_get_invitation.return_value = mock_invitation + mock_db.session.scalar.return_value = None + + with app.test_request_context( + "/activate", + method="POST", + json={ + "workspace_id": "workspace-123", + "email": "invitee@example.com", + "token": "valid_token", + }, + ): + with pytest.raises(AlreadyActivateError): + ActivateApi().post() + + mock_revoke_token.assert_not_called() + mock_create_tenant_member.assert_not_called() + mock_switch_tenant.assert_not_called() @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") def test_activation_with_invalid_token(self, mock_get_invitation, app: Flask): @@ -396,11 +448,11 @@ class TestActivateApi: @patch("controllers.console.auth.activate.db") def test_activation_returns_success_response( self, - mock_db, - mock_revoke_token, - mock_get_invitation, + mock_db: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, app: Flask, - mock_invitation, + mock_invitation: MagicMock, ): """ Test that activation returns a success response without authentication tokens. @@ -436,11 +488,11 @@ class TestActivateApi: @patch("controllers.console.auth.activate.db") def test_activation_without_workspace_id( self, - mock_db, - mock_revoke_token, - mock_get_invitation, + mock_db: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, app: Flask, - mock_invitation, + mock_invitation: MagicMock, ): """ Test account activation without workspace_id. @@ -476,12 +528,12 @@ class TestActivateApi: @patch("controllers.console.auth.activate.db") def test_activation_normalizes_email_before_lookup( self, - mock_db, - mock_revoke_token, - mock_get_invitation, + mock_db: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, app: Flask, - mock_invitation, - mock_account, + mock_invitation: MagicMock, + mock_account: MagicMock, ): """Ensure uppercase emails are normalized before lookup and revocation.""" mock_get_invitation.return_value = mock_invitation @@ -504,3 +556,77 @@ class TestActivateApi: assert response["result"] == "success" mock_get_invitation.assert_called_once_with("workspace-123", "Invitee@Example.com", "valid_token") mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token") + + @patch("controllers.console.auth.activate.TenantService.create_tenant_member") + @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") + @patch("controllers.console.auth.activate.RegisterService.revoke_token") + @patch("controllers.console.auth.activate.db") + def test_activation_for_existing_active_account_creates_membership_on_acceptance( + self, + mock_db: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, + mock_create_tenant_member: MagicMock, + app: Flask, + mock_invitation: MagicMock, + mock_account: MagicMock, + mock_switch_tenant: MagicMock, + ): + mock_account.status = AccountStatus.ACTIVE + mock_invitation["data"]["role"] = "admin" + mock_invitation["data"]["requires_setup"] = False + mock_get_invitation.return_value = mock_invitation + mock_db.session.scalar.return_value = None + + with app.test_request_context( + "/activate", + method="POST", + json={ + "workspace_id": "workspace-123", + "email": "invitee@example.com", + "token": "valid_token", + }, + ): + response = ActivateApi().post() + + assert response["result"] == "success" + mock_create_tenant_member.assert_called_once_with( + mock_invitation["tenant"], mock_account, mock_db.session, role=TenantAccountRole.ADMIN + ) + mock_switch_tenant.assert_called_once_with(mock_account, mock_invitation["tenant"].id) + mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token") + + @patch("controllers.console.auth.activate.TenantService.create_tenant_member") + @patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback") + @patch("controllers.console.auth.activate.RegisterService.revoke_token") + @patch("controllers.console.auth.activate.db") + def test_activation_legacy_active_member_invitation_does_not_require_setup( + self, + mock_db: MagicMock, + mock_revoke_token: MagicMock, + mock_get_invitation: MagicMock, + mock_create_tenant_member: MagicMock, + app: Flask, + mock_invitation: MagicMock, + mock_account: MagicMock, + mock_switch_tenant: MagicMock, + ): + mock_account.status = AccountStatus.ACTIVE + mock_get_invitation.return_value = mock_invitation + mock_db.session.scalar.return_value = "membership-id" + + with app.test_request_context( + "/activate", + method="POST", + json={ + "workspace_id": "workspace-123", + "email": "invitee@example.com", + "token": "valid_token", + }, + ): + response = ActivateApi().post() + + assert response["result"] == "success" + mock_create_tenant_member.assert_not_called() + mock_switch_tenant.assert_called_once_with(mock_account, mock_invitation["tenant"].id) + mock_revoke_token.assert_called_once_with("workspace-123", "invitee@example.com", "valid_token") 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 2cf8947014b..eccf5ed56d1 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,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, patch import pytest @@ -11,12 +12,6 @@ from models import Account from models.dataset import Pipeline -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - def make_account() -> Account: account = Account(name="Test User", email="user@example.com") account.id = "account-1" 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 f51b1ae1da4..0d18120b715 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,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, patch import pytest @@ -19,12 +20,6 @@ from graphon.variables.types import SegmentType from models.account import Account, TenantAccountRole -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture def fake_db(): db = MagicMock() 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 index a2748ad323e..5cc5af9592b 100644 --- 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 @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import PropertyMock, patch import pytest +from flask import Flask from controllers.console.datasets.rag_pipeline import rag_pipeline_workflow as module from models.account import Account, TenantAccountRole @@ -117,7 +118,7 @@ def test_published_rag_pipeline_workflows_serialize_items_before_session_closes( assert response["has_more"] is False -def test_rag_pipeline_workflow_patch_serializes_response_model(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_rag_pipeline_workflow_patch_serializes_response_model(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: workflow = _make_workflow(marked_name="Updated release") class _SessionContext: 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 101a640699f..76a09558987 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 import json from contextlib import ExitStack from inspect import unwrap +from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -39,6 +40,7 @@ from models.dataset import Dataset, DatasetQuery, Document from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus from models.model import ApiToken, App, AppMode, IconType, UploadFile from services.dataset_service import DatasetPermissionService, DatasetService +from services.enterprise import rbac_service as enterprise_rbac_service @pytest.fixture(autouse=True) @@ -211,6 +213,201 @@ class TestDatasetList: assert status == 200 assert resp["total"] == 2 + def test_get_attaches_current_user_permission_keys(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + dataset = make_dataset(id="dataset-1") + permissions = enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot( + default_permission_keys=["dataset.acl.readonly"], + overrides=[ + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-1", + permission_keys=["dataset.acl.readonly", "dataset.acl.edit"], + ) + ], + ) + ) + + with app.test_request_context("/datasets"): + with ( + patch.object(DatasetService, "get_datasets", return_value=([dataset], 1)), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ) as get_permissions, + ): + resp, status = method(api, "tenant-1", current_user) + + get_permissions.assert_called_once_with("tenant-1", current_user.id) + assert status == 200 + assert resp["data"][0]["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] + + def test_get_limits_to_own_datasets_without_default_read_permission(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse( + workspace=enterprise_rbac_service.WorkspacePermissionSnapshot( + permission_keys=["dataset.create_and_management"] + ) + ) + + with app.test_request_context("/datasets"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace(resource_ids=[]), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [] + assert get_datasets.call_args.kwargs["include_own_datasets"] is True + + def test_get_workspace_owner_bypasses_dataset_whitelist(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot(default_permission_keys=["dataset.preview"]) + ) + + with app.test_request_context("/datasets"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace(unrestricted=True, resource_ids=[]), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + assert get_datasets.call_args.kwargs["accessible_dataset_ids"] is None + + def test_get_limits_to_dataset_read_overrides(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-acl-shared", + permission_keys=["dataset.acl.preview"], + ), + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-full", + permission_keys=["dataset.full_access"], + ), + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-shared", + permission_keys=["dataset.preview"], + ), + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-hidden", + permission_keys=[], + ), + ] + ) + ) + + with app.test_request_context("/datasets"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace( + resource_ids=[ + "dataset-shared", + "dataset-acl-shared", + "dataset-full", + "dataset-whitelist-only", + ] + ), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [ + "dataset-acl-shared", + "dataset-full", + "dataset-shared", + "dataset-whitelist-only", + ] + assert get_datasets.call_args.kwargs["include_own_datasets"] is False + + def test_get_with_ids_applies_dataset_visibility(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse() + + with app.test_request_context("/datasets?ids=dataset-1"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets_by_ids", return_value=([], 0)) as get_datasets_by_ids, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace(resource_ids=[]), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + get_datasets_by_ids.assert_called_once_with( + ["dataset-1"], + "tenant-1", + user=current_user, + accessible_dataset_ids=[], + include_own_datasets=False, + ) + def test_get_with_tag_ids(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -504,6 +701,51 @@ class TestDatasetApiGet: assert status == 200 assert data["embedding_available"] is True + def test_get_attaches_permission_keys_when_rbac_enabled(self, app: Flask): + api = DatasetApi() + method = unwrap(api.get) + + dataset_id = "123e4567-e89b-12d3-a456-426614174000" + user = MagicMock(id="account-1") + tenant_id = "tenant-1" + dataset = make_dataset(id=dataset_id) + + with ( + app.test_request_context(f"/datasets/{dataset_id}"), + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object( + DatasetService, + "get_dataset", + return_value=dataset, + ), + patch.object( + DatasetService, + "check_dataset_permission", + return_value=None, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + enterprise_rbac_service.ResourcePermissionKeys( + resource_id=dataset_id, + permission_keys=["dataset.acl.readonly", "dataset.acl.edit"], + ) + ] + ) + ), + ) as get_permissions, + patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, + ): + provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] + + data, status = method(api, tenant_id, user, dataset_id) + + get_permissions.assert_called_once_with(tenant_id, user.id, dataset_id=dataset_id) + assert status == 200 + assert data["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] + def test_get_uses_default_external_retrieval_model(self, app: Flask): api = DatasetApi() 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 7cb41dc99cd..b7e16b91fb7 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_external.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_external.py @@ -171,6 +171,7 @@ class TestExternalDatasetCreateApi: dataset.external_retrieval_model = None dataset.doc_metadata = [] dataset.icon_info = None + dataset.permission_keys = [] dataset.summary_index_setting = MagicMock() dataset.summary_index_setting.enable = False 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 d1cb6b6a03d..552dc7d8217 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_banner.py +++ b/api/tests/unit_tests/controllers/console/explore/test_banner.py @@ -1,4 +1,5 @@ from datetime import datetime +from inspect import unwrap from unittest.mock import MagicMock, patch from flask import Flask @@ -7,12 +8,6 @@ import controllers.console.explore.banner as banner_module from models.enums import BannerStatus -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class TestBannerApi: def test_get_banners_with_requested_language(self, app: Flask): api = banner_module.BannerApi() 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 be6275b5cb8..c5780e46ede 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 @@ -13,10 +13,7 @@ type Payload = dict[str, object] type PayloadPatch = Callable[[Payload], AbstractContextManager[object]] -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func +from inspect import unwrap @pytest.fixture @@ -64,6 +61,7 @@ class TestInstalledAppsListApi: assert "app_model_configs" in compiled_filter assert "workflow_id" in compiled_filter assert "app_model_config_id" in compiled_filter + assert "apps.mode != 'agent'" in compiled_filter def test_get_installed_apps( self, app: Flask, current_user: MagicMock, tenant_id: str, installed_app: MagicMock diff --git a/api/tests/unit_tests/controllers/console/explore/test_parameter.py b/api/tests/unit_tests/controllers/console/explore/test_parameter.py index 7aaecbff14f..9ee9403baaf 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_parameter.py +++ b/api/tests/unit_tests/controllers/console/explore/test_parameter.py @@ -1,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, patch import pytest @@ -7,12 +8,6 @@ from controllers.console.app.error import AppUnavailableError from models.model import AppMode -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class TestAppParameterApi: def test_get_app_none(self): api = module.AppParameterApi() 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 ef08aa1b36a..8a2e14cce9b 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,3 +1,4 @@ +from inspect import unwrap from unittest.mock import ANY, patch from flask import Flask @@ -7,12 +8,6 @@ from models import Account from models.model import AppMode, IconType -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - def make_account(interface_language: str | None) -> Account: account = Account(name="Test User", email="user@example.com") account.id = "account-1" 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 07e674afad6..ae05b8f6a0e 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 @@ -1,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, PropertyMock, patch from uuid import uuid4 @@ -10,12 +11,6 @@ from controllers.console.explore.error import NotCompletionAppError from services.errors.message import MessageNotExistsError -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - def make_saved_message(): msg = MagicMock() msg.id = str(uuid4()) @@ -68,7 +63,7 @@ class TestSavedMessageListApi: result = method(api, current_user, installed_app) pagination_mock.assert_called_once() - assert pagination_mock.call_args.args[1] is current_user + assert pagination_mock.call_args.args[2] is current_user assert result["limit"] == 20 assert result["has_more"] is False assert len(result["data"]) == 2 @@ -101,7 +96,7 @@ class TestSavedMessageListApi: result = method(api, current_user, installed_app) save_mock.assert_called_once() - assert save_mock.call_args.args[1] is current_user + assert save_mock.call_args.args[2] is current_user assert result == {"result": "success"} def test_post_message_not_exists(self, app: Flask, payload_patch): @@ -141,7 +136,7 @@ class TestSavedMessageApi: result, status = method(api, current_user, installed_app, str(uuid4())) delete_mock.assert_called_once() - assert delete_mock.call_args.args[1] is current_user + assert delete_mock.call_args.args[2] is current_user assert status == 204 assert result == "" 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 c5b2f0bd9ba..83cfcadd093 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/explore/test_workflow.py @@ -1,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, patch import pytest @@ -14,12 +15,6 @@ from models.model import AppMode from services.errors.llm import InvokeRateLimitError -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture def app(): app = Flask(__name__) diff --git a/api/tests/unit_tests/controllers/console/explore/test_wraps.py b/api/tests/unit_tests/controllers/console/explore/test_wraps.py index 2c1acfc3d65..69c380487ed 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/explore/test_wraps.py @@ -18,12 +18,6 @@ from controllers.console.explore.wraps import ( ) -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - def test_installed_app_required_not_found(): @installed_app_required def view(installed_app): diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py index cfc0299cc2c..11916c87b68 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from flask import Flask from werkzeug.exceptions import HTTPException, NotFound from controllers.console.snippets import snippet_workflow as snippet_workflow_module @@ -52,7 +53,7 @@ def test_get_snippet_requires_snippet_id(app): view() -def test_get_snippet_injects_resolved_snippet(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_snippet_injects_resolved_snippet(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() @snippet_workflow_module.get_snippet @@ -72,7 +73,7 @@ def test_get_snippet_injects_resolved_snippet(app, monkeypatch: pytest.MonkeyPat assert result is snippet -def test_get_snippet_raises_not_found_when_snippet_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_snippet_raises_not_found_when_snippet_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @snippet_workflow_module.get_snippet def view(**kwargs): return kwargs @@ -89,7 +90,7 @@ def test_get_snippet_raises_not_found_when_snippet_missing(app, monkeypatch: pyt view(snippet_id="snippet-1") -def test_draft_workflow_get_raises_when_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_draft_workflow_get_raises_when_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() monkeypatch.setattr( snippet_workflow_module, @@ -105,7 +106,7 @@ def test_draft_workflow_get_raises_when_missing(app, monkeypatch: pytest.MonkeyP handler(api, snippet=snippet) -def test_draft_workflow_post_returns_400_for_invalid_graph(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_draft_workflow_post_returns_400_for_invalid_graph(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() sync_draft_workflow = Mock(side_effect=ValueError("invalid graph")) @@ -145,7 +146,7 @@ def test_published_workflow_get_returns_none_when_not_published(app) -> None: assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None -def test_published_workflow_post_returns_400_when_publish_fails(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_published_workflow_post_returns_400_when_publish_fails(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() merged_snippet = _snippet() @@ -180,7 +181,7 @@ def test_published_workflow_post_returns_400_when_publish_fails(app, monkeypatch session.commit.assert_not_called() -def test_default_block_configs_delegates_to_service(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_default_block_configs_delegates_to_service(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: get_default_block_configs = Mock(return_value=[{"type": "llm"}]) monkeypatch.setattr( snippet_workflow_module, @@ -198,7 +199,7 @@ def test_default_block_configs_delegates_to_service(app, monkeypatch: pytest.Mon get_default_block_configs.assert_called_once() -def test_restore_published_snippet_workflow_to_draft_success(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_restore_published_snippet_workflow_to_draft_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: workflow = SimpleNamespace( unique_hash="restored-hash", updated_at=None, @@ -226,7 +227,7 @@ def test_restore_published_snippet_workflow_to_draft_success(app, monkeypatch: p assert response["hash"] == "restored-hash" -def test_restore_published_snippet_workflow_to_draft_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_restore_published_snippet_workflow_to_draft_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: user = _account("account-1") snippet = _snippet() @@ -311,7 +312,7 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra assert exc.value.description == "invalid snippet workflow graph" -def test_workflow_run_detail_raises_not_found_when_run_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: snippet = _snippet() monkeypatch.setattr( snippet_workflow_module, @@ -327,7 +328,9 @@ def test_workflow_run_detail_raises_not_found_when_run_missing(app, monkeypatch: handler(api, snippet=snippet, run_id="run-1") -def test_draft_node_last_run_raises_not_found_when_execution_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_draft_node_last_run_raises_not_found_when_execution_missing( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: snippet = _snippet() draft_workflow = SimpleNamespace(id="workflow-1") monkeypatch.setattr( @@ -347,7 +350,7 @@ def test_draft_node_last_run_raises_not_found_when_execution_missing(app, monkey handler(api, snippet=snippet, node_id="llm-1") -def test_workflow_task_stop_uses_queue_flag_and_graph_command(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_workflow_task_stop_uses_queue_flag_and_graph_command(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: set_stop_flag = Mock() send_stop_command = Mock() monkeypatch.setattr( diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py index b885b9d601f..e6bee6fe1d3 100644 --- a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py @@ -27,7 +27,7 @@ def _make_account() -> Account: @pytest.fixture(autouse=True) -def _patch_snippet_service_factory(monkeypatch): +def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch): def factory(): service_factory = module.SnippetService if isinstance(service_factory, type): @@ -64,7 +64,7 @@ def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable( module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") -def test_conversation_variables_returns_empty_list(app): +def test_conversation_variables_returns_empty_list(app: Flask): api = module.SnippetConversationVariableCollectionApi() handler = _unwrap(api.get) @@ -74,7 +74,7 @@ def test_conversation_variables_returns_empty_list(app): assert result == WorkflowDraftVariableList(variables=[]) -def test_system_variables_returns_empty_list(app): +def test_system_variables_returns_empty_list(app: Flask): api = module.SnippetSystemVariableCollectionApi() handler = _unwrap(api.get) @@ -84,7 +84,7 @@ def test_system_variables_returns_empty_list(app): assert result == WorkflowDraftVariableList(variables=[]) -def test_delete_variable_collection_deletes_current_user_variables(app, monkeypatch): +def test_delete_variable_collection_deletes_current_user_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): draft_var_service = SimpleNamespace(delete_user_workflow_variables=Mock()) monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) db_session = Mock() @@ -101,7 +101,7 @@ def test_delete_variable_collection_deletes_current_user_variables(app, monkeypa db_session.commit.assert_called_once() -def test_variable_collection_get_raises_when_draft_workflow_missing(app, monkeypatch): +def test_variable_collection_get_raises_when_draft_workflow_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( module, "SnippetService", @@ -116,7 +116,7 @@ def test_variable_collection_get_raises_when_draft_workflow_missing(app, monkeyp handler(api, _make_account(), snippet=SimpleNamespace(id="snippet-1")) -def test_node_variable_collection_get_lists_node_variables(app, monkeypatch): +def test_node_variable_collection_get_lists_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): variables = WorkflowDraftVariableList(variables=[SimpleNamespace(id="var-1")]) list_node_variables = Mock(return_value=variables) @@ -149,7 +149,7 @@ def test_node_variable_collection_get_lists_node_variables(app, monkeypatch): list_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") -def test_node_variable_collection_delete_deletes_node_variables(app, monkeypatch): +def test_node_variable_collection_delete_deletes_node_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): delete_node_variables = Mock() draft_var_service = SimpleNamespace(delete_node_variables=delete_node_variables) monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) @@ -168,7 +168,7 @@ def test_node_variable_collection_delete_deletes_node_variables(app, monkeypatch db_session.commit.assert_called_once() -def test_variable_patch_returns_variable_when_no_changes(app, monkeypatch): +def test_variable_patch_returns_variable_when_no_changes(app: Flask, monkeypatch: pytest.MonkeyPatch): variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), update_variable=Mock()) db_session = Mock() @@ -192,7 +192,7 @@ def test_variable_patch_returns_variable_when_no_changes(app, monkeypatch): db_session.commit.assert_not_called() -def test_variable_delete_deletes_variable(app, monkeypatch): +def test_variable_delete_deletes_variable(app: Flask, monkeypatch: pytest.MonkeyPatch): variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") delete_variable = Mock() draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), delete_variable=delete_variable) @@ -212,7 +212,7 @@ def test_variable_delete_deletes_variable(app, monkeypatch): db_session.commit.assert_called_once() -def test_variable_reset_returns_no_content_when_reset_result_is_none(app, monkeypatch): +def test_variable_reset_returns_no_content_when_reset_result_is_none(app: Flask, monkeypatch: pytest.MonkeyPatch): variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") draft_workflow = SimpleNamespace(id="workflow-1") draft_var_service = SimpleNamespace( @@ -240,7 +240,7 @@ def test_variable_reset_returns_no_content_when_reset_result_is_none(app, monkey db_session.commit.assert_called_once() -def test_environment_variables_returns_workflow_environment_variables(app, monkeypatch): +def test_environment_variables_returns_workflow_environment_variables(app: Flask, monkeypatch: pytest.MonkeyPatch): env_var = SimpleNamespace( id="env-1", name="API_KEY", 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 dc3dd00a6c0..84a70835437 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -2,15 +2,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch import pytest -from sqlalchemy.orm import Session - - -class SessionMatcher: - def __eq__(self, other): - return isinstance(other, Session) - - from flask import Flask +from sqlalchemy.orm import Session, scoped_session from werkzeug.exceptions import Forbidden import controllers.console.tag.tags as module @@ -27,6 +20,11 @@ from models.enums import TagType from services.tag_service import UpdateTagPayload +class SessionMatcher: + def __eq__(self, other): + return isinstance(other, Session | scoped_session) + + def unwrap(func): """ Recursively unwrap decorated functions. @@ -193,9 +191,10 @@ class TestTagUpdateDeleteApi: result, status = method(api, admin_user, "tag-1") assert status == 200 - update_payload, tag_id = update_tags_mock.call_args.args + update_payload, tag_id, session = update_tags_mock.call_args.args assert update_payload == UpdateTagPayload(name="updated") assert tag_id == "tag-1" + assert session == module.db.session assert result["binding_count"] == "3" def test_patch_forbidden(self, app: Flask, readonly_user, payload_patch): @@ -221,7 +220,7 @@ class TestTagUpdateDeleteApi: ): result, status = method(api, "tag-1") - delete_mock.assert_called_once_with("tag-1") + delete_mock.assert_called_once_with("tag-1", module.db.session) assert status == 204 diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 487cf8f54fd..bab825ca6f0 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -3,7 +3,7 @@ from __future__ import annotations import builtins import uuid from datetime import UTC, datetime -from unittest.mock import MagicMock +from unittest.mock import ANY, MagicMock import pytest from flask import Flask @@ -114,7 +114,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat assert response[0]["name"] == "Weather API" assert response[0]["api_endpoint"] == extension.api_endpoint assert response[0]["api_key"].startswith(extension.api_key[:3]) - service_mock.assert_called_once_with("tenant-123") + service_mock.assert_called_once_with(ANY, "tenant-123") def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -132,7 +132,7 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt response, status = APIBasedExtensionAPI().post() args, _ = save_mock.call_args - created_extension: APIBasedExtension = args[0] + created_extension: APIBasedExtension = args[1] assert created_extension.tenant_id == "tenant-123" assert created_extension.name == payload["name"] assert created_extension.api_endpoint == payload["api_endpoint"] @@ -157,7 +157,7 @@ def test_api_based_extension_detail_get_fetches_extension(app: Flask, monkeypatc assert response["id"] == extension.id assert response["name"] == extension.name - service_mock.assert_called_once_with("tenant-123", str(extension_id)) + service_mock.assert_called_once_with(ANY, "tenant-123", str(extension_id)) def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkeypatch: pytest.MonkeyPatch): @@ -187,7 +187,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey assert existing_extension.name == payload["name"] assert existing_extension.api_endpoint == payload["api_endpoint"] assert existing_extension.api_key == "keep-me" - save_mock.assert_called_once_with(existing_extension) + save_mock.assert_called_once_with(ANY, existing_extension) assert response["name"] == payload["name"] assert response["api_key"] == _masked_api_key("keep-me") @@ -217,7 +217,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas response = APIBasedExtensionDetailAPI().post(extension_id) assert existing_extension.api_key == "new-secret" - save_mock.assert_called_once_with(existing_extension) + save_mock.assert_called_once_with(ANY, existing_extension) assert response["name"] == payload["name"] assert response["api_key"] == _masked_api_key(payload["api_key"]) @@ -239,6 +239,6 @@ def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeyp ): response, status = APIBasedExtensionDetailAPI().delete(extension_id) - delete_mock.assert_called_once_with(existing_extension) + delete_mock.assert_called_once_with(ANY, existing_extension) assert status == 204 assert response == "" diff --git a/api/tests/unit_tests/controllers/console/test_init_validate.py b/api/tests/unit_tests/controllers/console/test_init_validate.py index 3077304cbed..4954e0dc96a 100644 --- a/api/tests/unit_tests/controllers/console/test_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_init_validate.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from flask import Flask from controllers.console import init_validate from controllers.console.error import AlreadySetupError, InitValidateFailedError @@ -35,7 +36,7 @@ def test_get_init_status_not_started(monkeypatch: pytest.MonkeyPatch) -> None: assert result.status == "not_started" -def test_validate_init_password_already_setup(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_validate_init_password_already_setup(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda: 1) app.secret_key = "test-secret" @@ -45,7 +46,7 @@ def test_validate_init_password_already_setup(app, monkeypatch: pytest.MonkeyPat init_validate.validate_init_password(init_validate.InitValidatePayload(password="pw")) -def test_validate_init_password_wrong_password(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_validate_init_password_wrong_password(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda: 0) monkeypatch.setenv("INIT_PASSWORD", "expected") @@ -57,7 +58,7 @@ def test_validate_init_password_wrong_password(app, monkeypatch: pytest.MonkeyPa assert init_validate.session.get("is_init_validated") is False -def test_validate_init_password_success(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_validate_init_password_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda: 0) monkeypatch.setenv("INIT_PASSWORD", "expected") @@ -74,7 +75,7 @@ def test_get_init_validate_status_not_self_hosted(monkeypatch: pytest.MonkeyPatc assert init_validate.get_init_validate_status() is True -def test_get_init_validate_status_validated_session(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_init_validate_status_validated_session(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setenv("INIT_PASSWORD", "expected") app.secret_key = "test-secret" @@ -84,7 +85,7 @@ def test_get_init_validate_status_validated_session(app, monkeypatch: pytest.Mon assert init_validate.get_init_validate_status() is True -def test_get_init_validate_status_setup_exists(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_init_validate_status_setup_exists(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setenv("INIT_PASSWORD", "expected") monkeypatch.setattr(init_validate, "Session", lambda *_args, **_kwargs: _SessionStub(True)) @@ -96,7 +97,7 @@ def test_get_init_validate_status_setup_exists(app, monkeypatch: pytest.MonkeyPa assert init_validate.get_init_validate_status() is True -def test_get_init_validate_status_not_validated(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_init_validate_status_not_validated(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") monkeypatch.setenv("INIT_PASSWORD", "expected") monkeypatch.setattr(init_validate, "Session", lambda *_args, **_kwargs: _SessionStub(False)) diff --git a/api/tests/unit_tests/controllers/console/test_remote_files.py b/api/tests/unit_tests/controllers/console/test_remote_files.py index e7127aef236..25245e743c5 100644 --- a/api/tests/unit_tests/controllers/console/test_remote_files.py +++ b/api/tests/unit_tests/controllers/console/test_remote_files.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock import httpx import pytest +from flask import Flask from controllers.common.errors import FileTooLargeError, RemoteFileUploadError, UnsupportedFileTypeError from controllers.console import remote_files as remote_files_module @@ -82,7 +83,7 @@ def _mock_upload_dependencies( return file_service_cls, current_user -def test_get_remote_file_info_uses_head_when_successful(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_remote_file_info_uses_head_when_successful(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = remote_files_module.GetRemoteFileInfo() handler = unwrap(api.get) decoded_url = "https://example.com/test.txt" @@ -103,7 +104,7 @@ def test_get_remote_file_info_uses_head_when_successful(app, monkeypatch: pytest make_request.assert_called_once_with("HEAD", decoded_url) -def test_get_remote_file_info_preserves_unencoded_target_query(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_remote_file_info_preserves_unencoded_target_query(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = remote_files_module.GetRemoteFileInfo() handler = unwrap(api.get) target_url = "http://example.com/api/aiagent/httpview/txt" @@ -124,7 +125,9 @@ def test_get_remote_file_info_preserves_unencoded_target_query(app, monkeypatch: make_request.assert_called_once_with("HEAD", f"{target_url}?{query}") -def test_get_remote_file_info_falls_back_to_get_and_uses_default_headers(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_remote_file_info_falls_back_to_get_and_uses_default_headers( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: api = remote_files_module.GetRemoteFileInfo() handler = unwrap(api.get) decoded_url = "https://example.com/test.txt" @@ -147,7 +150,7 @@ def test_get_remote_file_info_falls_back_to_get_and_uses_default_headers(app, mo assert make_request.call_args_list[1].kwargs == {"timeout": 3} -def test_remote_file_upload_success_when_fetch_falls_back_to_get(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_remote_file_upload_success_when_fetch_falls_back_to_get(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = remote_files_module.RemoteFileUpload() handler = unwrap(api.post) url = "https://example.com/report.txt" @@ -220,7 +223,7 @@ def test_remote_file_upload_fetches_content_with_second_get_when_head_succeeds( assert file_service_cls.return_value.upload_file.call_args.kwargs["content"] == b"downloaded-content" -def test_remote_file_upload_raises_when_fallback_get_still_not_ok(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_remote_file_upload_raises_when_fallback_get_still_not_ok(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = remote_files_module.RemoteFileUpload() handler = unwrap(api.post) url = "https://example.com/fail.txt" @@ -238,7 +241,7 @@ def test_remote_file_upload_raises_when_fallback_get_still_not_ok(app, monkeypat handler(api, _make_account()) -def test_remote_file_upload_raises_on_httpx_request_error(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_remote_file_upload_raises_on_httpx_request_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = remote_files_module.RemoteFileUpload() handler = unwrap(api.post) url = "https://example.com/fail.txt" @@ -252,7 +255,7 @@ def test_remote_file_upload_raises_on_httpx_request_error(app, monkeypatch: pyte handler(api, _make_account()) -def test_remote_file_upload_rejects_oversized_file(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_remote_file_upload_rejects_oversized_file(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = remote_files_module.RemoteFileUpload() handler = unwrap(api.post) url = "https://example.com/large.bin" @@ -267,7 +270,9 @@ def test_remote_file_upload_rejects_oversized_file(app, monkeypatch: pytest.Monk handler(api, current_user) -def test_remote_file_upload_translates_service_file_too_large_error(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_remote_file_upload_translates_service_file_too_large_error( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: api = remote_files_module.RemoteFileUpload() handler = unwrap(api.post) url = "https://example.com/large.bin" @@ -282,7 +287,9 @@ def test_remote_file_upload_translates_service_file_too_large_error(app, monkeyp handler(api, current_user) -def test_remote_file_upload_translates_service_unsupported_type_error(app, monkeypatch: pytest.MonkeyPatch) -> None: +def test_remote_file_upload_translates_service_unsupported_type_error( + app: Flask, monkeypatch: pytest.MonkeyPatch +) -> None: api = remote_files_module.RemoteFileUpload() handler = unwrap(api.post) url = "https://example.com/file.exe" diff --git a/api/tests/unit_tests/controllers/console/test_spec.py b/api/tests/unit_tests/controllers/console/test_spec.py index 05a4befaa83..84c2004ec70 100644 --- a/api/tests/unit_tests/controllers/console/test_spec.py +++ b/api/tests/unit_tests/controllers/console/test_spec.py @@ -1,14 +1,9 @@ +from inspect import unwrap from unittest.mock import patch import controllers.console.spec as spec_module -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class TestSpecSchemaDefinitionsApi: def test_get_success(self): api = spec_module.SpecSchemaDefinitionsApi() diff --git a/api/tests/unit_tests/controllers/console/test_version.py b/api/tests/unit_tests/controllers/console/test_version.py index 8d8d324be1f..667e4f6f415 100644 --- a/api/tests/unit_tests/controllers/console/test_version.py +++ b/api/tests/unit_tests/controllers/console/test_version.py @@ -1,5 +1,8 @@ +import logging from unittest.mock import MagicMock, patch +import pytest + import controllers.console.version as version_module @@ -18,15 +21,15 @@ class TestHasNewVersion: ) assert result is False - def test_has_new_version_invalid_version(self): - with patch.object(version_module.logger, "warning") as log_warning: + def test_has_new_version_invalid_version(self, caplog: pytest.LogCaptureFixture): + with caplog.at_level(logging.WARNING, logger="controllers.console.version"): result = version_module._has_new_version( latest_version="invalid", current_version="1.0.0", ) assert result is False - log_warning.assert_called_once() + assert "Invalid version format" in caplog.text class TestCheckVersionUpdate: 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 c39d0930bec..e419428ca66 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -102,10 +102,10 @@ class TestChangeEmailSend: @patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1") def test_should_normalize_new_email_phase( self, - mock_extract_ip, - mock_is_ip_limit, - mock_send_email, - mock_get_change_data, + mock_extract_ip: MagicMock, + mock_is_ip_limit: MagicMock, + mock_send_email: MagicMock, + mock_get_change_data: MagicMock, app: Flask, ): mock_account = _build_account("current@example.com", "acc1") @@ -143,10 +143,10 @@ class TestChangeEmailSend: @patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1") def test_should_reject_new_email_phase_when_token_phase_is_not_old_verified( self, - mock_extract_ip, - mock_is_ip_limit, - mock_send_email, - mock_get_change_data, + mock_extract_ip: MagicMock, + mock_is_ip_limit: MagicMock, + mock_send_email: MagicMock, + mock_get_change_data: MagicMock, app: Flask, ): """GHSA-4q3w-q5mc-45rq: a phase-1 token must not unlock the new-email send step.""" @@ -178,10 +178,10 @@ class TestChangeEmailSend: @patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1") def test_should_reject_new_email_phase_when_token_account_id_does_not_match_current_user( self, - mock_extract_ip, - mock_is_ip_limit, - mock_send_email, - mock_get_change_data, + mock_extract_ip: MagicMock, + mock_is_ip_limit: MagicMock, + mock_send_email: MagicMock, + mock_get_change_data: MagicMock, app: Flask, ): from controllers.console.auth.error import InvalidTokenError 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 d8d1d02a169..cb06dbc27cd 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -49,6 +49,7 @@ class TestMemberInviteEmailApi: inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active") with ( + patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", False), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"), patch("controllers.console.workspace.members._count_new_member_invites", return_value=1), patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), @@ -76,3 +77,116 @@ class TestMemberInviteEmailApi: assert call_args.kwargs["role"] == TenantAccountRole.EDITOR assert call_args.kwargs["inviter"] == account mock_csrf.assert_called_once_with(ANY, account.id) + + @patch("controllers.console.workspace.members.FeatureService.get_features") + @patch("controllers.console.workspace.members.RegisterService.invite_new_member") + @patch("controllers.console.workspace.members.current_account_with_tenant") + @patch("controllers.console.wraps.db") + @patch("libs.login.check_csrf_token", return_value=None) + def test_invite_rbac_enabled_accepts_rbac_role_id( + self, + mock_csrf, + mock_db, + mock_current_account, + mock_invite_member, + mock_get_features, + app, + ): + """When RBAC is enabled, any non-empty role string should be accepted.""" + mock_get_features.return_value = _build_feature_flags() + mock_invite_member.return_value = "rbac-token" + + tenant = SimpleNamespace(id="tenant-1", name="Test Tenant") + inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active") + mock_current_account.return_value = (inviter, tenant.id) + + with patch("controllers.console.workspace.members.dify_config") as mock_config: + mock_config.RBAC_ENABLED = True + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + with app.test_request_context( + "/workspaces/current/members/invite-email", + method="POST", + json={"emails": ["user@example.com"], "role": "rbac-role-id-abc", "language": "en-US"}, + ): + account = Account(name="tester", email="tester@example.com") + account._current_tenant = tenant + g._login_user = account + g._current_tenant = tenant + response, status_code = MemberInviteEmailApi().post() + + assert status_code == 201 + mock_invite_member.assert_called_once() + call_args = mock_invite_member.call_args + assert call_args.kwargs["role"] == "rbac-role-id-abc" + + @patch("controllers.console.workspace.members.FeatureService.get_features") + @patch("controllers.console.workspace.members.current_account_with_tenant") + @patch("controllers.console.wraps.db") + @patch("libs.login.check_csrf_token", return_value=None) + def test_invite_rbac_disabled_rejects_invalid_role( + self, + mock_csrf, + mock_db, + mock_current_account, + mock_get_features, + app, + ): + """When RBAC is disabled, an invalid role string should be rejected.""" + mock_get_features.return_value = _build_feature_flags() + + tenant = SimpleNamespace(id="tenant-1", name="Test Tenant") + inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active") + mock_current_account.return_value = (inviter, tenant.id) + + with patch("controllers.console.workspace.members.dify_config") as mock_config: + mock_config.RBAC_ENABLED = False + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + with app.test_request_context( + "/workspaces/current/members/invite-email", + method="POST", + json={"emails": ["user@example.com"], "role": "invalid-role", "language": "en-US"}, + ): + account = Account(name="tester", email="tester@example.com") + account._current_tenant = tenant + g._login_user = account + g._current_tenant = tenant + response, status_code = MemberInviteEmailApi().post() + + assert status_code == 400 + assert response["code"] == "invalid-role" + + @patch("controllers.console.workspace.members.FeatureService.get_features") + @patch("controllers.console.workspace.members.current_account_with_tenant") + @patch("controllers.console.wraps.db") + @patch("libs.login.check_csrf_token", return_value=None) + def test_invite_rbac_disabled_rejects_owner_role( + self, + mock_csrf, + mock_db, + mock_current_account, + mock_get_features, + app, + ): + """When RBAC is disabled, owner role should be rejected for invite.""" + mock_get_features.return_value = _build_feature_flags() + + tenant = SimpleNamespace(id="tenant-1", name="Test Tenant") + inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active") + mock_current_account.return_value = (inviter, tenant.id) + + with patch("controllers.console.workspace.members.dify_config") as mock_config: + mock_config.RBAC_ENABLED = False + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + with app.test_request_context( + "/workspaces/current/members/invite-email", + method="POST", + json={"emails": ["user@example.com"], "role": "owner", "language": "en-US"}, + ): + account = Account(name="tester", email="tester@example.com") + account._current_tenant = tenant + g._login_user = account + g._current_tenant = tenant + response, status_code = MemberInviteEmailApi().post() + + assert status_code == 400 + assert response["code"] == "invalid-role" diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 937505dab28..618a1f52180 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -2,24 +2,29 @@ from typing import override from unittest.mock import MagicMock, patch import pytest -from flask import Flask +from flask import Flask, request from flask_login import LoginManager, UserMixin from pydantic import BaseModel from werkzeug.exceptions import HTTPException +from controllers.common.wraps import _extract_resource_id from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout from controllers.console.workspace.error import AccountNotInitializedError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_enabled, cloud_edition_billing_rate_limit_check, cloud_edition_billing_resource_check, cloud_utm_record, enterprise_license_required, + is_admin_or_owner_required, model_validate, only_edition_cloud, only_edition_enterprise, only_edition_self_hosted, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -165,6 +170,165 @@ class TestCurrentContextInjection: assert Handler().get() == ("user-99", "tenant-456") +class TestRbacPermissionRequired: + """Test enterprise RBAC decorator.""" + + def test_resource_scoped_check_uses_resource_id(self): + current_user = make_account("account-1") + + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + def protected_view(**kwargs): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-1")), + patch("controllers.common.wraps._extract_resource_id", return_value="app-123") as mock_extract, + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check, + ): + assert protected_view(app_id="app-123") == "ok" + + mock_extract.assert_called_once_with("app", {"app_id": "app-123"}) + mock_owned.assert_called_once_with("tenant-1", "account-1", "app", "app-123") + mock_check.assert_called_once_with( + "tenant-1", + "account-1", + scene="app_delete", + resource_type="app", + resource_id="app-123", + ) + + def test_workspace_scoped_check_skips_resource_id_extraction(self): + current_user = make_account("account-2") + + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) + def protected_view(): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-2")), + patch("controllers.common.wraps._extract_resource_id") as mock_extract, + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check, + ): + assert protected_view() == "ok" + + mock_extract.assert_not_called() + mock_owned.assert_not_called() + mock_check.assert_called_once_with( + "tenant-2", + "account-2", + scene="dataset_create_and_management", + resource_type="dataset", + resource_id=None, + ) + + def test_workspace_scene_omits_resource_type(self): + current_user = make_account("account-3") + + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + def protected_view(): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-3")), + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check, + ): + assert protected_view() == "ok" + + mock_check.assert_called_once_with( + "tenant-3", + "account-3", + scene="workspace_role_manage", + resource_type=None, + resource_id=None, + ) + + def test_resource_owned_app_skips_rbac_check(self): + current_user = make_account("account-4") + + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + def protected_view(**kwargs): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-4")), + patch("controllers.common.wraps._extract_resource_id", return_value="app-123"), + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check") as mock_check, + ): + assert protected_view(app_id="app-123") == "ok" + + mock_owned.assert_called_once_with("tenant-4", "account-4", "app", "app-123") + mock_check.assert_not_called() + + def test_resource_owned_dataset_skips_rbac_check(self): + current_user = make_account("account-5") + + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) + def protected_view(**kwargs): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-5")), + patch("controllers.common.wraps._extract_resource_id", return_value="dataset-123"), + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check") as mock_check, + ): + assert protected_view(dataset_id="dataset-123") == "ok" + + mock_owned.assert_called_once_with("tenant-5", "account-5", "dataset", "dataset-123") + mock_check.assert_not_called() + + def test_extract_resource_id_prefers_path_args(self): + app = Flask(__name__) + + with app.test_request_context("/"): + request.view_args = {"app_id": "view-app"} + + assert _extract_resource_id("app", {"app_id": "path-app"}) == "path-app" + + def test_extract_resource_id_falls_back_to_request_view_args(self): + app = Flask(__name__) + + with app.test_request_context("/"): + request.view_args = {"app_id": "view-app"} + + assert _extract_resource_id("app") == "view-app" + + def test_extract_resource_id_supports_legacy_route_aliases(self): + app = Flask(__name__) + + with app.test_request_context("/apps/app-1/api-keys"): + request.view_args = {"resource_id": "app-1"} + assert _extract_resource_id(RBACResourceScope.APP) == "app-1" + + with app.test_request_context("/agent/agent-1/features"): + request.view_args = {"agent_id": "agent-1"} + assert _extract_resource_id(RBACResourceScope.APP) == "agent-1" + + with app.test_request_context("/datasets/dataset-1/api-keys"): + request.view_args = {"resource_id": "dataset-1"} + assert _extract_resource_id(RBACResourceScope.DATASET) == "dataset-1" + + def test_legacy_admin_decorator_noops_when_rbac_enabled(self): + @is_admin_or_owner_required + def protected_view(): + return "ok" + + with patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True): + assert protected_view() == "ok" + + class TestModelValidationInjection: """Test request model validation decorator.""" @@ -439,7 +603,7 @@ class TestRateLimiting: @patch("controllers.console.wraps.redis_client") @patch("controllers.console.wraps.db") - def test_should_allow_requests_within_rate_limit(self, mock_db, mock_redis): + def test_should_allow_requests_within_rate_limit(self, mock_db: MagicMock, mock_redis: MagicMock): """Test that requests within rate limit are allowed""" # Arrange mock_rate_limit = MagicMock() @@ -467,7 +631,7 @@ class TestRateLimiting: @patch("controllers.console.wraps.redis_client") @patch("controllers.console.wraps.db") - def test_should_reject_requests_over_rate_limit(self, mock_db, mock_redis): + def test_should_reject_requests_over_rate_limit(self, mock_db: MagicMock, mock_redis: MagicMock): """Test that requests over rate limit are rejected and logged""" # Arrange app = create_app_with_login() @@ -556,7 +720,7 @@ class TestSystemSetup: """Test system setup decorator""" @patch("controllers.console.wraps.db") - def test_should_allow_when_setup_complete(self, mock_db): + def test_should_allow_when_setup_complete(self, mock_db: MagicMock): """Test that requests are allowed when setup is complete""" # Arrange @@ -573,7 +737,7 @@ class TestSystemSetup: @patch("controllers.console.wraps.db") @patch("controllers.console.wraps.os.environ.get") - def test_should_raise_not_init_validate_error_with_init_password(self, mock_environ_get, mock_db): + def test_should_raise_not_init_validate_error_with_init_password(self, mock_environ_get, mock_db: MagicMock): """Test NotInitValidateError when INIT_PASSWORD is set but setup not complete""" # Arrange mock_db.session.scalar.return_value = None # No setup @@ -590,7 +754,7 @@ class TestSystemSetup: @patch("controllers.console.wraps.db") @patch("controllers.console.wraps.os.environ.get") - def test_should_raise_not_setup_error_without_init_password(self, mock_environ_get, mock_db): + def test_should_raise_not_setup_error_without_init_password(self, mock_environ_get, mock_db: MagicMock): """Test NotSetupError when no INIT_PASSWORD and setup not complete""" # Arrange mock_db.session.scalar.return_value = None # No setup 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 f70450955a4..f7310b5fec8 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,3 +1,4 @@ +from inspect import unwrap from unittest.mock import MagicMock, patch from flask import Flask @@ -8,12 +9,6 @@ from controllers.console.workspace.agent_providers import ( ) -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class TestAgentProviderListApi: def test_get_success(self, app: Flask): api = AgentProviderListApi() 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 494cbbf0c37..321e2a68f39 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_members.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py @@ -1,4 +1,6 @@ from contextlib import nullcontext +from inspect import unwrap +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -25,12 +27,6 @@ from controllers.console.workspace.members import ( from services.errors.account import AccountAlreadyInTenantError -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class TestMemberListApi: def test_get_success(self, app: Flask): api = MemberListApi() @@ -43,8 +39,8 @@ class TestMemberListApi: member.name = "Member" member.email = "member@test.com" member.avatar = "avatar.png" - member.role = "admin" - member.status = "active" + member.current_role = SimpleNamespace(value="admin") + member.status = SimpleNamespace(value="active") members = [member] with ( @@ -55,6 +51,53 @@ class TestMemberListApi: assert status == 200 assert len(result["accounts"]) == 1 + assert result["accounts"][0]["role"] == "admin" + assert result["accounts"][0]["roles"] == [{"id": "admin", "name": "admin"}] + + def test_get_with_rbac_enabled_fetches_roles_in_batch(self, app): + api = MemberListApi() + method = unwrap(api.get) + + tenant = MagicMock(id="tenant-1") + user = MagicMock(id="acct-1", current_tenant=tenant) + member = SimpleNamespace( + id="m1", + name="Member", + email="member@test.com", + avatar=None, + last_login_at=1, + last_active_at=2, + created_at=3, + current_role=SimpleNamespace(value="editor"), + status=SimpleNamespace(value="active"), + ) + role_item = SimpleNamespace( + account_id="m1", + roles=[ + SimpleNamespace(id="workspace.owner", name="Owner"), + SimpleNamespace(id="workspace.editor", name="Editor"), + ], + ) + + with ( + app.test_request_context("/"), + patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "tenant-1")), + patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", True), + patch("controllers.console.workspace.members.TenantService.get_tenant_members", return_value=[member]), + patch( + "controllers.console.workspace.members.enterprise_rbac_service.RBACService.MemberRoles.batch_get", + return_value=[role_item], + ) as mock_batch_get, + ): + result, status = method(api) + + assert status == 200 + assert result["accounts"][0]["role"] == "editor" + assert result["accounts"][0]["roles"] == [ + {"id": "workspace.owner", "name": "Owner"}, + {"id": "workspace.editor", "name": "Editor"}, + ] + mock_batch_get.assert_called_once_with("tenant-1", "acct-1", ["m1"]) def test_get_no_tenant(self, app: Flask): api = MemberListApi() @@ -190,7 +233,9 @@ class TestMemberInviteEmailApi: ): result, status = method(api, user) - assert result["invitation_results"][0]["status"] == "success" + assert status == 201 + assert result["invitation_results"][0]["status"] == "already_member" + assert result["invitation_results"][0]["message"] == "Account already in workspace." def test_invite_invalid_role(self, app: Flask): api = MemberInviteEmailApi() 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 d938558806f..21055390362 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 @@ -20,10 +20,7 @@ VALID_UUID = "123e4567-e89b-12d3-a456-426614174000" INVALID_UUID = "123" -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func +from inspect import unwrap class TestModelProviderListApi: 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 00977e6d7b6..3374c49caff 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_models.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_models.py @@ -1,3 +1,4 @@ +from inspect import unwrap from types import SimpleNamespace from unittest.mock import patch @@ -19,12 +20,6 @@ from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.validate import CredentialsValidateFailedError -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - class TestDefaultModelApi: def test_get_success(self, app: Flask): api = DefaultModelApi() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py new file mode 100644 index 00000000000..1ad9637b7bb --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -0,0 +1,503 @@ +"""Controller tests for ``controllers.console.workspace.rbac``. + +The controllers here are thin: almost every non-trivial behaviour lives in +``services.enterprise.rbac_service`` (covered by its own suite). These tests +therefore focus on the Flask-layer concerns the service layer cannot exercise: + +* ``_current_ids`` raises 404 when the session has no tenant. +* The pydantic request models accept / reject bodies as expected. + +We explicitly avoid "happy-path" integration tests through the full +decorator stack — those belong in e2e tests where a real Dify session is +available — to keep this suite fast and resilient to ancillary auth wiring +changes. +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from flask import Flask +from pydantic import ValidationError +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.console.workspace import rbac as rbac_mod + + +@pytest.fixture +def app(): + flask_app = Flask(__name__) + flask_app.config["TESTING"] = True + return flask_app + + +def _enabled(enabled: bool): + return patch("controllers.console.workspace.rbac.dify_config.ENTERPRISE_ENABLED", enabled) + + +class TestCurrentIds: + def test_rejects_missing_tenant(self): + with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user: + mock_user.return_value = (SimpleNamespace(id="acct-1"), None) + with pytest.raises(NotFound): + rbac_mod._current_ids() + + def test_returns_tuple(self): + with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user: + mock_user.return_value = (SimpleNamespace(id="acct-1"), "tenant-1") + assert rbac_mod._current_ids() == ("tenant-1", "acct-1") + + +class TestAccessMatrixAccountNames: + def test_hydrates_missing_account_names(self): + items = [ + rbac_mod.svc.AccessMatrixItem( + accounts=[ + {"account_id": "acct-1", "account_name": "Alice", "binding_id": "binding-1"}, + {"account_id": "acct-2", "account_name": "", "binding_id": "binding-2"}, + ] + ) + ] + + with patch( + "controllers.console.workspace.rbac._account_names_by_ids", + return_value={"acct-2": {"name": "Bob", "avatar": "ava"}}, + ) as mock_names: + rbac_mod._hydrate_access_matrix_account_names(items) + + mock_names.assert_called_once_with(["acct-2"]) + assert items[0].accounts[0].account_id == "acct-1" + assert items[0].accounts[0].account_name == "Alice" + assert items[0].accounts[1].account_id == "acct-2" + assert items[0].accounts[1].account_name == "Bob" + assert items[0].accounts[1].avatar == "ava" + + def test_hydrates_resource_user_account_names(self): + items = [ + rbac_mod.svc.ResourceUserAccessPolicies( + account={"account_id": "acct-1", "account_name": ""}, + roles=[], + access_policies=[], + ) + ] + + with patch( + "controllers.console.workspace.rbac._account_names_by_ids", + return_value={"acct-1": {"name": "Alice", "avatar": ""}}, + ): + rbac_mod._hydrate_resource_user_account_names(items) + + assert items[0].account.account_name == "Alice" + + +class TestPydanticModels: + """The internal `_…Request` models are the contract between the browser + and the controllers. We only check non-obvious branches (enum parsing, + missing required fields) — trivial `str` fields are not worth asserting. + """ + + def test_role_upsert_requires_name(self): + with pytest.raises(ValidationError): + rbac_mod._RoleUpsertRequest.model_validate({}) + + def test_role_upsert_to_mutation_preserves_fields(self): + payload = rbac_mod._RoleUpsertRequest.model_validate( + { + "name": "Owner", + "description": "full access", + "permission_keys": ["workspace.member.manage"], + } + ) + mutation = payload.to_mutation() + assert mutation.description == "full access" + assert mutation.permission_keys == ["workspace.member.manage"] + + def test_access_policy_create_parses_resource_type_enum(self): + parsed = rbac_mod._AccessPolicyCreateRequest.model_validate( + { + "name": "Full access", + "resource_type": "app", + "description": "", + "permission_keys": [], + } + ) + assert parsed.resource_type is rbac_mod.svc.RBACResourceType.APP + + def test_access_policy_create_rejects_unknown_resource_type(self): + with pytest.raises(ValidationError): + rbac_mod._AccessPolicyCreateRequest.model_validate({"name": "bad", "resource_type": "unknown"}) + + def test_resource_access_scope_requires_scope(self): + with pytest.raises(ValidationError): + rbac_mod._ResourceAccessScopeRequest.model_validate({}) + + def test_resource_access_scope_defaults_empty_account_ids(self): + parsed = rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "specific"}) + assert parsed.scope is rbac_mod._AccessScope.SPECIFIC + + def test_resource_access_scope_coerce_null_account_ids(self): + rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "all"}) + + def test_resource_access_scope_rejects_unknown_scope(self): + with pytest.raises(ValidationError): + rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "team"}) + + def test_replace_bindings_keeps_role_binding_contract(self): + parsed = rbac_mod._ReplaceBindingsRequest.model_validate({"role_ids": None}) + assert parsed.role_ids == [] + + def test_replace_member_roles_coerce_null_list(self): + parsed = rbac_mod._ReplaceMemberRolesRequest.model_validate({"role_ids": None}) + assert parsed.role_ids == [] + + def test_pagination_query_accepts_page_and_limit_aliases(self): + parsed = rbac_mod._PaginationQuery.model_validate({"page": 3, "limit": 25, "reverse": True}) + assert parsed.page_number == 3 + assert parsed.results_per_page == 25 + assert parsed.reverse is True + + def test_pagination_query_accepts_legacy_inner_names(self): + parsed = rbac_mod._PaginationQuery.model_validate({"page_number": 4, "results_per_page": 30, "reverse": False}) + assert parsed.page_number == 4 + assert parsed.results_per_page == 30 + assert parsed.reverse is False + + +class TestPaginationMapping: + def test_roles_get_returns_legacy_compatible_roles_when_rbac_disabled(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?page=1&limit=2&include_owner=1"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list, + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + assert response["data"] == [ + { + "id": "owner", + "tenant_id": "", + "type": "workspace", + "category": "global_system_default", + "name": "owner", + "description": "", + "is_builtin": True, + "permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"]), + "role_tag": "owner", + }, + { + "id": "admin", + "tenant_id": "", + "type": "workspace", + "category": "global_system_default", + "name": "admin", + "description": "", + "is_builtin": True, + "permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"]), + "role_tag": "", + }, + ] + assert response["pagination"] == { + "total_count": 5, + "per_page": 2, + "current_page": 1, + "total_pages": 3, + } + mock_list.assert_not_called() + + def test_roles_get_filters_out_owner_when_include_owner_is_zero(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?include_owner=0"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"), + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + names = [r["name"] for r in response["data"]] + assert "owner" not in names + + def test_roles_get_keeps_owner_when_include_owner_is_one(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?include_owner=1"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"), + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + names = [r["name"] for r in response["data"]] + assert "owner" in names + + def test_roles_get_filters_out_owner_by_default(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"), + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + names = [r["name"] for r in response["data"]] + assert "owner" not in names + + def test_roles_get_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?page=2&limit=50&reverse=true&include_owner=1"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + _, kwargs = mock_list.call_args + options = kwargs["options"] + assert options.page_number == 2 + assert options.results_per_page == 50 + assert options.reverse is True + + +class TestResourceAccessScopeBindings: + def test_app_user_access_policy_assignment_forwards_ids(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/apps/app-1/users/acct-target/access-policies", + method="PUT", + json={"access_policy_ids": ["policy-1", "policy-2"]}, + ), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")), + patch( + "controllers.console.workspace.rbac.svc.RBACService.AppAccess.replace_user_access_policies" + ) as mock_replace, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAppUserAccessPolicyAssignmentApi.put)( + rbac_mod.RBACAppUserAccessPolicyAssignmentApi(), + "app-1", + "acct-target", + ) + + tenant_id, actor_id, app_id, target_id, payload = mock_replace.call_args.args + assert (tenant_id, actor_id, app_id, target_id) == ( + "tenant-1", + "acct-actor", + "app-1", + "acct-target", + ) + assert payload.access_policy_ids == ["policy-1", "policy-2"] + + def test_app_member_bindings_delete_forwards_account_ids(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/apps/app-1/access-policies/policy-1/member-bindings", + method="DELETE", + json={"account_ids": ["acct-2", "acct-3"]}, + ), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")), + patch("controllers.console.workspace.rbac.svc.RBACService.AppAccess.delete_member_bindings") as mock_delete, + ): + response = inspect.unwrap(rbac_mod.RBACAppMemberBindingsApi.delete)( + rbac_mod.RBACAppMemberBindingsApi(), + "app-1", + "policy-1", + ) + + assert response == {"result": "success"} + tenant_id, actor_id, app_id, policy_id, payload = mock_delete.call_args.args + assert (tenant_id, actor_id, app_id, policy_id) == ("tenant-1", "acct-actor", "app-1", "policy-1") + assert payload.account_ids == ["acct-2", "acct-3"] + + def test_dataset_member_bindings_delete_forwards_account_ids(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/datasets/dataset-1/access-policies/policy-1/member-bindings", + method="DELETE", + json={"account_ids": ["acct-2"]}, + ), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")), + patch( + "controllers.console.workspace.rbac.svc.RBACService.DatasetAccess.delete_member_bindings" + ) as mock_delete, + ): + response = inspect.unwrap(rbac_mod.RBACDatasetMemberBindingsApi.delete)( + rbac_mod.RBACDatasetMemberBindingsApi(), + "dataset-1", + "policy-1", + ) + + assert response == {"result": "success"} + tenant_id, actor_id, dataset_id, policy_id, payload = mock_delete.call_args.args + assert (tenant_id, actor_id, dataset_id, policy_id) == ("tenant-1", "acct-actor", "dataset-1", "policy-1") + assert payload.account_ids == ["acct-2"] + + +class TestPaginationForwarding: + def test_role_members_get_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles/role-1/members?page=2&limit=50&reverse=true"), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.members") as mock_members, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACRoleMembersApi.get)(rbac_mod.RBACRoleMembersApi(), "role-1") + + _, _, role_id = mock_members.call_args.args + _, kwargs = mock_members.call_args + assert role_id == "role-1" + options = kwargs["options"] + assert options.page_number == 2 + assert options.results_per_page == 50 + assert options.reverse is True + + def test_access_policies_get_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/access-policies?resource_type=app&page=3&limit=25&reverse=false" + ), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.list") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAccessPoliciesApi.get)(rbac_mod.RBACAccessPoliciesApi()) + + _, kwargs = mock_list.call_args + assert kwargs["resource_type"] == "app" + options = kwargs["options"] + assert options.page_number == 3 + assert options.results_per_page == 25 + assert options.reverse is False + + def test_workspace_app_matrix_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/workspace/apps/access-policy?page=4&limit=10"), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.app_matrix") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACWorkspaceAppMatrixApi.get)(rbac_mod.RBACWorkspaceAppMatrixApi()) + + _, kwargs = mock_list.call_args + options = kwargs["options"] + assert options.page_number == 4 + assert options.results_per_page == 10 + assert options.reverse is None + + def test_workspace_dataset_matrix_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/workspace/datasets/access-policy?page=5&limit=15&reverse=true" + ), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.dataset_matrix") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACWorkspaceDatasetMatrixApi.get)(rbac_mod.RBACWorkspaceDatasetMatrixApi()) + + _, kwargs = mock_list.call_args + options = kwargs["options"] + assert options.page_number == 5 + assert options.results_per_page == 15 + assert options.reverse is True + + +class TestAccessPolicyBindingLockUnlock: + def test_lock_forwards_binding_id(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/access-policy-bindings/binding-1/lock", method="PUT"), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicyBindings.lock") as mock_lock, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAccessPolicyBindingLockApi.put)( + rbac_mod.RBACAccessPolicyBindingLockApi(), "binding-1" + ) + + mock_lock.assert_called_once_with("tenant-1", "acct-1", "binding-1") + + def test_unlock_forwards_binding_id(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/access-policy-bindings/binding-1/unlock", method="PUT"), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicyBindings.unlock") as mock_unlock, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAccessPolicyBindingUnlockApi.put)( + rbac_mod.RBACAccessPolicyBindingUnlockApi(), "binding-1" + ) + + mock_unlock.assert_called_once_with("tenant-1", "acct-1", "binding-1") + + +class TestRoleCopy: + def test_role_copy_forwards_path_id(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles/role-1/copy", method="POST", json={}), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.copy") as mock_copy, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACRoleCopyApi.post)(rbac_mod.RBACRoleCopyApi(), "role-1") + + mock_copy.assert_called_once_with("tenant-1", "acct-1", "role-1", copy_member=True) + + +class TestWorkspaceRbacGuards: + def test_role_create_requires_workspace_role_manage(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/roles", + method="POST", + json={"name": "test_role", "permission_keys": []}, + ), + patch("libs.login.dify_config.LOGIN_DISABLED", True), + patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True), + patch( + "controllers.common.wraps.current_account_with_tenant", + return_value=(SimpleNamespace(id="acct-1"), "tenant-1"), + ), + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=False), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.create") as mock_create, + ): + with pytest.raises(Forbidden): + rbac_mod.RBACRolesApi().post() + + mock_create.assert_not_called() + + def test_access_policy_create_requires_workspace_role_manage(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/access-policies", + method="POST", + json={"name": "full_access", "resource_type": "app", "permission_keys": []}, + ), + patch("libs.login.dify_config.LOGIN_DISABLED", True), + patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True), + patch( + "controllers.common.wraps.current_account_with_tenant", + return_value=(SimpleNamespace(id="acct-1"), "tenant-1"), + ), + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=False), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.create") as mock_create, + ): + with pytest.raises(Forbidden): + rbac_mod.RBACAccessPoliciesApi().post() + + mock_create.assert_not_called() + + +class TestDumpHelper: + def test_dump_returns_plain_dict(self): + role = rbac_mod.svc.RBACRole(id="role-1", type="workspace", name="Owner") + dumped = rbac_mod._dump(role) + assert isinstance(dumped, dict) + assert "role_id" not in dumped diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py index b8914fc26cb..e8e005a1b83 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py @@ -1,8 +1,9 @@ from inspect import unwrap from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import ANY, Mock import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.console.workspace import snippets as snippets_module @@ -69,7 +70,7 @@ def test_normalize_snippet_list_query_args_sorts_indexed_values(): } -def test_list_snippets_returns_pagination(app, monkeypatch): +def test_list_snippets_returns_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch): snippets = [_snippet()] tag_id = "11111111-1111-1111-1111-111111111111" get_snippets = Mock(return_value=(snippets, 1, False)) @@ -94,6 +95,7 @@ def test_list_snippets_returns_pagination(app, monkeypatch): } get_snippets.assert_called_once_with( tenant_id="tenant-1", + session=ANY, page=2, limit=10, keyword=None, @@ -103,7 +105,7 @@ def test_list_snippets_returns_pagination(app, monkeypatch): ) -def test_create_snippet_defaults_unknown_type_and_returns_created(app, monkeypatch): +def test_create_snippet_defaults_unknown_type_and_returns_created(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") snippet = _snippet() create_snippet = Mock(return_value=snippet) @@ -139,7 +141,7 @@ def test_create_snippet_defaults_unknown_type_and_returns_created(app, monkeypat assert create_snippet.call_args.kwargs["snippet_type"] == snippets_module.SnippetType.NODE -def test_create_snippet_rejects_forbidden_nodes(app, monkeypatch): +def test_create_snippet_rejects_forbidden_nodes(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") create_snippet = Mock() monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet) @@ -168,7 +170,7 @@ def test_create_snippet_rejects_forbidden_nodes(app, monkeypatch): create_snippet.assert_not_called() -def test_get_snippet_detail_raises_when_missing(app, monkeypatch): +def test_get_snippet_detail_raises_when_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) api = snippets_module.CustomizedSnippetDetailApi() @@ -179,7 +181,7 @@ def test_get_snippet_detail_raises_when_missing(app, monkeypatch): handler(api, "tenant-1", snippet_id="snippet-1") -def test_get_snippet_detail_returns_snippet(app, monkeypatch): +def test_get_snippet_detail_returns_snippet(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet() monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"})) @@ -194,7 +196,7 @@ def test_get_snippet_detail_returns_snippet(app, monkeypatch): assert response == {"id": "snippet-1"} -def test_patch_snippet_returns_400_for_empty_payload(app, monkeypatch): +def test_patch_snippet_returns_400_for_empty_payload(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet() user = _account("user-1") monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) @@ -213,7 +215,7 @@ def test_patch_snippet_returns_400_for_empty_payload(app, monkeypatch): assert response == {"message": "No valid fields to update"} -def test_patch_snippet_updates_and_commits(app, monkeypatch): +def test_patch_snippet_updates_and_commits(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") snippet = _snippet() updated_snippet = _snippet(name="New") @@ -250,7 +252,7 @@ def test_patch_snippet_updates_and_commits(app, monkeypatch): session.commit.assert_called_once() -def test_delete_snippet_deletes_and_commits(app, monkeypatch): +def test_delete_snippet_deletes_and_commits(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet() session = SimpleNamespace(merge=Mock(return_value=snippet), commit=Mock()) delete_snippet = Mock() @@ -276,7 +278,7 @@ def test_delete_snippet_deletes_and_commits(app, monkeypatch): session.commit.assert_called_once() -def test_export_snippet_returns_yaml_attachment(app, monkeypatch): +def test_export_snippet_returns_yaml_attachment(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet(name="Snippet One") export_snippet_dsl = Mock(return_value="version: 0.1.0\nkind: snippet\n") session = SimpleNamespace() @@ -307,7 +309,7 @@ def test_export_snippet_returns_yaml_attachment(app, monkeypatch): export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True) -def test_import_snippet_returns_202_for_pending_confirmation(app, monkeypatch): +def test_import_snippet_returns_202_for_pending_confirmation(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") result = SnippetImportInfo(id="import-1", status=ImportStatus.PENDING, imported_dsl_version="999.0.0") import_snippet = Mock(return_value=result) @@ -347,7 +349,7 @@ def test_import_snippet_returns_202_for_pending_confirmation(app, monkeypatch): session.commit.assert_called_once() -def test_import_snippet_returns_400_for_failed_import(app, monkeypatch): +def test_import_snippet_returns_400_for_failed_import(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") result = SnippetImportInfo(id="import-1", status=ImportStatus.FAILED, error="Invalid DSL") import_snippet = Mock(return_value=result) @@ -380,7 +382,7 @@ def test_import_snippet_returns_400_for_failed_import(app, monkeypatch): session.commit.assert_called_once() -def test_import_confirm_returns_200_for_completed_import(app, monkeypatch): +def test_import_confirm_returns_200_for_completed_import(app: Flask, monkeypatch: pytest.MonkeyPatch): user = _account("account-1") result = SnippetImportInfo(id="import-1", status=ImportStatus.COMPLETED, snippet_id="snippet-1") confirm_import = Mock(return_value=result) @@ -413,7 +415,7 @@ def test_import_confirm_returns_200_for_completed_import(app, monkeypatch): session.commit.assert_called_once() -def test_check_dependencies_raises_when_snippet_missing(app, monkeypatch): +def test_check_dependencies_raises_when_snippet_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) api = snippets_module.CustomizedSnippetCheckDependenciesApi() @@ -424,7 +426,7 @@ def test_check_dependencies_raises_when_snippet_missing(app, monkeypatch): handler(api, "tenant-1", snippet_id="snippet-1") -def test_check_dependencies_returns_dependency_result(app, monkeypatch): +def test_check_dependencies_returns_dependency_result(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = _snippet() check_dependencies = Mock( return_value=SimpleNamespace(model_dump=Mock(return_value={"dependencies": [], "missing_dependencies": []})) @@ -455,7 +457,7 @@ def test_check_dependencies_returns_dependency_result(app, monkeypatch): check_dependencies.assert_called_once_with(snippet=snippet) -def test_increment_use_count_raises_when_snippet_missing(app, monkeypatch): +def test_increment_use_count_raises_when_snippet_missing(app: Flask, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) api = snippets_module.CustomizedSnippetUseCountIncrementApi() @@ -469,7 +471,7 @@ def test_increment_use_count_raises_when_snippet_missing(app, monkeypatch): handler(api, "tenant-1", snippet_id="snippet-1") -def test_increment_use_count_returns_refreshed_count(app, monkeypatch): +def test_increment_use_count_returns_refreshed_count(app: Flask, monkeypatch: pytest.MonkeyPatch): snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", use_count=2) merged_snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", use_count=3) session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock(), refresh=Mock()) diff --git a/api/tests/unit_tests/controllers/files/test_image_preview.py b/api/tests/unit_tests/controllers/files/test_image_preview.py index 49846b89ee9..9ad7120c30f 100644 --- a/api/tests/unit_tests/controllers/files/test_image_preview.py +++ b/api/tests/unit_tests/controllers/files/test_image_preview.py @@ -1,4 +1,5 @@ import types +from inspect import unwrap from unittest.mock import patch import pytest @@ -7,12 +8,6 @@ from werkzeug.exceptions import NotFound import controllers.files.image_preview as module -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - @pytest.fixture(autouse=True) def mock_db(): """ diff --git a/api/tests/unit_tests/controllers/files/test_tool_files.py b/api/tests/unit_tests/controllers/files/test_tool_files.py index edb91c3f262..a41e2a056b1 100644 --- a/api/tests/unit_tests/controllers/files/test_tool_files.py +++ b/api/tests/unit_tests/controllers/files/test_tool_files.py @@ -1,4 +1,5 @@ import types +from inspect import unwrap from unittest.mock import patch import pytest @@ -7,12 +8,6 @@ from werkzeug.exceptions import Forbidden, NotFound import controllers.files.tool_files as module -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - def fake_request(args: dict): return types.SimpleNamespace(args=types.SimpleNamespace(to_dict=lambda flat=True: args)) diff --git a/api/tests/unit_tests/controllers/files/test_upload.py b/api/tests/unit_tests/controllers/files/test_upload.py index 7c98b088ce3..5281dcf0645 100644 --- a/api/tests/unit_tests/controllers/files/test_upload.py +++ b/api/tests/unit_tests/controllers/files/test_upload.py @@ -1,5 +1,6 @@ import io import types +from inspect import unwrap from unittest.mock import patch import pytest @@ -9,12 +10,6 @@ import controllers.files.upload as module from core.workflow.file_reference import build_file_reference -def unwrap(func): - while hasattr(func, "__wrapped__"): - func = func.__wrapped__ - return func - - def fake_request(args: dict, file=None): return types.SimpleNamespace( args=types.SimpleNamespace(to_dict=lambda flat=True: args), 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 cc581c0c759..c958034126d 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 @@ -237,9 +237,10 @@ class TestGetUserTenant: monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False) with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user: - mock_get.return_value = mock_tenant - mock_get_user.return_value = mock_user - result = protected_view() + with patch("controllers.inner_api.plugin.wraps.user_logged_in"): + mock_get.return_value = mock_tenant + mock_get_user.return_value = mock_user + result = protected_view() # Assert assert result["tenant"] == mock_tenant @@ -293,9 +294,10 @@ class TestGetUserTenant: monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False) with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user: - mock_get.return_value = mock_tenant - mock_get_user.return_value = mock_user - result = protected_view() + with patch("controllers.inner_api.plugin.wraps.user_logged_in"): + mock_get.return_value = mock_tenant + mock_get_user.return_value = mock_user + result = protected_view() # Assert assert result["tenant"] == mock_tenant diff --git a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py index ffe0c4e6b34..96f1dcaed56 100644 --- a/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/test_auth_wraps.py @@ -13,6 +13,7 @@ from controllers.inner_api.wraps import ( billing_inner_api_only, enterprise_inner_api_only, enterprise_inner_api_user_auth, + inner_api_only, plugin_inner_api_only, ) from models.model import EndUser @@ -154,6 +155,57 @@ class TestEnterpriseInnerApiOnly: assert exc_info.value.code == 401 +class TestInnerApiOnly: + """Test inner_api_only decorator.""" + + def test_should_allow_when_inner_api_enabled_and_valid_key(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}): + with patch.object(dify_config, "INNER_API", True): + with patch.object(dify_config, "INNER_API_KEY", "valid_key"): + result = protected_view() + + assert result == "success" + + def test_should_return_404_when_inner_api_disabled(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(): + with patch.object(dify_config, "INNER_API", False): + with pytest.raises(HTTPException) as exc_info: + protected_view() + assert exc_info.value.code == 404 + + def test_should_return_401_when_api_key_missing(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(headers={}): + with patch.object(dify_config, "INNER_API", True): + with patch.object(dify_config, "INNER_API_KEY", "valid_key"): + with pytest.raises(HTTPException) as exc_info: + protected_view() + assert exc_info.value.code == 401 + + def test_should_return_401_when_api_key_invalid(self, app: Flask): + @inner_api_only + def protected_view(): + return "success" + + with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}): + with patch.object(dify_config, "INNER_API", True): + with patch.object(dify_config, "INNER_API_KEY", "valid_key"): + with pytest.raises(HTTPException) as exc_info: + protected_view() + assert exc_info.value.code == 401 + + class TestEnterpriseInnerApiUserAuth: """Test enterprise_inner_api_user_auth decorator for HMAC-based user authentication""" diff --git a/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py new file mode 100644 index 00000000000..fa648e0335c --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_knowledge_retrieval.py @@ -0,0 +1,233 @@ +"""Unit tests for the inner knowledge retrieval controller.""" + +from unittest.mock import patch + +import pytest +from flask import Flask + +from controllers.inner_api import bp as inner_api_bp +from core.workflow.nodes.knowledge_retrieval.exc import RateLimitExceededError +from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMetadata +from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveResponse, InnerKnowledgeRetrieveUsage +from services.errors.knowledge_retrieval import ( + ExternalKnowledgeRetrievalError, + InnerKnowledgeRetrieveAppNotFoundError, + InnerKnowledgeRetrieveDatasetTenantMismatchError, +) + + +@pytest.fixture +def inner_api_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(inner_api_bp) + return app + + +def _headers(api_key: str | None = "inner-key") -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if api_key is not None: + headers["X-Inner-Api-Key"] = api_key + return headers + + +def _payload() -> dict[str, object]: + return { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "workflow", + }, + "dataset_ids": ["dataset-1"], + "query": "reset password", + "retrieval": { + "mode": "multiple", + "top_k": 4, + }, + "metadata_filtering": { + "mode": "disabled", + }, + "attachment_ids": [], + } + + +class TestInnerKnowledgeRetrieveApi: + def test_post_returns_401_when_api_key_missing(self, inner_api_app: Flask): + with patch("configs.dify_config.INNER_API", True): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(api_key=None), + ) + + assert response.status_code == 401 + assert response.get_json()["code"] == "inner_api_unauthorized" + + def test_post_returns_401_when_api_key_invalid(self, inner_api_app: Flask): + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(api_key="wrong-key"), + ) + + assert response.status_code == 401 + assert response.get_json()["code"] == "inner_api_unauthorized" + + def test_post_returns_400_for_invalid_body(self, inner_api_app: Flask): + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json={"caller": {"tenant_id": "tenant-1"}}, + headers=_headers(), + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_request" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_404_for_service_not_found_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = InnerKnowledgeRetrieveAppNotFoundError("app missing") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 404 + assert response.get_json()["code"] == "app_not_found" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_403_for_service_forbidden_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = InnerKnowledgeRetrieveDatasetTenantMismatchError("wrong tenant") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 403 + assert response.get_json()["code"] == "dataset_tenant_mismatch" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_422_for_retrieval_config_value_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = ValueError("invalid reranking config") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 422 + assert response.get_json()["code"] == "retrieval_config_invalid" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_429_for_rate_limit_error(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = RateLimitExceededError("knowledge rate limited") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 429 + assert response.get_json()["code"] == "knowledge_rate_limited" + + def test_post_returns_400_for_manual_metadata_without_conditions(self, inner_api_app: Flask): + payload = _payload() + payload["metadata_filtering"] = {"mode": "manual"} + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=payload, + headers=_headers(), + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_request" + + def test_post_returns_400_for_automatic_metadata_without_model_config(self, inner_api_app: Flask): + payload = _payload() + payload["metadata_filtering"] = {"mode": "automatic"} + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=payload, + headers=_headers(), + ) + + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_request" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_502_for_external_knowledge_failure(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.side_effect = ExternalKnowledgeRetrievalError("upstream failed") + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 502 + assert response.get_json()["code"] == "external_knowledge_failed" + + @patch("controllers.inner_api.knowledge.retrieval.InnerKnowledgeRetrievalService.retrieve") + def test_post_returns_service_response(self, mock_retrieve, inner_api_app: Flask): + mock_retrieve.return_value = InnerKnowledgeRetrieveResponse( + results=[ + Source( + metadata=SourceMetadata( + dataset_id="dataset-1", + dataset_name="Docs", + document_id="document-1", + document_name="FAQ.md", + data_source_type="upload_file", + ), + title="FAQ.md", + files=[], + content="Reset your password from settings.", + summary=None, + ) + ], + usage=InnerKnowledgeRetrieveUsage( + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + prompt_unit_price="0", + completion_unit_price="0", + prompt_price_unit="0.001", + completion_price_unit="0.001", + prompt_price="0", + completion_price="0", + total_price="0", + currency="USD", + latency=0, + ), + ) + + with patch("configs.dify_config.INNER_API", True), patch("configs.dify_config.INNER_API_KEY", "inner-key"): + response = inner_api_app.test_client().post( + "/inner/api/knowledge/retrieve", + json=_payload(), + headers=_headers(), + ) + + assert response.status_code == 200 + data = response.get_json() + assert data["results"][0]["metadata"]["_source"] == "knowledge" + assert data["results"][0]["title"] == "FAQ.md" + assert data["usage"]["total_tokens"] == 0 diff --git a/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py new file mode 100644 index 00000000000..87511a32b8c --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/test_runtime_credentials.py @@ -0,0 +1,208 @@ +"""Unit tests for runtime credential inner API.""" + +import inspect +from unittest.mock import MagicMock, patch + +from flask import Flask + +from controllers.inner_api.runtime_credentials import ( + EnterpriseRuntimeCredentialsResolve, + InnerRuntimeCredentialsResolvePayload, +) + + +def test_runtime_credentials_payload_accepts_items(): + payload = InnerRuntimeCredentialsResolvePayload.model_validate( + { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", + } + ], + } + ) + + assert payload.tenant_id == "tenant-1" + assert payload.credentials[0].provider == "langgenius/openai/openai" + assert payload.credentials[0].kind == "model" + + +@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token") +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager") +def test_runtime_model_credentials_resolve_returns_decrypted_values( + mock_provider_manager_factory, + mock_session_cls, + mock_db, + mock_decrypt_token, + app: Flask, +): + provider_configuration = MagicMock() + provider_configuration.provider.provider_credential_schema.credential_form_schemas = [] + provider_configuration.extract_secret_variables.return_value = ["openai_api_key"] + provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"] + + provider_configurations = MagicMock() + provider_configurations.get.return_value = provider_configuration + provider_manager = MagicMock() + provider_manager.get_configurations.return_value = provider_configurations + mock_provider_manager_factory.return_value = provider_manager + + credential = MagicMock() + credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}' + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = credential + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + mock_decrypt_token.return_value = "sk-test" + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/openai/openai", + "kind": "model", + } + ], + } + body, status_code = unwrapped(handler) + + assert status_code == 200 + assert body["credentials"][0]["kind"] == "model" + assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test" + assert body["credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1" + mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted") + + +@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager") +def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask): + provider_configurations = MagicMock() + provider_configurations.get.return_value = None + provider_manager = MagicMock() + provider_manager.get_configurations.return_value = provider_configurations + mock_provider_manager_factory.return_value = provider_manager + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 404 + assert "provider" in body["message"] + + +@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter") +@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache") +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.ToolManager") +def test_runtime_tool_credentials_resolve_returns_decrypted_values( + mock_tool_manager, + mock_session_cls, + mock_db, + mock_cache_cls, + mock_create_encrypter, + app: Flask, +): + provider_controller = MagicMock() + provider_controller.get_credentials_schema_by_type.return_value = [] + mock_tool_manager.get_builtin_provider.return_value = provider_controller + + builtin_provider = MagicMock() + builtin_provider.id = "credential-1" + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = builtin_provider + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + + provider_encrypter = MagicMock() + provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"} + mock_create_encrypter.return_value = (provider_encrypter, MagicMock()) + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [ + { + "credential_id": "credential-1", + "provider": "langgenius/tavily/tavily", + "kind": "tool", + } + ], + } + body, status_code = unwrapped(handler) + + assert status_code == 200 + assert body["credentials"][0]["kind"] == "tool" + assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily" + assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret" + compiled = str(session.execute.call_args.args[0].compile(compile_kwargs={"literal_binds": True})) + assert "tool_builtin_providers.provider = 'langgenius/tavily/tavily'" in compiled + + +@patch("controllers.inner_api.runtime_credentials.db") +@patch("controllers.inner_api.runtime_credentials.Session") +@patch("controllers.inner_api.runtime_credentials.ToolManager") +def test_runtime_tool_credentials_resolve_rejects_unknown_credential( + mock_tool_manager, + mock_session_cls, + mock_db, + app: Flask, +): + mock_tool_manager.get_builtin_provider.return_value = MagicMock() + + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + session.execute.return_value.scalar_one_or_none.return_value = None + mock_session_cls.return_value = session + mock_db.engine = MagicMock() + + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 404 + assert "credential" in body["message"] + + +def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask): + handler = EnterpriseRuntimeCredentialsResolve() + unwrapped = inspect.unwrap(handler.post) + with app.test_request_context(): + with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns: + mock_ns.payload = { + "tenant_id": "tenant-1", + "credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}], + } + body, status_code = unwrapped(handler) + + assert status_code == 400 + assert "kind" in body["message"] diff --git a/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py b/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py index 7d2193adc69..25ae0778d4b 100644 --- a/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/inner_api/workspace/test_workspace.py @@ -116,7 +116,9 @@ class TestEnterpriseWorkspace: assert result["tenant"]["id"] == "tenant-id" assert result["tenant"]["name"] == "My Workspace" mock_tenant_svc.create_tenant.assert_called_once_with("My Workspace", is_from_dashboard=True) - mock_tenant_svc.create_tenant_member.assert_called_once_with(mock_tenant, mock_account, role="owner") + mock_tenant_svc.create_tenant_member.assert_called_once_with( + mock_tenant, mock_account, mock_db.session, role="owner" + ) mock_event.send.assert_called_once_with(mock_tenant) @patch("controllers.inner_api.workspace.workspace.db") diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py index 70302810d2c..d79b5bd642c 100644 --- a/api/tests/unit_tests/controllers/openapi/conftest.py +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -35,7 +35,7 @@ def _stub_execute( @pytest.fixture -def bypass_pipeline(monkeypatch): +def bypass_pipeline(monkeypatch: pytest.MonkeyPatch): """Stub PipelineRouter._execute so endpoints skip real auth at request time. Module-level @auth_router.guard(...) captures the real router at import diff --git a/api/tests/unit_tests/controllers/openapi/test_account.py b/api/tests/unit_tests/controllers/openapi/test_account.py index 5bab035e457..b4af7dda4a6 100644 --- a/api/tests/unit_tests/controllers/openapi/test_account.py +++ b/api/tests/unit_tests/controllers/openapi/test_account.py @@ -167,14 +167,14 @@ def _session_auth_data() -> AuthData: ) -def _stub_session_deps(monkeypatch, rows): +def _stub_session_deps(monkeypatch: pytest.MonkeyPatch, rows): mod = sys.modules[_ACCOUNT_MOD] monkeypatch.setattr(mod, "get_auth_ctx", lambda: SimpleNamespace()) monkeypatch.setattr(mod, "list_active_sessions", lambda *args, **kwargs: rows) monkeypatch.setattr(mod, "db", MagicMock()) -def test_sessions_list_valid_query_parses_page_and_limit(app, monkeypatch): +def test_sessions_list_valid_query_parses_page_and_limit(app: Flask, monkeypatch: pytest.MonkeyPatch): """A valid ?page&limit round-trips through SessionListQuery into the response envelope.""" api = AccountSessionsApi() _stub_session_deps(monkeypatch, []) @@ -187,7 +187,7 @@ def test_sessions_list_valid_query_parses_page_and_limit(app, monkeypatch): assert body["data"] == [] -def test_sessions_list_defaults_when_query_omitted(app, monkeypatch): +def test_sessions_list_defaults_when_query_omitted(app: Flask, monkeypatch: pytest.MonkeyPatch): """No query → the model's defaults (page=1, limit=100) drive the envelope.""" api = AccountSessionsApi() _stub_session_deps(monkeypatch, []) @@ -209,7 +209,7 @@ def test_sessions_list_defaults_when_query_omitted(app, monkeypatch): "foo=bar", # extra='forbid' ], ) -def test_sessions_list_rejects_out_of_bounds_query(app, monkeypatch, query): +def test_sessions_list_rejects_out_of_bounds_query(app: Flask, monkeypatch: pytest.MonkeyPatch, query): """Out-of-range / unknown query params raise 422 instead of being silently coerced.""" api = AccountSessionsApi() _stub_session_deps(monkeypatch, []) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py index 0dbb595ba11..ddd72f604d6 100644 --- a/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -6,6 +6,9 @@ import sys from types import SimpleNamespace from unittest.mock import Mock +import pytest +from flask import Flask + from controllers.openapi._models import AppRunRequest @@ -30,7 +33,9 @@ def test_app_run_request_with_query(): assert req.query == "hello" -def test_run_chat_always_calls_generate_with_streaming_true(app, bypass_pipeline, monkeypatch): +def test_run_chat_always_calls_generate_with_streaming_true( + app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch +): """_run_chat must always invoke AppGenerateService.generate with streaming=True.""" from controllers.openapi.app_run import _run_chat @@ -56,7 +61,7 @@ def test_stop_task_endpoint_registered(openapi_app): assert "/openapi/v1/apps//tasks//stop" in rules -def test_stop_task_calls_queue_manager_and_graph_engine(app, bypass_pipeline, monkeypatch): +def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): import uuid from controllers.openapi.app_run import AppRunTaskStopApi diff --git a/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py b/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py index b2a115f955d..6e2b909e494 100644 --- a/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py +++ b/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py @@ -1,13 +1,26 @@ import logging +from typing import Protocol, cast + +import pytest from controllers.openapi._audit import EVENT_APP_RUN_OPENAPI, emit_app_run +class _AuditLogRecord(Protocol): + audit: bool + event: str + app_id: str + tenant_id: str + caller_kind: str + mode: str + surface: str + + def test_event_constant(): assert EVENT_APP_RUN_OPENAPI == "app.run.openapi" -def test_emit_app_run_logs_with_audit_extra(caplog): +def test_emit_app_run_logs_with_audit_extra(caplog: pytest.LogCaptureFixture): with caplog.at_level(logging.INFO, logger="controllers.openapi._audit"): emit_app_run( app_id="app1", @@ -16,7 +29,7 @@ def test_emit_app_run_logs_with_audit_extra(caplog): mode="chat", surface="apps", ) - record = next(r for r in caplog.records if r.message and "app.run.openapi" in r.message) + record = cast(_AuditLogRecord, next(r for r in caplog.records if r.message and "app.run.openapi" in r.message)) assert record.audit is True assert record.event == EVENT_APP_RUN_OPENAPI assert record.app_id == "app1" diff --git a/api/tests/unit_tests/controllers/openapi/test_meta_version.py b/api/tests/unit_tests/controllers/openapi/test_meta_version.py index 8f9e1016e8e..57de0c517f4 100644 --- a/api/tests/unit_tests/controllers/openapi/test_meta_version.py +++ b/api/tests/unit_tests/controllers/openapi/test_meta_version.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + def test_version_endpoint_returns_200_without_auth(openapi_app): client = openapi_app.test_client() @@ -30,7 +32,7 @@ def test_version_endpoint_ignores_bearer_header(openapi_app): assert "edition" in payload -def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch): +def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pytest.MonkeyPatch): from configs import dify_config monkeypatch.setattr(dify_config, "EDITION", "CLOUD") @@ -42,7 +44,7 @@ def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch): assert response.get_json()["edition"] == "CLOUD" -def test_version_endpoint_falls_back_to_self_hosted_on_unexpected_edition(openapi_app, monkeypatch): +def test_version_endpoint_falls_back_to_self_hosted_on_unexpected_edition(openapi_app, monkeypatch: pytest.MonkeyPatch): from configs import dify_config monkeypatch.setattr(dify_config, "EDITION", "EXPERIMENTAL") diff --git a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py index 78f2d0f20d0..51d5ecdd36f 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py +++ b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py @@ -8,6 +8,7 @@ from types import SimpleNamespace from unittest.mock import Mock import pytest +from flask import Flask from werkzeug.exceptions import NotFound from controllers.openapi.auth.data import AuthData @@ -51,7 +52,7 @@ class TestOpenApiWorkflowEventsApi: return OpenApiWorkflowEventsApi() - def test_not_found_when_run_missing(self, app, bypass_pipeline, monkeypatch): + def test_not_found_when_run_missing(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): module = sys.modules["controllers.openapi.workflow_events"] repo_mock = Mock() repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = None @@ -76,7 +77,9 @@ class TestOpenApiWorkflowEventsApi: auth_data=_make_auth_data(app_model, caller, "account"), ) - def test_not_found_when_run_belongs_to_different_app(self, app, bypass_pipeline, monkeypatch): + def test_not_found_when_run_belongs_to_different_app( + self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch + ): module = sys.modules["controllers.openapi.workflow_events"] run = _make_workflow_run(app_id="other-app") repo_mock = Mock() @@ -102,7 +105,9 @@ class TestOpenApiWorkflowEventsApi: auth_data=_make_auth_data(app_model, caller, "account"), ) - def test_account_caller_checks_created_by_account(self, app, bypass_pipeline, monkeypatch): + def test_account_caller_checks_created_by_account( + self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch + ): """Account caller must match created_by == caller.id and role == ACCOUNT.""" module = sys.modules["controllers.openapi.workflow_events"] run = _make_workflow_run(created_by_role=CreatorUserRole.ACCOUNT, created_by="acct-1") @@ -141,7 +146,9 @@ class TestOpenApiWorkflowEventsApi: ) assert resp.mimetype == "text/event-stream" - def test_account_caller_rejected_for_end_user_run(self, app, bypass_pipeline, monkeypatch): + def test_account_caller_rejected_for_end_user_run( + self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch + ): module = sys.modules["controllers.openapi.workflow_events"] run = _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1") repo_mock = Mock() @@ -167,7 +174,9 @@ class TestOpenApiWorkflowEventsApi: auth_data=_make_auth_data(app_model, caller, "account"), ) - def test_end_user_caller_checks_created_by_end_user(self, app, bypass_pipeline, monkeypatch): + def test_end_user_caller_checks_created_by_end_user( + self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch + ): """End-user caller must match created_by == caller.id and role == END_USER.""" module = sys.modules["controllers.openapi.workflow_events"] run = _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1") @@ -202,7 +211,7 @@ class TestOpenApiWorkflowEventsApi: ) assert resp.mimetype == "text/event-stream" - def test_finished_run_returns_single_sse_event(self, app, bypass_pipeline, monkeypatch): + def test_finished_run_returns_single_sse_event(self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """A finished run returns a single done-event SSE response without streaming.""" from datetime import UTC, datetime diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py index 4c09491ab59..cf9fa671987 100644 --- a/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces_members.py @@ -34,6 +34,7 @@ from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity from controllers.openapi import bp as openapi_bp from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload +from controllers.openapi.auth.data import AuthData from controllers.openapi.workspaces import ( WorkspaceMemberApi, WorkspaceMemberRoleApi, @@ -228,7 +229,7 @@ def test_role_payload_rejects_extra_field(): MemberRoleUpdatePayload.model_validate({"role": "normal", "extra": "x"}) -def test_invite_rejects_invalid_body_with_422(app, bypass_pipeline): +def test_invite_rejects_invalid_body_with_422(app: Flask, bypass_pipeline): """Invalid invite body → 422 via @accepts (was 400 through _validate_body).""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -245,7 +246,7 @@ def test_invite_rejects_invalid_body_with_422(app, bypass_pipeline): api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id)) -def test_update_role_rejects_invalid_body_with_422(app, bypass_pipeline): +def test_update_role_rejects_invalid_body_with_422(app: Flask, bypass_pipeline): """Invalid role-update body surfaces as 422 through @accepts (was 400).""" ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -267,7 +268,9 @@ def test_update_role_rejects_invalid_body_with_422(app, bypass_pipeline): # --------------------------------------------------------------------------- -def test_switch_returns_workspace_detail_with_current_true(app, bypass_pipeline, monkeypatch): +def test_switch_returns_workspace_detail_with_current_true( + app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch +): """Happy path: switch service is called, then the workspace+membership row is re-queried so the returned `current` reflects post-commit state. """ @@ -298,7 +301,9 @@ def test_switch_returns_workspace_detail_with_current_true(app, bypass_pipeline, assert switch_mock.called -def test_switch_404s_when_service_raises_account_not_link_tenant(app, bypass_pipeline, monkeypatch): +def test_switch_404s_when_service_raises_account_not_link_tenant( + app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch +): """If switch_tenant raises (e.g. Tenant.status != NORMAL), the body surfaces as NotFound, not 500.""" ws_id = str(uuid.uuid4()) @@ -326,7 +331,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant(app, bypass_pip # --------------------------------------------------------------------------- -def test_members_list_returns_normalized_rows(app, bypass_pipeline, monkeypatch): +def test_members_list_returns_normalized_rows(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -364,7 +369,7 @@ def test_members_list_returns_normalized_rows(app, bypass_pipeline, monkeypatch) assert body["data"][0]["status"] == "active" -def test_members_list_paginates_with_query_params(app, bypass_pipeline, monkeypatch): +def test_members_list_paginates_with_query_params(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """`?page=2&limit=2` slices service output and reports total/has_more.""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -404,7 +409,7 @@ def test_members_list_paginates_with_query_params(app, bypass_pipeline, monkeypa assert [d["id"] for d in body["data"]] == ["m-2", "m-3"] -def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypatch): +def test_members_list_rejects_unknown_query_param(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """Strict (`extra='forbid'`) — typos like `?pg=2` surface as 422 (unified via @accepts).""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -425,7 +430,9 @@ def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypa # --------------------------------------------------------------------------- -def test_invite_happy_path_returns_invite_url_and_member_id(app, bypass_pipeline, monkeypatch): +def test_invite_happy_path_returns_invite_url_and_member_id( + app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch +): ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -507,7 +514,7 @@ def _invite_request(app, ws_id: str, acct_id: uuid.UUID): ) -def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch): +def test_invite_blocked_by_saas_members_cap(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """SaaS billing plan member cap → MemberLimitExceeded (403).""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -541,7 +548,7 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch): invite_mock.assert_not_called() -def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch): +def test_invite_blocked_by_ee_workspace_members_license(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """EE License workspace_members cap → MemberLicenseExceeded (403). Note: billing.enabled is False (EE without SaaS billing); only the @@ -583,7 +590,7 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo invite_mock.assert_not_called() -def test_invite_ce_passes_when_both_caps_disabled(app, bypass_pipeline, monkeypatch): +def test_invite_ce_passes_when_both_caps_disabled(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """CE deployment (no billing, no license) → quota gate is a no-op, invite proceeds normally.""" ws_id = str(uuid.uuid4()) @@ -619,7 +626,7 @@ def test_invite_ce_passes_when_both_caps_disabled(app, bypass_pipeline, monkeypa assert body["email"] == "new@example.com" -def test_invite_400_when_already_in_tenant(app, bypass_pipeline, monkeypatch): +def test_invite_400_when_already_in_tenant(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMembersApi() @@ -650,7 +657,7 @@ def test_invite_400_when_already_in_tenant(app, bypass_pipeline, monkeypatch): # --------------------------------------------------------------------------- -def test_delete_member_happy_path(app, bypass_pipeline, monkeypatch): +def test_delete_member_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -692,7 +699,7 @@ def test_delete_member_happy_path(app, bypass_pipeline, monkeypatch): (MemberNotInTenantError("not in tenant"), NotFound), ], ) -def test_delete_member_exception_mapping(app, bypass_pipeline, monkeypatch, exc, expected): +def test_delete_member_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, exc, expected): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -725,7 +732,7 @@ def test_delete_member_exception_mapping(app, bypass_pipeline, monkeypatch, exc, ) -def test_delete_member_404_when_member_missing(app, bypass_pipeline, monkeypatch): +def test_delete_member_404_when_member_missing(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberApi() @@ -757,7 +764,7 @@ def test_delete_member_404_when_member_missing(app, bypass_pipeline, monkeypatch # --------------------------------------------------------------------------- -def test_update_role_happy_path(app, bypass_pipeline, monkeypatch): +def test_update_role_happy_path(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberRoleApi() @@ -801,7 +808,7 @@ def test_update_role_happy_path(app, bypass_pipeline, monkeypatch): (MemberNotInTenantError("not in tenant"), NotFound), ], ) -def test_update_role_exception_mapping(app, bypass_pipeline, monkeypatch, exc, expected): +def test_update_role_exception_mapping(app: Flask, bypass_pipeline, monkeypatch, exc, expected): ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4()) acct_id = uuid.uuid4() api = WorkspaceMemberRoleApi() @@ -841,7 +848,7 @@ def test_update_role_exception_mapping(app, bypass_pipeline, monkeypatch, exc, e # --------------------------------------------------------------------------- -def test_load_tenant_rejects_archived_workspace(app, bypass_pipeline, monkeypatch): +def test_load_tenant_rejects_archived_workspace(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """Member management against an archived workspace → 404.""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() @@ -869,7 +876,7 @@ def test_load_tenant_rejects_archived_workspace(app, bypass_pipeline, monkeypatc # --------------------------------------------------------------------------- -def test_invite_400_when_register_error(app, bypass_pipeline, monkeypatch): +def test_invite_400_when_register_error(app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch): """AccountRegisterError (frozen email, workspace creation blocked) → 400.""" ws_id = str(uuid.uuid4()) acct_id = uuid.uuid4() 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 f5e8453c5cd..14a00a9af82 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 @@ -2,7 +2,9 @@ Unit tests for Service API File Preview endpoint """ +import logging import uuid +from typing import Protocol, cast from unittest.mock import Mock, patch import pytest @@ -12,6 +14,12 @@ from controllers.service_api.app.file_preview import FilePreviewApi from models.model import App, EndUser, Message, MessageFile, UploadFile +class _FilePreviewLogRecord(Protocol): + file_id: str + app_id: str + error: str + + class TestFilePreviewApi: """Test suite for FilePreviewApi""" @@ -348,8 +356,9 @@ 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: FilePreviewApi): + def test_validate_file_ownership_unexpected_error_logging( + self, file_preview_api: FilePreviewApi, caplog: pytest.LogCaptureFixture + ): """Test that unexpected errors are logged properly""" file_id = str(uuid.uuid4()) app_id = str(uuid.uuid4()) @@ -359,14 +368,19 @@ class TestFilePreviewApi: mock_db.session.scalar.side_effect = Exception("Unexpected database error") # Execute and assert exception - with pytest.raises(FileAccessDeniedError) as exc_info: - file_preview_api._validate_file_ownership(file_id, app_id) + with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"): + with pytest.raises(FileAccessDeniedError) as exc_info: + file_preview_api._validate_file_ownership(file_id, app_id) # Verify error message assert "File access validation failed" in str(exc_info.value) - # Verify logging was called - mock_logger.exception.assert_called_once_with( - "Unexpected error during file ownership validation", - extra={"file_id": file_id, "app_id": app_id, "error": "Unexpected database error"}, - ) + # Verify logging was called with the structured context fields. The ``extra`` keys + # are attached to the LogRecord as attributes, so they are not in ``caplog.text``. + assert len(caplog.records) == 1 + log_record = caplog.records[0] + assert log_record.getMessage() == "Unexpected error during file ownership validation" + record = cast(_FilePreviewLogRecord, log_record) + assert record.file_id == file_id + assert record.app_id == app_id + assert record.error == "Unexpected database error" 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 9c310a4f456..687c1a67b1a 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 @@ -7,6 +7,7 @@ from pytest_mock import MockerFixture from controllers.service_api.end_user.end_user import EndUserApi from controllers.service_api.end_user.error import EndUserNotFoundError +from models.enums import EndUserType from models.model import App, EndUser @@ -29,7 +30,7 @@ class TestEndUserApi: end_user.id = str(uuid4()) end_user.tenant_id = app_model.tenant_id end_user.app_id = app_model.id - end_user.type = "service_api" + end_user.type = EndUserType.SERVICE_API end_user.external_user_id = "external-123" end_user.name = "Alice" end_user._is_anonymous = True diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index 898eb2e86b7..3667c8ef2df 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -6,6 +6,32 @@ from collections.abc import Iterator import pytest from flask import Flask +USER_PROPERTY_SCHEMA = { + "description": ( + "User identifier, unique within the application. This identifier scopes data access; resources created with " + "one `user` value are only visible when queried with the same `user` value." + ), + "type": "string", +} +GENERIC_FILE_SCHEMA = {"description": "The file to upload.", "format": "binary", "type": "string"} +DOCUMENT_CREATE_DATA_SCHEMA = { + "description": ( + "JSON string containing configuration. Accepts the same fields as " + "[Create Document by Text](/api-reference/documents/create-document-by-text) (`indexing_technique`, " + "`doc_form`, `doc_language`, `process_rule`, `retrieval_model`, `embedding_model`, " + "`embedding_model_provider`) except `name` and `text`." + ), + "type": "string", +} +DOCUMENT_UPDATE_DATA_SCHEMA = { + "description": ( + "JSON string containing document update settings such as `doc_form`, `doc_language`, `process_rule`, " + "`retrieval_model`, `embedding_model`, and `embedding_model_provider`. `name` and `text` are not used " + "for file updates." + ), + "type": "string", +} + def _schema_refs(value: object) -> set[str]: refs: set[str] = set() @@ -57,6 +83,36 @@ def _multipart_form_schema(operation: dict[str, object]) -> dict[str, object]: return schema +def _json_body_schema(payload: dict[str, object], operation: dict[str, object]) -> dict[str, object]: + request_body = operation.get("requestBody") + assert isinstance(request_body, dict) + content = request_body.get("content") + assert isinstance(content, dict) + json_media = content.get("application/json") + assert isinstance(json_media, dict) + schema = json_media.get("schema") + assert isinstance(schema, dict) + + ref = schema.get("$ref") + if isinstance(ref, str): + schema_name = ref.removeprefix("#/components/schemas/") + resolved = payload["components"]["schemas"][schema_name] + assert isinstance(resolved, dict) + return resolved + + return schema + + +def _response_content_types(operation: dict[str, object], status_code: str = "200") -> set[str]: + responses = operation.get("responses") + assert isinstance(responses, dict) + response = responses.get(status_code) + assert isinstance(response, dict) + content = response.get("content") + assert isinstance(content, dict) + return set(content) + + @pytest.mark.parametrize( ("first_kwargs", "second_kwargs"), [ @@ -79,6 +135,25 @@ def test_inline_model_name_includes_list_constraints( assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model) +def test_uuid_path_format_is_derived_from_route_converter(): + from flask_restx import swagger as restx_swagger + + from libs.flask_restx_compat import install_swagger_compatibility + + app = Flask(__name__) + with app.app_context(): + install_swagger_compatibility() + params = restx_swagger.extract_path_params("/resources/") + + assert params["custom_resource_uuid"] == { + "format": "uuid", + "in": "path", + "name": "custom_resource_uuid", + "required": True, + "type": "string", + } + + def test_openapi_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.console import bp as console_bp @@ -131,8 +206,12 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch: create_schema = _multipart_form_schema(create_operation) create_properties = create_schema["properties"] assert isinstance(create_properties, dict) - assert create_properties["file"] == {"type": "string", "format": "binary"} - assert create_properties["data"] == {"type": "string"} + assert create_properties["file"] == { + "description": "Document file to upload.", + "format": "binary", + "type": "string", + } + assert create_properties["data"] == DOCUMENT_CREATE_DATA_SCHEMA assert create_schema["required"] == ["file"] assert create_operation["requestBody"]["required"] is True @@ -145,12 +224,41 @@ def test_service_document_file_routes_document_multipart_form_data(monkeypatch: update_schema = _multipart_form_schema(update_operation) update_properties = update_schema["properties"] assert isinstance(update_properties, dict) - assert update_properties["file"] == {"type": "string", "format": "binary"} - assert update_properties["data"] == {"type": "string"} + assert update_properties["file"] == { + "description": "Replacement document file to upload.", + "format": "binary", + "type": "string", + } + assert update_properties["data"] == DOCUMENT_UPDATE_DATA_SCHEMA assert "required" not in update_schema assert update_operation["requestBody"]["required"] is False +def test_service_openapi_merges_public_api_reference_descriptions(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + + chat_operation = payload["paths"]["/chat-messages"]["post"] + assert chat_operation["summary"] == "Send Chat Message" + assert chat_operation["description"] == "Send a request to the chat application." + assert chat_operation["tags"] == ["Chats", "Chatflows"] + assert chat_operation["responses"]["200"]["description"].startswith("Successful response.") + + rename_operation = payload["paths"]["/conversations/{c_id}/name"]["post"] + assert rename_operation["summary"] == "Rename Conversation" + assert rename_operation["tags"] == ["Conversations"] + assert _parameters_by_name(rename_operation)["c_id"]["description"] == "Conversation ID." + + def test_service_document_list_documents_query_params_render(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.service_api import bp as service_api_bp @@ -170,6 +278,282 @@ def test_service_document_list_documents_query_params_render(monkeypatch: pytest assert params[name]["in"] == "query" +def test_service_openapi_documents_decorator_user_contracts(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + required_json_user_operations = ( + ("/completion-messages", "post"), + ("/completion-messages/{task_id}/stop", "post"), + ("/chat-messages", "post"), + ("/chat-messages/{task_id}/stop", "post"), + ("/messages/{message_id}/feedbacks", "post"), + ("/form/human_input/{form_token}", "post"), + ("/workflows/run", "post"), + ("/workflows/{workflow_id}/run", "post"), + ("/workflows/tasks/{task_id}/stop", "post"), + ) + for path, method in required_json_user_operations: + schema = _json_body_schema(payload, paths[path][method]) + assert schema["properties"]["user"] == USER_PROPERTY_SCHEMA + assert "user" in schema["required"] + + optional_json_user_operations = ( + ("/text-to-audio", "post"), + ("/conversations/{c_id}", "delete"), + ("/conversations/{c_id}/name", "post"), + ("/conversations/{c_id}/variables/{variable_id}", "put"), + ) + for path, method in optional_json_user_operations: + schema = _json_body_schema(payload, paths[path][method]) + assert schema["properties"]["user"] == USER_PROPERTY_SCHEMA + assert "user" not in schema.get("required", []) + + messages_params = _parameters_by_name(paths["/messages"]["get"]) + assert messages_params["user"]["in"] == "query" + assert messages_params["user"]["required"] is False + + events_params = _parameters_by_name(paths["/workflow/{task_id}/events"]["get"]) + assert events_params["user"]["in"] == "query" + assert events_params["user"]["required"] is True + + +def test_service_openapi_documents_app_multipart_contracts(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + for path in ("/files/upload", "/audio-to-text"): + schema = _multipart_form_schema(paths[path]["post"]) + if path == "/audio-to-text": + assert schema["properties"]["file"] == { + "description": ( + "Audio file to transcribe. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, " + "`audio/wav`, and `audio/amr`. File size limit is `30 MB`." + ), + "format": "binary", + "type": "string", + } + else: + assert schema["properties"]["file"] == GENERIC_FILE_SCHEMA + assert schema["properties"]["user"] == USER_PROPERTY_SCHEMA + assert schema["required"] == ["file"] + + pipeline_schema = _multipart_form_schema(paths["/datasets/pipeline/file-upload"]["post"]) + assert pipeline_schema["properties"]["file"] == GENERIC_FILE_SCHEMA + assert pipeline_schema["required"] == ["file"] + + +def test_service_openapi_documents_non_json_response_media_types(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + assert _response_content_types(paths["/chat-messages"]["post"]) == { + "application/json", + "text/event-stream", + } + assert _response_content_types(paths["/workflow/{task_id}/events"]["get"]) == {"text/event-stream"} + assert _response_content_types(paths["/text-to-audio"]["post"]) == {"audio/mpeg"} + assert _response_content_types(paths["/files/{file_id}/preview"]["get"]) == { + "application/octet-stream", + "application/pdf", + "audio/aac", + "audio/flac", + "audio/mp4", + "audio/mpeg", + "audio/ogg", + "audio/wav", + "audio/x-m4a", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "text/plain", + "video/mp4", + "video/quicktime", + "video/webm", + } + assert _response_content_types(paths["/datasets/{dataset_id}/documents/download-zip"]["post"]) == { + "application/zip" + } + + +def test_service_openapi_documents_uuid_params_and_deprecated_routes(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + dataset_params = _parameters_by_name(paths["/datasets/{dataset_id}"]["get"]) + assert dataset_params["dataset_id"]["schema"] == { + "description": "Knowledge base ID.", + "format": "uuid", + "type": "string", + } + + conversation_params = _parameters_by_name(paths["/conversations/{c_id}"]["delete"]) + assert conversation_params["c_id"]["schema"] == { + "description": "Conversation ID.", + "format": "uuid", + "type": "string", + } + + assert paths["/datasets/{dataset_id}/document/create_by_file"]["post"]["deprecated"] is True + assert paths["/datasets/{dataset_id}/documents/{document_id}/update_by_text"]["post"]["deprecated"] is True + + +def test_service_openapi_documents_path_action_enums(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + annotation_params = _parameters_by_name(paths["/apps/annotation-reply/{action}"]["post"]) + assert annotation_params["action"]["schema"]["enum"] == ["enable", "disable"] + + document_status_params = _parameters_by_name(paths["/datasets/{dataset_id}/documents/status/{action}"]["patch"]) + assert document_status_params["action"]["schema"]["enum"] == ["enable", "disable", "archive", "un_archive"] + + metadata_params = _parameters_by_name(paths["/datasets/{dataset_id}/metadata/built-in/{action}"]["post"]) + assert metadata_params["action"]["schema"]["enum"] == ["enable", "disable"] + + +def test_service_openapi_documents_conditional_payload_schemas(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + rename_schema = _json_body_schema(payload, paths["/conversations/{c_id}/name"]["post"]) + auto_generate_branch, manual_name_branch = rename_schema["anyOf"] + assert auto_generate_branch["properties"]["auto_generate"]["enum"] == [True] + assert auto_generate_branch["required"] == ["auto_generate"] + assert manual_name_branch["properties"]["auto_generate"]["enum"] == [False] + assert manual_name_branch["properties"]["name"]["pattern"] == r".*\S.*" + assert manual_name_branch["required"] == ["name"] + for branch in rename_schema["anyOf"]: + assert branch["properties"]["user"] == USER_PROPERTY_SCHEMA + + document_update_schema = payload["components"]["schemas"]["DocumentTextUpdate"] + with_text_branch, without_text_branch = document_update_schema["anyOf"] + assert with_text_branch["properties"]["text"]["type"] == "string" + assert with_text_branch["properties"]["name"]["type"] == "string" + assert with_text_branch["required"] == ["name", "text"] + assert without_text_branch["properties"]["text"]["type"] == "null" + + +def test_service_openapi_does_not_encode_docs_coverage_boundaries(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + paths = payload["paths"] + + for path_item in paths.values(): + assert isinstance(path_item, dict) + for method in ("delete", "get", "patch", "post", "put"): + operation = path_item.get(method) + if not isinstance(operation, dict): + continue + assert "x-dify-api-reference-visibility" not in operation + assert "x-dify-api-lifecycle" not in operation + + assert paths["/datasets/{dataset_id}/document/create_by_text"]["post"]["deprecated"] is True + assert paths["/datasets/{dataset_id}/document/create_by_file"]["post"]["deprecated"] is True + assert paths["/datasets/{dataset_id}/documents/{document_id}/update-by-file"]["post"]["deprecated"] is True + + +def test_service_openapi_documents_auth_and_compatibility_payloads(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.service_api import bp as service_api_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(service_api_bp) + + payload = app.test_client().get("/v1/openapi.json").get_json() + + assert payload["components"]["securitySchemes"]["Bearer"] == { + "bearerFormat": "API_KEY", + "description": "Use the Service API key as a Bearer token in the Authorization header.", + "scheme": "bearer", + "type": "http", + } + + tag_unbinding_schema = payload["components"]["schemas"]["TagUnbindingPayload"] + assert tag_unbinding_schema["description"] == ( + "Accepts either the legacy tag_id payload or the normalized tag_ids payload." + ) + tag_id_schema, tag_ids_schema = tag_unbinding_schema["anyOf"] + assert tag_id_schema["properties"]["tag_id"]["description"] == ("Legacy single tag ID accepted by the Service API.") + assert tag_id_schema["required"] == ["tag_id", "target_id"] + assert tag_ids_schema["properties"]["tag_ids"]["minItems"] == 1 + assert tag_ids_schema["required"] == ["tag_ids", "target_id"] + + def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.console import bp as console_bp 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 0fea04845d2..15c441e5876 100644 --- a/api/tests/unit_tests/core/agent/strategy/test_plugin.py +++ b/api/tests/unit_tests/core/agent/strategy/test_plugin.py @@ -81,7 +81,7 @@ class TestPluginAgentStrategyInitialization: class TestGetParameters: - def test_get_parameters_returns_parameters(self, strategy, mock_declaration) -> None: + def test_get_parameters_returns_parameters(self, strategy: PluginAgentStrategy, mock_declaration) -> None: result = strategy.get_parameters() assert result == mock_declaration.parameters @@ -92,7 +92,7 @@ class TestGetParameters: class TestInitializeParameters: - def test_initialize_parameters_success(self, strategy, mock_declaration) -> None: + def test_initialize_parameters_success(self, strategy: PluginAgentStrategy, mock_declaration) -> None: params = {"param1": "value1"} result = strategy.initialize_parameters(params.copy()) @@ -114,13 +114,13 @@ class TestInitializeParameters: {"param1": {}, "param2": "value"}, ], ) - def test_initialize_parameters_edge_cases(self, strategy, input_params) -> None: + def test_initialize_parameters_edge_cases(self, strategy: PluginAgentStrategy, input_params) -> None: result = strategy.initialize_parameters(input_params.copy()) for param in strategy.declaration.parameters: assert param.name in result - def test_initialize_parameters_invalid_input_type(self, strategy) -> None: + def test_initialize_parameters_invalid_input_type(self, strategy: PluginAgentStrategy) -> None: with pytest.raises(AttributeError): strategy.initialize_parameters(None) @@ -131,7 +131,7 @@ class TestInitializeParameters: class TestInvoke: - def test_invoke_success_all_arguments(self, strategy, mocker) -> None: + def test_invoke_success_all_arguments(self, strategy: PluginAgentStrategy, mocker: MockerFixture) -> None: mock_manager = MagicMock() mock_manager.invoke = MagicMock(return_value=iter(["msg1", "msg2"])) @@ -171,7 +171,7 @@ class TestInvoke: assert call_kwargs["message_id"] == "msg_1" assert call_kwargs["context"] is not None - def test_invoke_with_credentials(self, strategy, mocker) -> None: + def test_invoke_with_credentials(self, strategy: PluginAgentStrategy, mocker: MockerFixture) -> None: mock_manager = MagicMock() mock_manager.invoke = MagicMock(return_value=iter([])) @@ -243,7 +243,7 @@ class TestInvoke: assert result == [] mock_manager.invoke.assert_called_once() - def test_invoke_convert_raises_exception(self, strategy, mocker) -> None: + def test_invoke_convert_raises_exception(self, strategy: PluginAgentStrategy, mocker: MockerFixture) -> None: mocker.patch( "core.agent.strategy.plugin.PluginAgentClient", return_value=MagicMock(), @@ -257,7 +257,7 @@ class TestInvoke: with pytest.raises(ValueError): list(strategy._invoke(params={}, user_id="user_1")) - def test_invoke_manager_raises_exception(self, strategy, mocker) -> None: + def test_invoke_manager_raises_exception(self, strategy: PluginAgentStrategy, mocker: MockerFixture) -> None: mock_manager = MagicMock() mock_manager.invoke.side_effect = RuntimeError("invoke failed") 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 f5e4b099936..b983a945ad5 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 @@ -21,7 +21,7 @@ def mock_db_session(mocker: MockerFixture): @pytest.fixture -def runner(mocker, mock_db_session): +def runner(mocker: MockerFixture, mock_db_session): r = BaseAgentRunner.__new__(BaseAgentRunner) r.tenant_id = "tenant" r.user_id = "user" @@ -42,13 +42,13 @@ def runner(mocker, mock_db_session): class TestRepack: - def test_sets_empty_if_none(self, runner, mocker: MockerFixture): + def test_sets_empty_if_none(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_keeps_existing(self, runner: BaseAgentRunner, mocker: MockerFixture): entity = mocker.MagicMock() entity.app_config.prompt_template.simple_prompt_template = "abc" result = runner._repack_app_generate_entity(entity) @@ -61,7 +61,7 @@ class TestRepack: class TestUpdatePromptTool: - def test_replaces_prompt_tool_parameters_with_tool_schema(self, runner, mocker: MockerFixture): + def test_replaces_prompt_tool_parameters_with_tool_schema(self, runner: BaseAgentRunner, mocker: MockerFixture): tool = mocker.MagicMock() schema = { "type": "object", @@ -83,7 +83,7 @@ class TestUpdatePromptTool: class TestCreateAgentThought: - def test_with_files(self, runner, mock_db_session, mocker: MockerFixture): + def test_with_files(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): mock_thought = mocker.MagicMock(id=10) mocker.patch.object(module, "MessageAgentThought", return_value=mock_thought) @@ -91,7 +91,7 @@ class TestCreateAgentThought: assert result == "10" assert runner.agent_thought_count == 1 - def test_without_files(self, runner, mock_db_session, mocker: MockerFixture): + def test_without_files(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): mock_thought = mocker.MagicMock(id=11) mocker.patch.object(module, "MessageAgentThought", return_value=mock_thought) @@ -112,12 +112,12 @@ class TestSaveAgentThought: agent.thought = "" return agent - def test_not_found(self, runner, mock_db_session): + def test_not_found(self, runner: BaseAgentRunner, mock_db_session): mock_db_session.scalar.return_value = None 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: MockerFixture): + def test_full_update(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent @@ -152,7 +152,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: MockerFixture): + def test_label_fallback_when_none(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) agent.tool = "unknown_tool" mock_db_session.scalar.return_value = agent @@ -162,7 +162,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: MockerFixture): + def test_json_failure_paths(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent @@ -183,13 +183,13 @@ class TestSaveAgentThought: assert mock_db_session.commit.called - def test_messages_ids_none(self, runner, mock_db_session, mocker: MockerFixture): + def test_messages_ids_none(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_success_dict_serialization(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): agent = self.setup_agent(mocker) mock_db_session.scalar.return_value = agent @@ -215,19 +215,19 @@ class TestSaveAgentThought: class TestOrganizeUserPrompt: - def test_no_files(self, runner, mock_db_session, mocker: MockerFixture): + def test_no_files(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_with_files_no_config(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_image_detail_low_fallback(self, runner: BaseAgentRunner, 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) @@ -247,27 +247,27 @@ class TestOrganizeUserPrompt: class TestOrganizeHistory: - def test_empty(self, runner, mock_db_session, mocker: MockerFixture): + def test_empty(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_with_answer_only(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_skip_current_message(self, runner: BaseAgentRunner, 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: MockerFixture): + def test_with_tool_calls_invalid_json(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock( tool="tool1", tool_input="invalid", @@ -283,7 +283,7 @@ class TestOrganizeHistory: result = runner.organize_agent_history([]) assert isinstance(result, list) - def test_empty_tool_name_split(self, runner, mock_db_session, mocker: MockerFixture): + def test_empty_tool_name_split(self, runner: BaseAgentRunner, 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) @@ -292,7 +292,7 @@ class TestOrganizeHistory: result = runner.organize_agent_history([]) assert isinstance(result, list) - def test_valid_json_tool_flow(self, runner, mock_db_session, mocker: MockerFixture): + def test_valid_json_tool_flow(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): thought = mocker.MagicMock( tool="tool1", tool_input=json.dumps({"tool1": {"x": 1}}), @@ -321,7 +321,7 @@ class TestOrganizeHistory: class TestConvertToolToPromptMessageTool: - def test_basic_conversion(self, runner, mocker: MockerFixture): + def test_basic_conversion(self, runner: BaseAgentRunner, mocker: MockerFixture): tool = mocker.MagicMock(tool_name="tool1") tool_entity = mocker.MagicMock() @@ -347,7 +347,7 @@ class TestConvertToolToPromptMessageTool: class TestInitPromptToolsExtended: - def test_agent_tool_branch(self, runner, mocker: MockerFixture): + def test_agent_tool_branch(self, runner: BaseAgentRunner, 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")) @@ -355,7 +355,7 @@ class TestInitPromptToolsExtended: tools, prompts = runner._init_prompt_tools() assert "agent_tool" in tools - def test_exception_in_conversion(self, runner, mocker: MockerFixture): + def test_exception_in_conversion(self, runner: BaseAgentRunner, 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) @@ -370,7 +370,7 @@ class TestInitPromptToolsExtended: class TestAdditionalCoverage: - def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker: MockerFixture): + def test_save_agent_thought_existing_labels(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" agent.tool_labels = {"tool1": {"en_US": "existing"}} @@ -381,7 +381,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: MockerFixture): + def test_save_agent_thought_tool_meta_string(self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture): agent = mocker.MagicMock() agent.tool = "tool1" agent.tool_labels = {} @@ -391,7 +391,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: MockerFixture): + def test_convert_dataset_retriever_tool(self, runner: BaseAgentRunner, mocker: MockerFixture): ds_tool = mocker.MagicMock() ds_tool.entity.identity.name = "ds" ds_tool.entity.description.llm = "desc" @@ -408,7 +408,9 @@ 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: MockerFixture): + def test_organize_user_prompt_with_file_objects( + self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture + ): mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()] file_config = mocker.MagicMock() @@ -427,7 +429,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: MockerFixture): + def test_organize_history_without_tool_names(self, runner: BaseAgentRunner, 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) @@ -437,7 +439,9 @@ class TestAdditionalCoverage: result = runner.organize_agent_history([]) assert isinstance(result, list) - def test_organize_history_multiple_tools_split(self, runner, mock_db_session, mocker: MockerFixture): + def test_organize_history_multiple_tools_split( + self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture + ): thought = mocker.MagicMock( tool="tool1;tool2", tool_input=json.dumps({"tool1": {}, "tool2": {}}), @@ -455,7 +459,7 @@ class TestAdditionalCoverage: class TestConvertDatasetRetrieverTool: - def test_required_param_added(self, runner, mocker: MockerFixture): + def test_required_param_added(self, runner: BaseAgentRunner, mocker: MockerFixture): ds_tool = mocker.MagicMock() ds_tool.entity.identity.name = "ds" ds_tool.entity.description.llm = "desc" @@ -518,7 +522,7 @@ class TestBaseAgentRunnerInit: class TestBaseAgentRunnerCoverage: - def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker: MockerFixture): + def test_init_prompt_tools_adds_dataset_tools(self, runner: BaseAgentRunner, mocker: MockerFixture): dataset_tool = mocker.MagicMock() dataset_tool.entity.identity.name = "ds" runner.dataset_tools = [dataset_tool] @@ -530,7 +534,9 @@ class TestBaseAgentRunnerCoverage: assert tools["ds"] == dataset_tool assert len(prompt_tools) == 1 - def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker: MockerFixture): + def test_save_agent_thought_json_dumps_fallbacks( + self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture + ): agent = mocker.MagicMock() agent.tool = "tool1" agent.tool_labels = {} @@ -568,7 +574,9 @@ 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: MockerFixture): + def test_save_agent_thought_skips_empty_tool_name( + self, runner: BaseAgentRunner, mock_db_session, mocker: MockerFixture + ): agent = mocker.MagicMock() agent.tool = "tool1;;" agent.tool_labels = {} @@ -582,7 +590,9 @@ 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: MockerFixture): + def test_organize_history_includes_system_prompt( + self, runner: BaseAgentRunner, 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=[]) @@ -592,7 +602,9 @@ class TestBaseAgentRunnerCoverage: assert system_message in result - def test_organize_history_tool_inputs_and_observation_none(self, runner, mock_db_session, mocker: MockerFixture): + def test_organize_history_tool_inputs_and_observation_none( + self, runner: BaseAgentRunner, 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 314305d371a..a6cae351b17 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 @@ -95,25 +95,25 @@ class TestFillInputs: ("", {"x": "y"}, ""), ], ) - def test_fill_in_inputs(self, runner, instruction, inputs, expected): + def test_fill_in_inputs(self, runner: DummyRunner, instruction, inputs, expected): result = runner._fill_in_inputs_from_external_data_tools(instruction, inputs) assert result == expected class TestConvertDictToAction: - def test_convert_valid_dict(self, runner): + def test_convert_valid_dict(self, runner: DummyRunner): action_dict = {"action": "test", "action_input": {"a": 1}} action = runner._convert_dict_to_action(action_dict) assert action.action_name == "test" assert action.action_input == {"a": 1} - def test_convert_missing_keys(self, runner): + def test_convert_missing_keys(self, runner: DummyRunner): with pytest.raises(KeyError): runner._convert_dict_to_action({"invalid": 1}) class TestFormatAssistantMessage: - def test_format_assistant_message_multiple_scratchpads(self, runner): + def test_format_assistant_message_multiple_scratchpads(self, runner: DummyRunner): sp1 = AgentScratchpadUnit( agent_response="resp1", thought="thought1", @@ -131,7 +131,7 @@ class TestFormatAssistantMessage: result = runner._format_assistant_message([sp1, sp2]) assert "Final Answer:" in result - def test_format_with_final(self, runner): + def test_format_with_final(self, runner: DummyRunner): scratchpad = AgentScratchpadUnit( agent_response="Done", thought="", @@ -144,7 +144,7 @@ class TestFormatAssistantMessage: result = runner._format_assistant_message([scratchpad]) assert "Final Answer" in result - def test_format_with_action_and_observation(self, runner): + def test_format_with_action_and_observation(self, runner: DummyRunner): scratchpad = AgentScratchpadUnit( agent_response="resp", thought="thinking", @@ -161,12 +161,12 @@ class TestFormatAssistantMessage: class TestHandleInvokeAction: - def test_handle_invoke_action_tool_not_present(self, runner): + def test_handle_invoke_action_tool_not_present(self, runner: DummyRunner): action = AgentScratchpadUnit.Action(action_name="missing", action_input={}) 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: MockerFixture): + def test_tool_with_json_string_args(self, runner: DummyRunner, mocker: MockerFixture): action = AgentScratchpadUnit.Action(action_name="tool", action_input=json.dumps({"a": 1})) tool_instance = MagicMock() tool_instances = {"tool": tool_instance} @@ -181,7 +181,7 @@ class TestHandleInvokeAction: class TestOrganizeHistoricPromptMessages: - def test_empty_history(self, runner, mocker: MockerFixture): + def test_empty_history(self, runner: DummyRunner, mocker: MockerFixture): mocker.patch( "core.agent.cot_agent_runner.AgentHistoryPromptTransform.get_prompt", return_value=[], @@ -191,7 +191,7 @@ class TestOrganizeHistoricPromptMessages: class TestRun: - def test_run_handles_empty_parser_output(self, runner, mocker: MockerFixture): + def test_run_handles_empty_parser_output(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -203,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: MockerFixture): + def test_run_with_action_and_tool_invocation(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -224,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: MockerFixture): + def test_run_respects_max_iteration_boundary(self, runner: DummyRunner, mocker: MockerFixture): runner.app_config.agent.max_iteration = 1 message = MagicMock() message.id = "msg-id" @@ -246,7 +246,7 @@ class TestRun: with pytest.raises(AgentMaxIterationError): list(runner.run(message, "query", {"tool": MagicMock()})) - def test_run_basic_flow(self, runner, mocker: MockerFixture): + def test_run_basic_flow(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -258,7 +258,7 @@ class TestRun: results = list(runner.run(message, "query", {"name": "John"})) assert results - def test_run_max_iteration_error(self, runner, mocker: MockerFixture): + def test_run_max_iteration_error(self, runner: DummyRunner, mocker: MockerFixture): runner.app_config.agent.max_iteration = 0 message = MagicMock() message.id = "msg-id" @@ -273,7 +273,7 @@ class TestRun: with pytest.raises(AgentMaxIterationError): list(runner.run(message, "query", {})) - def test_run_increase_usage_aggregation(self, runner, mocker: MockerFixture): + def test_run_increase_usage_aggregation(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" runner.app_config.agent.max_iteration = 2 @@ -330,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: MockerFixture): + def test_run_when_no_action_branch(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -342,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: MockerFixture): + def test_run_usage_missing_key_branch(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -355,7 +355,7 @@ class TestRun: list(runner.run(message, "query", {})) - def test_run_prompt_tool_update_branch(self, runner, mocker: MockerFixture): + def test_run_prompt_tool_update_branch(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -387,7 +387,7 @@ class TestRun: runner.update_prompt_message_tool.assert_called_once() - def test_historic_with_assistant_and_tool_calls(self, runner): + def test_historic_with_assistant_and_tool_calls(self, runner: DummyRunner): from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, ToolPromptMessage assistant = AssistantPromptMessage(content="thinking") @@ -400,7 +400,7 @@ class TestRun: result = runner._organize_historic_prompt_messages([]) assert isinstance(result, list) - def test_historic_final_flush_branch(self, runner): + def test_historic_final_flush_branch(self, runner: DummyRunner): from graphon.model_runtime.entities.message_entities import AssistantPromptMessage assistant = AssistantPromptMessage(content="final") @@ -411,7 +411,7 @@ class TestRun: class TestInitReactState: - def test_init_react_state_resets_state(self, runner, mocker: MockerFixture): + def test_init_react_state_resets_state(self, runner: DummyRunner, mocker: MockerFixture): mocker.patch.object(runner, "_organize_historic_prompt_messages", return_value=["historic"]) runner._agent_scratchpad = ["old"] runner._query = "old" @@ -424,7 +424,7 @@ class TestInitReactState: class TestHandleInvokeActionExtended: - def test_tool_with_invalid_json_string_args(self, runner, mocker: MockerFixture): + def test_tool_with_invalid_json_string_args(self, runner: DummyRunner, mocker: MockerFixture): action = AgentScratchpadUnit.Action(action_name="tool", action_input="not-json") tool_instance = MagicMock() tool_instances = {"tool": tool_instance} @@ -443,11 +443,11 @@ class TestHandleInvokeActionExtended: class TestFillInputsEdgeCases: - def test_fill_inputs_with_empty_inputs(self, runner): + def test_fill_inputs_with_empty_inputs(self, runner: DummyRunner): result = runner._fill_in_inputs_from_external_data_tools("Hello {{x}}", {}) assert result == "Hello {{x}}" - def test_fill_inputs_with_exception_in_replace(self, runner): + def test_fill_inputs_with_exception_in_replace(self, runner: DummyRunner): class BadValue: def __str__(self): raise Exception("fail") @@ -458,7 +458,7 @@ class TestFillInputsEdgeCases: class TestOrganizeHistoricPromptMessagesExtended: - def test_user_message_flushes_scratchpad(self, runner, mocker: MockerFixture): + def test_user_message_flushes_scratchpad(self, runner: DummyRunner, mocker: MockerFixture): from graphon.model_runtime.entities.message_entities import UserPromptMessage user_message = UserPromptMessage(content="Hi") @@ -473,7 +473,7 @@ class TestOrganizeHistoricPromptMessagesExtended: result = runner._organize_historic_prompt_messages([]) assert result == ["final"] - def test_tool_message_without_scratchpad_raises(self, runner): + def test_tool_message_without_scratchpad_raises(self, runner: DummyRunner): from graphon.model_runtime.entities.message_entities import ToolPromptMessage runner.history_prompt_messages = [ToolPromptMessage(content="obs", tool_call_id="1")] @@ -481,7 +481,7 @@ class TestOrganizeHistoricPromptMessagesExtended: with pytest.raises(NotImplementedError): runner._organize_historic_prompt_messages([]) - def test_agent_history_transform_invocation(self, runner, mocker: MockerFixture): + def test_agent_history_transform_invocation(self, runner: DummyRunner, mocker: MockerFixture): mock_transform = MagicMock() mock_transform.get_prompt.return_value = [] @@ -496,7 +496,7 @@ class TestOrganizeHistoricPromptMessagesExtended: class TestRunAdditionalBranches: - def test_run_with_no_action_final_answer_empty(self, runner, mocker: MockerFixture): + def test_run_with_no_action_final_answer_empty(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -508,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: MockerFixture): + def test_run_with_final_answer_action_string(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -522,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: MockerFixture): + def test_run_with_final_answer_action_dict(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" @@ -536,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: MockerFixture): + def test_run_with_string_final_answer(self, runner: DummyRunner, mocker: MockerFixture): message = MagicMock() message.id = "msg-id" 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 0d949c357dc..e910c59d368 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 @@ -17,7 +17,7 @@ from graphon.model_runtime.entities.message_entities import ( @pytest.fixture -def runner(mocker, dummy_tool_factory): +def runner(mocker: MockerFixture, dummy_tool_factory): runner = CotCompletionAgentRunner.__new__(CotCompletionAgentRunner) runner._instruction = "Test instruction" @@ -40,7 +40,11 @@ def runner(mocker, dummy_tool_factory): class TestOrganizeInstructionPrompt: def test_success_all_placeholders( - self, runner, dummy_app_config_factory, dummy_agent_config_factory, dummy_prompt_entity_factory + self, + runner: CotCompletionAgentRunner, + dummy_app_config_factory, + dummy_agent_config_factory, + dummy_prompt_entity_factory, ): template = ( "{{instruction}} | {{tools}} | {{tool_names}} | {{historic_messages}} | {{agent_scratchpad}} | {{query}}" @@ -58,12 +62,14 @@ class TestOrganizeInstructionPrompt: tools_payload = json.loads(result.split(" | ")[1]) assert {item["name"] for item in tools_payload} == {"toolA", "toolB"} - def test_agent_none_raises(self, runner, dummy_app_config_factory): + def test_agent_none_raises(self, runner: CotCompletionAgentRunner, dummy_app_config_factory): runner.app_config = dummy_app_config_factory(agent=None) with pytest.raises(ValueError, match="Agent configuration is not set"): runner._organize_instruction_prompt() - def test_prompt_entity_none_raises(self, runner, dummy_app_config_factory, dummy_agent_config_factory): + def test_prompt_entity_none_raises( + self, runner: CotCompletionAgentRunner, dummy_app_config_factory, dummy_agent_config_factory + ): runner.app_config = dummy_app_config_factory(agent=dummy_agent_config_factory(prompt_entity=None)) with pytest.raises(ValueError, match="prompt entity is not set"): runner._organize_instruction_prompt() @@ -75,7 +81,7 @@ class TestOrganizeInstructionPrompt: class TestOrganizeHistoricPrompt: - def test_with_user_and_assistant_string(self, runner, mocker: MockerFixture): + def test_with_user_and_assistant_string(self, runner: CotCompletionAgentRunner, mocker: MockerFixture): user_msg = UserPromptMessage(content="Hello") assistant_msg = AssistantPromptMessage(content="Hi there") @@ -90,7 +96,7 @@ class TestOrganizeHistoricPrompt: assert "Question: Hello" in result assert "Hi there" in result - def test_assistant_list_with_text_content(self, runner, mocker: MockerFixture): + def test_assistant_list_with_text_content(self, runner: CotCompletionAgentRunner, mocker: MockerFixture): text_content = TextPromptMessageContent(data="Partial answer") assistant_msg = AssistantPromptMessage(content=[text_content]) @@ -104,7 +110,9 @@ class TestOrganizeHistoricPrompt: assert "Partial answer" in result - def test_assistant_list_with_non_text_content_ignored(self, runner, mocker: MockerFixture): + def test_assistant_list_with_non_text_content_ignored( + self, runner: CotCompletionAgentRunner, mocker: MockerFixture + ): non_text_content = ImagePromptMessageContent(format="url", mime_type="image/png") assistant_msg = AssistantPromptMessage(content=[non_text_content]) @@ -117,7 +125,7 @@ class TestOrganizeHistoricPrompt: result = runner._organize_historic_prompt() assert result == "" - def test_empty_history(self, runner, mocker: MockerFixture): + def test_empty_history(self, runner: CotCompletionAgentRunner, mocker: MockerFixture): mocker.patch.object( runner, "_organize_historic_prompt_messages", 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 3a4347e7239..9b2a1d70fdf 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 @@ -147,12 +147,12 @@ def runner(mocker: MockerFixture): class TestToolCallChecks: @pytest.mark.parametrize(("tool_calls", "expected"), [([], False), ([MagicMock()], True)]) - def test_check_tool_calls(self, runner, tool_calls, expected): + def test_check_tool_calls(self, runner: FunctionCallAgentRunner, tool_calls, expected): chunk = DummyChunk(message=DummyMessage(tool_calls=tool_calls)) assert runner.check_tool_calls(chunk) is expected @pytest.mark.parametrize(("tool_calls", "expected"), [([], False), ([MagicMock()], True)]) - def test_check_blocking_tool_calls(self, runner, tool_calls, expected): + def test_check_blocking_tool_calls(self, runner: FunctionCallAgentRunner, tool_calls, expected): result = DummyResult(message=DummyMessage(tool_calls=tool_calls)) assert runner.check_blocking_tool_calls(result) is expected @@ -163,7 +163,7 @@ class TestToolCallChecks: class TestExtractToolCalls: - def test_extract_tool_calls_with_valid_json(self, runner): + def test_extract_tool_calls_with_valid_json(self, runner: FunctionCallAgentRunner): tool_call = MagicMock() tool_call.id = "1" tool_call.function.name = "tool" @@ -174,7 +174,7 @@ class TestExtractToolCalls: assert calls == [("1", "tool", {"a": 1})] - def test_extract_tool_calls_empty_arguments(self, runner): + def test_extract_tool_calls_empty_arguments(self, runner: FunctionCallAgentRunner): tool_call = MagicMock() tool_call.id = "1" tool_call.function.name = "tool" @@ -185,7 +185,7 @@ class TestExtractToolCalls: assert calls == [("1", "tool", {})] - def test_extract_blocking_tool_calls(self, runner): + def test_extract_blocking_tool_calls(self, runner: FunctionCallAgentRunner): tool_call = MagicMock() tool_call.id = "2" tool_call.function.name = "block" @@ -203,16 +203,16 @@ class TestExtractToolCalls: class TestInitSystemMessage: - def test_init_system_message_empty_prompt_messages(self, runner): + def test_init_system_message_empty_prompt_messages(self, runner: FunctionCallAgentRunner): result = runner._init_system_message("system", []) assert len(result) == 1 - def test_init_system_message_insert_at_start(self, runner): + def test_init_system_message_insert_at_start(self, runner: FunctionCallAgentRunner): msgs = [MagicMock()] result = runner._init_system_message("system", msgs) assert result[0].content == "system" - def test_init_system_message_no_template(self, runner): + def test_init_system_message_no_template(self, runner: FunctionCallAgentRunner): result = runner._init_system_message("", []) assert result == [] @@ -223,15 +223,15 @@ class TestInitSystemMessage: class TestOrganizeUserQuery: - def test_without_files(self, runner): + def test_without_files(self, runner: FunctionCallAgentRunner): result = runner._organize_user_query("query", []) assert len(result) == 1 - def test_with_none_query(self, runner): + def test_with_none_query(self, runner: FunctionCallAgentRunner): result = runner._organize_user_query(None, []) assert len(result) == 1 - def test_with_files_uses_image_detail_config(self, runner, mocker: MockerFixture): + def test_with_files_uses_image_detail_config(self, runner: FunctionCallAgentRunner, mocker: MockerFixture): file_content = TextPromptMessageContent(data="file-content") mock_to_prompt = mocker.patch( "core.agent.fc_agent_runner.file_manager.to_prompt_message_content", @@ -255,7 +255,7 @@ class TestOrganizeUserQuery: class TestClearUserPromptImageMessages: - def test_clear_text_and_image_content(self, runner): + def test_clear_text_and_image_content(self, runner: FunctionCallAgentRunner): text = MagicMock() text.type = "text" text.data = "hello" @@ -271,7 +271,7 @@ class TestClearUserPromptImageMessages: result = runner._clear_user_prompt_image_messages([user_msg]) assert isinstance(result, list) - def test_clear_includes_file_placeholder(self, runner): + def test_clear_includes_file_placeholder(self, runner: FunctionCallAgentRunner): text = TextPromptMessageContent(data="hello") image = ImagePromptMessageContent(format="url", mime_type="image/png") document = DocumentPromptMessageContent(format="url", mime_type="application/pdf") @@ -289,7 +289,7 @@ class TestClearUserPromptImageMessages: class TestRunMethod: - def test_run_non_streaming_no_tool_calls(self, runner): + def test_run_non_streaming_no_tool_calls(self, runner: FunctionCallAgentRunner): message = MagicMock(id="m1") dummy_message = DummyMessage(content="hello") result = DummyResult(message=dummy_message, usage=build_usage()) @@ -303,7 +303,7 @@ class TestRunMethod: queue_calls = runner.queue_manager.publish.call_args_list assert any(call.args and call.args[0].__class__.__name__ == "QueueMessageEndEvent" for call in queue_calls) - def test_run_streaming_branch(self, runner): + def test_run_streaming_branch(self, runner: FunctionCallAgentRunner): message = MagicMock(id="m1") runner.stream_tool_call = True @@ -318,7 +318,7 @@ class TestRunMethod: outputs = list(runner.run(message, "query")) assert len(outputs) == 1 - def test_run_streaming_tool_calls_list_content(self, runner): + def test_run_streaming_tool_calls_list_content(self, runner: FunctionCallAgentRunner): message = MagicMock(id="m1") runner.stream_tool_call = True @@ -341,7 +341,7 @@ class TestRunMethod: outputs = list(runner.run(message, "query")) assert len(outputs) >= 1 - def test_run_non_streaming_list_content(self, runner): + def test_run_non_streaming_list_content(self, runner: FunctionCallAgentRunner): message = MagicMock(id="m1") content = [TextPromptMessageContent(data="hi")] dummy_message = DummyMessage(content=content) @@ -353,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: MockerFixture): + def test_run_streaming_tool_call_inputs_type_error(self, runner: FunctionCallAgentRunner, mocker: MockerFixture): message = MagicMock(id="m1") runner.stream_tool_call = True @@ -381,7 +381,7 @@ class TestRunMethod: outputs = list(runner.run(message, "query")) assert len(outputs) == 1 - def test_run_with_missing_tool_instance(self, runner): + def test_run_with_missing_tool_instance(self, runner: FunctionCallAgentRunner): message = MagicMock(id="m1") tool_call = MagicMock() @@ -399,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: MockerFixture): + def test_run_with_tool_instance_and_files(self, runner: FunctionCallAgentRunner, mocker: MockerFixture): message = MagicMock(id="m1") tool_call = MagicMock() @@ -434,7 +434,7 @@ class TestRunMethod: for call in runner.queue_manager.publish.call_args_list ) - def test_run_max_iteration_error(self, runner): + def test_run_max_iteration_error(self, runner: FunctionCallAgentRunner): runner.app_config.agent.max_iteration = 0 message = MagicMock(id="m1") 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 3a239eac0e7..e4e4f99c6d4 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 @@ -32,7 +32,7 @@ def base_config(valid_uuid): @pytest.fixture -def mock_dataset_service(mocker, valid_uuid): +def mock_dataset_service(mocker: MockerFixture, valid_uuid): mock_dataset = MagicMock() mock_dataset.tenant_id = "tenant1" @@ -318,3 +318,26 @@ class TestIsDatasetExists: return_value=mock_dataset, ) assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid) + + +# ============================== +# extract_dataset_config_for_legacy_compatibility tests +# ============================== + + +class TestExtractDatasetConfigForLegacyCompatibility: + def test_skips_empty_tool_entry(self): + # A malformed empty tool dict in agent_mode.tools must be skipped, not + # crash with `IndexError` on `list(tool.keys())[0]`. The sibling + # convert() already guards this with `if len(tool) == 1`. + config = { + "agent_mode": { + "enabled": True, + "strategy": PlanningStrategy.ROUTER, + "tools": [{}], + } + } + + result = DatasetConfigManager.extract_dataset_config_for_legacy_compatibility("tenant1", AppMode.CHAT, config) + + assert result["agent_mode"]["tools"] == [{}] diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index 6c36815753e..b8cdf471ca3 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -36,7 +36,7 @@ def generator(mocker: MockerFixture) -> AgentAppGenerator: class TestGenerateGuards: - def test_rejects_blocking_mode(self, generator, mocker: MockerFixture): + def test_rejects_blocking_mode(self, generator: AgentAppGenerator, mocker: MockerFixture): with pytest.raises(AgentAppGeneratorError, match="only supports streaming"): generator.generate( app_model=mocker.MagicMock(), @@ -46,7 +46,7 @@ class TestGenerateGuards: streaming=False, ) - def test_requires_query(self, generator, mocker: MockerFixture): + def test_requires_query(self, generator: AgentAppGenerator, mocker: MockerFixture): with pytest.raises(AgentAppGeneratorError, match="query is required"): generator.generate( app_model=mocker.MagicMock(), @@ -55,7 +55,7 @@ class TestGenerateGuards: invoke_from=InvokeFrom.WEB_APP, ) - def test_rejects_blank_query(self, generator, mocker: MockerFixture): + def test_rejects_blank_query(self, generator: AgentAppGenerator, mocker: MockerFixture): with pytest.raises(AgentAppGeneratorError, match="query is required"): generator.generate( app_model=mocker.MagicMock(), @@ -66,6 +66,16 @@ class TestGenerateGuards: class TestGenerateSuccess: + def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self): + assert ( + AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1") + is None + ) + assert ( + AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1") + == "snap-1" + ) + def test_generate_orchestrates_and_starts_worker(self, generator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") user = DummyAccount("user") @@ -103,7 +113,7 @@ class TestGenerateSuccess: thread_obj.start.assert_called_once() generator._resolve_agent.assert_called_once_with(app_model) - def test_generate_loads_existing_conversation(self, generator, mocker: MockerFixture): + def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") generator._resolve_agent = mocker.MagicMock( return_value=(mocker.MagicMock(id="a"), mocker.MagicMock(id="s"), mocker.MagicMock()) @@ -134,7 +144,9 @@ class TestGenerateSuccess: get_conv.assert_called_once() - def test_generate_does_not_include_trace_session_id_in_extras(self, generator, mocker: MockerFixture): + def test_generate_does_not_include_trace_session_id_in_extras( + self, generator: AgentAppGenerator, mocker: MockerFixture + ): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") user = DummyAccount("user") @@ -180,7 +192,7 @@ class TestGenerateWorker: mocker.patch("libs.flask_utils.preserve_flask_contexts", ctx_manager) - def _wire(self, generator, mocker: MockerFixture, *, run_side_effect=None, handled=False): + def _wire(self, generator: AgentAppGenerator, mocker: MockerFixture, *, run_side_effect=None, handled=False): generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock(id="conv")) generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock(id="msg")) generator._run_input_guards = mocker.MagicMock(return_value=(handled, "query")) @@ -201,12 +213,25 @@ class TestGenerateWorker: mocker.patch(f"{MODULE}.AgentAppRunner", return_value=runner) return runner - def _call(self, generator, mocker: MockerFixture, queue_manager, *, is_resume=False, query="query"): + def _call( + self, + generator, + mocker: MockerFixture, + queue_manager, + *, + is_resume=False, + query="query", + runtime_session_snapshot_id="s", + ): generator._generate_worker( flask_app=mocker.MagicMock(), context=mocker.MagicMock(), application_generate_entity=mocker.MagicMock( - agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m"), query=query + agent_id="a", + agent_config_snapshot_id="s", + agent_runtime_session_snapshot_id=runtime_session_snapshot_id, + model_conf=mocker.MagicMock(model="m"), + query=query, ), queue_manager=queue_manager, conversation_id="conv", @@ -215,13 +240,22 @@ class TestGenerateWorker: is_resume=is_resume, ) - def test_happy_path_runs_backend(self, generator, mocker: MockerFixture): + def test_happy_path_runs_backend(self, generator: AgentAppGenerator, mocker: MockerFixture): runner = self._wire(generator, mocker) queue_manager = mocker.MagicMock() self._call(generator, mocker, queue_manager) runner.run.assert_called_once() queue_manager.publish_error.assert_not_called() + def test_worker_passes_runtime_session_scope_to_runner(self, generator, mocker: MockerFixture): + runner = self._wire(generator, mocker) + queue_manager = mocker.MagicMock() + + self._call(generator, mocker, queue_manager, runtime_session_snapshot_id=None) + + assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s" + assert runner.run.call_args.kwargs["session_scope_snapshot_id"] is None + def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture): runner = self._wire(generator, mocker, handled=True) queue_manager = mocker.MagicMock() @@ -248,7 +282,7 @@ class TestGenerateWorker: self._call(generator, mocker, queue_manager) queue_manager.publish_error.assert_not_called() - def test_unexpected_error_is_published(self, generator, mocker: MockerFixture): + def test_unexpected_error_is_published(self, generator: AgentAppGenerator, mocker: MockerFixture): self._wire(generator, mocker, run_side_effect=ValueError("boom")) queue_manager = mocker.MagicMock() self._call(generator, mocker, queue_manager) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index 48dea5583c0..4f920df0ec6 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -4,6 +4,8 @@ saved, using the deterministic fake backend client (no live stack).""" from __future__ import annotations +from collections.abc import Iterator +from datetime import UTC, datetime from types import SimpleNamespace from typing import Any, override from unittest.mock import MagicMock @@ -11,7 +13,17 @@ from unittest.mock import MagicMock import pytest from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.ask_human import AskHumanToolResult -from dify_agent.protocol import CancelRunRequest, CancelRunResponse, RuntimeLayerSpec +from dify_agent.protocol import ( + CancelRunRequest, + CancelRunResponse, + PydanticAIStreamRunEvent, + RunEvent, + RunStartedEvent, + RunSucceededEvent, + RunSucceededEventData, + RuntimeLayerSpec, +) +from pydantic_ai.messages import PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta from clients.agent_backend import ( AgentBackendError, @@ -67,6 +79,58 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): return super().cancel_run(run_id, request=request) +class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + ) + yield PydanticAIStreamRunEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + ) + yield RunSucceededEvent( + id="4-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output={"text": "hello agent"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + +class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartStartEvent(index=0, part=TextPart(content="hello")), + ) + yield RunSucceededEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output={"text": "hello agent"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + class _FakeSessionStore: def __init__( self, @@ -75,6 +139,7 @@ class _FakeSessionStore: ) -> None: self.loaded = loaded self._loaded_session = loaded_session + self.loaded_scopes: list[AgentAppSessionScope] = [] self.saved: list[ tuple[ AgentAppSessionScope, @@ -87,9 +152,11 @@ class _FakeSessionStore: ] = [] def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: + self.loaded_scopes.append(scope) return self.loaded def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None: + self.loaded_scopes.append(scope) if self._loaded_session is not None: return self._loaded_session if self.loaded is None: @@ -160,6 +227,20 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None: ) +def _message_end(qm: _FakeQueueManager) -> QueueMessageEndEvent: + return next(e for e in qm.events if isinstance(e, QueueMessageEndEvent)) + + +def _saved_user_query(qm: _FakeQueueManager) -> str: + llm_result = _message_end(qm).llm_result + assert llm_result is not None + prompt_messages = llm_result.prompt_messages + assert len(prompt_messages) == 1 + content = prompt_messages[0].content + assert isinstance(content, str) + return content + + def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"} store = _FakeSessionStore() @@ -175,6 +256,7 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): assert chunk_events[0].chunk.delta.message.content == "hello agent" assert end_events[0].llm_result.message.content == "hello agent" assert end_events[0].llm_result.model == "gpt-4o-mini" + assert _saved_user_query(qm) == "hello" # The conversation session snapshot is persisted for multi-turn continuity. assert store.saved saved_scope, saved_run_id, saved_snapshot, saved_specs, pending_form_id, pending_tool_call_id = store.saved[0] @@ -193,6 +275,35 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): ] +def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_duplicate_terminal_chunk(): + client = _StreamingFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello ", "agent"] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "hello agent" + assert store.saved + + +def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal_suffix(): + client = _StreamingPartStartFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "hello agent" + + def test_prior_session_snapshot_is_threaded_into_request(): prior = CompositorSessionSnapshot(layers=[]) client = FakeAgentBackendRunClient() @@ -205,6 +316,31 @@ def test_prior_session_snapshot_is_threaded_into_request(): assert client.request.session_snapshot is prior +def test_debug_session_scope_can_reuse_conversation_across_config_snapshots(): + prior = CompositorSessionSnapshot(layers=[]) + client = FakeAgentBackendRunClient() + store = _FakeSessionStore(loaded=prior) + qm = _FakeQueueManager() + + _runner(client, store).run( + dify_context=_dify_ctx(), + agent_id="agent-1", + agent_config_snapshot_id="snap-new", + agent_soul=_soul(), + conversation_id="conv-1", + query="hello", + message_id="msg-1", + model_name="gpt-4o-mini", + queue_manager=qm, # type: ignore[arg-type] + session_scope_snapshot_id=None, + ) + + assert client.request is not None + assert client.request.session_snapshot is prior + assert store.loaded_scopes[0].agent_config_snapshot_id is None + assert store.saved[0][0].agent_config_snapshot_id is None + + def test_failed_run_raises_agent_backend_error(): client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) store = _FakeSessionStore() @@ -256,6 +392,7 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation(): assert created_params.conversation_id == "conv-1" assert created_params.workflow_execution_id is None assert [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert _saved_user_query(qm) == "hello" # The pause correlation is persisted so a form submission can resume the run. assert store.saved assert store.saved[0][4] == "form-1" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index e1c8d51b0d9..24dd61cb951 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -11,6 +11,8 @@ from __future__ import annotations from types import SimpleNamespace from typing import Any +import pytest + import core.app.features.annotation_reply.annotation_reply as annotation_mod import core.moderation.input_moderation as input_moderation_mod from core.app.apps.agent_app.app_generator import AgentAppGenerator @@ -42,7 +44,7 @@ def _make_entity(query: str = "hello") -> SimpleNamespace: ) -def _patch_moderation(monkeypatch, *, returns=None, raises: Exception | None = None) -> None: +def _patch_moderation(monkeypatch: pytest.MonkeyPatch, *, returns=None, raises: Exception | None = None) -> None: class _FakeModeration: def check(self, **kwargs: Any): if raises is not None: @@ -52,7 +54,7 @@ def _patch_moderation(monkeypatch, *, returns=None, raises: Exception | None = N monkeypatch.setattr(input_moderation_mod, "InputModeration", _FakeModeration) -def _patch_annotation(monkeypatch, *, reply=None) -> None: +def _patch_annotation(monkeypatch: pytest.MonkeyPatch, *, reply=None) -> None: class _FakeAnnotation: def query(self, **kwargs: Any): return reply @@ -65,8 +67,15 @@ def _answer_text(events: list[Any]) -> str: return end.llm_result.message.content +def _saved_user_query(events: list[Any]) -> str: + end = next(e for e in events if isinstance(e, QueueMessageEndEvent)) + prompt_messages = end.llm_result.prompt_messages + assert len(prompt_messages) == 1 + return prompt_messages[0].content + + class TestRunInputGuards: - def test_no_guards_passes_through(self, monkeypatch): + def test_no_guards_passes_through(self, monkeypatch: pytest.MonkeyPatch): _patch_moderation(monkeypatch, returns=(False, {}, "hello")) _patch_annotation(monkeypatch, reply=None) qm = _FakeQueueManager() @@ -82,7 +91,7 @@ class TestRunInputGuards: assert query == "hello" assert qm.events == [] - def test_moderation_override_sanitizes_query(self, monkeypatch): + def test_moderation_override_sanitizes_query(self, monkeypatch: pytest.MonkeyPatch): _patch_moderation(monkeypatch, returns=(True, {}, "[redacted]")) _patch_annotation(monkeypatch, reply=None) qm = _FakeQueueManager() @@ -98,7 +107,7 @@ class TestRunInputGuards: assert query == "[redacted]" assert qm.events == [] - def test_moderation_block_short_circuits(self, monkeypatch): + def test_moderation_block_short_circuits(self, monkeypatch: pytest.MonkeyPatch): _patch_moderation(monkeypatch, raises=ModerationError("blocked preset answer")) _patch_annotation(monkeypatch, reply=None) qm = _FakeQueueManager() @@ -113,8 +122,9 @@ class TestRunInputGuards: assert handled is True assert any(isinstance(e, QueueLLMChunkEvent) for e in qm.events) assert _answer_text(qm.events) == "blocked preset answer" + assert _saved_user_query(qm.events) == "forbidden" - def test_annotation_hit_short_circuits(self, monkeypatch): + def test_annotation_hit_short_circuits(self, monkeypatch: pytest.MonkeyPatch): _patch_moderation(monkeypatch, returns=(False, {}, "what is your name")) _patch_annotation(monkeypatch, reply=SimpleNamespace(id="anno-1", content="I am the annotated Iris.")) qm = _FakeQueueManager() @@ -131,3 +141,4 @@ class TestRunInputGuards: assert len(annotation_events) == 1 assert annotation_events[0].message_annotation_id == "anno-1" assert _answer_text(qm.events) == "I am the annotated Iris." + assert _saved_user_query(qm.events) == "what is your name" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index 637c0fad5d2..c0a226ad575 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -44,7 +44,7 @@ def _snapshot() -> SimpleNamespace: class TestResolveAgentById: - def test_success_returns_agent_snapshot_soul(self, monkeypatch): + def test_success_returns_agent_snapshot_soul(self, monkeypatch: pytest.MonkeyPatch): agent = SimpleNamespace(id="agent-1") snapshot = _snapshot() _patch_session(monkeypatch, [agent, snapshot]) @@ -59,24 +59,24 @@ class TestResolveAgentById: assert soul.model is not None assert soul.model.model == "gpt-4o-mini" - def test_agent_missing_raises(self, monkeypatch): + def test_agent_missing_raises(self, monkeypatch: pytest.MonkeyPatch): _patch_session(monkeypatch, [None]) with pytest.raises(AgentAppGeneratorError, match="Agent not found"): AgentAppGenerator._resolve_agent_by_id(tenant_id="t1", agent_id="x", snapshot_id="snap-1") - def test_no_published_version_raises(self, monkeypatch): + def test_no_published_version_raises(self, monkeypatch: pytest.MonkeyPatch): _patch_session(monkeypatch, [SimpleNamespace(id="agent-1")]) with pytest.raises(AgentAppGeneratorError, match="no published version"): AgentAppGenerator._resolve_agent_by_id(tenant_id="t1", agent_id="agent-1", snapshot_id=None) - def test_snapshot_missing_raises(self, monkeypatch): + def test_snapshot_missing_raises(self, monkeypatch: pytest.MonkeyPatch): _patch_session(monkeypatch, [SimpleNamespace(id="agent-1"), None]) with pytest.raises(AgentAppGeneratorError, match="published version not found"): AgentAppGenerator._resolve_agent_by_id(tenant_id="t1", agent_id="agent-1", snapshot_id="snap-1") class TestResolveAgent: - def test_success_chains_to_resolve_by_id(self, monkeypatch): + def test_success_chains_to_resolve_by_id(self, monkeypatch: pytest.MonkeyPatch): bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1") inner_agent = SimpleNamespace(id="agent-1") snapshot = _snapshot() @@ -90,7 +90,7 @@ class TestResolveAgent: assert snap is snapshot assert soul.model is not None - def test_unbound_app_raises(self, monkeypatch): + def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch): _patch_session(monkeypatch, [None]) app_model = SimpleNamespace(id="app-1", tenant_id="t1") with pytest.raises(AgentAppGeneratorError, match="has no bound Agent"): diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 83f9b697b75..85a2423f6b1 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -144,6 +144,40 @@ class TestAgentAppRuntimeRequestBuilder: assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" + def test_build_maps_agent_soul_knowledge_to_knowledge_layer(self): + soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "langgenius/openai/openai", + "model": "gpt-4o-mini", + }, + "knowledge": { + "datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}], + "query_config": { + "top_k": 3, + "score_threshold": 0.5, + "score_threshold_enabled": False, + }, + }, + } + ) + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(soul)) + + knowledge = next(layer for layer in result.request.composition.layers if layer.name == "knowledge") + assert knowledge.type == "dify.knowledge_base" + assert knowledge.deps == {"execution_context": "execution_context"} + dumped_config = knowledge.config.model_dump(mode="json", by_alias=True) + assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"] + assert dumped_config["retrieval"]["mode"] == "multiple" + assert dumped_config["retrieval"]["top_k"] == 3 + assert dumped_config["retrieval"]["score_threshold"] == 0.0 + def test_build_raises_when_model_missing(self): builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py index 65a7e0bc8ec..458b0a26dbe 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py @@ -22,7 +22,7 @@ from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, Agen def _scope( - conversation_id: str = "conv-1", agent_id: str = "agent-1", agent_config_snapshot_id: str = "snap-1" + conversation_id: str = "conv-1", agent_id: str = "agent-1", agent_config_snapshot_id: str | None = "snap-1" ) -> AgentAppSessionScope: return AgentAppSessionScope( tenant_id="tenant-1", @@ -125,6 +125,36 @@ def test_second_turn_updates_same_conversation_row(): assert rows[0].backend_run_id == "run-2" +def test_debug_scope_with_null_snapshot_id_updates_same_conversation_row(): + store = AgentAppRuntimeSessionStore() + scope = _scope(agent_config_snapshot_id=None) + store.save_active_snapshot( + scope=scope, + backend_run_id="run-1", + snapshot=_snapshot(messages=1), + runtime_layer_specs=_runtime_layer_specs(), + ) + store.save_active_snapshot( + scope=scope, + backend_run_id="run-2", + snapshot=_snapshot(messages=3), + runtime_layer_specs=_runtime_layer_specs(), + ) + + loaded = store.load_active_snapshot(scope) + + assert loaded is not None + assert loaded.layers[0].runtime_state["messages"] == [ + {"role": "user", "content": "m0"}, + {"role": "user", "content": "m1"}, + {"role": "user", "content": "m2"}, + ] + with session_factory.create_session() as session: + row = session.query(AgentRuntimeSession).one() + assert row.agent_config_snapshot_id is None + assert row.backend_run_id == "run-2" + + def test_mark_cleaned_then_load_returns_none_and_save_resurrects(): store = AgentAppRuntimeSessionStore() store.save_active_snapshot( 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 0260235b03a..d7988cbf74d 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 @@ -14,7 +14,7 @@ def runner(): class TestAgentChatAppRunnerRun: - def test_run_app_not_found(self, runner, mocker: MockerFixture): + def test_run_app_not_found(self, runner: AgentChatAppRunner, 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) @@ -23,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: MockerFixture): + def test_run_moderation_error_direct_output(self, runner: AgentChatAppRunner, 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() @@ -46,7 +46,7 @@ class TestAgentChatAppRunnerRun: runner.direct_output.assert_called_once() - def test_run_annotation_reply_short_circuits(self, runner, mocker: MockerFixture): + def test_run_annotation_reply_short_circuits(self, runner: AgentChatAppRunner, 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() @@ -75,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: MockerFixture): + def test_run_hosting_moderation_short_circuits(self, runner: AgentChatAppRunner, 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() @@ -99,7 +99,7 @@ class TestAgentChatAppRunnerRun: runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock()) - def test_run_model_schema_missing(self, runner, mocker: MockerFixture): + def test_run_model_schema_missing(self, runner: AgentChatAppRunner, 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) @@ -141,7 +141,7 @@ class TestAgentChatAppRunnerRun: (LLMMode.COMPLETION, "CotCompletionAgentRunner"), ], ) - def test_run_chain_of_thought_modes(self, runner, mocker: MockerFixture, mode, expected_runner): + def test_run_chain_of_thought_modes(self, runner: AgentChatAppRunner, 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) @@ -197,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: MockerFixture): + def test_run_invalid_llm_mode_raises(self, runner: AgentChatAppRunner, 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) @@ -243,7 +243,9 @@ 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: MockerFixture): + def test_run_function_calling_strategy_selected_by_features( + self, runner: AgentChatAppRunner, 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) @@ -299,7 +301,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: MockerFixture): + def test_run_conversation_not_found(self, runner: AgentChatAppRunner, 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) @@ -333,7 +335,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: MockerFixture): + def test_run_message_not_found(self, runner: AgentChatAppRunner, 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) @@ -367,7 +369,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: MockerFixture): + def test_run_invalid_agent_strategy_raises(self, runner: AgentChatAppRunner, 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/pipeline/test_pipeline_generator.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py index 06fd9e48064..67cea557118 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 @@ -173,6 +173,9 @@ def test_generate_published_pipeline_creates_documents_and_delay(generator, mock mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"}) mocker.patch("services.dataset_service.DocumentService.get_documents_position", return_value=1) + features = SimpleNamespace() + mocker.patch("services.feature_service.FeatureService.get_features", return_value=features) + check_limits = mocker.patch("services.dataset_service.DocumentService.check_document_creation_limits") document1 = SimpleNamespace( id="doc1", @@ -226,9 +229,53 @@ def test_generate_published_pipeline_creates_documents_and_delay(generator, mock assert result["batch"] assert len(result["documents"]) == 2 + check_limits.assert_called_once_with(len(datasource_info_list), features) task_proxy.delay.assert_called_once() +def test_generate_published_pipeline_rejects_when_document_creation_limits_exceeded(generator, mocker: MockerFixture): + pipeline = _build_pipeline() + workflow = _build_workflow() + + session = DummySession() + _patch_session(mocker, session) + + datasource_info_list = [{"name": "file1"}, {"name": "file2"}] + mocker.patch.object( + generator, + "_format_datasource_info_list", + return_value=datasource_info_list, + ) + mocker.patch.object( + module.PipelineConfigManager, + "get_pipeline_config", + return_value=SimpleNamespace(app_id="pipe", rag_pipeline_variables=[]), + ) + + features = SimpleNamespace() + mocker.patch("services.feature_service.FeatureService.get_features", return_value=features) + check_limits = mocker.patch( + "services.dataset_service.DocumentService.check_document_creation_limits", + side_effect=ValueError("document limit exceeded"), + ) + + db_session = MagicMock() + mocker.patch.object(module.db, "session", db_session) + + with pytest.raises(ValueError, match="document limit exceeded"): + generator.generate( + pipeline=pipeline, + workflow=workflow, + user=_build_user(), + args=_build_args(), + invoke_from=InvokeFrom.PUBLISHED_PIPELINE, + streaming=False, + ) + + check_limits.assert_called_once_with(len(datasource_info_list), features) + db_session.add.assert_not_called() + + def test_generate_is_retry_calls_generate(generator, mocker: MockerFixture): pipeline = _build_pipeline() workflow = _build_workflow() 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 5f13c6aff64..30961051a75 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 @@ -288,7 +288,7 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture): assert resumed_state.outputs == baseline_outputs -def test_resume_emits_resumption_start_reason(mocker) -> None: +def test_resume_emits_resumption_start_reason(mocker: MockerFixture) -> None: _patch_tool_node(mocker) paused_state = _build_runtime_state("resume-reason") 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 c02683e13f4..f02994fd61c 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 @@ -112,7 +112,7 @@ def test_run_uses_single_node_execution_branch( assert entry_kwargs["graph_runtime_state"] is graph_runtime_state -def test_single_node_run_validates_target_node_config(monkeypatch) -> None: +def test_single_node_run_validates_target_node_config(monkeypatch: pytest.MonkeyPatch) -> None: runner = WorkflowBasedAppRunner( queue_manager=MagicMock(spec=AppQueueManager), variable_loader=MagicMock(), diff --git a/api/tests/unit_tests/core/app/features/test_annotation_reply.py b/api/tests/unit_tests/core/app/features/test_annotation_reply.py index e721a77079f..ef4ee0dca6b 100644 --- a/api/tests/unit_tests/core/app/features/test_annotation_reply.py +++ b/api/tests/unit_tests/core/app/features/test_annotation_reply.py @@ -2,6 +2,8 @@ import logging from types import SimpleNamespace from unittest.mock import Mock, patch +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature @@ -132,7 +134,7 @@ class TestAnnotationReplyFeature: _, _, _, _, _, _, _, from_source, _ = mock_annotation_service.add_annotation_history.call_args[0] assert from_source == "console" - def test_query_logs_and_returns_none_on_exception(self, caplog): + def test_query_logs_and_returns_none_on_exception(self, caplog: pytest.LogCaptureFixture): feature = AnnotationReplyFeature() annotation_setting = SimpleNamespace( score_threshold=None, diff --git a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py index 1ac9a4d8c0c..61ced61d8c8 100644 --- a/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_timeslice_layer.py @@ -1,5 +1,8 @@ +import logging from unittest.mock import Mock, patch +import pytest + from core.app.layers.timeslice_layer import TimeSliceLayer from graphon.graph_engine.entities.commands import CommandType, GraphEngineCommand from services.workflow.entities import WorkflowScheduleCFSPlanEntity @@ -64,21 +67,19 @@ class TestTimeSliceLayer: scheduler.remove_job.assert_called_once_with("job-1") - def test_checker_job_handles_resource_limit_without_command_channel(self): + def test_checker_job_handles_resource_limit_without_command_channel(self, caplog: pytest.LogCaptureFixture): scheduler = Mock() scheduler.running = True cfs_plan_scheduler = Mock(plan=Mock()) cfs_plan_scheduler.can_schedule.return_value = SchedulerCommand.RESOURCE_LIMIT_REACHED - with ( - patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler), - patch("core.app.layers.timeslice_layer.logger") as mock_logger, - ): + with patch("core.app.layers.timeslice_layer.TimeSliceLayer.scheduler", scheduler): layer = TimeSliceLayer(cfs_plan_scheduler=cfs_plan_scheduler) - layer._checker_job("job-1") + with caplog.at_level(logging.ERROR, logger="core.app.layers.timeslice_layer"): + layer._checker_job("job-1") scheduler.remove_job.assert_called_once_with("job-1") - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_checker_job_sends_pause_command(self): scheduler = Mock() 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 f82cf201422..ccdb658b491 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 @@ -1,7 +1,10 @@ +import logging from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import Mock, patch +import pytest + from core.app.layers.trigger_post_layer import TriggerPostLayer from core.workflow.system_variables import build_system_variables from graphon.graph_events import ( @@ -114,7 +117,7 @@ class TestTriggerPostLayer: repo.update.assert_called_once_with(trigger_log) session.commit.assert_called_once() - def test_on_event_handles_missing_trigger_log(self): + def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture): runtime_state = SimpleNamespace( outputs={}, variable_pool=VariablePool.from_bootstrap( @@ -126,7 +129,6 @@ class TestTriggerPostLayer: with ( patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, - patch("core.app.layers.trigger_post_layer.logger") as mock_logger, ): session = Mock() mock_session_factory.create_session.return_value.__enter__.return_value = session @@ -142,9 +144,10 @@ class TestTriggerPostLayer: ) layer.initialize(runtime_state, Mock()) - layer.on_event(GraphRunFailedEvent(error="boom")) + with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"): + layer.on_event(GraphRunFailedEvent(error="boom")) - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) session.commit.assert_not_called() def test_on_event_ignores_non_status_events(self): diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index 92fe3cbec67..0863d5a8f73 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -1,5 +1,6 @@ """Unit tests for the message cycle manager optimization.""" +import logging from types import SimpleNamespace from unittest.mock import Mock, patch @@ -344,7 +345,9 @@ class TestMessageCycleManagerOptimization: db_session.close.assert_called_once() mock_redis.setex.assert_called_once() - def test_generate_conversation_name_worker_falls_back_when_generation_fails(self, message_cycle_manager): + def test_generate_conversation_name_worker_falls_back_when_generation_fails( + self, message_cycle_manager, caplog: pytest.LogCaptureFixture + ): """Fallback to truncated query when LLM generation fails.""" flask_app = Flask(__name__) conversation = SimpleNamespace( @@ -362,19 +365,19 @@ class TestMessageCycleManagerOptimization: patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config, - patch("core.app.task_pipeline.message_cycle_manager.logger") as mock_logger, ): mock_db.session = db_session mock_redis.get.return_value = None mock_llm_generator.generate_conversation_name.side_effect = RuntimeError("generation failed") mock_dify_config.DEBUG = True - message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) + with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"): + message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) assert conversation.name == (long_query[:47] + "...") db_session.commit.assert_called_once() db_session.close.assert_called_once() - mock_logger.exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager): """Populate task metadata from annotation reply events. 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 addce649d56..f74c2d48814 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 @@ -40,7 +40,7 @@ class DummyDocumentExtractorNode(DummyNode): class TestDifyNodeFactory: @staticmethod - def _stub_node_resolution(monkeypatch, node_class): + def _stub_node_resolution(monkeypatch: pytest.MonkeyPatch, node_class): monkeypatch.setattr( "core.workflow.node_factory.resolve_workflow_node_class", lambda **_kwargs: node_class, 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 04ce5249049..b283f8a211d 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 @@ -20,7 +20,9 @@ class TestObservabilityLayerExtras: assert layer._is_disabled is False assert layer._tracer is tracer - def test_init_tracer_disables_when_get_tracer_fails(self, monkeypatch: pytest.MonkeyPatch, caplog): + def test_init_tracer_disables_when_get_tracer_fails( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): 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) @@ -70,7 +72,7 @@ class TestObservabilityLayerExtras: layer.on_event(object()) - def test_on_graph_end_clears_unfinished_contexts(self, caplog): + def test_on_graph_end_clears_unfinished_contexts(self, caplog: pytest.LogCaptureFixture): layer = ObservabilityLayer() layer._node_contexts["exec"] = SimpleNamespace(span=object(), token="token") @@ -107,7 +109,7 @@ class TestObservabilityLayerExtras: assert calls == [] - def test_on_node_run_start_logs_warning_when_span_creation_fails(self, caplog): + def test_on_node_run_start_logs_warning_when_span_creation_fails(self, caplog: pytest.LogCaptureFixture): layer = ObservabilityLayer() layer._is_disabled = False @@ -166,7 +168,9 @@ class TestObservabilityLayerExtras: assert ended == ["ended"] assert "exec" not in layer._node_contexts - def test_on_node_run_end_logs_detach_failure(self, monkeypatch: pytest.MonkeyPatch, caplog): + def test_on_node_run_end_logs_detach_failure( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): 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 f8e13ca8083..c8efaec33dc 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 @@ -218,7 +218,7 @@ 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): + def test_handle_graph_run_succeeded_enqueues_parent_trace_context(self, monkeypatch: pytest.MonkeyPatch): trace_tasks: list[TraceTask] = [] trace_manager = SimpleNamespace(user_id="user", add_trace_task=lambda task: trace_tasks.append(task)) layer, _, _, _ = _make_layer( 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 8842d678c7a..baf51489dfb 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_manager.py @@ -89,7 +89,9 @@ def test_get_datasource_runtime_delegates_to_provider_controller(mocker: MockerF ), ], ) -def test_get_datasource_plugin_provider_creates_controller_and_caches(mocker, datasource_type, controller_path): +def test_get_datasource_plugin_provider_creates_controller_and_caches( + mocker: MockerFixture, datasource_type, controller_path +): _invalidate_recyclable_contextvars() provider_entity = types.SimpleNamespace(declaration=object(), plugin_id="plugin", plugin_unique_identifier="uniq") diff --git a/api/tests/unit_tests/core/file/test_remote_fetcher.py b/api/tests/unit_tests/core/file/test_remote_fetcher.py index ec377e8dc43..c9612a062ac 100644 --- a/api/tests/unit_tests/core/file/test_remote_fetcher.py +++ b/api/tests/unit_tests/core/file/test_remote_fetcher.py @@ -36,7 +36,7 @@ def _signed_url(*, base_url: str, path: str, payload: str, secret: str = "test-s return f"{base_url}{path}?{query}" -def _patch_file_fetcher_config(monkeypatch): +def _patch_file_fetcher_config(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(remote_fetcher.dify_config, "FILES_URL", "http://localhost:5001") monkeypatch.setattr(remote_fetcher.dify_config, "INTERNAL_FILES_URL", "http://api:5001") monkeypatch.setattr(remote_fetcher.dify_config, "SECRET_KEY", "test-secret") @@ -44,7 +44,7 @@ def _patch_file_fetcher_config(monkeypatch): monkeypatch.setattr(remote_fetcher.time, "time", lambda: 1700000100) -def _patch_session(monkeypatch): +def _patch_session(monkeypatch: pytest.MonkeyPatch): session = MagicMock() session_cm = MagicMock() session_cm.__enter__.return_value = session @@ -53,19 +53,19 @@ def _patch_session(monkeypatch): return session -def _patch_ssrf_make_request(monkeypatch, response=None): +def _patch_ssrf_make_request(monkeypatch: pytest.MonkeyPatch, response=None): make_request = MagicMock(return_value=response) if response is not None else MagicMock() monkeypatch.setattr(remote_fetcher.ssrf_proxy, "make_request", make_request) return make_request -def _patch_signer_times(monkeypatch): +def _patch_signer_times(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.datasource.datasource_file_manager.time.time", lambda: 1700000000) monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000) monkeypatch.setattr("core.tools.tool_file_manager.time.time", lambda: 1700000000) -def test_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch): +def test_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) session = _patch_session(monkeypatch) upload_file = SimpleNamespace( @@ -102,7 +102,7 @@ def test_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch): ssrf_make_request.assert_not_called() -def test_make_request_resolves_upload_preview_url_generated_by_signer(monkeypatch): +def test_make_request_resolves_upload_preview_url_generated_by_signer(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_signer_times(monkeypatch) session = _patch_session(monkeypatch) @@ -132,7 +132,7 @@ def test_make_request_resolves_upload_preview_url_generated_by_signer(monkeypatc ssrf_make_request.assert_not_called() -def test_make_request_resolves_sign_tool_file_url_with_empty_extension(monkeypatch): +def test_make_request_resolves_sign_tool_file_url_with_empty_extension(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_signer_times(monkeypatch) session = _patch_session(monkeypatch) @@ -161,7 +161,7 @@ def test_make_request_resolves_sign_tool_file_url_with_empty_extension(monkeypat ssrf_make_request.assert_not_called() -def test_make_request_resolves_tool_manager_url_with_empty_extension(monkeypatch): +def test_make_request_resolves_tool_manager_url_with_empty_extension(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_signer_times(monkeypatch) session = _patch_session(monkeypatch) @@ -189,7 +189,7 @@ def test_make_request_resolves_tool_manager_url_with_empty_extension(monkeypatch ssrf_make_request.assert_not_called() -def test_make_request_resolves_datasource_manager_url_with_empty_extension(monkeypatch): +def test_make_request_resolves_datasource_manager_url_with_empty_extension(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_signer_times(monkeypatch) _patch_session(monkeypatch) @@ -222,7 +222,7 @@ def test_make_request_resolves_datasource_manager_url_with_empty_extension(monke ssrf_make_request.assert_not_called() -def test_head_signed_upload_file_url_returns_metadata_without_storage_content(monkeypatch): +def test_head_signed_upload_file_url_returns_metadata_without_storage_content(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) session = _patch_session(monkeypatch) upload_file = SimpleNamespace( @@ -259,7 +259,7 @@ def test_head_signed_upload_file_url_returns_metadata_without_storage_content(mo ssrf_make_request.assert_not_called() -def test_make_request_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch): +def test_make_request_get_signed_upload_file_url_reads_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) upload_file = SimpleNamespace( @@ -291,7 +291,7 @@ def test_make_request_get_signed_upload_file_url_reads_storage_without_ssrf(monk ssrf_make_request.assert_not_called() -def test_make_request_head_signed_upload_file_url_returns_metadata_without_ssrf(monkeypatch): +def test_make_request_head_signed_upload_file_url_returns_metadata_without_ssrf(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) upload_file = SimpleNamespace( @@ -325,7 +325,7 @@ def test_make_request_head_signed_upload_file_url_returns_metadata_without_ssrf( ssrf_make_request.assert_not_called() -def test_make_request_get_unsigned_dify_url_delegates_to_ssrf_proxy(monkeypatch): +def test_make_request_get_unsigned_dify_url_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) get_upload_file = MagicMock() monkeypatch.setattr(remote_fetcher._file_access_controller, "get_upload_file", get_upload_file) @@ -345,7 +345,7 @@ def test_make_request_get_unsigned_dify_url_delegates_to_ssrf_proxy(monkeypatch) ) -def test_make_request_post_signed_upload_file_url_delegates_to_ssrf_proxy(monkeypatch): +def test_make_request_post_signed_upload_file_url_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) get_upload_file = MagicMock() monkeypatch.setattr(remote_fetcher._file_access_controller, "get_upload_file", get_upload_file) @@ -369,7 +369,7 @@ def test_make_request_post_signed_upload_file_url_delegates_to_ssrf_proxy(monkey ) -def test_get_signed_image_preview_url_uses_image_preview_signature(monkeypatch): +def test_get_signed_image_preview_url_uses_image_preview_signature(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) upload_file = SimpleNamespace( @@ -401,7 +401,7 @@ def test_get_signed_image_preview_url_uses_image_preview_signature(monkeypatch): ssrf_make_request.assert_not_called() -def test_image_preview_url_with_file_preview_signature_delegates_to_ssrf_proxy(monkeypatch): +def test_image_preview_url_with_file_preview_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) proxy_response = httpx.Response(403, request=httpx.Request("GET", "http://localhost:5001/bad")) ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response) @@ -421,7 +421,7 @@ def test_image_preview_url_with_file_preview_signature_delegates_to_ssrf_proxy(m ) -def test_duplicate_signature_query_value_delegates_to_ssrf_proxy(monkeypatch): +def test_duplicate_signature_query_value_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) url = ( _signed_url( @@ -444,7 +444,7 @@ def test_duplicate_signature_query_value_delegates_to_ssrf_proxy(monkeypatch): ) -def test_malformed_timestamp_delegates_to_ssrf_proxy(monkeypatch): +def test_malformed_timestamp_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) url = _signed_url( base_url="http://localhost:5001", @@ -464,7 +464,7 @@ def test_malformed_timestamp_delegates_to_ssrf_proxy(monkeypatch): ) -def test_expired_signature_delegates_to_ssrf_proxy(monkeypatch): +def test_expired_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) monkeypatch.setattr(remote_fetcher.time, "time", lambda: 1700004001) url = _signed_url( @@ -485,7 +485,7 @@ def test_expired_signature_delegates_to_ssrf_proxy(monkeypatch): ) -def test_invalid_signature_delegates_to_ssrf_proxy(monkeypatch): +def test_invalid_signature_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) proxy_response = httpx.Response(403, request=httpx.Request("GET", "http://localhost:5001/bad")) ssrf_make_request = _patch_ssrf_make_request(monkeypatch, proxy_response) @@ -502,7 +502,7 @@ def test_invalid_signature_delegates_to_ssrf_proxy(monkeypatch): ) -def test_host_mismatch_delegates_to_ssrf_proxy(monkeypatch): +def test_host_mismatch_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) url = _signed_url( base_url="http://example.com", @@ -522,7 +522,7 @@ def test_host_mismatch_delegates_to_ssrf_proxy(monkeypatch): ) -def test_unsupported_dify_path_delegates_to_ssrf_proxy(monkeypatch): +def test_unsupported_dify_path_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) url = _signed_url( base_url="http://localhost:5001", @@ -543,7 +543,7 @@ def test_unsupported_dify_path_delegates_to_ssrf_proxy(monkeypatch): ) -def test_invalid_url_scheme_delegates_to_ssrf_proxy(monkeypatch): +def test_invalid_url_scheme_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) url = f"file:///tmp/files/{UPLOAD_FILE_ID}/file-preview?timestamp=1700000000&nonce=nonce&sign=ignored" proxy_response = httpx.Response(403, request=httpx.Request("GET", url)) @@ -559,7 +559,7 @@ def test_invalid_url_scheme_delegates_to_ssrf_proxy(monkeypatch): ) -def test_invalid_url_port_delegates_to_ssrf_proxy(monkeypatch): +def test_invalid_url_port_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) url = f"http://localhost:invalid/files/{UPLOAD_FILE_ID}/file-preview?timestamp=1700000000&nonce=nonce&sign=ignored" proxy_response = httpx.Response(403, request=httpx.Request("GET", "http://proxy.example/fallback")) @@ -575,7 +575,7 @@ def test_invalid_url_port_delegates_to_ssrf_proxy(monkeypatch): ) -def test_invalid_configured_file_origin_delegates_to_ssrf_proxy(monkeypatch): +def test_invalid_configured_file_origin_delegates_to_ssrf_proxy(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) monkeypatch.setattr(remote_fetcher.dify_config, "FILES_URL", "") monkeypatch.setattr(remote_fetcher.dify_config, "INTERNAL_FILES_URL", "file:///tmp/files") @@ -597,7 +597,7 @@ def test_invalid_configured_file_origin_delegates_to_ssrf_proxy(monkeypatch): ) -def test_signed_upload_file_url_returns_404_when_record_missing(monkeypatch): +def test_signed_upload_file_url_returns_404_when_record_missing(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) get_upload_file = MagicMock(return_value=None) @@ -617,7 +617,7 @@ def test_signed_upload_file_url_returns_404_when_record_missing(monkeypatch): ssrf_make_request.assert_not_called() -def test_get_signed_tool_file_url_reads_storage_without_ssrf(monkeypatch): +def test_get_signed_tool_file_url_reads_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) session = _patch_session(monkeypatch) tool_file = SimpleNamespace( @@ -651,7 +651,7 @@ def test_get_signed_tool_file_url_reads_storage_without_ssrf(monkeypatch): ssrf_make_request.assert_not_called() -def test_signed_tool_file_url_returns_404_when_record_missing(monkeypatch): +def test_signed_tool_file_url_returns_404_when_record_missing(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) get_tool_file = MagicMock(return_value=None) @@ -671,7 +671,7 @@ def test_signed_tool_file_url_returns_404_when_record_missing(monkeypatch): ssrf_make_request.assert_not_called() -def test_get_signed_datasource_file_url_reads_upload_storage_without_ssrf(monkeypatch): +def test_get_signed_datasource_file_url_reads_upload_storage_without_ssrf(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) upload_file = SimpleNamespace( @@ -705,7 +705,7 @@ def test_get_signed_datasource_file_url_reads_upload_storage_without_ssrf(monkey ssrf_make_request.assert_not_called() -def test_get_signed_datasource_file_url_reads_tool_storage_when_upload_missing(monkeypatch): +def test_get_signed_datasource_file_url_reads_tool_storage_when_upload_missing(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) tool_file = SimpleNamespace( @@ -740,7 +740,7 @@ def test_get_signed_datasource_file_url_reads_tool_storage_when_upload_missing(m ssrf_make_request.assert_not_called() -def test_signed_datasource_file_url_returns_404_when_records_missing(monkeypatch): +def test_signed_datasource_file_url_returns_404_when_records_missing(monkeypatch: pytest.MonkeyPatch): _patch_file_fetcher_config(monkeypatch) _patch_session(monkeypatch) get_upload_file = MagicMock(return_value=None) diff --git a/api/tests/unit_tests/core/mcp/session/test_base_session.py b/api/tests/unit_tests/core/mcp/session/test_base_session.py index 1dd916bcf12..ca090eeda4d 100644 --- a/api/tests/unit_tests/core/mcp/session/test_base_session.py +++ b/api/tests/unit_tests/core/mcp/session/test_base_session.py @@ -1,3 +1,4 @@ +import logging import queue import time from concurrent.futures import Future, ThreadPoolExecutor @@ -511,10 +512,8 @@ def test_receive_loop_http_error_unknown_id(streams): @pytest.mark.timeout(10) -def test_receive_loop_validation_error_notification(streams): - from core.mcp.session.base_session import logger - - with patch.object(logger, "warning") as mock_warning: +def test_receive_loop_validation_error_notification(streams, caplog: pytest.LogCaptureFixture): + with caplog.at_level(logging.WARNING, logger="core.mcp.session.base_session"): read_stream, write_stream = streams session = MockSession(read_stream, write_stream, ReceiveRequest, RootModel[MockNotification]) @@ -523,7 +522,7 @@ def test_receive_loop_validation_error_notification(streams): read_stream.put(SessionMessage(message=JSONRPCMessage.model_validate(notif_payload))) time.sleep(1.0) - assert mock_warning.called + assert "Failed to validate notification" in caplog.text @pytest.mark.timeout(5) @@ -571,16 +570,16 @@ def test_session_exit_timeout(streams): @pytest.mark.timeout(10) -def test_receive_loop_fatal_exception(streams): +def test_receive_loop_fatal_exception(streams, caplog: pytest.LogCaptureFixture): read_stream, write_stream = streams session = MockSession(read_stream, write_stream, ReceiveRequest, ReceiveNotification) with patch.object(read_stream, "get", side_effect=RuntimeError("Fatal loop error")): - with patch("core.mcp.session.base_session.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.mcp.session.base_session"): with pytest.raises(RuntimeError, match="Fatal loop error"): with session: pass - mock_logger.exception.assert_called_with("Error in message processing loop") + assert "Error in message processing loop" in caplog.text @pytest.mark.timeout(5) 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 bea808516d7..613982f9c03 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 @@ -7,6 +7,7 @@ from pytest_mock import MockerFixture from core.plugin.endpoint.exc import EndpointSetupFailedError from core.plugin.entities.plugin_daemon import PluginDaemonInnerError from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient +from core.plugin.impl.exc import PluginLLMPollingUnsupportedError from core.trigger.errors import ( EventIgnoreError, TriggerInvokeError, @@ -167,3 +168,10 @@ class TestBasePluginClientImpl: with pytest.raises(expected): client._handle_plugin_daemon_error("PluginInvokeError", message) + + def test_handle_plugin_daemon_error_maps_unsupported_polling_to_typed_exception(self): + client = BasePluginClient() + message = json.dumps({"error_type": PluginLLMPollingUnsupportedError.__name__, "message": "m"}) + + with pytest.raises(PluginLLMPollingUnsupportedError): + client._handle_plugin_daemon_error("PluginInvokeError", message) 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 6dc572310c3..ac3df1e56fc 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 @@ -1,13 +1,17 @@ from __future__ import annotations import io +import json from types import SimpleNamespace import pytest from pytest_mock import MockerFixture from core.plugin.entities.plugin_daemon import PluginDaemonInnerError +from core.plugin.impl.exc import PluginInvokeError, PluginLLMPollingUnsupportedError from core.plugin.impl.model import PluginModelClient +from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMPollingStatus, LLMResult, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage class TestPluginModelClient: @@ -183,6 +187,113 @@ class TestPluginModelClient: ) ) + def test_start_llm_polling(self, mocker: MockerFixture): + client = PluginModelClient() + polling_result = LLMPollingResult( + status=LLMPollingStatus.RUNNING, + plugin_state={"task_id": "poll-1"}, + next_check_after_seconds=3, + ) + request_mock = mocker.patch.object( + client, + "_request_with_plugin_daemon_response", + return_value=polling_result, + ) + + result = client.start_llm_polling( + tenant_id="tenant-1", + user_id="user-1", + plugin_id="org/plugin:1", + provider="provider-a", + model="gpt-test", + credentials={"api_key": "key"}, + prompt_messages=[], + model_parameters={"temperature": 0.1}, + tools=[], + stop=["STOP"], + json_schema={"type": "object"}, + ) + + assert result == polling_result + call_kwargs = request_mock.call_args.kwargs + assert call_kwargs["path"] == "plugin/tenant-1/dispatch/model/polling/start" + assert call_kwargs["data"]["data"] == { + "provider": "provider-a", + "model_type": "llm", + "model": "gpt-test", + "credentials": {"api_key": "key"}, + "prompt_messages": [], + "model_parameters": {"temperature": 0.1}, + "tools": [], + "stop": ["STOP"], + "stream": False, + "json_schema": {"type": "object"}, + } + + def test_check_llm_polling(self, mocker: MockerFixture): + client = PluginModelClient() + polling_result = LLMPollingResult( + status=LLMPollingStatus.SUCCEEDED, + result=LLMResult( + model="gpt-test", + prompt_messages=[], + message=AssistantPromptMessage(content="done"), + usage=LLMUsage.empty_usage(), + ), + ) + request_mock = mocker.patch.object( + client, + "_request_with_plugin_daemon_response", + return_value=polling_result, + ) + + result = client.check_llm_polling( + tenant_id="tenant-1", + user_id="user-1", + plugin_id="org/plugin:1", + provider="provider-a", + model="gpt-test", + credentials={"api_key": "key"}, + plugin_state={"task_id": "poll-1"}, + ) + + assert result == polling_result + call_kwargs = request_mock.call_args.kwargs + assert call_kwargs["path"] == "plugin/tenant-1/dispatch/model/polling/check" + assert call_kwargs["data"]["data"] == { + "provider": "provider-a", + "model_type": "llm", + "model": "gpt-test", + "credentials": {"api_key": "key"}, + "plugin_state": {"task_id": "poll-1"}, + } + + def test_start_llm_polling_maps_unsupported_polling_invoke_error(self, mocker: MockerFixture): + client = PluginModelClient() + mocker.patch.object( + client, + "_request_with_plugin_daemon_response", + side_effect=PluginInvokeError( + json.dumps( + { + "error_type": PluginLLMPollingUnsupportedError.__name__, + "message": "Model `gpt-test` does not support polling.", + } + ) + ), + ) + + with pytest.raises(PluginLLMPollingUnsupportedError): + client.start_llm_polling( + tenant_id="tenant-1", + user_id="user-1", + plugin_id="org/plugin:1", + provider="provider-a", + model="gpt-test", + credentials={"api_key": "key"}, + prompt_messages=[], + ) + def test_get_llm_num_tokens(self, mocker: MockerFixture): client = PluginModelClient() mocker.patch.object( 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 3fd885b28fb..17973916779 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 @@ -14,7 +14,13 @@ from core.plugin.impl.model_runtime import TENANT_SCOPE_SCHEMA_CACHE_USER_ID, Pl from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime from core.plugin.plugin_service import PluginService 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.llm_entities import ( + LLMPollingResult, + LLMPollingStatus, + 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 @@ -282,6 +288,74 @@ class TestPluginModelRuntime: stream=True, ) + def test_start_llm_polling_resolves_plugin_fields(self) -> None: + client = Mock(spec=PluginModelClient) + polling_result = LLMPollingResult( + status=LLMPollingStatus.RUNNING, + plugin_state={"task_id": "poll-1"}, + next_check_after_seconds=2, + ) + client.start_llm_polling.return_value = polling_result + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + + result = runtime.start_llm_polling( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + model_parameters={"temperature": 0.2}, + prompt_messages=[], + tools=None, + stop=("END",), + json_schema={"type": "object"}, + ) + + assert result == polling_result + client.start_llm_polling.assert_called_once_with( + tenant_id="tenant", + user_id="user", + plugin_id="langgenius/openai", + provider="openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + prompt_messages=[], + model_parameters={"temperature": 0.2}, + tools=None, + stop=["END"], + json_schema={"type": "object"}, + ) + + def test_check_llm_polling_resolves_plugin_fields(self) -> None: + client = Mock(spec=PluginModelClient) + polling_result = LLMPollingResult( + status=LLMPollingStatus.SUCCEEDED, + result=model_runtime_module.LLMResult( + model="gpt-4o-mini", + prompt_messages=[], + message=AssistantPromptMessage(content="done"), + usage=LLMUsage.empty_usage(), + ), + ) + client.check_llm_polling.return_value = polling_result + runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService) + + result = runtime.check_llm_polling( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + plugin_state={"task_id": "poll-1"}, + ) + + assert result == polling_result + client.check_llm_polling.assert_called_once_with( + tenant_id="tenant", + user_id="user", + plugin_id="langgenius/openai", + provider="openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + plugin_state={"task_id": "poll-1"}, + ) + def test_invoke_llm_rejects_per_call_user_override(self) -> None: client = Mock(spec=PluginModelClient) client.invoke_llm.return_value = sentinel.result 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 e233bd2ef07..9224d6c88e8 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 @@ -81,7 +81,7 @@ def patched_runtime(monkeypatch: pytest.MonkeyPatch): return SimpleNamespace(session=session, storage=storage, lock=lock) -def test_create_indexes_documents_and_returns_self(monkeypatch, patched_runtime): +def test_create_indexes_documents_and_returns_self(monkeypatch: pytest.MonkeyPatch, patched_runtime): dataset = _dataset(_dataset_keyword_table(), keyword_number=2) keyword = Jieba(dataset) handler = MagicMock() @@ -111,7 +111,7 @@ def test_create_indexes_documents_and_returns_self(monkeypatch, patched_runtime) patched_runtime.lock.assert_called_once_with("keyword_indexing_lock_dataset-1", timeout=600) -def test_add_texts_supports_keywords_list_and_extract_fallback(monkeypatch, patched_runtime): +def test_add_texts_supports_keywords_list_and_extract_fallback(monkeypatch: pytest.MonkeyPatch, patched_runtime): keyword = Jieba(_dataset(_dataset_keyword_table(), keyword_number=3)) handler = MagicMock() handler.extract_keywords.return_value = {"auto"} @@ -135,7 +135,7 @@ def test_add_texts_supports_keywords_list_and_extract_fallback(monkeypatch, patc keyword._save_dataset_keyword_table.assert_called_once() -def test_add_texts_without_keywords_list_always_uses_extractor(monkeypatch, patched_runtime): +def test_add_texts_without_keywords_list_always_uses_extractor(monkeypatch: pytest.MonkeyPatch, patched_runtime): keyword = Jieba(_dataset(_dataset_keyword_table(), keyword_number=1)) handler = MagicMock() handler.extract_keywords.return_value = {"from-extractor"} @@ -162,7 +162,7 @@ def test_text_exists_handles_missing_and_existing_keyword_table(monkeypatch: pyt assert keyword.text_exists("node-x") is False -def test_delete_by_ids_updates_table_when_present(monkeypatch, patched_runtime): +def test_delete_by_ids_updates_table_when_present(monkeypatch: pytest.MonkeyPatch, patched_runtime): keyword = Jieba(_dataset(_dataset_keyword_table())) monkeypatch.setattr(keyword, "_get_dataset_keyword_table", MagicMock(return_value={"k": {"node-1", "node-2"}})) monkeypatch.setattr(keyword, "_delete_ids_from_keyword_table", MagicMock(return_value={"k": {"node-2"}})) @@ -174,7 +174,7 @@ def test_delete_by_ids_updates_table_when_present(monkeypatch, patched_runtime): keyword._save_dataset_keyword_table.assert_called_once_with({"k": {"node-2"}}) -def test_delete_by_ids_saves_none_when_keyword_table_is_missing(monkeypatch, patched_runtime): +def test_delete_by_ids_saves_none_when_keyword_table_is_missing(monkeypatch: pytest.MonkeyPatch, patched_runtime): keyword = Jieba(_dataset(_dataset_keyword_table())) monkeypatch.setattr(keyword, "_get_dataset_keyword_table", MagicMock(return_value=None)) monkeypatch.setattr(keyword, "_delete_ids_from_keyword_table", MagicMock()) @@ -186,7 +186,7 @@ def test_delete_by_ids_saves_none_when_keyword_table_is_missing(monkeypatch, pat keyword._save_dataset_keyword_table.assert_called_once_with(None) -def test_search_returns_documents_in_rank_order_and_applies_filter(monkeypatch, patched_runtime): +def test_search_returns_documents_in_rank_order_and_applies_filter(monkeypatch: pytest.MonkeyPatch, patched_runtime): class _FakeDocumentSegment: dataset_id = _Field("dataset_id") index_node_id = _Field("index_node_id") @@ -216,7 +216,7 @@ def test_search_returns_documents_in_rank_order_and_applies_filter(monkeypatch, assert documents[0].metadata["doc_hash"] == "hash-2" -def test_delete_removes_keyword_table_and_optional_file(monkeypatch, patched_runtime): +def test_delete_removes_keyword_table_and_optional_file(monkeypatch: pytest.MonkeyPatch, patched_runtime): db_keyword = _dataset_keyword_table(data_source_type="database") file_keyword = _dataset_keyword_table(data_source_type="object_storage") @@ -232,7 +232,7 @@ def test_delete_removes_keyword_table_and_optional_file(monkeypatch, patched_run assert patched_runtime.session.commit.call_count == 2 -def test_save_dataset_keyword_table_to_database(monkeypatch, patched_runtime): +def test_save_dataset_keyword_table_to_database(monkeypatch: pytest.MonkeyPatch, patched_runtime): dataset_keyword_table = _dataset_keyword_table(data_source_type="database") keyword = Jieba(_dataset(dataset_keyword_table)) @@ -243,7 +243,7 @@ def test_save_dataset_keyword_table_to_database(monkeypatch, patched_runtime): patched_runtime.session.commit.assert_called_once() -def test_save_dataset_keyword_table_to_file_storage(monkeypatch, patched_runtime): +def test_save_dataset_keyword_table_to_file_storage(monkeypatch: pytest.MonkeyPatch, patched_runtime): dataset_keyword_table = _dataset_keyword_table(data_source_type="file") keyword = Jieba(_dataset(dataset_keyword_table)) patched_runtime.storage.exists.return_value = True @@ -257,7 +257,7 @@ def test_save_dataset_keyword_table_to_file_storage(monkeypatch, patched_runtime assert isinstance(save_args[1], bytes) -def test_get_dataset_keyword_table_returns_existing_table_data(monkeypatch, patched_runtime): +def test_get_dataset_keyword_table_returns_existing_table_data(monkeypatch: pytest.MonkeyPatch, patched_runtime): existing = _dataset_keyword_table( keyword_table_dict={"__type__": "keyword_table", "__data__": {"table": {"kw": ["node-1"]}}} ) @@ -269,7 +269,7 @@ def test_get_dataset_keyword_table_returns_existing_table_data(monkeypatch, patc assert keyword_with_missing_payload._get_dataset_keyword_table() == {} -def test_get_dataset_keyword_table_creates_table_when_missing(monkeypatch, patched_runtime): +def test_get_dataset_keyword_table_creates_table_when_missing(monkeypatch: pytest.MonkeyPatch, patched_runtime): created_tables: list[SimpleNamespace] = [] def _fake_dataset_keyword_table(**kwargs): @@ -323,7 +323,7 @@ def test_retrieve_ids_by_query_ranks_by_keyword_frequency(monkeypatch: pytest.Mo assert ranked_ids == ["node-2"] -def test_update_segment_keywords_updates_when_segment_exists(monkeypatch, patched_runtime): +def test_update_segment_keywords_updates_when_segment_exists(monkeypatch: pytest.MonkeyPatch, patched_runtime): class _FakeDocumentSegment: dataset_id = _Field("dataset_id") index_node_id = _Field("index_node_id") 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 index 426ffc498b2..a0d047f6eda 100644 --- 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 @@ -3,6 +3,7 @@ from __future__ import annotations from types import SimpleNamespace from uuid import uuid4 +import pytest from sqlalchemy import select from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom @@ -112,7 +113,7 @@ def test_segment_attachment_lookup_grants_returned_upload_files_to_current_scope assert "upload_files.id IN" in whereclause -def test_knowledge_retrieval_grants_returned_segments_to_current_scope(monkeypatch) -> None: +def test_knowledge_retrieval_grants_returned_segments_to_current_scope(monkeypatch: pytest.MonkeyPatch) -> None: tenant_id = str(uuid4()) dataset_id = str(uuid4()) document_id = str(uuid4()) 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 067159398d2..d86ffd26478 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 @@ -9,7 +9,7 @@ import pytest from core.rag.models.document import Document -def _register_fake_factory_module(monkeypatch, module_path: str, class_name: str): +def _register_fake_factory_module(monkeypatch: pytest.MonkeyPatch, module_path: str, class_name: str): fake_module = types.ModuleType(module_path) fake_cls = type(class_name, (), {}) setattr(fake_module, class_name, fake_cls) diff --git a/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py b/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py index 051a1455aef..b33a7ba725c 100644 --- a/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py +++ b/api/tests/unit_tests/core/rag/embedding/test_cached_embedding.py @@ -7,6 +7,7 @@ This test file covers the methods not fully tested in test_embedding_service.py: """ import base64 +import logging from decimal import Decimal from unittest.mock import Mock, patch @@ -63,7 +64,9 @@ class TestCacheEmbeddingMultimodalDocuments: usage=usage, ) - def test_embed_single_multimodal_document_cache_miss(self, mock_model_instance, sample_multimodal_result): + def test_embed_single_multimodal_document_cache_miss( + self, mock_model_instance, sample_multimodal_result: EmbeddingResult + ): """Test embedding a single multimodal document when cache is empty.""" cache_embedding = CacheEmbedding(mock_model_instance) documents = [{"file_id": "file123", "content": "test content"}] @@ -188,7 +191,7 @@ class TestCacheEmbeddingMultimodalDocuments: assert len(result) == 3 assert result[0] == normalized_cached - def test_embed_multimodal_documents_nan_handling(self, mock_model_instance): + def test_embed_multimodal_documents_nan_handling(self, mock_model_instance, caplog: pytest.LogCaptureFixture): """Test handling of NaN values in multimodal embeddings.""" cache_embedding = CacheEmbedding(mock_model_instance) documents = [{"file_id": "valid"}, {"file_id": "nan"}] @@ -216,14 +219,14 @@ class TestCacheEmbeddingMultimodalDocuments: mock_session.scalar.return_value = None mock_model_instance.invoke_multimodal_embedding.return_value = embedding_result - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.WARNING, logger="core.rag.embedding.cached_embedding"): result = cache_embedding.embed_multimodal_documents(documents) assert len(result) == 2 assert result[0] is not None assert result[1] is None - mock_logger.warning.assert_called_once() + assert any(record.levelno == logging.WARNING for record in caplog.records) def test_embed_multimodal_documents_large_batch(self, mock_model_instance): """Test embedding large batch of multimodal documents respecting MAX_CHUNKS.""" @@ -463,7 +466,7 @@ class TestCacheEmbeddingQueryErrors: model_instance.credentials = {"api_key": "test-key"} return model_instance - def test_embed_query_api_error_debug_mode(self, mock_model_instance): + def test_embed_query_api_error_debug_mode(self, mock_model_instance, caplog: pytest.LogCaptureFixture): """Test handling of API errors in debug mode.""" cache_embedding = CacheEmbedding(mock_model_instance) query = "test query" @@ -475,14 +478,14 @@ class TestCacheEmbeddingQueryErrors: with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config: mock_config.DEBUG = True - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"): with pytest.raises(RuntimeError) as exc_info: cache_embedding.embed_query(query) assert "API Error" in str(exc_info.value) - mock_logger.exception.assert_called() + assert any(record.levelno == logging.ERROR for record in caplog.records) - def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance): + def test_embed_query_redis_set_error_debug_mode(self, mock_model_instance, caplog: pytest.LogCaptureFixture): """Test handling of Redis set errors in debug mode.""" cache_embedding = CacheEmbedding(mock_model_instance) query = "test query" @@ -514,11 +517,11 @@ class TestCacheEmbeddingQueryErrors: with patch("core.rag.embedding.cached_embedding.dify_config") as mock_config: mock_config.DEBUG = True - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="core.rag.embedding.cached_embedding"): with pytest.raises(RuntimeError): cache_embedding.embed_query(query) - mock_logger.exception.assert_called() + assert any(record.levelno == logging.ERROR for record in caplog.records) class TestCacheEmbeddingInitialization: diff --git a/api/tests/unit_tests/core/rag/embedding/test_embedding_service.py b/api/tests/unit_tests/core/rag/embedding/test_embedding_service.py index 4b8175b0b42..42d5ea4a393 100644 --- a/api/tests/unit_tests/core/rag/embedding/test_embedding_service.py +++ b/api/tests/unit_tests/core/rag/embedding/test_embedding_service.py @@ -44,6 +44,7 @@ Tests follow the Arrange-Act-Assert pattern for clarity. """ import base64 +import logging from decimal import Decimal from unittest.mock import Mock, patch @@ -406,7 +407,7 @@ class TestCacheEmbeddingDocuments: assert len(calls[1].kwargs["texts"]) == 10 assert len(calls[2].kwargs["texts"]) == 5 - def test_embed_documents_nan_handling(self, mock_model_instance): + def test_embed_documents_nan_handling(self, mock_model_instance, caplog): """Test handling of NaN values in embeddings. Verifies: @@ -446,7 +447,7 @@ class TestCacheEmbeddingDocuments: mock_session.scalar.return_value = None mock_model_instance.invoke_text_embedding.return_value = embedding_result - with patch("core.rag.embedding.cached_embedding.logger") as mock_logger: + with caplog.at_level(logging.WARNING, logger="core.rag.embedding.cached_embedding"): # Act result = cache_embedding.embed_documents(texts) @@ -461,8 +462,8 @@ class TestCacheEmbeddingDocuments: assert result[1] is None # Verify warning was logged - mock_logger.warning.assert_called_once() - assert "Normalized embedding is nan" in str(mock_logger.warning.call_args) + assert sum(1 for r in caplog.records if r.levelno == logging.WARNING) >= 1 + assert any("Normalized embedding is nan" in record.message for record in caplog.records) def test_embed_documents_api_connection_error(self, mock_model_instance): """Test handling of API connection errors during embedding. 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 45d6fc1cd07..51763ec5f36 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 @@ -1,6 +1,7 @@ """Primarily used for testing merged cell scenarios""" import io +import logging import os import tempfile from collections import UserDict @@ -548,7 +549,9 @@ def test_parse_docx_reads_real_paragraph_table_order(monkeypatch: pytest.MonkeyP os.remove(tmp_path) -def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monkeypatch: pytest.MonkeyPatch): +def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): extractor = object.__new__(WordExtractor) ext_image_id = "ext-image" @@ -709,10 +712,9 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke monkeypatch.setattr(we, "Run", FakeRun) monkeypatch.setattr(extractor, "_extract_images_from_docx", lambda doc: image_map) monkeypatch.setattr(extractor, "_table_to_markdown", lambda table, image_map: "TABLE-MARKDOWN") - logger_exception = MagicMock() - monkeypatch.setattr(we.logger, "exception", logger_exception) - content = extractor.parse_docx("dummy.docx") + with caplog.at_level(logging.ERROR, logger="core.rag.extractor.word_extractor"): + content = extractor.parse_docx("dummy.docx") assert "[EXT]" in content assert "[INT]" in content @@ -720,7 +722,7 @@ def test_parse_docx_covers_drawing_shapes_hyperlink_error_and_table_branch(monke assert "[LinkText](https://example.com)" in content assert "BrokenLink" in content assert "TABLE-MARKDOWN" in content - logger_exception.assert_called_once() + assert any(record.levelno == logging.ERROR for record in caplog.records) def test_parse_cell_paragraph_hyperlink_in_table_cell_http(): 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 35e581ccc15..05985d30985 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 @@ -73,6 +73,9 @@ class TestBaseAPIClient: assert client.session == "session" assert captured["headers"]["X-API-Key"] == "k" assert captured["headers"]["User-Agent"] == "WaterCrawl-Plugin" + assert captured["timeout"] is not None + assert captured["timeout"].connect is not None + assert captured["timeout"].read is not None def test_request_stream_and_non_stream_paths(self, monkeypatch: pytest.MonkeyPatch): class FakeSession: diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py index 182930b19d1..d2154f138a7 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_paragraph_index_processor.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import Any from unittest.mock import Mock, patch @@ -384,7 +385,7 @@ class TestParagraphIndexProcessor: with pytest.raises(ValueError, match="model_name and model_provider_name"): ParagraphIndexProcessor.generate_summary("tenant-1", "text", {"enable": True}) - def test_generate_summary_text_only_flow(self) -> None: + def test_generate_summary_text_only_flow(self, caplog) -> None: model_instance = Mock() model_instance.credentials = {"k": "v"} model_instance.model_type_instance.get_model_schema.return_value = SimpleNamespace(features=[]) @@ -402,19 +403,22 @@ class TestParagraphIndexProcessor: "core.rag.index_processor.processor.paragraph_index_processor.deduct_llm_quota", side_effect=RuntimeError("quota"), ), - patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): mock_provider_manager.return_value.get_provider_model_bundle.return_value = Mock() - summary, usage = ParagraphIndexProcessor.generate_summary( - "tenant-1", - "text content", - {"enable": True, "model_name": "model-a", "model_provider_name": "provider-a"}, - document_language="English", - ) + with caplog.at_level( + logging.WARNING, logger="core.rag.index_processor.processor.paragraph_index_processor" + ): + summary, usage = ParagraphIndexProcessor.generate_summary( + "tenant-1", + "text content", + {"enable": True, "model_name": "model-a", "model_provider_name": "provider-a"}, + document_language="English", + ) assert summary == "text summary" assert isinstance(usage, LLMUsage) - mock_logger.warning.assert_called_with("Failed to deduct quota for summary generation: %s", "quota") + assert sum(1 for r in caplog.records if r.levelno == logging.WARNING) == 1 + assert any("Failed to deduct quota for summary generation" in record.message for record in caplog.records) def test_generate_summary_handles_vision_and_image_conversion(self) -> None: model_instance = Mock() @@ -455,7 +459,7 @@ class TestParagraphIndexProcessor: assert summary == "vision summary" mock_extract_text.assert_not_called() - def test_generate_summary_fallbacks_for_prompt_and_result_types(self) -> None: + def test_generate_summary_fallbacks_for_prompt_and_result_types(self, caplog) -> None: model_instance = Mock() model_instance.credentials = {"k": "v"} model_instance.model_type_instance.get_model_schema.return_value = SimpleNamespace( @@ -482,21 +486,24 @@ class TestParagraphIndexProcessor: "core.rag.index_processor.processor.paragraph_index_processor.file_manager.to_prompt_message_content", side_effect=RuntimeError("bad image"), ), - patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, ): mock_provider_manager.return_value.get_provider_model_bundle.return_value = Mock() with pytest.raises(ValueError, match="Expected LLMResult"): - ParagraphIndexProcessor.generate_summary( - "tenant-1", - "text content", - {"enable": True, "model_name": "model-a", "model_provider_name": "provider-a"}, - ) + with caplog.at_level( + logging.WARNING, logger="core.rag.index_processor.processor.paragraph_index_processor" + ): + ParagraphIndexProcessor.generate_summary( + "tenant-1", + "text content", + {"enable": True, "model_name": "model-a", "model_provider_name": "provider-a"}, + ) - mock_logger.warning.assert_called_with( - "Failed to convert image file to prompt message content: %s", "bad image" + assert sum(1 for r in caplog.records if r.levelno == logging.WARNING) == 1 + assert any( + "Failed to convert image file to prompt message content" in record.message for record in caplog.records ) - def test_extract_images_from_text_handles_patterns_and_build_errors(self) -> None: + def test_extract_images_from_text_handles_patterns_and_build_errors(self, caplog) -> None: text = ( "![img](/files/11111111-1111-1111-1111-111111111111/image-preview) " "![img2](/files/22222222-2222-2222-2222-222222222222/file-preview) " @@ -532,13 +539,13 @@ class TestParagraphIndexProcessor: "core.rag.index_processor.processor.paragraph_index_processor.build_from_mapping", return_value=SimpleNamespace(id="file-1"), ) as mock_builder, - patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, + caplog.at_level(logging.WARNING, logger="core.rag.index_processor.processor.paragraph_index_processor"), ): files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text, session) assert len(files) == 1 assert mock_builder.call_count == 1 - mock_logger.warning.assert_not_called() + assert not any(record.levelno == logging.WARNING for record in caplog.records) def test_extract_images_from_text_returns_empty_when_no_matches(self) -> None: scalars_result = Mock() @@ -547,7 +554,7 @@ class TestParagraphIndexProcessor: session.scalars.return_value = scalars_result assert ParagraphIndexProcessor._extract_images_from_text("tenant-1", "no images here", session) == [] - def test_extract_images_from_text_logs_when_build_fails(self) -> None: + def test_extract_images_from_text_logs_when_build_fails(self, caplog) -> None: text = "![img](/files/11111111-1111-1111-1111-111111111111/image-preview)" image_upload = SimpleNamespace( id="11111111-1111-1111-1111-111111111111", @@ -569,14 +576,14 @@ class TestParagraphIndexProcessor: "core.rag.index_processor.processor.paragraph_index_processor.build_from_mapping", side_effect=RuntimeError("build failed"), ), - patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, + caplog.at_level(logging.WARNING, logger="core.rag.index_processor.processor.paragraph_index_processor"), ): files = ParagraphIndexProcessor._extract_images_from_text("tenant-1", text, session) assert files == [] - mock_logger.warning.assert_called_once() + assert sum(1 for r in caplog.records if r.levelno == logging.WARNING) == 1 - def test_extract_images_from_segment_attachments(self) -> None: + def test_extract_images_from_segment_attachments(self, caplog) -> None: image_upload = SimpleNamespace( id="file-1", name="image", @@ -609,13 +616,11 @@ class TestParagraphIndexProcessor: session = Mock() session.execute.return_value = execute_result - with ( - patch("core.rag.index_processor.processor.paragraph_index_processor.logger") as mock_logger, - ): + with caplog.at_level(logging.WARNING, logger="core.rag.index_processor.processor.paragraph_index_processor"): files = ParagraphIndexProcessor._extract_images_from_segment_attachments("tenant-1", "seg-1", session) assert len(files) == 1 - mock_logger.warning.assert_called_once() + assert sum(1 for r in caplog.records if r.levelno == logging.WARNING) == 1 def test_extract_images_from_segment_attachments_empty(self) -> None: execute_result = Mock() diff --git a/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py b/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py index 30600e64651..4ffd0a76433 100644 --- a/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py +++ b/api/tests/unit_tests/core/rag/indexing/processor/test_qa_index_processor.py @@ -1,3 +1,4 @@ +import logging from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -350,7 +351,7 @@ class TestQAIndexProcessor: assert all_qa_documents[0].metadata["answer"] == "A test." assert all_qa_documents[1].metadata["answer"] == "Coverage." - def test_format_qa_document_logs_errors(self, processor: QAIndexProcessor, fake_flask_app) -> None: + def test_format_qa_document_logs_errors(self, processor: QAIndexProcessor, fake_flask_app, caplog) -> None: all_qa_documents: list[Document] = [] source_document = Document(page_content="source text", metadata={"origin": "doc-1"}) @@ -359,12 +360,14 @@ class TestQAIndexProcessor: "core.rag.index_processor.processor.qa_index_processor.LLMGenerator.generate_qa_document", side_effect=RuntimeError("llm failure"), ), - patch("core.rag.index_processor.processor.qa_index_processor.logger") as mock_logger, + caplog.at_level(logging.ERROR, logger="core.rag.index_processor.processor.qa_index_processor"), ): processor._format_qa_document(fake_flask_app, "tenant-1", source_document, all_qa_documents, "English") assert all_qa_documents == [] - mock_logger.exception.assert_called_once_with("Failed to format qa document") + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "ERROR" + assert "Failed to format qa document" in caplog.records[0].message def test_format_split_text_extracts_question_answer_pairs(self, processor: QAIndexProcessor) -> None: parsed = processor._format_split_text("Q1: First?\nA1: One.\nQ2: Second?\nA2: Two.\n") diff --git a/api/tests/unit_tests/core/rag/rerank/test_reranker.py b/api/tests/unit_tests/core/rag/rerank/test_reranker.py index 8bc7dbf70db..565bb85b634 100644 --- a/api/tests/unit_tests/core/rag/rerank/test_reranker.py +++ b/api/tests/unit_tests/core/rag/rerank/test_reranker.py @@ -97,7 +97,9 @@ class TestRerankModelRunner: ), ] - def test_basic_reranking(self, rerank_runner, mock_model_instance, sample_documents): + def test_basic_reranking( + self, rerank_runner: RerankModelRunner, mock_model_instance, sample_documents: list[Document] + ): """Test basic reranking with cross-encoder model. Verifies: @@ -135,7 +137,9 @@ class TestRerankModelRunner: assert result[3].metadata["score"] == 0.65 assert result[0].page_content == sample_documents[2].page_content - def test_score_threshold_filtering(self, rerank_runner, mock_model_instance, sample_documents): + def test_score_threshold_filtering( + self, rerank_runner: RerankModelRunner, mock_model_instance, sample_documents: list[Document] + ): """Test score threshold filtering. Verifies: @@ -163,7 +167,9 @@ class TestRerankModelRunner: assert result[0].metadata["score"] == 0.90 assert result[1].metadata["score"] == 0.70 - def test_top_k_selection(self, rerank_runner, mock_model_instance, sample_documents): + def test_top_k_selection( + self, rerank_runner: RerankModelRunner, mock_model_instance, sample_documents: list[Document] + ): """Test top-k selection functionality. Verifies: @@ -191,7 +197,7 @@ class TestRerankModelRunner: assert result[0].metadata["score"] == 0.95 assert result[1].metadata["score"] == 0.85 - def test_document_deduplication_dify_provider(self, rerank_runner, mock_model_instance): + def test_document_deduplication_dify_provider(self, rerank_runner: RerankModelRunner, mock_model_instance): """Test document deduplication for dify provider. Verifies: @@ -235,7 +241,7 @@ class TestRerankModelRunner: assert len(call_kwargs["docs"]) == 2 # Duplicate removed assert len(result) == 2 - def test_document_deduplication_external_provider(self, rerank_runner, mock_model_instance): + def test_document_deduplication_external_provider(self, rerank_runner: RerankModelRunner, mock_model_instance): """Test document deduplication for external provider. Verifies: @@ -273,7 +279,9 @@ class TestRerankModelRunner: assert len(call_kwargs["docs"]) == 2 assert len(result) == 2 - def test_combined_threshold_and_top_k(self, rerank_runner, mock_model_instance, sample_documents): + def test_combined_threshold_and_top_k( + self, rerank_runner: RerankModelRunner, mock_model_instance, sample_documents: list[Document] + ): """Test combined score threshold and top-k selection. Verifies: @@ -307,7 +315,9 @@ class TestRerankModelRunner: assert result[0].metadata["score"] == 0.95 assert result[1].metadata["score"] == 0.85 - def test_metadata_preservation(self, rerank_runner, mock_model_instance, sample_documents): + def test_metadata_preservation( + self, rerank_runner: RerankModelRunner, mock_model_instance, sample_documents: list[Document] + ): """Test that original metadata is preserved after reranking. Verifies: @@ -334,7 +344,7 @@ class TestRerankModelRunner: assert result[0].metadata["score"] == 0.90 assert result[0].provider == "dify" - def test_empty_documents_list(self, rerank_runner, mock_model_instance): + def test_empty_documents_list(self, rerank_runner: RerankModelRunner, mock_model_instance): """Test handling of empty documents list. Verifies: @@ -523,7 +533,9 @@ class TestRerankModelRunnerMultimodal: docs_arg = mock_text_rerank.call_args.args[1] assert len(docs_arg) == 1 - def test_fetch_multimodal_rerank_image_query_invokes_multimodal_model(self, rerank_runner, mock_model_instance): + def test_fetch_multimodal_rerank_image_query_invokes_multimodal_model( + self, rerank_runner: RerankModelRunner, mock_model_instance + ): text_doc = Document( page_content="text-content", metadata={"doc_id": "txt-1", "doc_type": DocType.TEXT}, diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py new file mode 100644 index 00000000000..adcf5585d39 --- /dev/null +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_attachment_entry.py @@ -0,0 +1,36 @@ +"""Focused tests for attachment-aware dataset retrieval entry behavior.""" + +from unittest.mock import MagicMock, patch + +from core.rag.retrieval.dataset_retrieval import DatasetRetrieval +from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest + + +def test_knowledge_retrieval_allows_attachment_only_requests() -> None: + retrieval = DatasetRetrieval() + available_dataset = MagicMock(id="dataset-1") + + request = KnowledgeRetrievalRequest( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + dataset_ids=["dataset-1"], + query=None, + retrieval_mode="multiple", + top_k=4, + score_threshold=0.0, + reranking_mode="reranking_model", + reranking_enable=True, + attachment_ids=["attachment-1"], + ) + + with ( + patch.object(retrieval, "_check_knowledge_rate_limit"), + patch.object(retrieval, "_get_available_datasets", return_value=[available_dataset]), + patch.object(retrieval, "multiple_retrieve", return_value=[]) as mock_multiple, + ): + result = retrieval.knowledge_retrieval(request) + + assert result == [] + mock_multiple.assert_called_once() diff --git a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py index 976de10d89d..12117241b5d 100644 --- a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py +++ b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py @@ -126,6 +126,7 @@ Run with coverage: """ import asyncio +import logging import string import sys import types @@ -644,13 +645,13 @@ class TestTextSplitterBasePaths: with pytest.raises(NotImplementedError): asyncio.run(splitter.atransform_documents([Document(page_content="x", metadata={})])) - def test_merge_splits_logs_warning_for_oversized_total(self): + def test_merge_splits_logs_warning_for_oversized_total(self, caplog: pytest.LogCaptureFixture): """Cover logger.warning path in _merge_splits.""" splitter = RecursiveCharacterTextSplitter(chunk_size=5, chunk_overlap=1) - with patch("core.rag.splitter.text_splitter.logger.warning") as mock_warning: + with caplog.at_level(logging.WARNING, logger="core.rag.splitter.text_splitter"): merged = splitter._merge_splits(["abcdefghij", "b"], "", [10, 1]) assert merged - mock_warning.assert_called_once() + assert any(record.levelno == logging.WARNING for record in caplog.records) # ============================================================================ diff --git a/api/tests/unit_tests/core/schemas/test_registry.py b/api/tests/unit_tests/core/schemas/test_registry.py index 6174b66f470..8a3aa5030e2 100644 --- a/api/tests/unit_tests/core/schemas/test_registry.py +++ b/api/tests/unit_tests/core/schemas/test_registry.py @@ -2,6 +2,8 @@ import json from pathlib import Path from unittest.mock import patch +import pytest + from core.schemas.registry import SchemaRegistry @@ -71,7 +73,7 @@ class TestSchemaRegistry: assert registry.metadata[uri]["title"] == "Test Schema" assert registry.metadata[uri]["version"] == "v1" - def test_load_schema_invalid_json(self, tmp_path, caplog): + def test_load_schema_invalid_json(self, tmp_path: Path, caplog: pytest.LogCaptureFixture): schema_path = tmp_path / "invalid.json" schema_path.write_text("invalid json") @@ -81,7 +83,7 @@ class TestSchemaRegistry: assert "Failed to load schema v1/invalid" in caplog.text - def test_load_schema_os_error(self, tmp_path, caplog): + def test_load_schema_os_error(self, tmp_path: Path, caplog: pytest.LogCaptureFixture): schema_path = tmp_path / "error.json" schema_path.write_text("{}") diff --git a/api/tests/unit_tests/core/schemas/test_resolver.py b/api/tests/unit_tests/core/schemas/test_resolver.py index ba6fa0d5365..d53b01364eb 100644 --- a/api/tests/unit_tests/core/schemas/test_resolver.py +++ b/api/tests/unit_tests/core/schemas/test_resolver.py @@ -703,9 +703,12 @@ class TestSchemaResolverClass: # For schemas without refs, hybrid should be competitive or better if not expected: # No refs case - # Hybrid might be slightly slower due to JSON serialization overhead, - # but should not be dramatically worse - assert avg_hybrid < avg_recursive * 5 # At most 5x slower + relative_slowdown_limit = 5.0 + absolute_noise_budget_seconds = 2e-4 + + # JSON serialization has a fixed overhead that dominates tiny schemas, + # so allow a small absolute noise budget on top of the relative limit. + assert avg_hybrid < (avg_recursive * relative_slowdown_limit) + absolute_noise_budget_seconds def test_string_matching_edge_cases(self): """Test edge cases for string-based detection""" 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 3f6b1ec1545..a99fd1f248f 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 @@ -4,6 +4,7 @@ import calendar import math from datetime import date from types import SimpleNamespace +from zoneinfo import ZoneInfo import pytest @@ -66,6 +67,20 @@ def test_localtime_to_timestamp_tool(): ts_value = float(ts_message.strip()) assert math.isfinite(ts_value) assert ts_value >= 0 + assert ( + LocaltimeToTimestampTool.localtime_to_timestamp( + "2024-01-01 10:00:00", + "%Y-%m-%d %H:%M:%S", + ZoneInfo("UTC"), + ) + == 1704103200 + ) + with pytest.raises(ToolInvokeError, match="local_tz must be"): + LocaltimeToTimestampTool.localtime_to_timestamp( + "2024-01-01 10:00:00", + "%Y-%m-%d %H:%M:%S", + object(), # type: ignore[arg-type] + ) with pytest.raises(ToolInvokeError): LocaltimeToTimestampTool.localtime_to_timestamp("bad", "%Y-%m-%d %H:%M:%S", "UTC") diff --git a/api/tests/unit_tests/core/tools/utils/test_tool_engine_serialization.py b/api/tests/unit_tests/core/tools/utils/test_tool_engine_serialization.py index 4029edfb686..da699ef6101 100644 --- a/api/tests/unit_tests/core/tools/utils/test_tool_engine_serialization.py +++ b/api/tests/unit_tests/core/tools/utils/test_tool_engine_serialization.py @@ -459,23 +459,24 @@ class TestEndToEndSerialization: def _verify_all_complex_types_converted(self, data): """Helper method to verify all complex types were properly converted""" - if isinstance(data, dict): - for key, value in data.items(): - if key in ["id", "checksum"]: - # These should be strings (UUID/bytes converted) - assert isinstance(value, str) - elif key in ["created_at", "last_login", "timestamp", "uploaded_at"]: - # These should be strings (datetime converted) - assert isinstance(value, str) - elif key in ["total_time", "duration"]: - # These should be floats (Decimal converted) - assert isinstance(value, float) - elif key == "metrics": - # This should be a list (ndarray converted) - assert isinstance(value, list) - else: - # Recursively check nested structures - self._verify_all_complex_types_converted(value) - elif isinstance(data, list): - for item in data: - self._verify_all_complex_types_converted(item) + match data: + case dict(): + for key, value in data.items(): + if key in ["id", "checksum"]: + # These should be strings (UUID/bytes converted) + assert isinstance(value, str) + elif key in ["created_at", "last_login", "timestamp", "uploaded_at"]: + # These should be strings (datetime converted) + assert isinstance(value, str) + elif key in ["total_time", "duration"]: + # These should be floats (Decimal converted) + assert isinstance(value, float) + elif key == "metrics": + # This should be a list (ndarray converted) + assert isinstance(value, list) + else: + # Recursively check nested structures + self._verify_all_complex_types_converted(value) + case list(): + for item in data: + self._verify_all_complex_types_converted(item) diff --git a/api/tests/unit_tests/core/workflow/generator/test_runner.py b/api/tests/unit_tests/core/workflow/generator/test_runner.py index 067fb1cf950..ec7a8f32dfc 100644 --- a/api/tests/unit_tests/core/workflow/generator/test_runner.py +++ b/api/tests/unit_tests/core/workflow/generator/test_runner.py @@ -413,7 +413,7 @@ class TestWorkflowGeneratorTransientRetry: } ) - def test_retries_transient_invoke_error_then_succeeds(self, monkeypatch): + def test_retries_transient_invoke_error_then_succeeds(self, monkeypatch: pytest.MonkeyPatch): # The planner's first invoke raises a transient connection error; the # retry succeeds and the pipeline completes normally. Sleep is patched # out so the test doesn't actually wait for the backoff. @@ -443,7 +443,7 @@ class TestWorkflowGeneratorTransientRetry: # planner (failed once + retried) + builder = 3 invocations total. assert model_instance.invoke_llm.call_count == 3 - def test_gives_up_after_exhausting_transient_retries(self, monkeypatch): + def test_gives_up_after_exhausting_transient_retries(self, monkeypatch: pytest.MonkeyPatch): # Every attempt hits the transient error — once we exhaust the retry # budget the failure surfaces as a normal error envelope rather than # hanging or looping forever. @@ -471,7 +471,7 @@ class TestWorkflowGeneratorTransientRetry: assert model_instance.invoke_llm.call_count == _INVOKE_MAX_ATTEMPTS - def test_does_not_retry_permanent_invoke_error(self, monkeypatch): + def test_does_not_retry_permanent_invoke_error(self, monkeypatch: pytest.MonkeyPatch): # An auth error is permanent — retrying just burns latency and quota. # The runner must fail on the first attempt. # If the code wrongly slept here we'd want the test to still be fast; diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py index 1a2e09fd817..1ff87614b08 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_plugin_tools_builder.py @@ -17,6 +17,7 @@ from core.tools.entities.tool_entities import ( ToolInvokeMessage, ToolParameter, ) +from core.tools.tool_manager import ToolManager from core.workflow.nodes.agent_v2.plugin_tools_builder import ( WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError, @@ -32,6 +33,8 @@ class FakeRuntimeProvider: self.tool = tool self.last_agent_tool: AgentToolEntity | None = None self.last_invoke_from: InvokeFrom | None = None + self.last_allow_file_parameters: bool | None = None + self.last_use_default_for_missing_form_parameters: bool | None = None def get_agent_tool_runtime( self, @@ -41,11 +44,25 @@ class FakeRuntimeProvider: user_id: str | None = None, invoke_from: InvokeFrom = InvokeFrom.DEBUGGER, variable_pool: Any | None = None, + allow_file_parameters: bool = False, + use_default_for_missing_form_parameters: bool = False, ) -> Tool: self.last_agent_tool = agent_tool self.last_invoke_from = invoke_from + self.last_allow_file_parameters = allow_file_parameters + self.last_use_default_for_missing_form_parameters = use_default_for_missing_form_parameters if isinstance(self.tool, Exception): raise self.tool + if self.tool.runtime is not None: + runtime_parameters = ToolManager._convert_tool_parameters_type( + self.tool.get_merged_runtime_parameters(), + variable_pool, + agent_tool.tool_parameters, + typ="agent", + allow_file_parameters=allow_file_parameters, + use_default_for_missing_form_parameters=use_default_for_missing_form_parameters, + ) + self.tool.runtime.runtime_parameters.update(runtime_parameters) return self.tool @@ -103,6 +120,67 @@ def _tool(*, runtime_parameters: dict[str, Any] | None = None) -> FakeTool: return FakeTool(entity=entity, runtime=runtime) +def _file_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="audio_file", + label=I18nObject(en_US="Audio File"), + type=ToolParameter.ToolParameterType.FILE, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The audio file to be converted.", + ) + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="asr", + label=I18nObject(en_US="Speech To Text"), + provider="audio", + ), + description=ToolDescription(human=I18nObject(en_US="Speech To Text"), llm="Convert audio file to text."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + +def _tts_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="text", + label=I18nObject(en_US="Text"), + type=ToolParameter.ToolParameterType.STRING, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The text to be converted.", + ), + ToolParameter( + name="model", + label=I18nObject(en_US="Model"), + type=ToolParameter.ToolParameterType.SELECT, + form=ToolParameter.ToolParameterForm.FORM, + required=True, + options=[ + {"value": "provider-a#model-a", "label": {"en_US": "model-a(provider-a)"}}, + {"value": "provider-b#model-b", "label": {"en_US": "model-b(provider-b)"}}, + ], + ), + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="tts", + label=I18nObject(en_US="Text To Speech"), + provider="audio", + ), + description=ToolDescription(human=I18nObject(en_US="Text To Speech"), llm="Convert text to audio file."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + def _build( builder: WorkflowAgentPluginToolsBuilder, tools: AgentSoulToolsConfig, @@ -157,6 +235,62 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): assert runtime_provider.last_agent_tool.provider_type.value == "plugin" +def test_builds_dify_plugin_tool_with_file_llm_parameter(): + runtime_provider = FakeRuntimeProvider(_file_tool()) + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "asr", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.tool_name == "asr" + assert prepared.runtime_parameters == {} + assert prepared.parameters[0].name == "audio_file" + assert prepared.parameters[0].type == "file" + # The public Agent backend DTO carries non-scalar tool inputs in + # ``parameters``; legacy JSON schema generation omits file fields. + assert prepared.parameters_json_schema == {"type": "object", "properties": {}, "required": []} + assert runtime_provider.last_allow_file_parameters is True + assert runtime_provider.last_use_default_for_missing_form_parameters is True + + +def test_builds_dify_plugin_tool_with_missing_required_select_default(): + runtime_provider = FakeRuntimeProvider(_tts_tool()) + builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "audio", + "provider_type": "builtin", + "tool_name": "tts", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + prepared = result.tools[0] + assert prepared.tool_name == "tts" + assert prepared.runtime_parameters == {"model": "provider-a#model-a"} + assert runtime_provider.last_use_default_for_missing_form_parameters is True + + def test_rejects_duplicate_exposed_tool_names(): builder = WorkflowAgentPluginToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool())) tools = AgentSoulToolsConfig.model_validate( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 3cd77f7f2ea..9313aea51e6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -21,6 +21,9 @@ from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding from models.agent_config_entities import ( AgentSoulConfig, AgentSoulModelConfig, + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, ) @@ -321,6 +324,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): "secret_refs": [ {"variable": "TOKEN", "credential_id": "credential-1"}, {"name": "API_KEY", "provider_credential_id": "credential-2"}, + {"name": "EDITABLE_TOKEN", "value": "credential-3"}, {"ref": "missing-name"}, ], }, @@ -341,6 +345,7 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): assert config["secret_refs"] == [ {"name": "TOKEN", "ref": "credential-1"}, {"name": "API_KEY", "ref": "credential-2"}, + {"name": "EDITABLE_TOKEN", "ref": "credential-3"}, ] assert config["sandbox"] is None @@ -491,6 +496,119 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): assert plugin_tools_builder.last_invoke_from == context.dify_context.invoke_from +def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig.model_validate( + { + "prompt": {"system_prompt": "You are careful."}, + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + }, + "knowledge": { + "datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}], + "query_config": { + "top_k": 6, + "score_threshold": 0.4, + "score_threshold_enabled": True, + }, + }, + } + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} + knowledge_layer = layers["knowledge"] + assert knowledge_layer["type"] == "dify.knowledge_base" + assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + assert knowledge_layer["config"] == { + "dataset_ids": ["dataset-1", "dataset-2"], + "retrieval": { + "mode": "multiple", + "top_k": 6, + "score_threshold": 0.4, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": None, + "weights": None, + "model": None, + }, + "metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None}, + "max_result_content_chars": 2000, + "max_observation_chars": 12000, + } + + +def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig.model_validate( + { + "prompt": {"system_prompt": "You are careful."}, + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + }, + "knowledge": { + "datasets": [{"id": "dataset-1"}], + "query_config": {}, + }, + } + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge") + assert knowledge_layer["config"]["retrieval"]["top_k"] == 4 + + +def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids(): + context = _context() + snapshot = AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=AgentSoulConfig.model_validate( + { + "prompt": {"system_prompt": "You are careful."}, + "model": { + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + }, + "knowledge": { + "datasets": [{"id": " "}, {}], + }, + } + ), + ) + context = replace(context, snapshot=snapshot) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + dumped = result.request.model_dump(mode="json") + assert all(layer["name"] != "knowledge" for layer in dumped["composition"]["layers"]) + + def test_build_passes_saved_session_snapshot_to_agent_backend_request(): session_snapshot = CompositorSessionSnapshot(layers=[]) context = replace(_context(), session_snapshot=session_snapshot) @@ -630,6 +748,40 @@ def test_array_output_emits_typed_items_per_array_item(): assert output_schema["required"] == ["tags"] +def test_nested_declared_output_emits_object_and_array_child_schema(): + profile_output = DeclaredOutputConfig( + name="profile", + type=DeclaredOutputType.OBJECT, + children=[ + DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), + DeclaredOutputChildConfig( + name="nickname", + type=DeclaredOutputType.STRING, + required=False, + description="Optional display name", + ), + DeclaredOutputChildConfig( + name="addresses", + type=DeclaredOutputType.ARRAY, + array_item=DeclaredArrayItem( + type=DeclaredOutputType.OBJECT, + description="Address item", + children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], + ), + ), + ], + ) + + schema = WorkflowAgentRuntimeRequestBuilder._schema_for_declared_output(profile_output) + + assert schema["properties"]["email"] == {"type": "string"} + assert schema["properties"]["nickname"] == {"type": "string", "description": "Optional display name"} + assert schema["properties"]["addresses"]["items"]["properties"]["city"] == {"type": "string"} + assert schema["properties"]["addresses"]["items"]["description"] == "Address item" + assert schema["properties"]["addresses"]["items"]["required"] == ["city"] + assert schema["required"] == ["email", "addresses"] + + def test_effective_declared_outputs_passthrough_when_user_declared(): """effective_declared_outputs() must return user-provided outputs verbatim when non-empty; only empty input gets PRD defaults injected.""" @@ -829,3 +981,36 @@ def test_feature_manifest_marks_human_supported_when_configured(): assert manifest["reserved_status"]["human"] == "supported_by_ask_human_hitl" # configured human no longer produces a "not executed" warning assert all("human" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) + + +def test_feature_manifest_marks_knowledge_supported_without_warning_when_configured(): + from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest + + soul = AgentSoulConfig.model_validate( + { + "knowledge": { + "datasets": [{"id": "dataset-1", "name": "Product Docs"}], + } + } + ) + + manifest = build_runtime_feature_manifest(soul) + assert "knowledge" in manifest["supported"] + assert "knowledge" not in manifest["reserved"] + assert manifest["reserved_status"]["knowledge"] == "supported_by_knowledge_layer" + assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"]) + + +def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured(): + from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest + + soul = AgentSoulConfig.model_validate( + { + "knowledge": { + "datasets": [{"id": " "}, {}], + } + } + ) + + manifest = build_runtime_feature_manifest(soul) + assert manifest["reserved_status"]["knowledge"] == "not_configured" diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py index bb7947ce656..67e7bfafca7 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py @@ -315,7 +315,7 @@ def test_cleanup_layer_fans_out_to_every_active_session(): assert {entry[1] for entry in session_store.cleaned} == {"cleanup-run-many"} -def test_cleanup_layer_warns_when_http_enabled_but_client_missing(caplog): +def test_cleanup_layer_warns_when_http_enabled_but_client_missing(caplog: pytest.LogCaptureFixture): """The HTTP cleanup branch must defensively skip when no client was wired. This is the deployment-misconfig path: ``_HTTP_CLEANUP_SUPPORTED`` was @@ -347,7 +347,7 @@ def test_cleanup_layer_warns_when_http_enabled_but_client_missing(caplog): assert any("no agent backend client is wired in" in record.message for record in caplog.records) -def test_cleanup_layer_skips_workflow_terminal_when_workflow_run_id_missing(caplog): +def test_cleanup_layer_skips_workflow_terminal_when_workflow_run_id_missing(caplog: pytest.LogCaptureFixture): """``workflow_run_id`` is the keying field; without it the fanout cannot target a row, so the layer logs a warning and bails.""" import logging 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 909de623d82..79581cc8811 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 @@ -1396,7 +1396,7 @@ class TestSaveMultimodalOutputAndConvertResultToMarkdown: mock_file_saver.save_binary_string.assert_not_called() mock_file_saver.save_remote_url.assert_not_called() - def test_unknown_item_type(self, llm_node_for_multimodal, caplog): + def test_unknown_item_type(self, llm_node_for_multimodal, caplog: pytest.LogCaptureFixture): llm_node, mock_file_saver = llm_node_for_multimodal unknown_item = self._UnknownItem() 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 51049f87923..7a6328ffb4b 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 @@ -332,7 +332,9 @@ def test_email_delivery_method_extracts_variable_selectors() -> None: assert method.extract_variable_selectors() == [["start", "name"]] -def test_email_delivery_method_extracts_variable_selectors_skips_short_selectors(monkeypatch) -> None: +def test_email_delivery_method_extracts_variable_selectors_skips_short_selectors( + monkeypatch: pytest.MonkeyPatch, +) -> None: method = EmailDeliveryMethod( enabled=True, config=EmailDeliveryConfig( 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 bd18402c583..0baee47665c 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -1,18 +1,26 @@ from collections.abc import Mapping from types import SimpleNamespace -from unittest.mock import MagicMock, patch, sentinel +from unittest.mock import MagicMock, Mock, patch, sentinel import pytest from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom +from core.plugin.impl.model import PluginModelClient +from core.plugin.impl.model_runtime import PluginModelRuntime +from core.plugin.plugin_service import PluginService from core.workflow import node_factory from core.workflow import template_rendering as workflow_template_rendering +from core.workflow.node_runtime import DifyPreparedLLM from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeType +from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.nodes.code.entities import CodeLanguage from graphon.nodes.llm.entities import LLMNodeData from graphon.nodes.llm.node import LLMNode +from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.variables.segments import ArrayObjectSegment, StringSegment @@ -35,6 +43,41 @@ def _node_constructor(*, return_value): return constructor +def _build_llm_model_schema(*, features: list[ModelFeature] | None = None) -> AIModelEntity: + return AIModelEntity( + model="model", + label=I18nObject(en_US="Model"), + model_type=ModelType.LLM, + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={}, + features=features, + ) + + +class _ModelTypeInstanceStub(LargeLanguageModel): + def __init__(self, *, model_runtime: object) -> None: + self.model_runtime = model_runtime + + +class _ModelInstanceStub: + def __init__( + self, + *, + model_runtime: object, + model_schema: AIModelEntity, + ) -> None: + self.provider = "langgenius/openai/openai" + self.model_name = "model" + self.credentials = {"api_key": "secret"} + self.parameters = {} + self.stop = () + self.model_type_instance = _ModelTypeInstanceStub(model_runtime=model_runtime) + self._model_schema = model_schema + + def get_model_schema(self) -> AIModelEntity: + return self._model_schema + + class TestResolveWorkflowNodeClass: def test_matching_version_uses_registry_mapping(self, monkeypatch) -> None: document_extractor_class = sentinel.document_extractor_class @@ -667,7 +710,7 @@ class TestDifyNodeFactoryCreateNode: memory = sentinel.memory factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance) factory._build_memory_for_llm_node = MagicMock(return_value=memory) - with patch.object(node_factory, "DifyPreparedLLM", return_value=wrapped_model_instance) as prepared_llm: + with patch.object(factory, "_wrap_model_instance_for_node", return_value=wrapped_model_instance) as wrap_model: kwargs = factory._build_llm_compatible_node_init_kwargs( node_class=sentinel.node_class, node_data=node_data, @@ -686,9 +729,70 @@ class TestDifyNodeFactoryCreateNode: node_data=node_data, model_instance=sentinel.model_instance, ) - prepared_llm.assert_called_once_with(sentinel.model_instance) + wrap_model.assert_called_once_with( + node_data=node_data, + model_instance=sentinel.model_instance, + ) assert kwargs["model_instance"] is wrapped_model_instance + def test_build_llm_compatible_node_init_kwargs_uses_polling_wrapper_for_polling_llm_node(self, factory): + 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": False, "variable_selector": []}, + "vision": {"enabled": False}, + } + ) + plugin_runtime = PluginModelRuntime( + tenant_id="tenant-id", + user_id="user-id", + client=Mock(spec=PluginModelClient), + plugin_service=PluginService, + ) + model_instance = _ModelInstanceStub( + model_runtime=plugin_runtime, + model_schema=_build_llm_model_schema(features=[ModelFeature.POLLING]), + ) + factory._build_model_instance_for_llm_node = MagicMock(return_value=model_instance) + factory._build_memory_for_llm_node = MagicMock(return_value=sentinel.memory) + + 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=False, + include_retriever_attachment_loader=False, + include_jinja2_template_renderer=False, + ) + + assert isinstance(kwargs["model_instance"], LLMPollingCapableProtocol) + + @pytest.mark.parametrize("node_type", [BuiltinNodeTypes.QUESTION_CLASSIFIER, BuiltinNodeTypes.PARAMETER_EXTRACTOR]) + def test_wrap_model_instance_keeps_non_llm_graph_nodes_on_plain_wrapper(self, node_type): + plugin_runtime = PluginModelRuntime( + tenant_id="tenant-id", + user_id="user-id", + client=Mock(spec=PluginModelClient), + plugin_service=PluginService, + ) + model_instance = _ModelInstanceStub( + model_runtime=plugin_runtime, + model_schema=_build_llm_model_schema(features=[ModelFeature.POLLING]), + ) + + wrapped = node_factory.DifyNodeFactory._wrap_model_instance_for_node( + node_data=SimpleNamespace(type=node_type), + model_instance=model_instance, + ) + + assert type(wrapped) is DifyPreparedLLM + assert not isinstance(wrapped, LLMPollingCapableProtocol) + def test_create_node_passes_alias_preserving_llm_data_to_constructor(self, monkeypatch, factory): created_node = object() constructor = _node_constructor(return_value=created_node) 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 bdccea478d9..b32b9f74df7 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -7,6 +7,10 @@ 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.plugin.impl.exc import PluginLLMPollingUnsupportedError +from core.plugin.impl.model import PluginModelClient +from core.plugin.impl.model_runtime import PluginModelRuntime +from core.plugin.plugin_service import PluginService from core.workflow import node_runtime from core.workflow.file_reference import parse_file_reference from core.workflow.human_input_adapter import ( @@ -21,6 +25,7 @@ from core.workflow.node_runtime import ( DifyFileReferenceFactory, DifyHumanInputNodeRuntime, DifyPreparedLLM, + DifyPreparedPollingLLM, DifyPromptMessageSerializer, DifyRetrieverAttachmentLoader, DifyToolFileManager, @@ -31,23 +36,61 @@ from core.workflow.node_runtime import ( ) from graphon.file import File, FileTransferMethod, FileType 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.llm_entities import LLMPollingResult, LLMPollingStatus +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType +from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData +from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.variables.segments import ArrayFileSegment, FileSegment from tests.workflow_test_utils import build_test_run_context -def _build_model_schema() -> AIModelEntity: +def _build_model_schema(*, features: list[ModelFeature] | None = None) -> AIModelEntity: return AIModelEntity( model="gpt-4o-mini", label=I18nObject(en_US="GPT-4o mini"), model_type=ModelType.LLM, fetch_from=FetchFrom.PREDEFINED_MODEL, model_properties={}, + features=features, ) +class _ModelTypeInstanceStub(LargeLanguageModel): + def __init__( + self, + *, + model_schema: AIModelEntity | None, + model_runtime: object | None = None, + ) -> None: + self.model_runtime = model_runtime + self.get_model_schema = Mock(return_value=model_schema) + + +class _ModelInstanceStub: + def __init__( + self, + *, + model_schema: AIModelEntity | None, + model_runtime: object | None = None, + invoke_llm_result: object = sentinel.result, + get_llm_num_tokens_result: int = 32, + ) -> None: + self.provider = "langgenius/openai/openai" + self.model_name = "gpt-4o-mini" + self.parameters = {"temperature": 0.2} + self.stop = ("stop",) + self.credentials = {"api_key": "secret"} + self.model_type_instance = _ModelTypeInstanceStub( + model_schema=model_schema, + model_runtime=model_runtime, + ) + self.get_llm_num_tokens = Mock(return_value=get_llm_num_tokens_result) + self.invoke_llm = Mock(return_value=invoke_llm_result) + + def _build_run_context(*, invoke_from: InvokeFrom | str = InvokeFrom.DEBUGGER) -> dict[str, object]: return build_test_run_context( tenant_id="tenant-id", @@ -126,17 +169,8 @@ def test_dify_file_reference_factory_passes_tenant_id(monkeypatch: pytest.Monkey def test_dify_prepared_llm_wraps_model_instance_calls() -> None: model_schema = _build_model_schema() - model_type_instance = SimpleNamespace(get_model_schema=Mock(return_value=model_schema)) - model_instance = SimpleNamespace( - provider="langgenius/openai/openai", - model_name="gpt-4o-mini", - parameters={"temperature": 0.2}, - stop=("stop",), - credentials={"api_key": "secret"}, - model_type_instance=model_type_instance, - get_llm_num_tokens=Mock(return_value=32), - invoke_llm=Mock(return_value=sentinel.result), - ) + model_instance = _ModelInstanceStub(model_schema=model_schema) + model_type_instance = model_instance.model_type_instance prepared = DifyPreparedLLM(model_instance) assert prepared.provider == "langgenius/openai/openai" @@ -167,11 +201,8 @@ def test_dify_prepared_llm_wraps_model_instance_calls() -> None: def test_dify_prepared_llm_requires_model_schema() -> None: - model_instance = SimpleNamespace( - model_name="gpt-4o-mini", - credentials={}, - model_type_instance=SimpleNamespace(get_model_schema=Mock(return_value=None)), - ) + model_instance = _ModelInstanceStub(model_schema=None) + model_instance.credentials = {} prepared = DifyPreparedLLM(model_instance) with pytest.raises(ValueError, match="Model schema not found"): @@ -179,12 +210,7 @@ def test_dify_prepared_llm_requires_model_schema() -> None: def test_dify_prepared_llm_delegates_structured_output_helper(monkeypatch: pytest.MonkeyPatch) -> None: - model_instance = SimpleNamespace( - provider="langgenius/openai/openai", - model_name="gpt-4o-mini", - credentials={"api_key": "secret"}, - model_type_instance=SimpleNamespace(get_model_schema=Mock(return_value=_build_model_schema())), - ) + model_instance = _ModelInstanceStub(model_schema=_build_model_schema()) prepared = DifyPreparedLLM(model_instance) invoke_structured = MagicMock(return_value=sentinel.structured) monkeypatch.setattr(node_runtime, "invoke_llm_with_structured_output", invoke_structured) @@ -217,6 +243,94 @@ def test_dify_prepared_llm_identifies_structured_output_errors() -> None: assert prepared.is_structured_output_parse_error(ValueError("other")) is False +def test_dify_prepared_polling_llm_delegates_to_plugin_runtime() -> None: + polling_result = LLMPollingResult( + status=LLMPollingStatus.RUNNING, + plugin_state={"task_id": "poll-1"}, + next_check_after_seconds=2, + ) + plugin_runtime = PluginModelRuntime( + tenant_id="tenant-id", + user_id="user-id", + client=Mock(spec=PluginModelClient), + plugin_service=PluginService, + ) + plugin_runtime.start_llm_polling = Mock(return_value=polling_result) # type: ignore[method-assign] + plugin_runtime.check_llm_polling = Mock(return_value=polling_result) # type: ignore[method-assign] + model_instance = _ModelInstanceStub( + model_schema=_build_model_schema(features=[ModelFeature.POLLING]), + model_runtime=plugin_runtime, + ) + + prepared = DifyPreparedPollingLLM(model_instance) + + assert isinstance(prepared, LLMPollingCapableProtocol) + assert ( + prepared.start_llm_polling( + prompt_messages=[], + model_parameters={"temperature": 0.1}, + tools=[], + stop=("END",), + json_schema={"type": "object"}, + ) + == polling_result + ) + assert ( + prepared.check_llm_polling( + plugin_state={"task_id": "poll-1"}, + ) + == polling_result + ) + plugin_runtime.start_llm_polling.assert_called_once_with( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + prompt_messages=[], + model_parameters={"temperature": 0.1}, + tools=[], + stop=("END",), + json_schema={"type": "object"}, + ) + plugin_runtime.check_llm_polling.assert_called_once_with( + provider="langgenius/openai/openai", + model="gpt-4o-mini", + credentials={"api_key": "secret"}, + plugin_state={"task_id": "poll-1"}, + ) + + +def test_dify_prepared_polling_llm_raise_exception_when_polling_is_unsupported() -> None: + llm_result = node_runtime.LLMResult( + model="gpt-4o-mini", + prompt_messages=[], + message=AssistantPromptMessage(content="sync-result"), + usage=node_runtime.LLMUsage.empty_usage(), + ) + plugin_runtime = PluginModelRuntime( + tenant_id="tenant-id", + user_id="user-id", + client=Mock(), + plugin_service=Mock(), + ) + plugin_runtime.start_llm_polling = Mock(side_effect=PluginLLMPollingUnsupportedError("Polling unsupported")) # type: ignore[method-assign] + model_instance = _ModelInstanceStub( + model_schema=_build_model_schema(features=[ModelFeature.POLLING]), + model_runtime=plugin_runtime, + invoke_llm_result=llm_result, + ) + + prepared = DifyPreparedPollingLLM(model_instance) + + with pytest.raises(PluginLLMPollingUnsupportedError): + prepared.start_llm_polling( + prompt_messages=[], + model_parameters={"temperature": 0.1}, + tools=None, + stop=None, + json_schema=None, + ) + + def test_dify_prompt_message_serializer_delegates(monkeypatch: pytest.MonkeyPatch) -> None: serialize = MagicMock(return_value={"prompt": "value"}) monkeypatch.setattr(node_runtime.PromptMessageUtil, "prompt_messages_to_prompt_for_saving", serialize) diff --git a/api/tests/unit_tests/enterprise/telemetry/test_exporter.py b/api/tests/unit_tests/enterprise/telemetry/test_exporter.py index 6bdae139237..674a2026131 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_exporter.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_exporter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -88,11 +89,10 @@ def test_api_key_and_custom_headers_merge(mock_metric_exporter: MagicMock, mock_ assert ("x-custom", "foo") in headers -@patch("enterprise.telemetry.exporter.logger") @patch("enterprise.telemetry.exporter.GRPCSpanExporter") @patch("enterprise.telemetry.exporter.GRPCMetricExporter") def test_api_key_overrides_conflicting_header( - mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, mock_logger: MagicMock + mock_metric_exporter: MagicMock, mock_span_exporter: MagicMock, caplog ) -> None: """Test that API key overrides conflicting authorization header and logs warning.""" mock_config = SimpleNamespace( @@ -105,7 +105,8 @@ def test_api_key_overrides_conflicting_header( ENTERPRISE_OTLP_API_KEY="test-key", ) - EnterpriseExporter(mock_config) + with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"): + EnterpriseExporter(mock_config) # Verify Bearer header takes precedence assert mock_span_exporter.call_args is not None @@ -116,11 +117,8 @@ def test_api_key_overrides_conflicting_header( assert ("authorization", "Basic old") not in headers # Verify warning was logged - mock_logger.warning.assert_called_once() - assert mock_logger.warning.call_args is not None - warning_message = mock_logger.warning.call_args[0][0] - assert "ENTERPRISE_OTLP_API_KEY is set" in warning_message - assert "authorization" in warning_message + assert "ENTERPRISE_OTLP_API_KEY is set" in caplog.text + assert "authorization" in caplog.text @patch("enterprise.telemetry.exporter.GRPCSpanExporter") @@ -535,33 +533,33 @@ def test_export_span_cross_workflow_parent_context() -> None: assert kwargs["context"] is not None -@patch("enterprise.telemetry.exporter.logger") -def test_export_span_logs_exception_on_error(mock_logger: MagicMock) -> None: +def test_export_span_logs_exception_on_error(caplog) -> None: """If the span block raises, the exception is logged and context is still cleared.""" exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer() mock_tracer.start_as_current_span.side_effect = RuntimeError("boom") - exporter.export_span(name="bad.span", attributes={}) # must not raise + with caplog.at_level(logging.ERROR, logger="enterprise.telemetry.exporter"): + exporter.export_span(name="bad.span", attributes={}) # must not raise - mock_logger.exception.assert_called_once() - assert "bad.span" in mock_logger.exception.call_args[0][1] + assert "Failed to export span" in caplog.text + assert "bad.span" in caplog.text -@patch("enterprise.telemetry.exporter.logger") -def test_export_span_invalid_trace_correlation_logs_warning(mock_logger: MagicMock) -> None: +def test_export_span_invalid_trace_correlation_logs_warning(caplog) -> None: """Invalid UUID for trace_correlation_override triggers a warning log.""" exporter, mock_tracer, mock_span = _make_exporter_with_mock_tracer() parent_uid = "987fbc97-4bed-5078-9f07-9141ba07c9f3" - exporter.export_span( - name="link.span", - attributes={}, - correlation_id="not-a-valid-uuid", - parent_span_id_source=parent_uid, - ) + with caplog.at_level(logging.WARNING, logger="enterprise.telemetry.exporter"): + exporter.export_span( + name="link.span", + attributes={}, + correlation_id="not-a-valid-uuid", + parent_span_id_source=parent_uid, + ) - mock_logger.warning.assert_called() + assert "Invalid trace correlation UUID for cross-workflow link" in caplog.text # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/enterprise/telemetry/test_metric_handler.py b/api/tests/unit_tests/enterprise/telemetry/test_metric_handler.py index 56c42a57d5d..9a04841e390 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_metric_handler.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_metric_handler.py @@ -222,7 +222,7 @@ def test_idempotency_first_seen(sample_envelope, mock_redis): ) -def test_idempotency_redis_failure_fails_open(sample_envelope, mock_redis, caplog): +def test_idempotency_redis_failure_fails_open(sample_envelope, mock_redis, caplog: pytest.LogCaptureFixture): mock_redis.set.side_effect = Exception("Redis unavailable") handler = EnterpriseMetricHandler() diff --git a/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py b/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py index 0edd0ace27e..791c665d93c 100644 --- a/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py +++ b/api/tests/unit_tests/enterprise/telemetry/test_telemetry_log.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging import uuid -from unittest.mock import MagicMock, patch + +import pytest # --------------------------------------------------------------------------- # compute_trace_id_hex @@ -135,134 +137,128 @@ class TestEmitTelemetryLog: compute_trace_id_hex.cache_clear() compute_span_id_hex.cache_clear() - @patch("enterprise.telemetry.telemetry_log.logger") - def test_logs_info_with_event_name_and_signal(self, mock_logger: MagicMock) -> None: + def test_logs_info_with_event_name_and_signal(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log( + event_name="dify.workflow.run", + attributes={"tenant_id": "t1"}, + signal="metric_only", + ) - emit_telemetry_log( - event_name="dify.workflow.run", - attributes={"tenant_id": "t1"}, - signal="metric_only", - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.INFO + assert record.getMessage() == "telemetry.metric_only" + assert hasattr(record, "attributes") + assert record.attributes["dify.event.name"] == "dify.workflow.run" + assert record.attributes["dify.event.signal"] == "metric_only" + assert record.attributes["tenant_id"] == "t1" - mock_logger.info.assert_called_once() - args, kwargs = mock_logger.info.call_args - assert args[0] == "telemetry.%s" - assert args[1] == "metric_only" - extra = kwargs["extra"] - assert extra["attributes"]["dify.event.name"] == "dify.workflow.run" - assert extra["attributes"]["dify.event.signal"] == "metric_only" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_no_log_when_info_disabled(self, mock_logger: MagicMock) -> None: + def test_no_log_when_info_disabled(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = False + with caplog.at_level(logging.WARNING, logger="dify.telemetry"): + emit_telemetry_log(event_name="dify.workflow.run", attributes={}) - emit_telemetry_log(event_name="dify.workflow.run", attributes={}) + assert len(caplog.records) == 0 - mock_logger.info.assert_not_called() - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None: + def test_trace_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source=uid) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" in extra - assert len(extra["trace_id"]) == 32 + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "trace_id") + assert len(record.trace_id) == 32 - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_id_absent_when_invalid_source(self, mock_logger: MagicMock) -> None: + def test_trace_id_absent_when_invalid_source(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id") - emit_telemetry_log(event_name="test.event", attributes={}, trace_id_source="bad-id") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert not hasattr(record, "trace_id") - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" not in extra - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_span_id_added_to_extra_when_valid_uuid(self, mock_logger: MagicMock) -> None: + def test_span_id_added_to_extra_when_valid_uuid(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, span_id_source=uid) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "span_id" in extra - assert len(extra["span_id"]) == 16 + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "span_id") + assert len(record.span_id) == 16 - @patch("enterprise.telemetry.telemetry_log.logger") - def test_tenant_id_added_when_provided(self, mock_logger: MagicMock) -> None: + def test_tenant_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99") - emit_telemetry_log(event_name="test.event", attributes={}, tenant_id="tenant-99") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "tenant_id") + assert record.tenant_id == "tenant-99" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["tenant_id"] == "tenant-99" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_user_id_added_when_provided(self, mock_logger: MagicMock) -> None: + def test_user_id_added_when_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42") - emit_telemetry_log(event_name="test.event", attributes={}, user_id="user-42") + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "user_id") + assert record.user_id == "user-42" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["user_id"] == "user-42" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_tenant_and_user_id_absent_when_not_provided(self, mock_logger: MagicMock) -> None: + def test_tenant_and_user_id_absent_when_not_provided(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}) - emit_telemetry_log(event_name="test.event", attributes={}) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert not hasattr(record, "tenant_id") + assert not hasattr(record, "user_id") - extra = mock_logger.info.call_args.kwargs["extra"] - assert "tenant_id" not in extra - assert "user_id" not in extra - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_caller_attributes_merged_into_attrs(self, mock_logger: MagicMock) -> None: + def test_caller_attributes_merged_into_attrs(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log( + event_name="dify.node.run", + attributes={"node_type": "code", "elapsed": 0.5}, + ) - emit_telemetry_log( - event_name="dify.node.run", - attributes={"node_type": "code", "elapsed": 0.5}, - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "attributes") + assert record.attributes["node_type"] == "code" + assert record.attributes["elapsed"] == 0.5 - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["node_type"] == "code" - assert extra["attributes"]["elapsed"] == 0.5 - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_signal_span_detail_forwarded(self, mock_logger: MagicMock) -> None: + def test_signal_span_detail_forwarded(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_telemetry_log - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail") - emit_telemetry_log(event_name="test.event", attributes={}, signal="span_detail") - - args = mock_logger.info.call_args[0] - assert args[1] == "span_detail" - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["dify.event.signal"] == "span_detail" + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "telemetry.span_detail" + assert hasattr(record, "attributes") + assert record.attributes["dify.event.signal"] == "span_detail" # --------------------------------------------------------------------------- @@ -277,51 +273,50 @@ class TestEmitMetricOnlyEvent: compute_trace_id_hex.cache_clear() compute_span_id_hex.cache_clear() - @patch("enterprise.telemetry.telemetry_log.logger") - def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, mock_logger: MagicMock) -> None: + def test_delegates_to_emit_telemetry_log_with_metric_only_signal(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = True + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_metric_only_event( + event_name="dify.app.created", + attributes={"app_id": "app-1"}, + tenant_id="t1", + user_id="u1", + ) - emit_metric_only_event( - event_name="dify.app.created", - attributes={"app_id": "app-1"}, - tenant_id="t1", - user_id="u1", - ) + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "attributes") + assert record.attributes["dify.event.signal"] == "metric_only" + assert record.attributes["dify.event.name"] == "dify.app.created" + assert record.attributes["app_id"] == "app-1" + assert hasattr(record, "tenant_id") + assert record.tenant_id == "t1" + assert hasattr(record, "user_id") + assert record.user_id == "u1" - mock_logger.info.assert_called_once() - extra = mock_logger.info.call_args.kwargs["extra"] - assert extra["attributes"]["dify.event.signal"] == "metric_only" - assert extra["attributes"]["dify.event.name"] == "dify.app.created" - assert extra["attributes"]["app_id"] == "app-1" - assert extra["tenant_id"] == "t1" - assert extra["user_id"] == "u1" - - @patch("enterprise.telemetry.telemetry_log.logger") - def test_trace_and_span_ids_passed_through(self, mock_logger: MagicMock) -> None: + def test_trace_and_span_ids_passed_through(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = True uid = "123e4567-e89b-12d3-a456-426614174000" - emit_metric_only_event( - event_name="dify.workflow.run", - attributes={}, - trace_id_source=uid, - span_id_source=uid, - ) + with caplog.at_level(logging.INFO, logger="dify.telemetry"): + emit_metric_only_event( + event_name="dify.workflow.run", + attributes={}, + trace_id_source=uid, + span_id_source=uid, + ) - extra = mock_logger.info.call_args.kwargs["extra"] - assert "trace_id" in extra - assert "span_id" in extra + assert len(caplog.records) == 1 + record = caplog.records[0] + assert hasattr(record, "trace_id") + assert hasattr(record, "span_id") - @patch("enterprise.telemetry.telemetry_log.logger") - def test_no_log_emitted_when_logger_disabled(self, mock_logger: MagicMock) -> None: + def test_no_log_emitted_when_logger_disabled(self, caplog: pytest.LogCaptureFixture) -> None: from enterprise.telemetry.telemetry_log import emit_metric_only_event - mock_logger.isEnabledFor.return_value = False + with caplog.at_level(logging.WARNING, logger="dify.telemetry"): + emit_metric_only_event(event_name="dify.workflow.run", attributes={}) - emit_metric_only_event(event_name="dify.workflow.run", attributes={}) - - mock_logger.info.assert_not_called() + assert len(caplog.records) == 0 diff --git a/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py b/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py index 75b61901b21..27f162de75e 100644 --- a/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py +++ b/api/tests/unit_tests/events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py @@ -1,11 +1,16 @@ import logging from types import SimpleNamespace +import pytest + from core.tools.errors import ToolProviderNotFoundError from events.event_handlers import delete_tool_parameters_cache_when_sync_draft_workflow as handler_module -def test_missing_tool_provider_does_not_log_error_traceback(monkeypatch, caplog): +def test_missing_tool_provider_does_not_log_error_traceback( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +): app = SimpleNamespace(id="workflow-id", tenant_id="tenant-id") workflow = SimpleNamespace( graph_dict={ 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 03479686bbe..3d2f8541f63 100644 --- a/api/tests/unit_tests/extensions/test_ext_request_logging.py +++ b/api/tests/unit_tests/extensions/test_ext_request_logging.py @@ -301,7 +301,9 @@ 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: pytest.MonkeyPatch, caplog): + def test_info_access_log_uses_dash_without_start_timestamp( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): 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/fields/test_dataset_fields.py b/api/tests/unit_tests/fields/test_dataset_fields.py index 125bcb26cf6..921e3882a96 100644 --- a/api/tests/unit_tests/fields/test_dataset_fields.py +++ b/api/tests/unit_tests/fields/test_dataset_fields.py @@ -82,6 +82,14 @@ def _dump_dataset_detail(payload): return DatasetDetailResponse.model_validate(payload).model_dump(mode="json") +def test_dataset_detail_preserves_permission_keys(): + response = _dump_dataset_detail( + _dataset_detail_payload(permission_keys=["dataset.acl.readonly", "dataset.acl.edit"]) + ) + + assert response["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] + + def test_dataset_detail_expands_legacy_null_nested_fields(): response = _dump_dataset_detail( _dataset_detail_payload( diff --git a/api/tests/unit_tests/libs/test_archive_storage.py b/api/tests/unit_tests/libs/test_archive_storage.py index 4363c235716..966e75f1f9e 100644 --- a/api/tests/unit_tests/libs/test_archive_storage.py +++ b/api/tests/unit_tests/libs/test_archive_storage.py @@ -16,7 +16,7 @@ from libs.archive_storage import ( BUCKET_NAME = "archive-bucket" -def _configure_storage(monkeypatch, **overrides): +def _configure_storage(monkeypatch: pytest.MonkeyPatch, **overrides): defaults = { "ARCHIVE_STORAGE_ENABLED": True, "ARCHIVE_STORAGE_ENDPOINT": "https://storage.example.com", diff --git a/api/tests/unit_tests/libs/test_pandas.py b/api/tests/unit_tests/libs/test_pandas.py index a4739dbbc2b..3eec25815a1 100644 --- a/api/tests/unit_tests/libs/test_pandas.py +++ b/api/tests/unit_tests/libs/test_pandas.py @@ -1,8 +1,10 @@ +from pathlib import Path + import pandas as pd import pytest -def test_pandas_csv(tmp_path, monkeypatch: pytest.MonkeyPatch): +def test_pandas_csv(tmp_path: 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) @@ -17,7 +19,7 @@ def test_pandas_csv(tmp_path, monkeypatch: pytest.MonkeyPatch): assert df2[df2.columns[1]].to_list() == data["col2"] -def test_pandas_xlsx(tmp_path, monkeypatch: pytest.MonkeyPatch): +def test_pandas_xlsx(tmp_path: 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) @@ -32,7 +34,7 @@ def test_pandas_xlsx(tmp_path, monkeypatch: pytest.MonkeyPatch): assert df2[df2.columns[1]].to_list() == data["col2"] -def test_pandas_xlsx_with_sheets(tmp_path, monkeypatch: pytest.MonkeyPatch): +def test_pandas_xlsx_with_sheets(tmp_path: 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_rate_limit_bearer.py b/api/tests/unit_tests/libs/test_rate_limit_bearer.py index 62363f5f600..f286bfd875d 100644 --- a/api/tests/unit_tests/libs/test_rate_limit_bearer.py +++ b/api/tests/unit_tests/libs/test_rate_limit_bearer.py @@ -75,7 +75,7 @@ def test_enforce_bearer_rate_limit_raises_429_with_retry_after(mock_build): @patch("libs.rate_limit._build_limiter") -def test_enforce_bearer_rate_limit_disabled_when_limit_is_zero(mock_build, monkeypatch): +def test_enforce_bearer_rate_limit_disabled_when_limit_is_zero(mock_build, monkeypatch: pytest.MonkeyPatch): # 0 disables the limit — short-circuit before building/consulting a limiter. monkeypatch.setattr( "libs.rate_limit.LIMIT_BEARER_PER_TOKEN", diff --git a/api/tests/unit_tests/libs/test_workspace_permission.py b/api/tests/unit_tests/libs/test_workspace_permission.py index 93e69e11c4b..e0c425e7c1e 100644 --- a/api/tests/unit_tests/libs/test_workspace_permission.py +++ b/api/tests/unit_tests/libs/test_workspace_permission.py @@ -127,7 +127,9 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.EnterpriseService") @patch("libs.workspace_permission.dify_config") - def test_enterprise_service_error_fails_open(self, mock_config, mock_enterprise_service, caplog): + def test_enterprise_service_error_fails_open( + self, mock_config, mock_enterprise_service, caplog: pytest.LogCaptureFixture + ): """On enterprise service error, should fail-open (allow) and log error.""" mock_config.ENTERPRISE_ENABLED = True diff --git a/api/tests/unit_tests/models/test_account_models.py b/api/tests/unit_tests/models/test_account_models.py index 25933dd15b6..512c043b0c8 100644 --- a/api/tests/unit_tests/models/test_account_models.py +++ b/api/tests/unit_tests/models/test_account_models.py @@ -12,6 +12,7 @@ This test suite covers: import base64 import secrets from datetime import UTC, datetime +from unittest.mock import patch from uuid import uuid4 import pytest @@ -347,7 +348,15 @@ class TestAccountRolePermissions: account.role = TenantAccountRole.ADMIN # Act & Assert - assert account.is_admin_or_owner + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert account.is_admin_or_owner + + def test_is_admin_or_owner_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_admin_or_owner def test_is_admin_or_owner_with_owner_role(self): """Test is_admin_or_owner property with owner role.""" @@ -383,8 +392,16 @@ class TestAccountRolePermissions: owner_account.role = TenantAccountRole.OWNER # Act & Assert - assert admin_account.is_admin - assert not owner_account.is_admin + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert admin_account.is_admin + assert not owner_account.is_admin + + def test_is_admin_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_admin def test_has_edit_permission_with_editing_roles(self): """Test has_edit_permission property with roles that have edit permission.""" @@ -400,7 +417,15 @@ class TestAccountRolePermissions: account.role = role # Act & Assert - assert account.has_edit_permission, f"Role {role} should have edit permission" + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert account.has_edit_permission, f"Role {role} should have edit permission" + + def test_has_edit_permission_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.has_edit_permission def test_has_edit_permission_without_editing_roles(self): """Test has_edit_permission property with roles that don't have edit permission.""" @@ -415,7 +440,8 @@ class TestAccountRolePermissions: account.role = role # Act & Assert - assert not account.has_edit_permission, f"Role {role} should not have edit permission" + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert not account.has_edit_permission, f"Role {role} should not have edit permission" def test_is_dataset_editor_property(self): """Test is_dataset_editor property.""" @@ -432,12 +458,21 @@ class TestAccountRolePermissions: account.role = role # Act & Assert - assert account.is_dataset_editor, f"Role {role} should have dataset edit permission" + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert account.is_dataset_editor, f"Role {role} should have dataset edit permission" # Test normal role doesn't have dataset edit permission normal_account = Account(name="Normal User", email="normal@example.com") normal_account.role = TenantAccountRole.NORMAL - assert not normal_account.is_dataset_editor + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert not normal_account.is_dataset_editor + + def test_is_dataset_editor_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_dataset_editor def test_is_dataset_operator_property(self): """Test is_dataset_operator property.""" @@ -449,8 +484,16 @@ class TestAccountRolePermissions: normal_account.role = TenantAccountRole.NORMAL # Act & Assert - assert dataset_operator.is_dataset_operator - assert not normal_account.is_dataset_operator + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert dataset_operator.is_dataset_operator + assert not normal_account.is_dataset_operator + + def test_is_dataset_operator_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_dataset_operator def test_current_role_property(self): """Test current_role property.""" diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py index 51e51fb6d46..5538a1981de 100644 --- a/api/tests/unit_tests/models/test_agent_config_entities.py +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -1,7 +1,12 @@ import pytest from core.workflow.file_reference import build_file_reference -from models.agent_config_entities import DeclaredOutputConfig, DeclaredOutputType +from models.agent_config_entities import ( + DeclaredArrayItem, + DeclaredOutputChildConfig, + DeclaredOutputConfig, + DeclaredOutputType, +) def test_file_default_value_accepts_canonical_reference_mapping() -> None: @@ -92,3 +97,90 @@ def test_array_file_default_value_rejects_legacy_item_shape() -> None: }, } ) + + +def test_declared_array_item_rejects_nested_arrays_and_non_object_children() -> None: + with pytest.raises(ValueError, match="nested arrays"): + DeclaredArrayItem(type=DeclaredOutputType.ARRAY) + + with pytest.raises(ValueError, match="array_item.children"): + DeclaredArrayItem( + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)], + ) + + +def test_declared_output_child_validates_shape_and_defaults() -> None: + file_child = DeclaredOutputChildConfig(name="report", type=DeclaredOutputType.FILE) + assert file_child.file is not None + + array_child = DeclaredOutputChildConfig(name="items", type=DeclaredOutputType.ARRAY) + assert array_child.array_item is not None + assert array_child.array_item.type == DeclaredOutputType.OBJECT + + with pytest.raises(ValueError, match="output child name"): + DeclaredOutputChildConfig(name="bad-name", type=DeclaredOutputType.STRING) + + with pytest.raises(ValueError, match="file metadata"): + DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING, file={}) + + with pytest.raises(ValueError, match="array_item is only allowed"): + DeclaredOutputChildConfig( + name="title", + type=DeclaredOutputType.STRING, + array_item={"type": DeclaredOutputType.STRING}, + ) + + with pytest.raises(ValueError, match="children is only allowed"): + DeclaredOutputChildConfig( + name="title", + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="label", type=DeclaredOutputType.STRING)], + ) + + +def test_declared_output_validates_shape_and_defaults() -> None: + file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) + assert file_output.file is not None + + array_output = DeclaredOutputConfig(name="items", type=DeclaredOutputType.ARRAY) + assert array_output.array_item is not None + assert array_output.array_item.type == DeclaredOutputType.OBJECT + + default_failure_strategy = DeclaredOutputConfig.model_validate( + {"name": "summary", "type": "string", "failure_strategy": None} + ) + assert default_failure_strategy.failure_strategy.on_failure == "stop" + + with pytest.raises(ValueError, match="output name"): + DeclaredOutputConfig(name="bad-name", type=DeclaredOutputType.STRING) + + with pytest.raises(ValueError, match="file metadata"): + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, file={}) + + with pytest.raises(ValueError, match="array_item is only allowed"): + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + array_item={"type": DeclaredOutputType.STRING}, + ) + + with pytest.raises(ValueError, match="children is only allowed"): + DeclaredOutputConfig( + name="summary", + type=DeclaredOutputType.STRING, + children=[DeclaredOutputChildConfig(name="title", type=DeclaredOutputType.STRING)], + ) + + with pytest.raises(ValueError, match="output check is only allowed"): + DeclaredOutputConfig.model_validate( + { + "name": "summary", + "type": "string", + "check": { + "enabled": True, + "prompt": "Compare output", + "benchmark_file_ref": {"name": "expected.pdf"}, + }, + } + ) diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 44a4e6af98b..bbfd411ca38 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -17,6 +17,8 @@ from unittest.mock import Mock, patch from urllib.parse import parse_qs, urlparse from uuid import uuid4 +import pytest + from core.rag.index_processor.constant.index_type import IndexTechniqueType from models.dataset import ( AppDatasetJoin, @@ -703,7 +705,7 @@ class TestDocumentSegmentIndexing: # Assert assert segment.hit_count == 5 - def test_document_segment_attachments_prefers_files_url_for_source_url(self, monkeypatch): + def test_document_segment_attachments_prefers_files_url_for_source_url(self, monkeypatch: pytest.MonkeyPatch): """Test attachment source URLs use FILES_URL before falling back to CONSOLE_API_URL.""" # Arrange segment = DocumentSegment( diff --git a/api/tests/unit_tests/models/test_end_user_type.py b/api/tests/unit_tests/models/test_end_user_type.py new file mode 100644 index 00000000000..55a4d35cbb9 --- /dev/null +++ b/api/tests/unit_tests/models/test_end_user_type.py @@ -0,0 +1,101 @@ +import ast +import inspect +from pathlib import Path + +from models.enums import EndUserType +from models.model import EndUser +from models.types import EnumText +from services.end_user_service import EndUserService + +API_ROOT = Path(__file__).resolve().parents[3] + + +def test_end_user_type_covers_persisted_creation_values(): + assert {member.value for member in EndUserType} == { + "browser", + "mcp", + "openapi", + "service-api", + "trigger", + } + + +def test_end_user_type_is_plain_persisted_value_enum(): + assert not hasattr(EndUserType, "from_invoke_from") + + +def test_end_user_service_creation_methods_accept_end_user_type(): + assert inspect.signature(EndUserService.get_or_create_end_user_by_type).parameters["type"].annotation is EndUserType + assert inspect.signature(EndUserService.create_end_user_batch).parameters["type"].annotation is EndUserType + + +def test_end_user_service_callers_pass_end_user_type(): + violations: list[str] = [] + method_names = {"get_or_create_end_user_by_type", "create_end_user_batch"} + + for source_path in API_ROOT.rglob("*.py"): + if "tests" in source_path.parts or ".venv" in source_path.parts: + continue + + tree = ast.parse(source_path.read_text(), filename=str(source_path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Attribute) or node.func.attr not in method_names: + continue + if not isinstance(node.func.value, ast.Name) or node.func.value.id != "EndUserService": + continue + + type_arg = next((keyword.value for keyword in node.keywords if keyword.arg == "type"), None) + if type_arg is None and node.args: + type_arg = node.args[0] + + if not ( + isinstance(type_arg, ast.Attribute) + and isinstance(type_arg.value, ast.Name) + and type_arg.value.id == "EndUserType" + ): + violations.append(f"{source_path.relative_to(API_ROOT)}:{node.lineno}") + + assert violations == [] + + +def test_end_user_type_column_uses_enum_text(): + column_type = EndUser.__table__.c.type.type + + assert isinstance(column_type, EnumText) + assert column_type._enum_class is EndUserType + + +def test_production_end_user_constructors_use_end_user_type_enum(): + violations: list[str] = [] + + for source_path in API_ROOT.rglob("*.py"): + if "tests" in source_path.parts or ".venv" in source_path.parts: + continue + + tree = ast.parse(source_path.read_text(), filename=str(source_path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Name) or node.func.id != "EndUser": + continue + + for keyword in node.keywords: + if keyword.arg != "type": + continue + value = keyword.value + uses_end_user_type_member = ( + isinstance(value, ast.Attribute) + and isinstance(value.value, ast.Name) + and value.value.id == "EndUserType" + ) + uses_end_user_service_type_parameter = ( + source_path.relative_to(API_ROOT) == Path("services/end_user_service.py") + and isinstance(value, ast.Name) + and value.id == "type" + ) + if not (uses_end_user_type_member or uses_end_user_service_type_parameter): + violations.append(f"{source_path.relative_to(API_ROOT)}:{node.lineno}") + + assert violations == [] diff --git a/api/tests/unit_tests/models/test_file_input_compat.py b/api/tests/unit_tests/models/test_file_input_compat.py index 1a41ccec9ea..6d6f2b42201 100644 --- a/api/tests/unit_tests/models/test_file_input_compat.py +++ b/api/tests/unit_tests/models/test_file_input_compat.py @@ -125,7 +125,9 @@ def test_rebuild_serialized_graph_files_without_lookup_preserves_scalar_values() assert rebuild_serialized_graph_files_without_lookup("plain-text") == "plain-text" -def test_build_file_from_stored_mapping_rebuilds_remote_urls_without_record_lookup(monkeypatch) -> None: +def test_build_file_from_stored_mapping_rebuilds_remote_urls_without_record_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: rebuilt_file = File( file_type=FileType.IMAGE, transfer_method=FileTransferMethod.REMOTE_URL, diff --git a/api/tests/unit_tests/models/test_snippet.py b/api/tests/unit_tests/models/test_snippet.py index f7a22f48f89..17f7cb3c9d4 100644 --- a/api/tests/unit_tests/models/test_snippet.py +++ b/api/tests/unit_tests/models/test_snippet.py @@ -2,6 +2,8 @@ import json from types import SimpleNamespace from unittest.mock import Mock +import pytest + from models.snippet import CustomizedSnippet @@ -11,7 +13,7 @@ def test_graph_dict_returns_empty_without_workflow_id() -> None: assert snippet.graph_dict == {} -def test_graph_dict_loads_published_workflow_graph(monkeypatch) -> None: +def test_graph_dict_loads_published_workflow_graph(monkeypatch: pytest.MonkeyPatch) -> None: workflow = SimpleNamespace(graph=json.dumps({"nodes": [{"id": "llm-1"}], "edges": []})) session = SimpleNamespace(get=Mock(return_value=workflow)) monkeypatch.setattr("models.snippet.db.session", session) @@ -21,7 +23,7 @@ def test_graph_dict_loads_published_workflow_graph(monkeypatch) -> None: session.get.assert_called_once() -def test_graph_dict_returns_empty_when_workflow_missing(monkeypatch) -> None: +def test_graph_dict_returns_empty_when_workflow_missing(monkeypatch: pytest.MonkeyPatch) -> None: session = SimpleNamespace(get=Mock(return_value=None)) monkeypatch.setattr("models.snippet.db.session", session) snippet = CustomizedSnippet(workflow_id="missing-workflow") @@ -36,7 +38,7 @@ def test_input_fields_list_parses_json_or_returns_empty() -> None: ] -def test_tags_returns_query_results_or_empty(monkeypatch) -> None: +def test_tags_returns_query_results_or_empty(monkeypatch: pytest.MonkeyPatch) -> None: tags = [SimpleNamespace(id="tag-1")] session = SimpleNamespace(scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=tags)))) monkeypatch.setattr("models.snippet.db.session", session) @@ -48,7 +50,7 @@ def test_tags_returns_query_results_or_empty(monkeypatch) -> None: assert snippet.tags == [] -def test_account_properties_and_author_name(monkeypatch) -> None: +def test_account_properties_and_author_name(monkeypatch: pytest.MonkeyPatch) -> None: account = SimpleNamespace(id="account-1", name="Ada") updated_account = SimpleNamespace(id="account-2", name="Grace") session = SimpleNamespace( diff --git a/api/tests/unit_tests/oss/__mock/aliyun_oss.py b/api/tests/unit_tests/oss/__mock/aliyun_oss.py index 27e1c0ad850..59434e072fa 100644 --- a/api/tests/unit_tests/oss/__mock/aliyun_oss.py +++ b/api/tests/unit_tests/oss/__mock/aliyun_oss.py @@ -3,7 +3,6 @@ import posixpath from unittest.mock import MagicMock import pytest -from _pytest.monkeypatch import MonkeyPatch from oss2 import Bucket from oss2.models import GetObjectResult, PutObjectResult @@ -85,7 +84,7 @@ MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" @pytest.fixture -def setup_aliyun_oss_mock(monkeypatch: MonkeyPatch): +def setup_aliyun_oss_mock(monkeypatch: pytest.MonkeyPatch): if MOCK: monkeypatch.setattr(Bucket, "__init__", MockAliyunOssClass.__init__) monkeypatch.setattr(Bucket, "put_object", MockAliyunOssClass.put_object) diff --git a/api/tests/unit_tests/oss/__mock/baidu_obs.py b/api/tests/unit_tests/oss/__mock/baidu_obs.py index d70a7c2eaab..49c0830cdfb 100644 --- a/api/tests/unit_tests/oss/__mock/baidu_obs.py +++ b/api/tests/unit_tests/oss/__mock/baidu_obs.py @@ -5,7 +5,6 @@ from io import BytesIO from types import SimpleNamespace import pytest -from _pytest.monkeypatch import MonkeyPatch from baidubce.services.bos.bos_client import BosClient from tests.unit_tests.oss.__mock.base import ( @@ -55,7 +54,7 @@ MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" @pytest.fixture -def setup_baidu_obs_mock(monkeypatch: MonkeyPatch): +def setup_baidu_obs_mock(monkeypatch: pytest.MonkeyPatch): if MOCK: monkeypatch.setattr(BosClient, "__init__", MockBaiduObsClass.__init__) monkeypatch.setattr(BosClient, "put_object", MockBaiduObsClass.put_object) diff --git a/api/tests/unit_tests/oss/__mock/local.py b/api/tests/unit_tests/oss/__mock/local.py index 95cc06958c6..ee01ab2a6ae 100644 --- a/api/tests/unit_tests/oss/__mock/local.py +++ b/api/tests/unit_tests/oss/__mock/local.py @@ -4,7 +4,6 @@ from pathlib import Path from unittest.mock import MagicMock, mock_open, patch import pytest -from _pytest.monkeypatch import MonkeyPatch from tests.unit_tests.oss.__mock.base import ( get_example_data, @@ -40,7 +39,7 @@ MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" @pytest.fixture -def setup_local_fs_mock(monkeypatch: MonkeyPatch): +def setup_local_fs_mock(monkeypatch: pytest.MonkeyPatch): if MOCK: monkeypatch.setattr(Path, "write_bytes", MockLocalFSClass.write_bytes) monkeypatch.setattr(Path, "read_bytes", MockLocalFSClass.read_bytes) diff --git a/api/tests/unit_tests/oss/__mock/tencent_cos.py b/api/tests/unit_tests/oss/__mock/tencent_cos.py index 5189b68e871..65b5bf31329 100644 --- a/api/tests/unit_tests/oss/__mock/tencent_cos.py +++ b/api/tests/unit_tests/oss/__mock/tencent_cos.py @@ -2,7 +2,6 @@ import os from unittest.mock import MagicMock import pytest -from _pytest.monkeypatch import MonkeyPatch from qcloud_cos import CosS3Client from qcloud_cos.streambody import StreamBody @@ -67,7 +66,7 @@ MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" @pytest.fixture -def setup_tencent_cos_mock(monkeypatch: MonkeyPatch): +def setup_tencent_cos_mock(monkeypatch: pytest.MonkeyPatch): if MOCK: monkeypatch.setattr(CosS3Client, "__init__", MockTencentCosClass.__init__) monkeypatch.setattr(CosS3Client, "put_object", MockTencentCosClass.put_object) diff --git a/api/tests/unit_tests/oss/__mock/volcengine_tos.py b/api/tests/unit_tests/oss/__mock/volcengine_tos.py index 649d93a2026..af35fdeb2d0 100644 --- a/api/tests/unit_tests/oss/__mock/volcengine_tos.py +++ b/api/tests/unit_tests/oss/__mock/volcengine_tos.py @@ -3,7 +3,6 @@ from collections import UserDict from unittest.mock import MagicMock import pytest -from _pytest.monkeypatch import MonkeyPatch from tos import TosClientV2 from tos.clientv2 import DeleteObjectOutput, GetObjectOutput, HeadObjectOutput, PutObjectOutput @@ -76,7 +75,7 @@ MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" @pytest.fixture -def setup_volcengine_tos_mock(monkeypatch: MonkeyPatch): +def setup_volcengine_tos_mock(monkeypatch: pytest.MonkeyPatch): if MOCK: monkeypatch.setattr(TosClientV2, "__init__", MockVolcengineTosClass.__init__) monkeypatch.setattr(TosClientV2, "put_object", MockVolcengineTosClass.put_object) diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index dbdf37a9053..089a5c74f3a 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -51,6 +51,19 @@ def test_locked_workflow_soul_rejects_soul_changes(): ComposerConfigValidator.validate_save_payload(payload) +def test_locked_workflow_node_job_only_allows_inline_soul_payload(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY, + "soul_lock": {"locked": True}, + "agent_soul": {"prompt": {"system_prompt": "changed"}}, + } + ) + + ComposerConfigValidator.validate_save_payload(payload) + + def test_agent_app_soul_allows_app_features_and_variables(): payload = ComposerSavePayload.model_validate( { diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py new file mode 100644 index 00000000000..f98c548d176 --- /dev/null +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -0,0 +1,225 @@ +from datetime import UTC, datetime +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from core.app.entities.app_invoke_entities import InvokeFrom +from models.enums import ConversationFromSource, MessageStatus +from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService + + +def test_resolve_source_accepts_frontend_aliases() -> None: + assert AgentObservabilityService.resolve_source(None) is None + assert AgentObservabilityService.resolve_source("all") is None + assert AgentObservabilityService.resolve_source("console") == InvokeFrom.EXPLORE + assert AgentObservabilityService.resolve_source("api") == InvokeFrom.SERVICE_API + assert AgentObservabilityService.resolve_source("web_app") == InvokeFrom.WEB_APP + + with pytest.raises(ValueError, match="Unsupported source"): + AgentObservabilityService.resolve_source("unknown") + + +def test_resolve_source_filter_accepts_structured_sources() -> None: + assert AgentObservabilityService.resolve_source_filter(None).kind == "all" + assert AgentObservabilityService.resolve_source_filter("webapp").kind == "webapp" + assert AgentObservabilityService.resolve_source_filter("webapp:app-1").app_id == "app-1" + + workflow_filter = AgentObservabilityService.resolve_source_filter("workflow:app-2:workflow-1:v1:node-1") + assert workflow_filter.kind == "workflow" + assert workflow_filter.app_id == "app-2" + assert workflow_filter.workflow_id == "workflow-1" + assert workflow_filter.workflow_version == "v1" + assert workflow_filter.node_id == "node-1" + + legacy_filter = AgentObservabilityService.resolve_source_filter("console") + assert legacy_filter.kind == "webapp" + assert legacy_filter.invoke_from == InvokeFrom.EXPLORE + + with pytest.raises(ValueError, match="Unsupported source"): + AgentObservabilityService.resolve_source_filter("workflow:broken") + + +def test_resolve_source_filters_accepts_multiple_structured_sources() -> None: + filters = AgentObservabilityService.resolve_source_filters(("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1")) + + assert [source_filter.kind for source_filter in filters] == ["webapp", "workflow"] + assert filters[0].app_id == "app-1" + assert filters[1].node_id == "node-1" + assert AgentObservabilityService.resolve_source_filters(())[0].kind == "all" + assert AgentObservabilityService.resolve_source_filters(("all", "webapp:app-1"))[0].kind == "all" + + +def test_apply_status_filter_accepts_multiple_statuses() -> None: + class FakeStmt: + def __init__(self): + self.conditions = [] + + def where(self, *conditions): + self.conditions.extend(conditions) + return self + + stmt = FakeStmt() + + result = AgentObservabilityService._apply_status_filter(stmt, ("success", "failed", "paused")) + + assert result is stmt + assert len(stmt.conditions) == 1 + with pytest.raises(ValueError, match="Unsupported status"): + AgentObservabilityService._apply_status_filter(FakeStmt(), ("unknown",)) + + +def test_list_logs_sorts_by_requested_field(monkeypatch: pytest.MonkeyPatch) -> None: + service = AgentObservabilityService(session=None) + app = SimpleNamespace(id="app-1") + rows = [ + {"id": "old", "source": {"id": "webapp:app-1"}, "created_at": 10, "updated_at": 100}, + {"id": "new", "source": {"id": "webapp:app-1"}, "created_at": 20, "updated_at": 50}, + ] + monkeypatch.setattr(service, "_list_webapp_conversation_logs", lambda **kwargs: rows) + monkeypatch.setattr(service, "_list_workflow_conversation_logs", lambda **kwargs: []) + + payload = service.list_logs( + app=app, # type: ignore[arg-type] + agent_id="agent-1", + params=AgentLogQueryParams(sources=("webapp:app-1",), sort_by="created_at", sort_order="asc"), + ) + + assert [item["id"] for item in payload["data"]] == ["old", "new"] + + +def test_source_serializers_return_structured_frontend_shape() -> None: + app = SimpleNamespace( + id="app-1", + name="Iris", + icon_type=SimpleNamespace(value="emoji"), + icon="robot", + icon_background="#fff", + ) + + webapp_source = AgentObservabilityService._serialize_webapp_source(app) # type: ignore[arg-type] + workflow_source = AgentObservabilityService._serialize_workflow_source( + app=app, # type: ignore[arg-type] + workflow_id="workflow-1", + workflow_version="v1", + node_id="node-1", + ) + + assert webapp_source == { + "id": "webapp:app-1", + "type": "webapp", + "app_id": "app-1", + "app_name": "Iris", + "app_icon_type": "emoji", + "app_icon": "robot", + "app_icon_background": "#fff", + "workflow_id": None, + "workflow_version": None, + "node_id": None, + } + assert workflow_source["id"] == "workflow:app-1:workflow-1:v1:node-1" + assert workflow_source["type"] == "workflow" + assert workflow_source["workflow_id"] == "workflow-1" + + +def test_serialize_log_message_returns_frontend_log_shape() -> None: + created_at = datetime(2026, 6, 17, 1, 2, 3, tzinfo=UTC) + updated_at = datetime(2026, 6, 17, 1, 3, 3, tzinfo=UTC) + message = SimpleNamespace( + id="message-1", + conversation_id="conversation-1", + query="hello", + answer="hi", + error=None, + status=MessageStatus.NORMAL, + invoke_from=InvokeFrom.EXPLORE, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id="account-1", + message_tokens=3, + answer_tokens=4, + total_price=Decimal("0.0001"), + currency="USD", + provider_response_latency=1.25, + created_at=created_at, + updated_at=updated_at, + ) + conversation = SimpleNamespace(name="Debug conversation") + + payload = AgentObservabilityService.serialize_log_message(message, conversation) # type: ignore[arg-type] + + assert payload == { + "id": "message-1", + "message_id": "message-1", + "conversation_id": "conversation-1", + "conversation_name": "Debug conversation", + "query": "hello", + "answer": "hi", + "status": "success", + "error": None, + "source": "explore", + "from_source": "console", + "from_end_user_id": None, + "from_account_id": "account-1", + "message_tokens": 3, + "answer_tokens": 4, + "total_tokens": 7, + "total_price": "0.0001", + "currency": "USD", + "latency": 1.25, + "created_at": int(created_at.timestamp()), + "updated_at": int(updated_at.timestamp()), + } + + +def test_build_charts_and_summary_match_monitoring_metrics() -> None: + rows = [ + { + "date": "2026-06-16", + "message_count": 2, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 30, + "total_price": Decimal("0.003"), + "avg_latency": 1.5, + "latency_sum": 3, + "answer_tokens": 12, + "like_count": 1, + }, + { + "date": "2026-06-17", + "message_count": 1, + "conversation_count": 1, + "end_user_count": 1, + "token_count": 20, + "total_price": Decimal("0.002"), + "avg_latency": 2, + "latency_sum": 2, + "answer_tokens": 8, + "like_count": 1, + }, + ] + + charts = AgentObservabilityService._build_charts(rows) + summary = AgentObservabilityService._build_summary(rows) + + assert charts["token_usage"] == [ + {"date": "2026-06-16", "token_count": 30, "total_price": "0.003", "currency": "USD"}, + {"date": "2026-06-17", "token_count": 20, "total_price": "0.002", "currency": "USD"}, + ] + assert charts["average_response_time"] == [ + {"date": "2026-06-16", "latency": 1500.0}, + {"date": "2026-06-17", "latency": 2000.0}, + ] + assert summary == { + "total_messages": 3, + "total_conversations": 2, + "total_end_users": 2, + "total_tokens": 50, + "total_price": "0.005", + "currency": "USD", + "average_session_interactions": 1.5, + "average_response_time": 1666.6667, + "tokens_per_second": 4.0, + "user_satisfaction_rate": 66.67, + } diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index bd2d2899e23..2c077e20b46 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -17,10 +17,13 @@ from models.agent import ( ) from models.agent_config_entities import ( AgentFileRefConfig, + DeclaredArrayItem, + DeclaredOutputChildConfig, DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, ) +from models.model import IconType from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -97,7 +100,7 @@ def test_agent_soul_has_model(): assert agent_soul_has_model(AgentSoulConfig()) is False -def test_load_workflow_composer_returns_empty_state(monkeypatch): +def test_load_workflow_composer_returns_empty_state(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) @@ -112,10 +115,10 @@ def test_load_workflow_composer_returns_empty_state(monkeypatch): effective = result["effective_declared_outputs"] assert [o["name"] for o in effective] == ["text", "files", "json"] files_output = next(o for o in effective if o["name"] == "files") - assert files_output["array_item"] == {"type": "file", "description": None} + assert files_output["array_item"] == {"type": "file", "description": None, "children": []} -def test_load_workflow_composer_serializes_existing_binding(monkeypatch): +def test_load_workflow_composer_serializes_existing_binding(monkeypatch: pytest.MonkeyPatch): binding = SimpleNamespace( agent_id="agent-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, @@ -217,7 +220,7 @@ def test_save_workflow_composer_rejects_agent_app_variant(): ) -def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch): +def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession(scalar=[None]) created_version = SimpleNamespace(id="version-1") @@ -246,7 +249,7 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch): assert fake_session.commits == 1 -def test_save_agent_app_composer_updates_current_version(monkeypatch): +def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.MonkeyPatch): agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None) fake_session = FakeSession(scalar=[agent]) updated = {} @@ -280,7 +283,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch): assert fake_session.commits == 1 -def test_agent_app_composer_candidates_and_impact(monkeypatch): +def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatch): bindings = [ SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-1"), SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-2"), @@ -313,7 +316,7 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch): assert impact["bindings"][1]["node_id"] == "node-2" -def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch): +def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch: pytest.MonkeyPatch): binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", @@ -339,7 +342,7 @@ def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch): assert effective_names == ["text", "files", "json"] -def test_serialize_workflow_state_passes_user_declared_outputs_through_effective(monkeypatch): +def test_serialize_workflow_state_passes_user_declared_outputs_through_effective(monkeypatch: pytest.MonkeyPatch): binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", @@ -366,7 +369,7 @@ def test_serialize_workflow_state_passes_user_declared_outputs_through_effective assert effective[0]["required"] is True -def test_composer_save_helpers_create_and_rebind_agents(monkeypatch): +def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) workflow_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") @@ -457,7 +460,126 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch): assert new_version_binding.current_snapshot_id == "new-version-1" -def test_composer_create_agents_syncs_active_config_has_model(monkeypatch): +def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + inline_agent = SimpleNamespace( + id="inline-agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="inline-version-1", + active_config_has_model=False, + updated_by=None, + ) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot( + id="inline-version-2", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=2, + ) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "new"}, + }, + "node_job": {"workflow_prompt": "use prior output"}, + } + ) + + updated_binding = AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + assert updated_binding.current_snapshot_id == "inline-version-2" + assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output" + assert updated_binding.updated_by == "account-1" + assert inline_agent.active_config_snapshot_id == "inline-version-2" + assert inline_agent.active_config_has_model is True + assert inline_agent.updated_by == "account-1" + + +def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + current_snapshot = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"old"}}', + ) + next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2) + roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER) + + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) + monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="inline-version-1", + ) + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW.value, + "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, + "agent_soul": {"prompt": {"system_prompt": "new"}}, + } + ) + + with pytest.raises(ValueError, match="workflow-only agent"): + AgentComposerService._save_node_job_only( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + node_id="node-1", + account_id="account-1", + binding=binding, + payload=payload, + ) + + +def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) monkeypatch.setattr( @@ -489,7 +611,7 @@ def test_composer_create_agents_syncs_active_config_has_model(monkeypatch): assert roster_agent.active_config_has_model is True -def test_composer_version_helpers_and_lookup_errors(monkeypatch): +def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession( scalar=[ 1, @@ -548,7 +670,7 @@ def test_composer_version_helpers_and_lookup_errors(monkeypatch): assert workflow.id == "workflow-1" -def test_composer_current_version_and_error_paths(monkeypatch): +def test_composer_current_version_and_error_paths(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession(scalar=[2]) monkeypatch.setattr(composer_service.db, "session", fake_session) payload = ComposerSavePayload.model_validate( @@ -595,7 +717,7 @@ def test_composer_current_version_and_error_paths(monkeypatch): ) -def test_roster_list_and_invite_options(monkeypatch): +def test_roster_list_and_invite_options(monkeypatch: pytest.MonkeyPatch): created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) updated_at = datetime(2026, 1, 3, 3, 4, 5, tzinfo=UTC) version_created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) @@ -649,6 +771,7 @@ def test_roster_list_and_invite_options(monkeypatch): lambda version_ids: {"version-1": version, "version-2": unconfigured_version}, ) monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) + monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: {"agent-1"}) listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20) invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1") @@ -661,11 +784,13 @@ def test_roster_list_and_invite_options(monkeypatch): assert listed["data"][0]["created_at"] == int(created_at.timestamp()) assert listed["data"][0]["updated_at"] == int(updated_at.timestamp()) assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp()) + assert listed["data"][0]["active_config_is_published"] is True + assert listed["data"][1]["active_config_is_published"] is False assert invited["data"][0]["is_in_current_workflow"] is True assert invited["data"][0]["existing_node_ids"] == ["node-1"] -def test_invite_options_uses_db_filtered_pagination(monkeypatch): +def test_invite_options_uses_db_filtered_pagination(monkeypatch: pytest.MonkeyPatch): configured_agent = Agent( id="agent-2", tenant_id="tenant-1", @@ -690,6 +815,7 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch): }, ) monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) + monkeypatch.setattr(service, "_load_published_active_snapshot_agent_ids", lambda **kwargs: set()) result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1) @@ -698,6 +824,41 @@ def test_invite_options_uses_db_filtered_pagination(monkeypatch): assert [item["id"] for item in result["data"]] == ["agent-2"] +def test_active_config_is_published_flags_handle_matching_and_empty_snapshots(): + agent = Agent( + id="agent-1", + tenant_id="tenant-1", + name="Published", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="version-1", + ) + draft_agent = Agent( + id="agent-2", + tenant_id="tenant-1", + name="Draft", + description="", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id=None, + ) + service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]])) + + flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent]) + + assert flags == {"agent-1": True, "agent-2": False} + assert service.active_config_is_published(tenant_id="tenant-1", agent=agent) is True + assert AgentRosterService(FakeSession()).load_active_config_is_published_by_agent_id( + tenant_id="tenant-1", + agents=[draft_agent], + ) == {"agent-2": False} + + def test_published_references_include_app_display_fields_and_sort_by_updated_at(): recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC) stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) @@ -754,15 +915,17 @@ def test_published_references_include_app_display_fields_and_sort_by_updated_at( assert references[0]["workflow_version"] == "published-recent" -def test_roster_update_archive_versions_and_detail(monkeypatch): - listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) +def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPatch): + listed_version = AgentConfigSnapshot(id="version-4", agent_id="agent-1", version=4) listed_version_created_at = datetime(2026, 1, 5, 3, 4, 5, tzinfo=UTC) listed_version.created_at = listed_version_created_at + older_listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) + older_listed_version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) revision_created_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) revision = SimpleNamespace( id="revision-1", previous_snapshot_id=None, - current_snapshot_id="version-1", + current_snapshot_id="version-2", revision=1, operation=AgentConfigRevisionOperation.CREATE_VERSION, summary=None, @@ -770,7 +933,10 @@ def test_roster_update_archive_versions_and_detail(monkeypatch): created_by="account-1", created_at=revision_created_at, ) - fake_session = FakeSession(scalar=["visible-revision"], scalars=[[listed_version], [revision]]) + fake_session = FakeSession( + scalar=["visible-revision"], + scalars=[[listed_version, older_listed_version], [older_listed_version, listed_version], [revision]], + ) agent = Agent( id="agent-1", tenant_id="tenant-1", @@ -781,7 +947,7 @@ def test_roster_update_archive_versions_and_detail(monkeypatch): source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) - version = AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1, config_snapshot='{"prompt":{}}') + version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2, config_snapshot='{"prompt":{}}') version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) service = AgentRosterService(fake_session) @@ -801,18 +967,27 @@ def test_roster_update_archive_versions_and_detail(monkeypatch): ) service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1") versions = service.list_agent_versions(tenant_id="tenant-1", agent_id="agent-1") - detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-1") + detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-2") assert updated["description"] == "new" assert agent.status == AgentStatus.ARCHIVED - assert versions[0]["id"] == "version-2" + assert versions[0]["id"] == "version-4" + assert versions[0]["version"] == 2 + assert versions[0]["display_version"] == 2 + assert versions[0]["snapshot_version"] == 4 + assert versions[1]["id"] == "version-2" + assert versions[1]["version"] == 1 + assert versions[1]["snapshot_version"] == 2 assert versions[0]["created_at"] == int(listed_version_created_at.timestamp()) + assert detail["version"] == 1 + assert detail["display_version"] == 1 + assert detail["snapshot_version"] == 2 assert detail["config_snapshot"] == {"prompt": {}} assert detail["created_at"] == int(version.created_at.timestamp()) assert detail["revisions"][0]["created_at"] == int(revision_created_at.timestamp()) -def test_roster_create_detail_and_lookup_helpers(monkeypatch): +def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession( scalar=[ SimpleNamespace(id="agent-1"), @@ -879,9 +1054,7 @@ def test_agent_app_visible_versions_exclude_draft_saves(): def test_app_list_all_excludes_agent_apps_by_default(): filters = AppService._build_app_list_filters( - "account-1", - "tenant-1", - AppListParams(mode="all"), + "account-1", "tenant-1", AppListParams(mode="all"), FakeSession(scalar=None, scalars=None) ) sql = " ".join(str(filter_) for filter_ in filters) @@ -1042,7 +1215,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): { "env": { "variables": [{"name": "MY_VAR", "value": "v"}], - "secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}], + "secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}], }, "tools": { "cli_tools": [ @@ -1051,7 +1224,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): "command": "apt-get install -y jq", "env": { "variables": [{"name": "JQ_COLOR", "value": "1"}], - "secret_refs": [{"name": "JQ_TOKEN", "id": "credential-2"}], + "secret_refs": [{"name": "JQ_TOKEN", "value": "credential-2"}], }, }, { @@ -1067,8 +1240,10 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): ) assert {variable.name for variable in config.env.variables} == {"MY_VAR"} assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"} + assert config.env.secret_refs[0].value == "credential-1" assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR" assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN" + assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2" class TestAgentAppBackingAgent: @@ -1086,6 +1261,7 @@ class TestAgentAppBackingAgent: app_id="app-1", name="Iris", description="clarifier", + role="research assistant", ) # Agent is bound to the app and is a roster/agent_app entry. @@ -1095,6 +1271,7 @@ class TestAgentAppBackingAgent: assert agent.status == AgentStatus.ACTIVE assert agent.agent_kind == AgentKind.DIFY_AGENT assert agent.name == "Iris" + assert agent.role == "research assistant" # A v1 snapshot + revision are seeded and wired as the active version. snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)] assert len(snapshots) == 1 @@ -1147,6 +1324,267 @@ class TestAgentAppBackingAgent: with pytest.raises(roster_service.AgentNotFoundError): service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-x") + def test_duplicate_agent_app_copies_app_config_and_active_soul(self, monkeypatch: pytest.MonkeyPatch): + source_config = SimpleNamespace( + opening_statement="hello", + suggested_questions='["q1"]', + suggested_questions_after_answer='{"enabled": true}', + speech_to_text='{"enabled": false}', + text_to_speech='{"enabled": false}', + more_like_this='{"enabled": false}', + model=None, + user_input_form=None, + dataset_query_variable=None, + pre_prompt=None, + agent_mode=None, + sensitive_word_avoidance=None, + retriever_resource='{"enabled": true}', + prompt_type="simple", + chat_prompt_config=None, + completion_prompt_config=None, + dataset_configs=None, + external_data_tools=None, + file_upload='{"image": {"enabled": true}}', + ) + target_config = SimpleNamespace(**dict.fromkeys(AgentRosterService._APP_MODEL_CONFIG_COPY_FIELDS)) + source_app = SimpleNamespace( + id="source-app", + tenant_id="tenant-1", + name="Iris", + description="source desc", + icon_type="emoji", + icon="robot", + icon_background="#fff", + api_rph=1, + api_rpm=2, + max_active_requests=3, + enable_site=False, + enable_api=True, + use_icon_as_answer_icon=True, + tracing="{}", + app_model_config=source_config, + ) + target_app = SimpleNamespace( + id="target-app", + app_model_config=target_config, + enable_site=True, + enable_api=True, + use_icon_as_answer_icon=False, + tracing=None, + ) + source_agent = Agent( + id="source-agent", + tenant_id="tenant-1", + name="Iris", + description="source desc", + role="Analyst", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="source-app", + active_config_snapshot_id="source-version", + active_config_has_model=True, + ) + target_agent = Agent( + id="target-agent", + tenant_id="tenant-1", + name="Iris copy", + description="source desc", + role="Analyst", + agent_kind=AgentKind.DIFY_AGENT, + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + app_id="target-app", + active_config_snapshot_id="target-version", + ) + source_version = AgentConfigSnapshot( + id="source-version", + tenant_id="tenant-1", + agent_id="source-agent", + version=1, + config_snapshot=_agent_soul_with_model(), + summary="configured", + version_note="v1", + created_by="account-1", + ) + target_version = AgentConfigSnapshot( + id="target-version", + tenant_id="tenant-1", + agent_id="target-agent", + version=1, + config_snapshot=AgentSoulConfig(), + created_by="account-1", + ) + session = FakeSession( + scalar=[source_agent, source_app, source_agent, target_agent, source_version, target_version], + scalars=[[]], + ) + captured: dict[str, object] = {} + + class FakeAppService: + def create_app(self, tenant_id: str, params, account: object) -> object: + captured["tenant_id"] = tenant_id + captured["params"] = params + captured["account"] = account + return target_app + + monkeypatch.setattr(roster_service, "AppService", FakeAppService) + monkeypatch.setattr( + roster_service.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + account = SimpleNamespace(id="account-1") + duplicated = AgentRosterService(session).duplicate_agent_app( + tenant_id="tenant-1", + agent_id="source-agent", + account=account, + ) + + assert duplicated is target_app + params = captured["params"] + assert params.name == "Iris copy" + assert params.mode == "agent" + assert params.agent_role == "Analyst" + assert target_app.enable_site is False + assert target_app.enable_api is True + assert target_app.use_icon_as_answer_icon is True + assert target_app.tracing == "{}" + assert target_config.opening_statement == "hello" + assert target_config.file_upload == '{"image": {"enabled": true}}' + assert target_config.updated_by == "account-1" + assert target_version.config_snapshot.model.model == "gpt-4o" + assert target_version.summary == "configured" + assert target_version.version_note == "v1" + assert target_agent.active_config_has_model is True + assert target_agent.updated_by == "account-1" + assert session.commits == 1 + + def test_duplicate_agent_app_inherits_webapp_access_mode(self, monkeypatch: pytest.MonkeyPatch): + source_app = SimpleNamespace( + id="source-app", + tenant_id="tenant-1", + name="Iris", + description="source desc", + icon_type=None, + icon="robot", + icon_background="#fff", + api_rph=1, + api_rpm=2, + max_active_requests=3, + enable_site=True, + enable_api=True, + use_icon_as_answer_icon=False, + tracing=None, + ) + source_agent = SimpleNamespace(id="source-agent", role="Analyst") + target_app = SimpleNamespace(id="target-app") + session = FakeSession() + service = AgentRosterService(session) + monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app) + monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent) + monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None) + monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) + monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") + + class FakeAppService: + def create_app(self, tenant_id: str, params, account: object) -> object: + return target_app + + access_mode_updates = [] + + class FakeWebAppAuth: + @classmethod + def get_app_access_mode_by_id(cls, app_id: str) -> object: + return SimpleNamespace(access_mode="private") + + @classmethod + def update_app_access_mode(cls, app_id: str, access_mode: str) -> None: + access_mode_updates.append((app_id, access_mode)) + + monkeypatch.setattr(roster_service, "AppService", FakeAppService) + monkeypatch.setattr( + roster_service.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), + ) + monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) + + duplicated = service.duplicate_agent_app( + tenant_id="tenant-1", + agent_id="source-agent", + account=SimpleNamespace(id="account-1"), + ) + + assert duplicated is target_app + assert access_mode_updates == [("target-app", "private")] + + def test_duplicate_agent_app_falls_back_to_public_access_mode(self, monkeypatch: pytest.MonkeyPatch): + source_app = SimpleNamespace( + id="source-app", + tenant_id="tenant-1", + name="Iris", + description="source desc", + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#fff", + api_rph=1, + api_rpm=2, + max_active_requests=3, + enable_site=True, + enable_api=True, + use_icon_as_answer_icon=False, + tracing=None, + ) + source_agent = SimpleNamespace(id="source-agent", role="Analyst") + target_app = SimpleNamespace(id="target-app") + session = FakeSession() + service = AgentRosterService(session) + monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app) + monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent) + monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None) + monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) + monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") + + class FakeAppService: + def create_app(self, tenant_id: str, params, account: object) -> object: + return target_app + + access_mode_updates = [] + + class FakeWebAppAuth: + @classmethod + def get_app_access_mode_by_id(cls, app_id: str) -> object: + raise ValueError("not found") + + @classmethod + def update_app_access_mode(cls, app_id: str, access_mode: str) -> None: + access_mode_updates.append((app_id, access_mode)) + + monkeypatch.setattr(roster_service, "AppService", FakeAppService) + monkeypatch.setattr( + roster_service.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), + ) + monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) + + service.duplicate_agent_app( + tenant_id="tenant-1", + agent_id="source-agent", + account=SimpleNamespace(id="account-1"), + ) + + assert access_mode_updates == [("target-app", "public")] + + def test_normalize_app_icon_type(self): + assert AgentRosterService._normalize_app_icon_type(None) is None + assert AgentRosterService._normalize_app_icon_type(IconType.EMOJI) == "emoji" + assert AgentRosterService._normalize_app_icon_type("image") == "image" + class TestListWorkflowsReferencingAppAgent: def test_groups_bindings_by_workflow_app_and_sorts_by_name(self): @@ -1263,7 +1701,22 @@ class TestWorkflowAgentDraftBindingSync: node_job_config=WorkflowNodeJobConfig( workflow_prompt="Summarize the upstream result.", declared_outputs=[ - DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary") + DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary"), + DeclaredOutputConfig( + name="profile", + type=DeclaredOutputType.OBJECT, + children=[ + DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), + DeclaredOutputChildConfig( + name="addresses", + type=DeclaredOutputType.ARRAY, + array_item=DeclaredArrayItem( + type=DeclaredOutputType.OBJECT, + children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], + ), + ), + ], + ), ], ), ) @@ -1279,6 +1732,9 @@ class TestWorkflowAgentDraftBindingSync: assert node_data["agent_declared_outputs"][0]["name"] == "summary" assert node_data["agent_declared_outputs"][0]["type"] == "string" assert node_data["agent_declared_outputs"][0]["description"] == "Short summary" + profile_output = node_data["agent_declared_outputs"][1] + assert profile_output["children"][0]["name"] == "email" + assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city" assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"] def test_creates_roster_binding_from_agent_node_graph(self): @@ -1731,7 +2187,7 @@ class TestWorkflowAgentDraftBindingSync: assert session.deleted == [stale_binding] -def test_dataset_rows_filters_malformed_ids(monkeypatch): +def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch): """Mention ids are user-editable text: a non-UUID id must read as missing (placeholder semantics), never reach the UUID-typed dataset query (E2E 500).""" captured = {} @@ -1755,7 +2211,7 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch): assert captured == {} -def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch): +def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch): """The slash-menu Tools tab needs both selection granularities: a provider hosts many tools (like an MCP server), so candidates return one provider-level entry (id = /*, = all tools) plus one per tool.""" @@ -1826,7 +2282,7 @@ def _patch_drive_keys(monkeypatch, existing_keys): return captured -def test_drive_ref_findings_reports_missing_keys(monkeypatch): +def test_drive_ref_findings_reports_missing_keys(monkeypatch: pytest.MonkeyPatch): _patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md"]) findings = AgentComposerService._drive_ref_findings( @@ -1837,7 +2293,7 @@ def test_drive_ref_findings_reports_missing_keys(monkeypatch): assert str(findings[0]["message"]).startswith("file_ref_dangling: ") -def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch): +def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch: pytest.MonkeyPatch): _patch_drive_keys(monkeypatch, existing_keys=["tender-analyzer/SKILL.md", "files/sample.pdf"]) assert ( @@ -1846,7 +2302,7 @@ def test_drive_ref_findings_clean_when_all_keys_exist(monkeypatch): ) -def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch): +def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch: pytest.MonkeyPatch): # No drive-backed ref at all -> no DB roundtrip, no findings. soul = _drive_soul( skills_files={"skills": [{"id": "legacy", "name": "Legacy"}], "files": [{"name": "u.pdf", "file_id": "u-1"}]} @@ -1855,7 +2311,7 @@ def test_drive_ref_findings_skips_refs_without_drive_keys(monkeypatch): assert findings == [] -def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch): +def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch: pytest.MonkeyPatch): from services.agent.errors import InvalidComposerConfigError _patch_drive_keys(monkeypatch, existing_keys=[]) @@ -1866,7 +2322,7 @@ def test_require_drive_refs_resolved_raises_with_stable_code(monkeypatch): ) -def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch): +def test_collect_validation_findings_appends_drive_findings_with_agent_context(monkeypatch: pytest.MonkeyPatch): from services.entities.agent_entities import ComposerSavePayload _patch_drive_keys(monkeypatch, existing_keys=[]) @@ -1892,7 +2348,7 @@ def test_collect_validation_findings_appends_drive_findings_with_agent_context(m # ── ENG-625 D5: soul-first ref removal ─────────────────────────────────────── -def _patch_remove_drive_refs_env(monkeypatch, *, soul_dict): +def _patch_remove_drive_refs_env(monkeypatch: pytest.MonkeyPatch, *, soul_dict): """Wire the classmethod's collaborators so soul editing + versioning is observable.""" from types import SimpleNamespace @@ -1917,7 +2373,7 @@ def _patch_remove_drive_refs_env(monkeypatch, *, soul_dict): return agent, captured, committed -def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch): +def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch: pytest.MonkeyPatch): soul_dict = { "skills_files": { "skills": [ @@ -1941,7 +2397,7 @@ def test_remove_drive_refs_drops_skill_by_slug_and_versions(monkeypatch): assert committed.get("committed") is True -def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch): +def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch: pytest.MonkeyPatch): soul_dict = {"skills_files": {"skills": [], "files": []}} agent, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) @@ -1955,7 +2411,7 @@ def test_remove_drive_refs_is_noop_when_ref_absent(monkeypatch): assert committed == {} -def test_remove_drive_refs_drops_file_by_key(monkeypatch): +def test_remove_drive_refs_drops_file_by_key(monkeypatch: pytest.MonkeyPatch): soul_dict = { "skills_files": { "skills": [], @@ -1975,7 +2431,7 @@ def test_remove_drive_refs_drops_file_by_key(monkeypatch): assert [f.drive_key for f in captured["agent_soul"].skills_files.files] == ["files/keep.pdf"] -def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch): +def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch: pytest.MonkeyPatch): soul_dict = { "skills_files": { "skills": [], @@ -2002,7 +2458,7 @@ def test_add_drive_file_ref_adds_or_replaces_file_and_versions(monkeypatch): assert committed.get("committed") is True -def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch): +def test_add_drive_file_ref_syncs_workflow_binding_snapshot(monkeypatch: pytest.MonkeyPatch): binding = SimpleNamespace(agent_id="agent-1", current_snapshot_id="snap-1", updated_by=None) _patch_remove_drive_refs_env(monkeypatch, soul_dict={"skills_files": {"skills": [], "files": []}}) monkeypatch.setattr( @@ -2031,7 +2487,7 @@ def test_remove_drive_refs_requires_exactly_one_scope(): # ── ENG-623/625: resolver helpers + save-path drive guard ──────────────────── -def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch): +def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch: pytest.MonkeyPatch): from types import SimpleNamespace import services.agent.composer_service as module @@ -2040,7 +2496,7 @@ def test_resolve_bound_agent_id_queries_active_roster_agent(monkeypatch): assert AgentComposerService.resolve_bound_agent_id(tenant_id="t-1", app_id="app-1") == "agent-9" -def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(monkeypatch): +def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(monkeypatch: pytest.MonkeyPatch): from types import SimpleNamespace def boom(cls, **kwargs): @@ -2063,7 +2519,7 @@ def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding(mon assert AgentComposerService.resolve_workflow_node_agent_id(tenant_id="t", app_id="a", node_id="n") == "agent-7" -def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch): +def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch: pytest.MonkeyPatch): from types import SimpleNamespace import services.agent.composer_service as module @@ -2076,7 +2532,7 @@ def test_remove_drive_refs_returns_none_without_agent_or_snapshot(monkeypatch): assert AgentComposerService.remove_drive_refs(tenant_id="t", agent_id="a", account_id="u", skill_slug="s") is None -def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch): +def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(monkeypatch: pytest.MonkeyPatch): from types import SimpleNamespace from services.entities.agent_entities import ComposerSavePayload @@ -2114,7 +2570,118 @@ def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies( assert guarded["agent_id"] == "agent-1" -def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch): +def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch: pytest.MonkeyPatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + guarded: dict[str, str] = {} + + def fake_guard(cls, *, tenant_id, agent_id, agent_soul): + guarded["tenant_id"] = tenant_id + guarded["agent_id"] = agent_id + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"} + + +def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch: pytest.MonkeyPatch): + payload = ComposerSavePayload.model_validate( + { + "variant": "workflow", + "save_strategy": "node_job_only", + "agent_soul": _drive_soul().model_dump(mode="json"), + "soul_lock": {"locked": False}, + } + ) + binding = WorkflowAgentNodeBinding( + tenant_id="t-1", + app_id="app-1", + workflow_id="wf-1", + workflow_version="draft", + node_id="n-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="version-1", + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr( + AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) + ) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) + monkeypatch.setattr( + AgentComposerService, + "_get_agent_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, + "_get_version_if_present", + classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), + ) + monkeypatch.setattr( + AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) + ) + monkeypatch.setattr( + AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []}) + ) + + def fail_guard(cls, *, tenant_id, agent_id, agent_soul): + raise AssertionError("roster node-job-only saves must not validate agent drive refs") + + monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard)) + + result = AgentComposerService.save_workflow_composer( + tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload + ) + + assert result == {"state": "ok", "validation": {"warnings": []}} + + +def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch: pytest.MonkeyPatch): soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}} _, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict) assert ( diff --git a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py index cd708127ae7..c0b6e6490f4 100644 --- a/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py +++ b/api/tests/unit_tests/services/agent/test_skill_tool_inference_service.py @@ -33,7 +33,7 @@ def _patch_soul_files(monkeypatch, files): monkeypatch.setattr(SkillToolInferenceService, "_manifest_files_from_soul", staticmethod(lambda **kwargs: files)) -def test_infer_returns_suggestions_with_inferred_from(monkeypatch): +def test_infer_returns_suggestions_with_inferred_from(monkeypatch: pytest.MonkeyPatch): service, drive = _service() _patch_soul_files(monkeypatch, ["SKILL.md", "scripts/transcribe.sh"]) raw = ( @@ -53,7 +53,7 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch): drive.preview.assert_called_once_with(tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md") -def test_infer_threads_manifest_files_into_the_prompt(monkeypatch): +def test_infer_threads_manifest_files_into_the_prompt(monkeypatch: pytest.MonkeyPatch): service, _ = _service() _patch_soul_files(monkeypatch, ["scripts/run.sh"]) captured: dict[str, str] = {} @@ -69,7 +69,7 @@ def test_infer_threads_manifest_files_into_the_prompt(monkeypatch): assert "ffmpeg" in captured["prompt"] # SKILL.md body present -def test_infer_not_inferable_passes_reason_through(monkeypatch): +def test_infer_not_inferable_passes_reason_through(monkeypatch: pytest.MonkeyPatch): service, _ = _service() _patch_soul_files(monkeypatch, []) raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}' @@ -78,7 +78,7 @@ def test_infer_not_inferable_passes_reason_through(monkeypatch): assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"} -def test_infer_retries_once_then_422(monkeypatch): +def test_infer_retries_once_then_422(monkeypatch: pytest.MonkeyPatch): service, _ = _service() _patch_soul_files(monkeypatch, []) calls: list[int] = [] @@ -96,7 +96,7 @@ def test_infer_retries_once_then_422(monkeypatch): assert exc_info.value.status_code == 422 -def test_infer_repairs_slightly_malformed_json(monkeypatch): +def test_infer_repairs_slightly_malformed_json(monkeypatch: pytest.MonkeyPatch): service, _ = _service() _patch_soul_files(monkeypatch, []) raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}' @@ -126,7 +126,7 @@ def test_binary_skill_md_maps_to_404(): # ── real-path coverage: _invoke / _manifest_files_from_soul / passthrough ──── -def test_invoke_maps_missing_default_model_to_400(monkeypatch): +def test_invoke_maps_missing_default_model_to_400(monkeypatch: pytest.MonkeyPatch): import services.agent.skill_tool_inference_service as module from core.errors.error import ProviderTokenNotInitError @@ -140,7 +140,7 @@ def test_invoke_maps_missing_default_model_to_400(monkeypatch): assert exc_info.value.status_code == 400 -def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch): +def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch: pytest.MonkeyPatch): import services.agent.skill_tool_inference_service as module fake_manager = MagicMock() @@ -173,7 +173,7 @@ def test_load_skill_md_passes_through_non_missing_drive_errors(): assert exc_info.value.code == "agent_not_found" -def _patch_inference_db(monkeypatch, *, agent, snapshot): +def _patch_inference_db(monkeypatch: pytest.MonkeyPatch, *, agent, snapshot): from types import SimpleNamespace import services.agent.skill_tool_inference_service as module @@ -182,7 +182,7 @@ def _patch_inference_db(monkeypatch, *, agent, snapshot): monkeypatch.setattr(module.db, "session", SimpleNamespace(scalar=lambda stmt: next(results))) -def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch): +def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch: pytest.MonkeyPatch): from types import SimpleNamespace soul_dict = { @@ -203,7 +203,7 @@ def test_manifest_files_from_soul_reads_active_snapshot(monkeypatch): assert files == ["scripts/a.sh"] -def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch): +def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkeypatch: pytest.MonkeyPatch): _patch_inference_db(monkeypatch, agent=None, snapshot=None) assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == [] @@ -213,7 +213,7 @@ def test_manifest_files_from_soul_degrades_when_agent_or_snapshot_missing(monkey assert SkillToolInferenceService._manifest_files_from_soul(tenant_id="t", agent_id="a", slug="s") == [] -def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch): +def test_manifest_files_from_soul_empty_when_slug_not_in_soul(monkeypatch: pytest.MonkeyPatch): from types import SimpleNamespace soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}]}} diff --git a/api/tests/unit_tests/services/auth/test_firecrawl_auth.py b/api/tests/unit_tests/services/auth/test_firecrawl_auth.py index 1458180570d..1369ff2ba05 100644 --- a/api/tests/unit_tests/services/auth/test_firecrawl_auth.py +++ b/api/tests/unit_tests/services/auth/test_firecrawl_auth.py @@ -66,7 +66,7 @@ class TestFirecrawlAuth: assert str(exc_info.value) == expected_error @patch("services.auth.firecrawl.firecrawl.httpx.post", autospec=True) - def test_should_validate_valid_credentials_successfully(self, mock_post, auth_instance): + def test_should_validate_valid_credentials_successfully(self, mock_post: MagicMock, auth_instance: FirecrawlAuth): """Test successful credential validation""" mock_response = MagicMock() mock_response.status_code = 200 @@ -97,7 +97,9 @@ class TestFirecrawlAuth: ], ) @patch("services.auth.firecrawl.firecrawl.httpx.post", autospec=True) - def test_should_handle_http_errors(self, mock_post, status_code, error_message, auth_instance): + def test_should_handle_http_errors( + self, mock_post: MagicMock, status_code, error_message, auth_instance: FirecrawlAuth + ): """Test handling of various HTTP error codes""" mock_response = MagicMock() mock_response.status_code = status_code @@ -120,7 +122,13 @@ class TestFirecrawlAuth: ) @patch("services.auth.firecrawl.firecrawl.httpx.post", autospec=True) def test_should_handle_unexpected_errors( - self, mock_post, status_code, response_text, has_json_error, expected_error_contains, auth_instance + self, + mock_post: MagicMock, + status_code, + response_text, + has_json_error, + expected_error_contains, + auth_instance: FirecrawlAuth, ): """Test handling of unexpected errors with various response formats""" mock_response = MagicMock() @@ -146,7 +154,9 @@ class TestFirecrawlAuth: ], ) @patch("services.auth.firecrawl.firecrawl.httpx.post", autospec=True) - def test_should_handle_network_errors(self, mock_post, exception_type, exception_message, auth_instance): + def test_should_handle_network_errors( + self, mock_post: MagicMock, exception_type, exception_message, auth_instance: FirecrawlAuth + ): """Test handling of various network-related errors including timeouts""" mock_post.side_effect = exception_type(exception_message) @@ -186,7 +196,7 @@ class TestFirecrawlAuth: assert mock_post.call_args[0][0] == "https://custom.firecrawl.dev/v1/crawl" @patch("services.auth.firecrawl.firecrawl.httpx.post", autospec=True) - def test_should_handle_timeout_with_retry_suggestion(self, mock_post, auth_instance): + def test_should_handle_timeout_with_retry_suggestion(self, mock_post: MagicMock, auth_instance: FirecrawlAuth): """Test that timeout errors are handled gracefully with appropriate error message""" mock_post.side_effect = httpx.TimeoutException("The request timed out after 30 seconds") diff --git a/api/tests/unit_tests/services/auth/test_jina_auth.py b/api/tests/unit_tests/services/auth/test_jina_auth.py index eb409c61d4e..e9a1035da29 100644 --- a/api/tests/unit_tests/services/auth/test_jina_auth.py +++ b/api/tests/unit_tests/services/auth/test_jina_auth.py @@ -36,7 +36,7 @@ class TestJinaAuth: assert str(exc_info.value) == "No API key provided" @patch("services.auth.jina.jina._http_client.post", autospec=True) - def test_should_validate_valid_credentials_successfully(self, mock_post): + def test_should_validate_valid_credentials_successfully(self, mock_post: MagicMock): """Test successful credential validation""" mock_response = MagicMock() mock_response.status_code = 200 @@ -54,7 +54,7 @@ class TestJinaAuth: ) @patch("services.auth.jina.jina._http_client.post", autospec=True) - def test_should_handle_http_402_error(self, mock_post): + def test_should_handle_http_402_error(self, mock_post: MagicMock): """Test handling of 402 Payment Required error""" mock_response = MagicMock() mock_response.status_code = 402 @@ -100,7 +100,7 @@ class TestJinaAuth: assert str(exc_info.value) == "Failed to authorize. Status code: 409. Error: Conflict error" @patch("services.auth.jina.jina._http_client.post", autospec=True) - def test_should_handle_http_500_error(self, mock_post): + def test_should_handle_http_500_error(self, mock_post: MagicMock): """Test handling of 500 Internal Server Error""" mock_response = MagicMock() mock_response.status_code = 500 @@ -115,7 +115,7 @@ class TestJinaAuth: assert str(exc_info.value) == "Failed to authorize. Status code: 500. Error: Internal server error" @patch("services.auth.jina.jina._http_client.post", autospec=True) - def test_should_handle_unexpected_error_with_text_response(self, mock_post): + def test_should_handle_unexpected_error_with_text_response(self, mock_post: MagicMock): """Test handling of unexpected errors with text response""" mock_response = MagicMock() mock_response.status_code = 403 @@ -163,7 +163,7 @@ class TestJinaAuth: assert str(exc_info.value) == "Unexpected error occurred while trying to authorize. Status code: 404" @patch("services.auth.jina.jina._http_client.post", autospec=True) - def test_should_handle_network_errors(self, mock_post): + def test_should_handle_network_errors(self, mock_post: MagicMock): """Test handling of network connection errors""" mock_post.side_effect = httpx.ConnectError("Network error") diff --git a/api/tests/unit_tests/services/data_migration/test_import_service.py b/api/tests/unit_tests/services/data_migration/test_import_service.py index 3fcc6c7ef88..bd130cdd78b 100644 --- a/api/tests/unit_tests/services/data_migration/test_import_service.py +++ b/api/tests/unit_tests/services/data_migration/test_import_service.py @@ -385,7 +385,7 @@ def test_workflow_tool_import_publishes_referenced_app_before_create(monkeypatch (IdStrategy.GENERATE_NEW_ID, ""), ], ) -def test_workflow_tool_import_id_follows_id_strategy(monkeypatch, id_strategy, expected_import_id): +def test_workflow_tool_import_id_follows_id_strategy(monkeypatch: pytest.MonkeyPatch, id_strategy, expected_import_id): created_kwargs = [] target_provider = type("WorkflowToolProvider", (), {"id": "target-workflow-tool-id"})() account = type("Account", (), {"id": "account-1"})() diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py new file mode 100644 index 00000000000..aa4780af0b4 --- /dev/null +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -0,0 +1,797 @@ +"""Unit tests for services.enterprise.rbac_service. + +The enterprise RBAC client is almost pure glue: each method turns a single +``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response +model. Rather than spinning up an HTTP server we monkeypatch that helper and +assert on the arguments it received; that catches both routing regressions +(wrong method / wrong path / wrong params) and model-shape regressions in +one place. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +from services.enterprise import rbac_service as svc + +MODULE = "services.enterprise.rbac_service" + + +@pytest.fixture +def mock_send(): + with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send: + yield send + + +def _call_args(send: MagicMock) -> SimpleNamespace: + """Return the most recent (method, endpoint, kwargs) sent to the mock.""" + send.assert_called_once() + args, kwargs = send.call_args + return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs) + + +class TestCatalog: + def test_workspace_catalog(self, mock_send: MagicMock): + mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]} + + out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/role-permissions/catalog" + assert call.tenant_id == "tenant-1" + assert call.account_id == "acct-1" + assert call.json is None + assert call.params is None + assert len(out.groups) == 1 + assert out.groups[0].group_key == "workspace" + + def test_app_catalog_endpoint(self, mock_send: MagicMock): + mock_send.return_value = {"groups": []} + svc.RBACService.Catalog.app("tenant-1") + assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app" + + def test_dataset_catalog_endpoint(self, mock_send: MagicMock): + mock_send.return_value = {"groups": []} + svc.RBACService.Catalog.dataset("tenant-1") + assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset" + + +class TestRoles: + def test_list_forwards_pagination_options(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "role-1", + "tenant_id": "tenant-1", + "type": "workspace", + "category": "global_custom", + "name": "Owner", + "permission_keys": ["workspace.member.manage"], + } + ], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, + } + + out = svc.RBACService.Roles.list( + "tenant-1", + "acct-1", + options=svc.ListOption(page_number=2, results_per_page=50, reverse=True), + ) + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles" + assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"} + assert out.pagination + assert out.pagination.total_count == 1 + + def test_list_omits_params_when_default(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + svc.RBACService.Roles.list("tenant-1") + assert _call_args(mock_send).params is None + + def test_list_forwards_include_owner(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + + svc.RBACService.Roles.list("tenant-1", include_owner=1) + + assert _call_args(mock_send).params == {"include_owner": 1} + + def test_list_coerces_null_permission_keys(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "role-1", + "tenant_id": "tenant-1", + "type": "workspace", + "category": "global_custom", + "name": "Owner", + "permission_keys": None, + } + ], + "pagination": None, + } + + out = svc.RBACService.Roles.list("tenant-1") + + assert out.data[0].permission_keys == [] + + def test_get_passes_id_query_param(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} + svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + + def test_members_forwards_role_id_and_pagination(self, mock_send: MagicMock): + mock_send.return_value = { + "role_id": "role-1", + "data": [{"account_id": "acct-2", "account_name": "Alice"}], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, + } + + out = svc.RBACService.Roles.members( + "tenant-1", + "acct-1", + "role-1", + options=svc.ListOption(page_number=1, results_per_page=20), + ) + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles/members" + assert call.params == {"page_number": 1, "results_per_page": 20, "role_id": "role-1"} + assert out.data[0].account_id == "acct-2" + assert out.data[0].account_name == "Alice" + assert out.pagination is not None + assert out.pagination.total_count == 1 + + def test_create_sends_body(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} + payload = svc.RoleMutation(name="Owner", description="full access", permission_keys=["workspace.member.manage"]) + svc.RBACService.Roles.create("tenant-1", "acct-1", payload) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/roles" + assert call.json == { + "name": "Owner", + "description": "full access", + "permission_keys": ["workspace.member.manage"], + "type": "workspace", + } + + def test_update_sends_id_param_and_body(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} + payload = svc.RoleMutation(name="Owner", permission_keys=["x"]) + svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload) + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + assert call.json == {"name": "Owner", "description": "", "permission_keys": ["x"], "type": "workspace"} + + def test_delete_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = {"message": "success"} + svc.RBACService.Roles.delete("tenant-1", None, "role-1") + + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + assert call.account_id is None + + def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1-copy", "type": "workspace", "name": "Owner copy"} + svc.RBACService.Roles.copy("tenant-1", "acct-1", "role-1") + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/roles/copy" + assert call.params == {"id": "role-1"} + assert call.account_id == "acct-1" + + +class TestAccessPolicyBindings: + def test_lock_sends_put_with_binding_id(self, mock_send: MagicMock): + mock_send.return_value = {"binding_id": "binding-1", "is_locked": True} + + out = svc.RBACService.AccessPolicyBindings.lock("tenant-1", "acct-1", "binding-1") + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/access-policy-bindings/lock" + assert call.json == {"binding_id": "binding-1"} + assert out.binding_id == "binding-1" + assert out.is_locked is True + + def test_unlock_sends_put_with_binding_id(self, mock_send: MagicMock): + mock_send.return_value = {"binding_id": "binding-1", "is_locked": False} + + out = svc.RBACService.AccessPolicyBindings.unlock("tenant-1", "acct-1", "binding-1") + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/access-policy-bindings/unlock" + assert call.json == {"binding_id": "binding-1"} + assert out.binding_id == "binding-1" + assert out.is_locked is False + + +class TestAccessPolicies: + def test_list_filters_by_resource_type(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + svc.RBACService.AccessPolicies.list( + "tenant-1", + "acct-1", + resource_type=svc.RBACResourceType.APP, + options=svc.ListOption(page_number=1), + ) + call = _call_args(mock_send) + assert call.endpoint == "/rbac/access-policies" + assert call.params == {"page_number": 1, "resource_type": "app"} + + def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): + mock_send.return_value = { + "id": "policy-1-copy", + "resource_type": "app", + "name": "Full access copy", + } + svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1") + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/access-policies/copy" + assert call.params == {"id": "policy-1"} + + def test_create_serialises_resource_type_enum(self, mock_send: MagicMock): + mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"} + payload = svc.AccessPolicyCreate( + name="KB only", + resource_type=svc.RBACResourceType.DATASET, + permission_keys=["dataset.acl.readonly"], + ) + svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload) + call = _call_args(mock_send) + assert call.method == "POST" + assert call.json == { + "name": "KB only", + "resource_type": "dataset", + "description": "", + "permission_keys": ["dataset.acl.readonly"], + } + + +class TestResourceAccess: + def test_app_whitelist_resources(self, mock_send: MagicMock): + mock_send.return_value = {"unrestricted": True, "resource_ids": ["app-1", "app-2"]} + + out = svc.RBACService.AppAccess.whitelist_resources("tenant-1", "acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/whitelist/resources" + assert call.params is None + assert out.unrestricted is True + assert out.resource_ids == ["app-1", "app-2"] + + def test_dataset_whitelist_resources(self, mock_send: MagicMock): + mock_send.return_value = {"resource_ids": ["dataset-1"]} + + out = svc.RBACService.DatasetAccess.whitelist_resources("tenant-1", "acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/datasets/whitelist/resources" + assert call.params is None + assert out.resource_ids == ["dataset-1"] + + def test_app_user_access_policies(self, mock_send: MagicMock): + mock_send.return_value = { + "scope": "app", + "data": [ + { + "account": {"account_id": "acct-1", "account_name": "Alice"}, + "roles": [ + { + "id": "role-1", + "type": "workspace", + "name": "Editor", + "permission_keys": [], + } + ], + "access_policies": [ + { + "id": "policy-1", + "resource_type": "app", + "name": "Can edit", + } + ], + } + ], + } + + out = svc.RBACService.AppAccess.user_access_policies("tenant-1", "acct-1", "app-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/user-access-policies" + assert call.params == {"app_id": "app-1"} + assert out.data[0].account.account_name == "Alice" + assert out.data[0].roles[0].id == "role-1" + assert out.data[0].access_policies[0].id == "policy-1" + + def test_dataset_replace_user_access_policies(self, mock_send: MagicMock): + mock_send.return_value = { + "access_policies": [{"id": "policy-1", "resource_type": "dataset", "name": "Can edit"}] + } + payload = svc.ReplaceUserAccessPolicies(access_policy_ids=["policy-1"]) + + out = svc.RBACService.DatasetAccess.replace_user_access_policies( + "tenant-1", "acct-actor", "dataset-1", "acct-target", payload + ) + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/datasets/user-access-policies" + assert call.params == {"dataset_id": "dataset-1", "account_id": "acct-target"} + assert call.json == {"access_policy_ids": ["policy-1"]} + assert out.access_policies[0].id == "policy-1" + + def test_dataset_whitelist(self, mock_send: MagicMock): + mock_send.return_value = {"account_ids": ["acct-2"]} + + out = svc.RBACService.DatasetAccess.whitelist("tenant-1", "acct-1", "dataset-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/datasets/whitelist" + assert call.params == {"dataset_id": "dataset-1"} + assert out.account_ids == ["acct-2"] + + def test_app_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"resource_id": "app-1", "items": []} + out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/access-policy" + assert call.params == {"app_id": "app-1"} + assert out.app_id == "app-1" + + def test_dataset_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"resource_id": "dataset-1", "items": []} + out = svc.RBACService.DatasetAccess.matrix("tenant-1", "acct-1", "dataset-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/datasets/access-policy" + assert call.params == {"dataset_id": "dataset-1"} + assert out.dataset_id == "dataset-1" + + def test_app_role_bindings_preserve_role_name(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "binding-1", + "tenant_id": "tenant-1", + "access_policy_id": "policy-1", + "resource_type": "app", + "resource_id": "app-1", + "role_id": "role-1", + "role_name": "Owner", + } + ] + } + + out = svc.RBACService.AppAccess.list_role_bindings("tenant-1", "acct-1", "app-1", "policy-1") + + assert out.data[0].role_name == "Owner" + + def test_app_member_bindings_preserve_account_name(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "binding-1", + "tenant_id": "tenant-1", + "access_policy_id": "policy-1", + "resource_type": "app", + "resource_id": "app-1", + "account_id": "acct-1", + "account_name": "Alice", + } + ] + } + + out = svc.RBACService.AppAccess.list_member_bindings("tenant-1", "acct-1", "app-1", "policy-1") + + assert out.data[0].account_name == "Alice" + + def test_app_delete_member_bindings_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = None + payload = svc.DeleteMemberBindings(account_ids=["acct-2", "acct-3"]) + svc.RBACService.AppAccess.delete_member_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/apps/access-policy/member-bindings" + assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} + assert call.json == {"account_ids": ["acct-2", "acct-3"]} + + def test_app_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.owner"], account_ids=["acct-2"]) + svc.RBACService.AppAccess.replace_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/apps/access-policy/bindings" + assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.owner"], "account_ids": ["acct-2"]} + + def test_dataset_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) + svc.RBACService.DatasetAccess.replace_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/datasets/access-policy/bindings" + assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} + + def test_dataset_delete_member_bindings_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = None + payload = svc.DeleteMemberBindings(account_ids=["acct-2"]) + svc.RBACService.DatasetAccess.delete_member_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/datasets/access-policy/member-bindings" + assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} + assert call.json == {"account_ids": ["acct-2"]} + + +class TestWorkspaceAccess: + def test_app_matrix(self, mock_send: MagicMock): + mock_send.return_value = { + "items": [], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 2, "total_pages": 1}, + } + out = svc.RBACService.WorkspaceAccess.app_matrix( + "tenant-1", + options=svc.ListOption(page_number=2, results_per_page=20), + ) + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/workspace/apps/access-policy" + assert call.params == {"page_number": 2, "results_per_page": 20} + assert out.pagination + assert out.pagination.current_page == 2 + + def test_dataset_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"items": []} + svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/workspace/datasets/access-policy" + assert call.params is None + + def test_workspace_matrix_coerces_null_bindings(self, mock_send: MagicMock): + mock_send.return_value = { + "items": [ + { + "policy": { + "id": "policy-1", + "resource_type": "app", + "name": "Workspace App Access", + }, + "roles": None, + "accounts": None, + } + ], + "pagination": None, + } + + out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") + + assert out.items[0].roles == [] + assert out.items[0].accounts == [] + + def test_workspace_app_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) + svc.RBACService.WorkspaceAccess.replace_app_bindings("tenant-1", "acct-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/workspace/apps/access-policy/bindings" + assert call.params == {"policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} + + def test_workspace_dataset_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) + svc.RBACService.WorkspaceAccess.replace_dataset_bindings("tenant-1", "acct-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/workspace/datasets/access-policy/bindings" + assert call.params == {"policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} + + def test_workspace_app_matrix_forwards_language_query_param(self, mock_send: MagicMock): + mock_send.return_value = {"items": [], "pagination": None} + app = Flask(__name__) + with app.test_request_context("/?language=en"): + svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") + + call = _call_args(mock_send) + assert call.params == {"language": "en"} + + +class TestMyPermissions: + def test_resource_snapshot_maps_defaults_and_overrides(self): + snapshot = svc.ResourcePermissionSnapshot( + default_permission_keys=["app.acl.view_layout"], + overrides=[ + svc.ResourcePermissionKeys( + resource_id="app-2", + permission_keys=["app.acl.view_layout", "app.acl.edit"], + ) + ], + ) + + assert snapshot.permission_keys_by_resource_ids(["app-1", "app-2"]) == { + "app-1": ["app.acl.view_layout"], + "app-2": ["app.acl.view_layout", "app.acl.edit"], + } + + def test_get_without_payload_uses_get(self, mock_send: MagicMock): + mock_send.return_value = { + "workspace": {"permission_keys": ["workspace.member.manage"]}, + "app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []}, + "dataset": {"default_permission_keys": [], "overrides": []}, + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/my-permissions" + assert call.json is None + assert call.params is None + assert out.workspace.permission_keys == ["workspace.member.manage"] + + @pytest.mark.parametrize( + ("role", "workspace_keys", "app_keys", "dataset_keys"), + [ + ( + "owner", + svc._LEGACY_WORKSPACE_OWNER_KEYS, + svc._LEGACY_APP_OWNER_KEYS, + svc._LEGACY_DATASET_OWNER_KEYS, + ), + ( + "admin", + svc._LEGACY_WORKSPACE_ADMIN_KEYS, + svc._LEGACY_APP_ADMIN_KEYS, + svc._LEGACY_DATASET_ADMIN_KEYS, + ), + ( + "editor", + svc._LEGACY_WORKSPACE_EDITOR_KEYS, + svc._LEGACY_APP_EDITOR_KEYS, + svc._LEGACY_DATASET_EDITOR_KEYS, + ), + ( + "normal", + svc._LEGACY_WORKSPACE_NORMAL_KEYS, + svc._LEGACY_APP_NORMAL_KEYS, + [], + ), + ( + "dataset_operator", + svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + [], + svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + ), + ], + ) + def test_get_uses_legacy_role_permissions_when_rbac_disabled( + self, + mock_send: MagicMock, + role: str, + workspace_keys: list[str], + app_keys: list[str], + dataset_keys: list[str], + ): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = role + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + mock_send.assert_not_called() + assert out.workspace.permission_keys == workspace_keys + assert out.app.default_permission_keys == app_keys + assert out.dataset.default_permission_keys == dataset_keys + assert out.app.overrides == [] + assert out.dataset.overrides == [] + + def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = None + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + mock_send.assert_not_called() + assert out.workspace.permission_keys == [] + assert out.app.default_permission_keys == [] + assert out.dataset.default_permission_keys == [] + + def test_get_with_single_resource_filters(self, mock_send: MagicMock): + mock_send.return_value = { + "workspace": {"permission_keys": []}, + "app": { + "default_permission_keys": [], + "overrides": [{"resource_id": "app-1", "permission_keys": ["app.acl.edit"]}], + }, + "dataset": {"default_permission_keys": [], "overrides": []}, + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/my-permissions" + assert call.params == {"app_id": "app-1"} + assert out.app.overrides[0].resource_id == "app-1" + + +class TestMemberRoles: + def test_get(self, mock_send: MagicMock): + mock_send.return_value = { + "account_id": "acct-2", + "roles": [ + { + "id": "role-1", + "type": "workspace", + "name": "Member", + } + ], + } + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/members/rbac-roles" + assert call.params == {"account_id": "acct-2"} + assert out.account_id == "acct-2" + assert out.roles[0].name == "Member" + + def test_replace(self, mock_send: MagicMock): + mock_send.return_value = {"account_id": "acct-2", "roles": []} + svc.RBACService.MemberRoles.replace( + "tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"] + ) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/members/rbac-roles" + assert call.params == {"account_id": "acct-2"} + assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]} + + def test_batch_get(self, mock_send: MagicMock): + mock_send.return_value = { + "acct-2": [ + {"id": "role-1", "name": "Admin", "type": "workspace"}, + {"id": "role-2", "name": "Editor", "type": "workspace"}, + ], + "acct-3": [], + } + + out = svc.RBACService.MemberRoles.batch_get("tenant-1", "acct-1", ["acct-2", "acct-3"]) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/members/rbac-roles/batch" + assert call.json == {"member_ids": ["acct-2", "acct-3"]} + assert out[0].account_id == "acct-2" + assert len(out[0].roles) == 2 + assert out[1].account_id == "acct-3" + assert out[1].roles == [] + + +class TestResourcePermissions: + def test_app_permissions_batch_get(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + {"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]}, + {"resource_id": "app-2", "permission_keys": []}, + ] + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"]) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/apps/permission-keys/batch" + assert call.json == {"app_ids": ["app-1", "app-2"]} + assert out == { + "app-1": ["app.acl.view_layout", "app.acl.edit"], + "app-2": [], + } + + def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = "editor" + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"]) + + mock_send.assert_not_called() + assert out == { + "app-1": svc._LEGACY_APP_EDITOR_KEYS, + "app-2": svc._LEGACY_APP_EDITOR_KEYS, + } + + def test_dataset_permissions_batch_get(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + {"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]}, + {"resource_id": "ds-2", "permission_keys": ["dataset.acl.edit"]}, + ] + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"]) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/datasets/permission-keys/batch" + assert call.json == {"dataset_ids": ["ds-1", "ds-2"]} + assert out == { + "ds-1": ["dataset.acl.readonly"], + "ds-2": ["dataset.acl.edit"], + } + + def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = "dataset_operator" + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"]) + + mock_send.assert_not_called() + assert out == { + "ds-1": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + "ds-2": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + } + + +class TestListOption: + def test_empty_produces_empty_params(self): + assert svc.ListOption().to_params() == {} + + def test_reverse_serialises_as_lowercase_bool(self): + assert svc.ListOption(reverse=False).to_params()["reverse"] == "false" + assert svc.ListOption(reverse=True).to_params()["reverse"] == "true" + + def test_extra_overrides_merge(self): + assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == { + "page_number": 1, + "resource_type": "app", + } diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_built_in_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_built_in_retrieval.py index 1928958ea4a..441a914ee62 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_built_in_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_built_in_retrieval.py @@ -1,3 +1,5 @@ +from pytest_mock import MockerFixture + from services.rag_pipeline.pipeline_template.built_in.built_in_retrieval import BuiltInPipelineTemplateRetrieval from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType @@ -8,7 +10,7 @@ def test_get_type() -> None: assert retrieval.get_type() == PipelineTemplateType.BUILTIN -def test_get_pipeline_templates(mocker) -> None: +def test_get_pipeline_templates(mocker: MockerFixture) -> None: mocker.patch.object( BuiltInPipelineTemplateRetrieval, "_get_builtin_data", @@ -26,7 +28,7 @@ def test_get_pipeline_templates(mocker) -> None: assert templates == {"pipeline_templates": [{"id": "tpl-1"}]} -def test_get_pipeline_template_detail(mocker) -> None: +def test_get_pipeline_template_detail(mocker: MockerFixture) -> None: mocker.patch.object( BuiltInPipelineTemplateRetrieval, "_get_builtin_data", @@ -43,7 +45,7 @@ def test_get_pipeline_template_detail(mocker) -> None: assert detail == {"id": "tpl-1", "name": "Template 1"} -def test_get_pipeline_templates_missing_language_returns_empty_dict(mocker) -> None: +def test_get_pipeline_templates_missing_language_returns_empty_dict(mocker: MockerFixture) -> None: mocker.patch.object( BuiltInPipelineTemplateRetrieval, "_get_builtin_data", @@ -56,7 +58,7 @@ def test_get_pipeline_templates_missing_language_returns_empty_dict(mocker) -> N assert result == {} -def test_get_pipeline_template_detail_returns_none_for_unknown_id(mocker) -> None: +def test_get_pipeline_template_detail_returns_none_for_unknown_id(mocker: MockerFixture) -> None: mocker.patch.object( BuiltInPipelineTemplateRetrieval, "_get_builtin_data", @@ -69,7 +71,7 @@ def test_get_pipeline_template_detail_returns_none_for_unknown_id(mocker) -> Non assert result is None -def test_get_builtin_data_reads_from_file_and_caches(mocker) -> None: +def test_get_builtin_data_reads_from_file_and_caches(mocker: MockerFixture) -> None: import json # Ensure no cached data @@ -98,7 +100,7 @@ def test_get_builtin_data_reads_from_file_and_caches(mocker) -> None: BuiltInPipelineTemplateRetrieval.builtin_data = None -def test_get_builtin_data_returns_cache_on_second_call(mocker) -> None: +def test_get_builtin_data_returns_cache_on_second_call(mocker: MockerFixture) -> None: cached_data = {"pipeline_templates": {"en-US": {}}} BuiltInPipelineTemplateRetrieval.builtin_data = cached_data diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py index 106b959a78b..168ec8fce3c 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_customized_retrieval.py @@ -1,10 +1,12 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from services.rag_pipeline.pipeline_template.customized.customized_retrieval import CustomizedPipelineTemplateRetrieval from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType -def test_get_pipeline_templates(mocker) -> None: +def test_get_pipeline_templates(mocker: MockerFixture) -> None: customized_template = SimpleNamespace( id="tpl-1", name="Custom Template", @@ -40,7 +42,7 @@ def test_get_pipeline_templates(mocker) -> None: } -def test_get_pipeline_template_detail_returns_detail(mocker) -> None: +def test_get_pipeline_template_detail_returns_detail(mocker: MockerFixture) -> None: session_mock = mocker.Mock() session_mock.get.return_value = SimpleNamespace( id="tpl-1", @@ -71,7 +73,7 @@ def test_get_pipeline_template_detail_returns_detail(mocker) -> None: } -def test_get_pipeline_template_detail_returns_none_when_not_found(mocker) -> None: +def test_get_pipeline_template_detail_returns_none_when_not_found(mocker: MockerFixture) -> None: session_mock = mocker.Mock() session_mock.get.return_value = None mocker.patch( diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_database_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_database_retrieval.py index 0175f66808b..41f60e45755 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_database_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_database_retrieval.py @@ -1,10 +1,12 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from services.rag_pipeline.pipeline_template.database.database_retrieval import DatabasePipelineTemplateRetrieval from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType -def test_get_pipeline_templates(mocker) -> None: +def test_get_pipeline_templates(mocker: MockerFixture) -> None: built_in_template = SimpleNamespace( id="tpl-1", name="Template 1", @@ -44,7 +46,7 @@ def test_get_pipeline_templates(mocker) -> None: } -def test_get_pipeline_template_detail_returns_detail(mocker) -> None: +def test_get_pipeline_template_detail_returns_detail(mocker: MockerFixture) -> None: session_mock = mocker.Mock() session_mock.get.return_value = SimpleNamespace( id="tpl-1", @@ -73,7 +75,7 @@ def test_get_pipeline_template_detail_returns_detail(mocker) -> None: } -def test_get_pipeline_template_detail_returns_none_when_not_found(mocker) -> None: +def test_get_pipeline_template_detail_returns_none_when_not_found(mocker: MockerFixture) -> None: session_mock = mocker.Mock() session_mock.get.return_value = None mocker.patch( diff --git a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py index 10b5bc7cf67..5da6684926c 100644 --- a/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py +++ b/api/tests/unit_tests/services/rag_pipeline/pipeline_template/test_remote_retrieval.py @@ -1,11 +1,12 @@ import pytest +from pytest_mock import MockerFixture from services.rag_pipeline.pipeline_template.database.database_retrieval import DatabasePipelineTemplateRetrieval from services.rag_pipeline.pipeline_template.pipeline_template_type import PipelineTemplateType from services.rag_pipeline.pipeline_template.remote.remote_retrieval import RemotePipelineTemplateRetrieval -def test_get_pipeline_templates_fallbacks_to_database_on_error(mocker) -> None: +def test_get_pipeline_templates_fallbacks_to_database_on_error(mocker: MockerFixture) -> None: fetch_mock = mocker.patch.object( RemotePipelineTemplateRetrieval, "fetch_pipeline_templates_from_dify_official", @@ -26,7 +27,7 @@ def test_get_pipeline_templates_fallbacks_to_database_on_error(mocker) -> None: fallback_mock.assert_called_once_with("en-US") -def test_get_pipeline_template_detail_fallbacks_to_database_on_error(mocker) -> None: +def test_get_pipeline_template_detail_fallbacks_to_database_on_error(mocker: MockerFixture) -> None: fetch_mock = mocker.patch.object( RemotePipelineTemplateRetrieval, "fetch_pipeline_template_detail_from_dify_official", @@ -46,7 +47,7 @@ def test_get_pipeline_template_detail_fallbacks_to_database_on_error(mocker) -> fallback_mock.assert_called_once_with("tpl-1") -def test_fetch_pipeline_templates_from_dify_official(mocker) -> None: +def test_fetch_pipeline_templates_from_dify_official(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.pipeline_template.remote.remote_retrieval" ".dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN", @@ -72,7 +73,7 @@ def test_fetch_pipeline_templates_from_dify_official(mocker) -> None: assert http_get_mock.call_count == 2 -def test_fetch_pipeline_template_detail_from_dify_official(mocker) -> None: +def test_fetch_pipeline_template_detail_from_dify_official(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.pipeline_template.remote.remote_retrieval" ".dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN", diff --git a/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py b/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py index 82a5598b13d..178f4595359 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_pipeline_generate_service.py @@ -2,6 +2,7 @@ from types import SimpleNamespace from typing import cast import pytest +from pytest_mock import MockerFixture from core.app.entities.app_invoke_entities import InvokeFrom from models.dataset import Pipeline @@ -9,7 +10,7 @@ from models.model import Account, App, EndUser from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService -def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker) -> None: +def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_DEFAULT_ACTIVE_REQUESTS", 5) mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_MAX_ACTIVE_REQUESTS", 3) @@ -20,7 +21,7 @@ def test_get_max_active_requests_uses_smallest_non_zero_limit(mocker) -> None: assert result == 3 -def test_get_max_active_requests_returns_zero_when_all_unlimited(mocker) -> None: +def test_get_max_active_requests_returns_zero_when_all_unlimited(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_DEFAULT_ACTIVE_REQUESTS", 0) mocker.patch("services.rag_pipeline.pipeline_generate_service.dify_config.APP_MAX_ACTIVE_REQUESTS", 0) @@ -39,7 +40,7 @@ def test_get_max_active_requests_returns_zero_when_all_unlimited(mocker) -> None (InvokeFrom.DEBUGGER, SimpleNamespace(id="wf-1"), None), ], ) -def test_get_workflow(mocker, invoke_from, workflow, expected_error) -> None: +def test_get_workflow(mocker: MockerFixture, invoke_from, workflow, expected_error) -> None: rag_pipeline_service_cls = mocker.patch("services.rag_pipeline.pipeline_generate_service.RagPipelineService") rag_pipeline_service = rag_pipeline_service_cls.return_value rag_pipeline_service.get_draft_workflow.return_value = workflow @@ -55,7 +56,7 @@ def test_get_workflow(mocker, invoke_from, workflow, expected_error) -> None: assert result == workflow -def test_generate_updates_document_status_and_returns_event_stream(mocker) -> None: +def test_generate_updates_document_status_and_returns_event_stream(mocker: MockerFixture) -> None: pipeline = cast(Pipeline, SimpleNamespace(id="pipeline-1")) user = cast(Account | EndUser, SimpleNamespace(id="user-1")) args = {"original_document_id": "doc-1", "query": "hello"} @@ -80,7 +81,7 @@ def test_generate_updates_document_status_and_returns_event_stream(mocker) -> No update_status_mock.assert_called_once_with("doc-1") -def test_update_document_status_updates_existing_document(mocker) -> None: +def test_update_document_status_updates_existing_document(mocker: MockerFixture) -> None: document = SimpleNamespace(indexing_status="completed") session_mock = mocker.Mock() @@ -99,7 +100,7 @@ def test_update_document_status_updates_existing_document(mocker) -> None: commit_mock.assert_called_once() -def test_update_document_status_skips_when_document_missing(mocker) -> None: +def test_update_document_status_skips_when_document_missing(mocker: MockerFixture) -> None: session_mock = mocker.Mock() session_mock.get.return_value = None add_mock = session_mock.add @@ -118,7 +119,7 @@ def test_update_document_status_skips_when_document_missing(mocker) -> None: # --- generate_single_iteration --- -def test_generate_single_iteration_delegates(mocker) -> None: +def test_generate_single_iteration_delegates(mocker: MockerFixture) -> None: mocker.patch.object(PipelineGenerateService, "_get_workflow", return_value=SimpleNamespace(id="wf-1")) generator_cls = mocker.patch("services.rag_pipeline.pipeline_generate_service.PipelineGenerator") @@ -138,7 +139,7 @@ def test_generate_single_iteration_delegates(mocker) -> None: # --- generate_single_loop --- -def test_generate_single_loop_delegates(mocker) -> None: +def test_generate_single_loop_delegates(mocker: MockerFixture) -> None: mocker.patch.object(PipelineGenerateService, "_get_workflow", return_value=SimpleNamespace(id="wf-1")) generator_cls = mocker.patch("services.rag_pipeline.pipeline_generate_service.PipelineGenerator") 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 dba5711795f..55ae8144d55 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 @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, Mock import pytest import yaml +from pytest_mock import MockerFixture from sqlalchemy.orm import Session from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE @@ -55,7 +56,7 @@ def test_get_leaked_dependencies_returns_empty_list_for_empty_input() -> None: assert result == [] -def test_get_leaked_dependencies_delegates_to_analysis_service(mocker) -> None: +def test_get_leaked_dependencies_delegates_to_analysis_service(mocker: MockerFixture) -> None: expected = [Mock()] get_leaked_mock = mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.get_leaked_dependencies", @@ -72,7 +73,7 @@ def test_get_leaked_dependencies_delegates_to_analysis_service(mocker) -> None: # --- check_dependencies --- -def test_check_dependencies_returns_empty_when_no_redis_data(mocker) -> None: +def test_check_dependencies_returns_empty_when_no_redis_data(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.redis_client.get", return_value=None, @@ -85,7 +86,7 @@ def test_check_dependencies_returns_empty_when_no_redis_data(mocker) -> None: assert result.leaked_dependencies == [] -def test_check_dependencies_returns_leaked_deps_from_redis(mocker) -> None: +def test_check_dependencies_returns_leaked_deps_from_redis(mocker: MockerFixture) -> None: from core.plugin.entities.plugin import PluginDependency from services.rag_pipeline.rag_pipeline_dsl_service import CheckDependenciesPendingData @@ -117,7 +118,7 @@ def test_check_dependencies_returns_leaked_deps_from_redis(mocker) -> None: # --- _extract_dependencies_from_model_config --- -def test_extract_dependencies_from_model_config_extracts_model(mocker) -> None: +def test_extract_dependencies_from_model_config_extracts_model(mocker: MockerFixture) -> None: analyze_mock = mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", return_value="langgenius/openai", @@ -130,7 +131,7 @@ def test_extract_dependencies_from_model_config_extracts_model(mocker) -> None: analyze_mock.assert_called_with("openai") -def test_extract_dependencies_from_model_config_extracts_tools(mocker) -> None: +def test_extract_dependencies_from_model_config_extracts_tools(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", return_value="x", @@ -159,7 +160,7 @@ def test_extract_dependencies_from_model_config_empty_config() -> None: # --- _extract_dependencies_from_workflow_graph --- -def test_extract_dependencies_from_workflow_graph_ignores_unknown_types(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_ignores_unknown_types(mocker: MockerFixture) -> None: service = RagPipelineDslService(session=Mock()) graph = {"nodes": [{"data": {"type": "some-unknown-type"}}]} @@ -176,7 +177,7 @@ def test_extract_dependencies_from_workflow_graph_handles_empty_graph() -> None: assert result == [] -def test_extract_dependencies_from_workflow_graph_handles_malformed_node(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_handles_malformed_node(mocker: MockerFixture) -> None: service = RagPipelineDslService(session=Mock()) # Node with TOOL type but invalid data should be caught by exception handler from graphon.enums import BuiltinNodeTypes @@ -205,7 +206,7 @@ def test_export_rag_pipeline_dsl_raises_when_dataset_missing() -> None: # --- import_rag_pipeline --- -def test_import_rag_pipeline_url_fetch_error(mocker) -> None: +def test_import_rag_pipeline_url_fetch_error(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.remote_fetcher.make_request", side_effect=Exception("fetch failed"), @@ -221,7 +222,7 @@ def test_import_rag_pipeline_url_fetch_error(mocker) -> None: assert "fetch failed" in result.error -def test_import_rag_pipeline_yaml_content_success(mocker) -> None: +def test_import_rag_pipeline_yaml_content_success(mocker: MockerFixture) -> None: yaml_content = """ version: 0.1.0 kind: rag_pipeline @@ -269,7 +270,7 @@ workflow: session.flush.assert_called() -def test_import_rag_pipeline_flushes_new_collection_binding_without_commit(mocker) -> None: +def test_import_rag_pipeline_flushes_new_collection_binding_without_commit(mocker: MockerFixture) -> None: yaml_content = """ version: 0.1.0 kind: rag_pipeline @@ -321,7 +322,7 @@ workflow: assert session.flush.call_count >= 2 -def test_import_rag_pipeline_pending_version(mocker) -> None: +def test_import_rag_pipeline_pending_version(mocker: MockerFixture) -> None: yaml_content = "version: 1.0.0\nkind: rag_pipeline\nrag_pipeline: {name: x}" mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.setex") service = RagPipelineDslService(session=Mock()) @@ -336,7 +337,7 @@ def test_import_rag_pipeline_pending_version(mocker) -> None: # --- confirm_import --- -def test_confirm_import_success(mocker) -> None: +def test_confirm_import_success(mocker: MockerFixture) -> None: from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData yaml_content = """ @@ -398,7 +399,7 @@ workflow: assert result.dataset_id == "d1" -def test_confirm_import_flushes_new_collection_binding_without_commit(mocker) -> None: +def test_confirm_import_flushes_new_collection_binding_without_commit(mocker: MockerFixture) -> None: from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData yaml_content = """ @@ -518,7 +519,7 @@ def test_extract_dependencies_from_workflow_graph_types(mocker, node_type) -> No # --- _create_or_update_pipeline --- -def test_create_or_update_pipeline_create_new(mocker) -> None: +def test_create_or_update_pipeline_create_new(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) account = Mock(current_tenant_id="t1", id="u1") @@ -549,7 +550,7 @@ def test_create_or_update_pipeline_create_new(mocker) -> None: # --- export_rag_pipeline_dsl comprehensive --- -def test_export_rag_pipeline_dsl_with_workflow(mocker) -> None: +def test_export_rag_pipeline_dsl_with_workflow(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) pipeline = Mock() @@ -591,7 +592,7 @@ def test_export_rag_pipeline_dsl_with_workflow(mocker) -> None: # --- _extract_dependencies_from_workflow_graph more types --- -def test_extract_dependencies_from_workflow_graph_datasource(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_datasource(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DatasourceNodeData.model_validate", return_value=Mock(provider_type="online", plugin_id="ds1"), @@ -642,7 +643,7 @@ def test_import_rag_pipeline_yaml_content_requires_mapping() -> None: assert "content must be a mapping" in result.error -def test_confirm_import_returns_failed_when_pending_data_is_invalid_type(mocker) -> None: +def test_confirm_import_returns_failed_when_pending_data_is_invalid_type(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.get", return_value=object()) service = RagPipelineDslService(session=Mock()) account = Mock(current_tenant_id="t1") @@ -653,7 +654,7 @@ def test_confirm_import_returns_failed_when_pending_data_is_invalid_type(mocker) assert "Invalid import information" in result.error -def test_append_workflow_export_data_filters_credentials(mocker) -> None: +def test_append_workflow_export_data_filters_credentials(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) workflow = Mock() @@ -691,7 +692,7 @@ def test_append_workflow_export_data_filters_credentials(mocker) -> None: assert "credential_id" not in nodes[1]["data"]["agent_parameters"]["tools"]["value"][0] -def test_create_rag_pipeline_dataset_raises_when_name_conflicts(mocker) -> None: +def test_create_rag_pipeline_dataset_raises_when_name_conflicts(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) session.scalar.return_value = Mock() @@ -707,7 +708,7 @@ def test_create_rag_pipeline_dataset_raises_when_name_conflicts(mocker) -> None: service.create_rag_pipeline_dataset("tenant-1", create_entity) -def test_create_rag_pipeline_dataset_generates_name_when_missing(mocker) -> None: +def test_create_rag_pipeline_dataset_generates_name_when_missing(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) session.scalar.return_value = None @@ -741,7 +742,7 @@ def test_create_rag_pipeline_dataset_generates_name_when_missing(mocker) -> None assert result["status"] == ImportStatus.COMPLETED -def test_append_workflow_export_data_encrypts_knowledge_retrieval_dataset_ids(mocker) -> None: +def test_append_workflow_export_data_encrypts_knowledge_retrieval_dataset_ids(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) workflow = Mock() @@ -773,7 +774,7 @@ def test_append_workflow_export_data_encrypts_knowledge_retrieval_dataset_ids(mo assert ids == ["enc-d1", "enc-d2"] -def test_confirm_import_updates_existing_dataset(mocker) -> None: +def test_confirm_import_updates_existing_dataset(mocker: MockerFixture) -> None: from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData yaml_content = ( @@ -812,7 +813,7 @@ def test_confirm_import_updates_existing_dataset(mocker) -> None: assert dataset.indexing_technique == "economy" -def test_import_rag_pipeline_yaml_url_handles_empty_content_after_github_rewrite(mocker) -> None: +def test_import_rag_pipeline_yaml_url_handles_empty_content_after_github_rewrite(mocker: MockerFixture) -> None: response = Mock() response.raise_for_status.return_value = None response.content = b"" @@ -835,7 +836,7 @@ def test_import_rag_pipeline_yaml_url_handles_empty_content_after_github_rewrite assert "raw.githubusercontent.com" in called_url -def test_create_or_update_pipeline_decrypts_knowledge_retrieval_dataset_ids(mocker) -> None: +def test_create_or_update_pipeline_decrypts_knowledge_retrieval_dataset_ids(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) account = Mock(id="u1", current_tenant_id="t1") @@ -866,7 +867,7 @@ def test_create_or_update_pipeline_decrypts_knowledge_retrieval_dataset_ids(mock assert draft_workflow.graph is not None -def test_create_or_update_pipeline_creates_draft_when_missing(mocker) -> None: +def test_create_or_update_pipeline_creates_draft_when_missing(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) account = Mock(id="u1", current_tenant_id="t1") @@ -882,7 +883,7 @@ def test_create_or_update_pipeline_creates_draft_when_missing(mocker) -> None: assert pipeline.workflow_id == "wf-new" -def test_import_rag_pipeline_url_size_exceeds_limit(mocker) -> None: +def test_import_rag_pipeline_url_size_exceeds_limit(mocker: MockerFixture) -> None: response = Mock() response.raise_for_status.return_value = None response.content = b"x" * (10 * 1024 * 1024 + 1) @@ -953,7 +954,7 @@ def test_append_workflow_export_data_raises_when_draft_workflow_missing() -> Non service._append_workflow_export_data(export_data={}, pipeline=Mock(tenant_id="t1"), include_secret=False) -def test_append_workflow_export_data_keeps_secret_fields_when_include_secret_true(mocker) -> None: +def test_append_workflow_export_data_keeps_secret_fields_when_include_secret_true(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) workflow = Mock() @@ -992,7 +993,7 @@ def test_append_workflow_export_data_keeps_secret_fields_when_include_secret_tru assert tool_values[0]["credential_id"] == "agent-secret" -def test_extract_dependencies_from_workflow_graph_skips_local_file_datasource(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_skips_local_file_datasource(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DatasourceNodeData.model_validate", return_value=Mock(provider_type="local_file", plugin_id="plugin-x"), @@ -1006,7 +1007,7 @@ def test_extract_dependencies_from_workflow_graph_skips_local_file_datasource(mo assert result == [] -def test_extract_dependencies_from_workflow_graph_knowledge_index_reranking(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_knowledge_index_reranking(mocker: MockerFixture) -> None: analyze = mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", side_effect=lambda provider: f"dep:{provider}", @@ -1031,7 +1032,7 @@ def test_extract_dependencies_from_workflow_graph_knowledge_index_reranking(mock assert analyze.call_count == 2 -def test_extract_dependencies_from_workflow_graph_multiple_retrieval_weighted_score(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_multiple_retrieval_weighted_score(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", return_value="dep:weighted", @@ -1053,7 +1054,7 @@ def test_extract_dependencies_from_workflow_graph_multiple_retrieval_weighted_sc assert result == ["dep:weighted"] -def test_extract_dependencies_from_workflow_graph_multiple_retrieval_reranking_model(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_multiple_retrieval_reranking_model(mocker: MockerFixture) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", return_value="dep:rerank", @@ -1075,7 +1076,7 @@ def test_extract_dependencies_from_workflow_graph_multiple_retrieval_reranking_m assert result == ["dep:rerank"] -def test_extract_dependencies_from_model_config_includes_dataset_reranking_and_tools(mocker) -> None: +def test_extract_dependencies_from_model_config_includes_dataset_reranking_and_tools(mocker: MockerFixture) -> None: model_analyze = mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", side_effect=["dep:model", "dep:rerank"], @@ -1107,7 +1108,7 @@ def test_extract_dependencies_from_model_config_includes_dataset_reranking_and_t tool_analyze.assert_called_once_with("google") -def test_check_version_compatibility_hits_major_older_branch(mocker) -> None: +def test_check_version_compatibility_hits_major_older_branch(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.CURRENT_DSL_VERSION", "1.0.0") status = check_version_compatibility("0.9.0", rag_pipeline_dsl_service.CURRENT_DSL_VERSION) @@ -1115,7 +1116,7 @@ def test_check_version_compatibility_hits_major_older_branch(mocker) -> None: assert status == ImportStatus.PENDING -def test_import_rag_pipeline_sets_default_version_and_kind(mocker) -> None: +def test_import_rag_pipeline_sets_default_version_and_kind(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) account = Mock(current_tenant_id="t1") @@ -1147,7 +1148,7 @@ def test_import_rag_pipeline_sets_default_version_and_kind(mocker) -> None: assert result.imported_dsl_version == "0.1.0" -def test_import_rag_pipeline_creates_pending_for_dependencies(mocker) -> None: +def test_import_rag_pipeline_creates_pending_for_dependencies(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) account = Mock(current_tenant_id="t1") @@ -1169,7 +1170,7 @@ workflow: {graph: {nodes: []}} setex.assert_called_once() -def test_confirm_import_returns_failed_when_pending_pipeline_missing(mocker) -> None: +def test_confirm_import_returns_failed_when_pending_pipeline_missing(mocker: MockerFixture) -> None: from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData pending = RagPipelinePendingData(import_mode="yaml-content", yaml_content="version: 0.1.0", pipeline_id="p1") @@ -1186,7 +1187,7 @@ def test_confirm_import_returns_failed_when_pending_pipeline_missing(mocker) -> assert result.status == ImportStatus.FAILED -def test_append_workflow_export_data_skips_empty_node_data(mocker) -> None: +def test_append_workflow_export_data_skips_empty_node_data(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) workflow = Mock() @@ -1204,7 +1205,7 @@ def test_append_workflow_export_data_skips_empty_node_data(mocker) -> None: assert "workflow" in export_data -def test_extract_dependencies_from_workflow_graph_multiple_config_none(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_multiple_config_none(mocker: MockerFixture) -> None: retrieval = Mock() retrieval.retrieval_mode = "multiple" retrieval.multiple_retrieval_config = None @@ -1221,7 +1222,7 @@ def test_extract_dependencies_from_workflow_graph_multiple_config_none(mocker) - assert result == [] -def test_extract_dependencies_from_workflow_graph_single_config_none(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_single_config_none(mocker: MockerFixture) -> None: retrieval = Mock() retrieval.retrieval_mode = "single" retrieval.single_retrieval_config = None @@ -1246,7 +1247,7 @@ def test_create_or_update_pipeline_raises_when_workflow_missing() -> None: service._create_or_update_pipeline(pipeline=None, data={"rag_pipeline": {"name": "x"}}, account=account) -def test_import_rag_pipeline_with_pipeline_id_uses_existing_dataset(mocker) -> None: +def test_import_rag_pipeline_with_pipeline_id_uses_existing_dataset(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) existing_dataset = Mock(id="d1", chunk_structure="text_model") @@ -1282,7 +1283,7 @@ def test_import_rag_pipeline_with_pipeline_id_uses_existing_dataset(mocker) -> N assert result.dataset_id == "d1" -def test_import_rag_pipeline_raises_for_chunk_structure_mismatch_on_published(mocker) -> None: +def test_import_rag_pipeline_raises_for_chunk_structure_mismatch_on_published(mocker: MockerFixture) -> None: session = cast(MagicMock, Mock()) service = RagPipelineDslService(session=cast(Session, session)) existing_dataset = Mock(id="d1", chunk_structure="hierarchical_model") @@ -1318,7 +1319,7 @@ def test_import_rag_pipeline_raises_for_chunk_structure_mismatch_on_published(mo assert "Chunk structure is not compatible" in result.error -def test_import_rag_pipeline_fails_when_no_knowledge_index_node(mocker) -> None: +def test_import_rag_pipeline_fails_when_no_knowledge_index_node(mocker: MockerFixture) -> None: service = RagPipelineDslService(session=Mock()) pipeline = Mock(id="p1", name="P", description="D", is_published=False) mocker.patch.object(service, "_create_or_update_pipeline", return_value=pipeline) @@ -1340,7 +1341,7 @@ def test_import_rag_pipeline_fails_when_no_knowledge_index_node(mocker) -> None: assert "Knowledge Index node" in result.error -def test_confirm_import_fails_when_no_knowledge_index_node(mocker) -> None: +def test_confirm_import_fails_when_no_knowledge_index_node(mocker: MockerFixture) -> None: from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelinePendingData yaml_content = ( @@ -1369,7 +1370,7 @@ def test_confirm_import_fails_when_no_knowledge_index_node(mocker) -> None: assert "Knowledge Index node" in result.error -def test_create_or_update_pipeline_saves_dependencies_to_redis(mocker) -> None: +def test_create_or_update_pipeline_saves_dependencies_to_redis(mocker: MockerFixture) -> None: from core.plugin.entities.plugin import PluginDependency session = cast(MagicMock, Mock()) @@ -1399,7 +1400,9 @@ def test_create_or_update_pipeline_saves_dependencies_to_redis(mocker) -> None: setex.assert_called_once() -def test_extract_dependencies_from_workflow_graph_knowledge_index_without_embedding_provider(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_knowledge_index_without_embedding_provider( + mocker: MockerFixture, +) -> None: mocker.patch( "services.rag_pipeline.rag_pipeline_dsl_service.DependenciesAnalysisService.analyze_model_provider_dependency", return_value="dep", @@ -1421,7 +1424,7 @@ def test_extract_dependencies_from_workflow_graph_knowledge_index_without_embedd assert result == [] -def test_extract_dependencies_from_workflow_graph_multiple_reranking_without_model(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_multiple_reranking_without_model(mocker: MockerFixture) -> None: retrieval = Mock() retrieval.retrieval_mode = "multiple" retrieval.multiple_retrieval_config.reranking_mode = "reranking_model" @@ -1439,7 +1442,7 @@ def test_extract_dependencies_from_workflow_graph_multiple_reranking_without_mod assert result == [] -def test_extract_dependencies_from_workflow_graph_multiple_weighted_without_weights(mocker) -> None: +def test_extract_dependencies_from_workflow_graph_multiple_weighted_without_weights(mocker: MockerFixture) -> None: retrieval = Mock() retrieval.retrieval_mode = "multiple" retrieval.multiple_retrieval_config.reranking_mode = "weighted_score" diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_manage_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_manage_service.py index bd75e699dc8..faf93f0ce8e 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_manage_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_manage_service.py @@ -1,9 +1,11 @@ from types import SimpleNamespace +from pytest_mock import MockerFixture + from services.rag_pipeline.rag_pipeline_manage_service import RagPipelineManageService -def test_list_rag_pipeline_datasources_marks_authorized(mocker) -> None: +def test_list_rag_pipeline_datasources_marks_authorized(mocker: MockerFixture) -> None: datasource_1 = SimpleNamespace(provider="notion", plugin_id="plugin-1", is_authorized=False) datasource_2 = SimpleNamespace(provider="jina", plugin_id="plugin-2", is_authorized=False) diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index b255595047d..669794ac6d4 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -4,6 +4,7 @@ from datetime import datetime from types import SimpleNamespace import pytest +from pytest_mock import MockerFixture from sqlalchemy.orm import sessionmaker from models import Account, Tenant @@ -113,7 +114,7 @@ def _make_recommended_plugin(plugin_id: str) -> PipelineRecommendedPlugin: return PipelineRecommendedPlugin(plugin_id=plugin_id, provider_name=plugin_id, type="tool", position=0, active=True) -def test_get_pipeline_templates_fallbacks_to_builtin_for_non_english_empty_result(mocker) -> None: +def test_get_pipeline_templates_fallbacks_to_builtin_for_non_english_empty_result(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") remote_retrieval = mocker.Mock() @@ -132,7 +133,7 @@ def test_get_pipeline_templates_fallbacks_to_builtin_for_non_english_empty_resul builtin_retrieval.fetch_pipeline_templates_from_builtin.assert_called_once_with("en-US") -def test_get_pipeline_templates_customized_mode_uses_customized_factory(mocker) -> None: +def test_get_pipeline_templates_customized_mode_uses_customized_factory(mocker: MockerFixture) -> None: retrieval = mocker.Mock() retrieval.get_pipeline_templates.return_value = {"pipeline_templates": [{"id": "custom-1"}]} @@ -146,7 +147,7 @@ def test_get_pipeline_templates_customized_mode_uses_customized_factory(mocker) @pytest.mark.parametrize("template_type", ["built-in", "customized"]) -def test_get_pipeline_template_detail_uses_expected_mode(mocker, template_type: str) -> None: +def test_get_pipeline_template_detail_uses_expected_mode(mocker: MockerFixture, template_type: str) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") retrieval = mocker.Mock() retrieval.get_pipeline_template_detail.return_value = {"id": "tpl-1"} @@ -161,7 +162,9 @@ def test_get_pipeline_template_detail_uses_expected_mode(mocker, template_type: factory_mock.get_pipeline_template_factory.assert_called_with(expected_mode) -def test_get_published_workflow_returns_none_when_pipeline_has_no_workflow_id(rag_pipeline_service) -> None: +def test_get_published_workflow_returns_none_when_pipeline_has_no_workflow_id( + rag_pipeline_service: RagPipelineService, +) -> None: pipeline = _make_pipeline(workflow_id=None) result = rag_pipeline_service.get_published_workflow(pipeline) @@ -169,7 +172,9 @@ def test_get_published_workflow_returns_none_when_pipeline_has_no_workflow_id(ra assert result is None -def test_get_all_published_workflow_returns_empty_for_unpublished_pipeline(rag_pipeline_service) -> None: +def test_get_all_published_workflow_returns_empty_for_unpublished_pipeline( + rag_pipeline_service: RagPipelineService, +) -> None: pipeline = _make_pipeline(workflow_id=None) session = SimpleNamespace() @@ -186,7 +191,7 @@ def test_get_all_published_workflow_returns_empty_for_unpublished_pipeline(rag_p assert has_more is False -def test_get_all_published_workflow_applies_limit_and_has_more(rag_pipeline_service) -> None: +def test_get_all_published_workflow_applies_limit_and_has_more(rag_pipeline_service: RagPipelineService) -> None: scalars_result = SimpleNamespace(all=lambda: ["wf1", "wf2", "wf3"]) session = SimpleNamespace(scalars=lambda stmt: scalars_result) pipeline = _make_pipeline(pipeline_id="pipeline-1", workflow_id="wf-live") @@ -207,7 +212,9 @@ def test_get_all_published_workflow_applies_limit_and_has_more(rag_pipeline_serv # --- sync_draft_workflow --- -def test_sync_draft_workflow_creates_new_when_none_exists(mocker, rag_pipeline_service) -> None: +def test_sync_draft_workflow_creates_new_when_none_exists( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=None) class FakeWorkflow: @@ -238,7 +245,9 @@ def test_sync_draft_workflow_creates_new_when_none_exists(mocker, rag_pipeline_s assert pipeline.workflow_id == "wf-new" -def test_sync_draft_workflow_raises_on_hash_mismatch(mocker, rag_pipeline_service) -> None: +def test_sync_draft_workflow_raises_on_hash_mismatch( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from services.errors.app import WorkflowHashNotEqualError existing_wf = _make_workflow(graph={"nodes": [{"id": "old"}]}) @@ -259,7 +268,7 @@ def test_sync_draft_workflow_raises_on_hash_mismatch(mocker, rag_pipeline_servic ) -def test_sync_draft_workflow_updates_existing(mocker, rag_pipeline_service) -> None: +def test_sync_draft_workflow_updates_existing(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: existing_wf = SimpleNamespace( unique_hash="hash-1", graph=None, @@ -293,7 +302,9 @@ def test_sync_draft_workflow_updates_existing(mocker, rag_pipeline_service) -> N # --- get_default_block_config --- -def test_get_default_block_config_returns_config_for_valid_type(mocker, rag_pipeline_service) -> None: +def test_get_default_block_config_returns_config_for_valid_type( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: fake_node_class = mocker.Mock() fake_node_class.get_default_config.return_value = {"type": "start", "config": {}} @@ -311,14 +322,16 @@ def test_get_default_block_config_returns_config_for_valid_type(mocker, rag_pipe assert result == {"type": "start", "config": {}} -def test_get_default_block_config_returns_none_for_unmapped_type(rag_pipeline_service) -> None: +def test_get_default_block_config_returns_none_for_unmapped_type(rag_pipeline_service: RagPipelineService) -> None: assert rag_pipeline_service.get_default_block_config("nonexistent-type") is None # --- update_workflow --- -def test_update_workflow_updates_allowed_fields(mocker, rag_pipeline_service) -> None: +def test_update_workflow_updates_allowed_fields( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: workflow = SimpleNamespace( id="wf-1", marked_name="", marked_comment="", updated_by=None, updated_at=None, disallowed="original" ) @@ -339,7 +352,9 @@ def test_update_workflow_updates_allowed_fields(mocker, rag_pipeline_service) -> assert result.updated_by == "u1" -def test_update_workflow_returns_none_when_not_found(mocker, rag_pipeline_service) -> None: +def test_update_workflow_returns_none_when_not_found( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: session = mocker.Mock() session.scalar.return_value = None @@ -357,7 +372,9 @@ def test_update_workflow_returns_none_when_not_found(mocker, rag_pipeline_servic # --- get_rag_pipeline_paginate_workflow_runs --- -def test_get_rag_pipeline_paginate_workflow_runs_delegates(mocker, rag_pipeline_service) -> None: +def test_get_rag_pipeline_paginate_workflow_runs_delegates( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: expected = mocker.Mock() repo_mock = mocker.Mock() repo_mock.get_paginated_workflow_runs.return_value = expected @@ -379,7 +396,9 @@ def test_get_rag_pipeline_paginate_workflow_runs_delegates(mocker, rag_pipeline_ # --- get_rag_pipeline_workflow_run --- -def test_get_rag_pipeline_workflow_run_delegates(mocker, rag_pipeline_service) -> None: +def test_get_rag_pipeline_workflow_run_delegates( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: expected = mocker.Mock() repo_mock = mocker.Mock() repo_mock.get_workflow_run_by_id.return_value = expected @@ -395,14 +414,18 @@ def test_get_rag_pipeline_workflow_run_delegates(mocker, rag_pipeline_service) - # --- is_workflow_exist --- -def test_is_workflow_exist_returns_true_when_draft_exists(mocker, rag_pipeline_service) -> None: +def test_is_workflow_exist_returns_true_when_draft_exists( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=1) pipeline = _make_pipeline() assert rag_pipeline_service.is_workflow_exist(pipeline) is True -def test_is_workflow_exist_returns_false_when_no_draft(mocker, rag_pipeline_service) -> None: +def test_is_workflow_exist_returns_false_when_no_draft( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=0) pipeline = _make_pipeline() @@ -412,7 +435,7 @@ def test_is_workflow_exist_returns_false_when_no_draft(mocker, rag_pipeline_serv # --- publish_workflow --- -def test_publish_workflow_success(mocker, rag_pipeline_service) -> None: +def test_publish_workflow_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: # Don't import Workflow from rag_pipeline to avoid confusion during patching # 1. Mock select to bypass SQLAlchemy validation @@ -483,7 +506,9 @@ def test_publish_workflow_success(mocker, rag_pipeline_service) -> None: # --- run_datasource_workflow_node --- -def test_run_datasource_workflow_node_website_crawl(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_website_crawl( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceProviderType # 1. Setup workflow and node @@ -559,7 +584,9 @@ def test_run_datasource_workflow_node_website_crawl(mocker, rag_pipeline_service # --- run_datasource_node_preview --- -def test_run_datasource_node_preview_online_document(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_online_document( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType # 1. Setup workflow and node @@ -632,7 +659,7 @@ def test_run_datasource_node_preview_online_document(mocker, rag_pipeline_servic # --- _handle_node_run_result --- -def test_handle_node_run_result_success(mocker, rag_pipeline_service) -> None: +def test_handle_node_run_result_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunSucceededEvent from graphon.node_events.base import NodeRunResult @@ -676,7 +703,7 @@ def test_handle_node_run_result_success(mocker, rag_pipeline_service) -> None: # --- get_first_step_parameters / get_second_step_parameters --- -def test_get_first_step_parameters_success(mocker, rag_pipeline_service) -> None: +def test_get_first_step_parameters_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: # 1. Setup mock workflow pipeline = mocker.Mock() workflow = mocker.Mock() @@ -694,7 +721,7 @@ def test_get_first_step_parameters_success(mocker, rag_pipeline_service) -> None assert result[0]["variable"] == "url" -def test_get_second_step_parameters_success(mocker, rag_pipeline_service) -> None: +def test_get_second_step_parameters_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: # 1. Setup mock workflow pipeline = mocker.Mock() workflow = mocker.Mock() @@ -722,7 +749,9 @@ def test_get_second_step_parameters_success(mocker, rag_pipeline_service) -> Non # --- publish_customized_pipeline_template --- -def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_service) -> None: +def test_publish_customized_pipeline_template_success( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: # 1. Setup mocks pipeline = _make_pipeline(workflow_id="wf-1", is_published=True) @@ -765,7 +794,7 @@ def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_servi # --- get_datasource_plugins --- -def test_get_datasource_plugins_success(mocker, rag_pipeline_service) -> None: +def test_get_datasource_plugins_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: # 1. Setup mocks dataset = _make_dataset() @@ -812,7 +841,7 @@ def test_get_datasource_plugins_success(mocker, rag_pipeline_service) -> None: # --- retry_error_document --- -def test_retry_error_document_success(mocker, rag_pipeline_service) -> None: +def test_retry_error_document_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: from models.dataset import Document, DocumentPipelineExecutionLog, Pipeline # 1. Setup mocks @@ -850,7 +879,7 @@ def test_retry_error_document_success(mocker, rag_pipeline_service) -> None: # --- set_datasource_variables --- -def test_set_datasource_variables_success(mocker, rag_pipeline_service) -> None: +def test_set_datasource_variables_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: from graphon.entities.workflow_node_execution import WorkflowNodeExecution from models.dataset import Pipeline @@ -909,7 +938,7 @@ def test_set_datasource_variables_success(mocker, rag_pipeline_service) -> None: # --- Utility Methods --- -def test_get_draft_workflow_success(mocker, rag_pipeline_service) -> None: +def test_get_draft_workflow_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: # 1. Setup mocks pipeline = _make_pipeline() @@ -925,7 +954,7 @@ def test_get_draft_workflow_success(mocker, rag_pipeline_service) -> None: assert result == workflow -def test_get_published_workflow_success(mocker, rag_pipeline_service) -> None: +def test_get_published_workflow_success(mocker: MockerFixture, rag_pipeline_service: RagPipelineService) -> None: # 1. Setup mocks pipeline = _make_pipeline(workflow_id="wf-pub") @@ -941,7 +970,7 @@ def test_get_published_workflow_success(mocker, rag_pipeline_service) -> None: assert result == workflow -def test_get_default_block_configs_success(rag_pipeline_service) -> None: +def test_get_default_block_configs_success(rag_pipeline_service: RagPipelineService) -> None: # This calls static methods on node classes, should be safe with default mocks or as-is # unless they access db. result = rag_pipeline_service.get_default_block_configs() @@ -949,7 +978,7 @@ def test_get_default_block_configs_success(rag_pipeline_service) -> None: assert len(result) > 0 -def test_get_default_block_config_success(rag_pipeline_service) -> None: +def test_get_default_block_config_success(rag_pipeline_service: RagPipelineService) -> None: from graphon.enums import BuiltinNodeTypes result = rag_pipeline_service.get_default_block_config(BuiltinNodeTypes.LLM) @@ -957,7 +986,9 @@ def test_get_default_block_config_success(rag_pipeline_service) -> None: assert result["type"] == "llm" -def test_publish_workflow_raises_when_draft_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_publish_workflow_raises_when_draft_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: session = mocker.Mock() session.scalar.return_value = None pipeline = _make_pipeline() @@ -967,7 +998,9 @@ def test_publish_workflow_raises_when_draft_workflow_missing(mocker, rag_pipelin rag_pipeline_service.publish_workflow(session=session, pipeline=pipeline, account=account) -def test_get_default_block_config_returns_none_when_mapped_type_missing(mocker, rag_pipeline_service) -> None: +def test_get_default_block_config_returns_none_when_mapped_type_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from graphon.enums import BuiltinNodeTypes mocker.patch("services.rag_pipeline.rag_pipeline.get_node_type_classes_mapping", return_value={}) @@ -975,7 +1008,9 @@ def test_get_default_block_config_returns_none_when_mapped_type_missing(mocker, assert rag_pipeline_service.get_default_block_config(BuiltinNodeTypes.START) is None -def test_get_default_block_config_injects_http_request_filter(mocker, rag_pipeline_service) -> None: +def test_get_default_block_config_injects_http_request_filter( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from graphon.enums import BuiltinNodeTypes fake_node_cls = mocker.Mock() @@ -992,7 +1027,9 @@ def test_get_default_block_config_injects_http_request_filter(mocker, rag_pipeli assert "http_request_config" in called_filters -def test_run_draft_workflow_node_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_run_draft_workflow_node_raises_when_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = _make_pipeline() account = _make_account() mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=None) @@ -1001,7 +1038,9 @@ def test_run_draft_workflow_node_raises_when_workflow_missing(mocker, rag_pipeli rag_pipeline_service.run_draft_workflow_node(pipeline, "node-1", {}, account) -def test_run_draft_workflow_node_saves_execution_and_variables(mocker, rag_pipeline_service) -> None: +def test_run_draft_workflow_node_saves_execution_and_variables( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db", mocker.Mock(engine=mocker.Mock())) pipeline = _make_pipeline() account = _make_account() @@ -1035,7 +1074,9 @@ def test_run_draft_workflow_node_saves_execution_and_variables(mocker, rag_pipel saver.save.assert_called_once() -def test_run_datasource_workflow_node_returns_error_when_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_returns_error_when_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = SimpleNamespace(id="p1", tenant_id="t1") mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=None) @@ -1053,7 +1094,9 @@ def test_run_datasource_workflow_node_returns_error_when_workflow_missing(mocker assert events[0]["event"] == "datasource_error" -def test_run_datasource_workflow_node_online_document_success(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_online_document_success( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceProviderType pipeline = SimpleNamespace(id="p1", tenant_id="t1") @@ -1099,7 +1142,9 @@ def test_run_datasource_workflow_node_online_document_success(mocker, rag_pipeli assert events[1]["event"] == "datasource_completed" -def test_run_datasource_workflow_node_online_drive_success(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_online_drive_success( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceProviderType pipeline = SimpleNamespace(id="p1", tenant_id="t1") @@ -1145,7 +1190,9 @@ def test_run_datasource_workflow_node_online_drive_success(mocker, rag_pipeline_ assert events[1]["event"] == "datasource_completed" -def test_handle_node_run_result_default_value_strategy(mocker, rag_pipeline_service) -> None: +def test_handle_node_run_result_default_value_strategy( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from datetime import datetime from graphon.enums import BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecutionStatus @@ -1190,7 +1237,9 @@ def test_handle_node_run_result_default_value_strategy(mocker, rag_pipeline_serv assert result.outputs["fallback"] == "ok" -def test_get_first_step_parameters_raises_when_datasource_node_missing(mocker, rag_pipeline_service) -> None: +def test_get_first_step_parameters_raises_when_datasource_node_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: workflow = SimpleNamespace(graph_dict={"nodes": []}, rag_pipeline_variables=[{"variable": "url"}]) mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow) @@ -1198,7 +1247,9 @@ def test_get_first_step_parameters_raises_when_datasource_node_missing(mocker, r rag_pipeline_service.get_first_step_parameters(SimpleNamespace(), "missing-node") -def test_get_second_step_parameters_handles_string_and_list_variable_references(mocker, rag_pipeline_service) -> None: +def test_get_second_step_parameters_handles_string_and_list_variable_references( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: workflow = SimpleNamespace( rag_pipeline_variables=[ {"variable": "url", "belong_to_node_id": "node-1"}, @@ -1226,7 +1277,9 @@ def test_get_second_step_parameters_handles_string_and_list_variable_references( assert result == [{"variable": "keep", "belong_to_node_id": "node-1"}] -def test_get_rag_pipeline_workflow_run_node_executions_empty_when_run_missing(mocker, rag_pipeline_service) -> None: +def test_get_rag_pipeline_workflow_run_node_executions_empty_when_run_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = _make_pipeline() mocker.patch.object(rag_pipeline_service, "get_rag_pipeline_workflow_run", return_value=None) @@ -1237,7 +1290,9 @@ def test_get_rag_pipeline_workflow_run_node_executions_empty_when_run_missing(mo assert result == [] -def test_get_rag_pipeline_workflow_run_node_executions_returns_sorted_executions(mocker, rag_pipeline_service) -> None: +def test_get_rag_pipeline_workflow_run_node_executions_returns_sorted_executions( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db", mocker.Mock(engine=mocker.Mock())) pipeline = _make_pipeline() mocker.patch.object(rag_pipeline_service, "get_rag_pipeline_workflow_run", return_value=SimpleNamespace(id="run-1")) @@ -1252,7 +1307,9 @@ def test_get_rag_pipeline_workflow_run_node_executions_returns_sorted_executions assert result == ["n1", "n2"] -def test_get_recommended_plugins_returns_empty_when_no_active_plugins(mocker, rag_pipeline_service) -> None: +def test_get_recommended_plugins_returns_empty_when_no_active_plugins( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalars.return_value.all.return_value = [] @@ -1264,7 +1321,9 @@ def test_get_recommended_plugins_returns_empty_when_no_active_plugins(mocker, ra } -def test_get_recommended_plugins_returns_installed_and_uninstalled(mocker, rag_pipeline_service) -> None: +def test_get_recommended_plugins_returns_installed_and_uninstalled( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: plugin_a = _make_recommended_plugin("plugin-a") plugin_b = _make_recommended_plugin("plugin-b") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") @@ -1284,7 +1343,9 @@ def test_get_recommended_plugins_returns_installed_and_uninstalled(mocker, rag_p assert result["uninstalled_recommended_plugins"] == [{"plugin_id": "plugin-b", "name": "Plugin B"}] -def test_get_node_last_run_delegates_to_repository(mocker, rag_pipeline_service) -> None: +def test_get_node_last_run_delegates_to_repository( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db", mocker.Mock(engine=mocker.Mock())) repo = mocker.Mock() repo.get_node_last_execution.return_value = "node-exec" @@ -1300,7 +1361,9 @@ def test_get_node_last_run_delegates_to_repository(mocker, rag_pipeline_service) assert result == "node-exec" -def test_set_datasource_variables_raises_when_node_id_missing(mocker, rag_pipeline_service) -> None: +def test_set_datasource_variables_raises_when_node_id_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = SimpleNamespace(id="p1", tenant_id="t1") workflow = mocker.Mock() mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=workflow) @@ -1309,7 +1372,9 @@ def test_set_datasource_variables_raises_when_node_id_missing(mocker, rag_pipeli rag_pipeline_service.set_datasource_variables(pipeline, {"start_node_id": ""}, SimpleNamespace(id="u1")) -def test_get_default_block_configs_skips_empty_configs(mocker, rag_pipeline_service) -> None: +def test_get_default_block_configs_skips_empty_configs( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from graphon.enums import BuiltinNodeTypes http_node = mocker.Mock() @@ -1333,7 +1398,9 @@ def test_get_default_block_configs_skips_empty_configs(mocker, rag_pipeline_serv empty_node.get_default_config.assert_called_once() -def test_run_datasource_workflow_node_returns_error_when_node_missing(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_returns_error_when_node_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = SimpleNamespace(id="p1", tenant_id="t1") workflow = SimpleNamespace(graph_dict={"nodes": []}) mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow) @@ -1353,7 +1420,9 @@ def test_run_datasource_workflow_node_returns_error_when_node_missing(mocker, ra assert "Datasource node data not found" in events[0]["error"] -def test_run_datasource_workflow_node_online_document_exception(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_online_document_exception( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = SimpleNamespace(id="p1", tenant_id="t1") workflow = SimpleNamespace( graph_dict={ @@ -1405,7 +1474,9 @@ def test_run_datasource_workflow_node_online_document_exception(mocker, rag_pipe assert "doc failed" in events[1]["error"] -def test_run_datasource_node_preview_raises_for_stream_non_string(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_raises_for_stream_non_string( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceMessage pipeline = SimpleNamespace(id="p1", tenant_id="t1") @@ -1453,7 +1524,9 @@ def test_run_datasource_node_preview_raises_for_stream_non_string(mocker, rag_pi ) -def test_get_first_step_parameters_returns_empty_when_no_rag_variables(mocker, rag_pipeline_service) -> None: +def test_get_first_step_parameters_returns_empty_when_no_rag_variables( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: workflow = SimpleNamespace( graph_dict={"nodes": [{"id": "node-1", "data": {"datasource_parameters": {"url": {"value": "literal"}}}}]}, rag_pipeline_variables=[], @@ -1465,7 +1538,9 @@ def test_get_first_step_parameters_returns_empty_when_no_rag_variables(mocker, r assert result == [] -def test_get_second_step_parameters_filters_first_step_variables(mocker, rag_pipeline_service) -> None: +def test_get_second_step_parameters_filters_first_step_variables( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: workflow = SimpleNamespace( graph_dict={ "nodes": [ @@ -1494,7 +1569,9 @@ def test_get_second_step_parameters_filters_first_step_variables(mocker, rag_pip assert result == [{"variable": "keep", "belong_to_node_id": "shared"}] -def test_retry_error_document_raises_when_execution_log_not_found(mocker, rag_pipeline_service) -> None: +def test_retry_error_document_raises_when_execution_log_not_found( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None) with pytest.raises(ValueError, match="Document pipeline execution log not found"): @@ -1503,7 +1580,9 @@ def test_retry_error_document_raises_when_execution_log_not_found(mocker, rag_pi ) -def test_get_datasource_plugins_raises_when_workflow_not_found(mocker, rag_pipeline_service) -> None: +def test_get_datasource_plugins_raises_when_workflow_not_found( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: dataset = SimpleNamespace(pipeline_id="p1") pipeline = SimpleNamespace(id="p1", tenant_id="t1") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) @@ -1513,7 +1592,9 @@ def test_get_datasource_plugins_raises_when_workflow_not_found(mocker, rag_pipel rag_pipeline_service.get_datasource_plugins("t1", "d1", True) -def test_handle_node_run_result_raises_when_no_terminal_event(mocker, rag_pipeline_service) -> None: +def test_handle_node_run_result_raises_when_no_terminal_event( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: node_instance = SimpleNamespace( workflow_id="wf-1", node_type="start", @@ -1534,7 +1615,9 @@ def test_handle_node_run_result_raises_when_no_terminal_event(mocker, rag_pipeli ) -def test_handle_node_run_result_marks_document_error_for_published_invoke(mocker, rag_pipeline_service) -> None: +def test_handle_node_run_result_marks_document_error_for_published_invoke( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.app.entities.app_invoke_entities import InvokeFrom from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunFailedEvent @@ -1595,7 +1678,9 @@ def test_handle_node_run_result_marks_document_error_for_published_invoke(mocker commit_mock.assert_called_once() -def test_run_datasource_node_preview_raises_for_unsupported_provider(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_raises_for_unsupported_provider( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = SimpleNamespace(id="p1", tenant_id="t1") workflow = SimpleNamespace( graph_dict={ @@ -1631,14 +1716,18 @@ def test_run_datasource_node_preview_raises_for_unsupported_provider(mocker, rag ) -def test_publish_customized_pipeline_template_raises_for_missing_pipeline(mocker, rag_pipeline_service) -> None: +def test_publish_customized_pipeline_template_raises_for_missing_pipeline( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=None) with pytest.raises(ValueError, match="Pipeline not found"): rag_pipeline_service.publish_customized_pipeline_template("p1", {}, _make_account(), "t1") -def test_publish_customized_pipeline_template_raises_for_missing_workflow_id(mocker, rag_pipeline_service) -> None: +def test_publish_customized_pipeline_template_raises_for_missing_workflow_id( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = _make_pipeline(workflow_id=None) mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=pipeline) @@ -1648,14 +1737,18 @@ def test_publish_customized_pipeline_template_raises_for_missing_workflow_id(moc ) -def test_get_pipeline_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None: +def test_get_pipeline_raises_when_dataset_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None) with pytest.raises(ValueError, match="Dataset not found"): rag_pipeline_service.get_pipeline("t1", "d1") -def test_get_pipeline_raises_when_pipeline_missing(mocker, rag_pipeline_service) -> None: +def test_get_pipeline_raises_when_pipeline_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: dataset = SimpleNamespace(pipeline_id="p1") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, None]) @@ -1663,7 +1756,7 @@ def test_get_pipeline_raises_when_pipeline_missing(mocker, rag_pipeline_service) rag_pipeline_service.get_pipeline("t1", "d1") -def test_init_uses_default_sessionmaker_when_none(mocker) -> None: +def test_init_uses_default_sessionmaker_when_none(mocker: MockerFixture) -> None: default_session_maker = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.sessionmaker", return_value=default_session_maker) mocker.patch("services.rag_pipeline.rag_pipeline.db", SimpleNamespace(engine=mocker.Mock())) @@ -1680,7 +1773,7 @@ def test_init_uses_default_sessionmaker_when_none(mocker) -> None: create_run_repo.assert_called_once_with(default_session_maker) -def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker) -> None: +def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.rag_pipeline.dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE", "remote") retrieval = mocker.Mock() retrieval.get_pipeline_templates.return_value = {"pipeline_templates": []} @@ -1694,7 +1787,7 @@ def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker) -> None: builtin.fetch_pipeline_templates_from_builtin.assert_not_called() -def test_update_customized_pipeline_template_commits_when_name_empty(mocker) -> None: +def test_update_customized_pipeline_template_commits_when_name_empty(mocker: MockerFixture) -> None: template = _make_customized_template() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=template) commit = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") @@ -1706,7 +1799,7 @@ def test_update_customized_pipeline_template_commits_when_name_empty(mocker) -> commit.assert_called_once() -def test_get_all_published_workflow_without_filters_has_no_more(rag_pipeline_service) -> None: +def test_get_all_published_workflow_without_filters_has_no_more(rag_pipeline_service: RagPipelineService) -> None: session = SimpleNamespace(scalars=lambda stmt: SimpleNamespace(all=lambda: ["wf1"])) pipeline = _make_pipeline(workflow_id="wf-live") @@ -1723,7 +1816,9 @@ def test_get_all_published_workflow_without_filters_has_no_more(rag_pipeline_ser assert has_more is False -def test_publish_workflow_skips_dataset_update_for_non_knowledge_nodes(mocker, rag_pipeline_service) -> None: +def test_publish_workflow_skips_dataset_update_for_non_knowledge_nodes( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: draft = SimpleNamespace( type="workflow", graph={"nodes": [{"data": {"type": "start"}}]}, @@ -1747,7 +1842,9 @@ def test_publish_workflow_skips_dataset_update_for_non_knowledge_nodes(mocker, r assert result is published -def test_get_default_block_config_returns_none_when_default_empty(mocker, rag_pipeline_service) -> None: +def test_get_default_block_config_returns_none_when_default_empty( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from graphon.enums import BuiltinNodeTypes node_cls = mocker.Mock() @@ -1761,7 +1858,9 @@ def test_get_default_block_config_returns_none_when_default_empty(mocker, rag_pi assert rag_pipeline_service.get_default_block_config("start") is None -def test_run_datasource_workflow_node_handles_variable_parameter_types(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_handles_variable_parameter_types( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceProviderType workflow = SimpleNamespace( @@ -1811,7 +1910,9 @@ def test_run_datasource_workflow_node_handles_variable_parameter_types(mocker, r assert events[0]["data"] == [] -def test_run_datasource_workflow_node_online_drive_branch(mocker, rag_pipeline_service) -> None: +def test_run_datasource_workflow_node_online_drive_branch( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceProviderType workflow = SimpleNamespace( @@ -1857,7 +1958,9 @@ def test_run_datasource_workflow_node_online_drive_branch(mocker, rag_pipeline_s assert events[1]["data"] == {"items": [1]} -def test_run_datasource_node_preview_not_published_uses_draft(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_not_published_uses_draft( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceMessage workflow = SimpleNamespace( @@ -1903,7 +2006,9 @@ def test_run_datasource_node_preview_not_published_uses_draft(mocker, rag_pipeli get_draft.assert_called_once() -def test_run_free_workflow_node_delegates_to_handle_result(mocker, rag_pipeline_service) -> None: +def test_run_free_workflow_node_delegates_to_handle_result( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: expected = SimpleNamespace(id="exec-1") handle = mocker.patch.object(rag_pipeline_service, "_handle_node_run_result", return_value=expected) @@ -1919,7 +2024,9 @@ def test_run_free_workflow_node_delegates_to_handle_result(mocker, rag_pipeline_ handle.assert_called_once() -def test_publish_customized_pipeline_template_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_publish_customized_pipeline_template_raises_when_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = _make_pipeline(workflow_id="wf-1") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", side_effect=[pipeline, None]) @@ -1927,7 +2034,9 @@ def test_publish_customized_pipeline_template_raises_when_workflow_missing(mocke rag_pipeline_service.publish_customized_pipeline_template("p1", {}, _make_account(), "t1") -def test_publish_customized_pipeline_template_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None: +def test_publish_customized_pipeline_template_raises_when_dataset_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: pipeline = _make_pipeline(workflow_id="wf-1") workflow = _make_workflow(workflow_id="wf-1") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") @@ -1940,7 +2049,9 @@ def test_publish_customized_pipeline_template_raises_when_dataset_missing(mocker rag_pipeline_service.publish_customized_pipeline_template("p1", {}, _make_account(), "t1") -def test_get_recommended_plugins_skips_manifest_when_missing(mocker, rag_pipeline_service) -> None: +def test_get_recommended_plugins_skips_manifest_when_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: plugin = _make_recommended_plugin("plugin-a") mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db") mock_db.session.scalars.return_value.all.return_value = [plugin] @@ -1953,7 +2064,9 @@ def test_get_recommended_plugins_skips_manifest_when_missing(mocker, rag_pipelin assert result["uninstalled_recommended_plugins"] == [] -def test_retry_error_document_raises_when_pipeline_missing(mocker, rag_pipeline_service) -> None: +def test_retry_error_document_raises_when_pipeline_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: exec_log = SimpleNamespace(pipeline_id="p1") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=exec_log) mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=None) @@ -1964,7 +2077,9 @@ def test_retry_error_document_raises_when_pipeline_missing(mocker, rag_pipeline_ ) -def test_retry_error_document_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_retry_error_document_raises_when_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: exec_log = SimpleNamespace(pipeline_id="p1") pipeline = SimpleNamespace(id="p1") mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=exec_log) @@ -1977,7 +2092,9 @@ def test_retry_error_document_raises_when_workflow_missing(mocker, rag_pipeline_ ) -def test_get_datasource_plugins_returns_empty_for_non_datasource_nodes(mocker, rag_pipeline_service) -> None: +def test_get_datasource_plugins_returns_empty_for_non_datasource_nodes( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: dataset = _make_dataset() pipeline = _make_pipeline() workflow = SimpleNamespace( @@ -1989,7 +2106,9 @@ def test_get_datasource_plugins_returns_empty_for_non_datasource_nodes(mocker, r assert rag_pipeline_service.get_datasource_plugins("t1", "d1", True) == [] -def test_publish_workflow_raises_when_knowledge_index_dataset_missing(mocker, rag_pipeline_service) -> None: +def test_publish_workflow_raises_when_knowledge_index_dataset_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: draft = SimpleNamespace( type="workflow", graph={"nodes": [{"data": {"type": "knowledge-index"}}]}, @@ -2012,7 +2131,9 @@ def test_publish_workflow_raises_when_knowledge_index_dataset_missing(mocker, ra rag_pipeline_service.publish_workflow(session=session, pipeline=pipeline, account=SimpleNamespace(id="u1")) -def test_run_datasource_node_preview_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_raises_when_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=None) with pytest.raises(RuntimeError, match="Workflow not initialized"): @@ -2026,7 +2147,9 @@ def test_run_datasource_node_preview_raises_when_workflow_missing(mocker, rag_pi ) -def test_run_datasource_node_preview_raises_when_node_missing(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_raises_when_node_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch.object( rag_pipeline_service, "get_published_workflow", return_value=SimpleNamespace(graph_dict={"nodes": []}) ) @@ -2042,7 +2165,9 @@ def test_run_datasource_node_preview_raises_when_node_missing(mocker, rag_pipeli ) -def test_run_datasource_node_preview_keeps_existing_user_input(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_keeps_existing_user_input( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: from core.datasource.entities.datasource_entities import DatasourceMessage workflow = SimpleNamespace( @@ -2088,7 +2213,9 @@ def test_run_datasource_node_preview_keeps_existing_user_input(mocker, rag_pipel assert result == {"ok": "1"} -def test_run_datasource_node_preview_ignores_non_variable_messages(mocker, rag_pipeline_service) -> None: +def test_run_datasource_node_preview_ignores_non_variable_messages( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: workflow = SimpleNamespace( graph_dict={ "nodes": [ @@ -2127,7 +2254,9 @@ def test_run_datasource_node_preview_ignores_non_variable_messages(mocker, rag_p assert result == {} -def test_set_datasource_variables_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: +def test_set_datasource_variables_raises_when_workflow_missing( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=None) with pytest.raises(ValueError, match="Workflow not initialized"): @@ -2138,7 +2267,9 @@ def test_set_datasource_variables_raises_when_workflow_missing(mocker, rag_pipel ) -def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published(mocker, rag_pipeline_service) -> None: +def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: dataset = _make_dataset() pipeline = _make_pipeline() workflow = SimpleNamespace( @@ -2156,7 +2287,9 @@ def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published( assert len(result) == 1 -def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag_pipeline_service) -> None: +def test_get_datasource_plugins_extracts_user_inputs_and_credentials( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: dataset = _make_dataset() pipeline = _make_pipeline() workflow = SimpleNamespace( @@ -2198,7 +2331,9 @@ def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag assert result[0]["credentials"][0]["id"] == "c1" -def test_get_pipeline_returns_pipeline_when_found(mocker, rag_pipeline_service) -> None: +def test_get_pipeline_returns_pipeline_when_found( + mocker: MockerFixture, rag_pipeline_service: RagPipelineService +) -> None: dataset = _make_dataset() pipeline = _make_pipeline() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) 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 287391c24c4..a05930c73ce 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 @@ -23,7 +23,7 @@ def proxy(mocker: MockerFixture): # --- delay --- -def test_delay_with_empty_entities_logs_warning_and_returns(mocker) -> None: +def test_delay_with_empty_entities_logs_warning_and_returns(mocker: MockerFixture) -> None: mocker.patch("services.rag_pipeline.rag_pipeline_task_proxy.TenantIsolatedTaskQueue") proxy = RagPipelineTaskProxy( dataset_tenant_id="tenant-1", @@ -37,7 +37,7 @@ def test_delay_with_empty_entities_logs_warning_and_returns(mocker) -> None: dispatch_mock.assert_not_called() -def test_delay_with_entities_calls_dispatch(mocker, proxy) -> None: +def test_delay_with_entities_calls_dispatch(mocker: MockerFixture, proxy) -> None: dispatch_mock = mocker.patch.object(proxy, "_dispatch") proxy.delay() @@ -48,7 +48,7 @@ def test_delay_with_entities_calls_dispatch(mocker, proxy) -> None: # --- _dispatch --- -def test_dispatch_billing_sandbox_uses_default_tenant_queue(mocker, proxy) -> None: +def test_dispatch_billing_sandbox_uses_default_tenant_queue(mocker: MockerFixture, proxy) -> None: upload_mock = mocker.patch.object(proxy, "_upload_invoke_entities", return_value="file-1") send_mock = mocker.patch.object(proxy, "_send_to_default_tenant_queue") @@ -65,7 +65,7 @@ def test_dispatch_billing_sandbox_uses_default_tenant_queue(mocker, proxy) -> No send_mock.assert_called_once_with("file-1") -def test_dispatch_billing_non_sandbox_uses_priority_tenant_queue(mocker, proxy) -> None: +def test_dispatch_billing_non_sandbox_uses_priority_tenant_queue(mocker: MockerFixture, proxy) -> None: upload_mock = mocker.patch.object(proxy, "_upload_invoke_entities", return_value="file-1") send_mock = mocker.patch.object(proxy, "_send_to_priority_tenant_queue") @@ -82,7 +82,7 @@ def test_dispatch_billing_non_sandbox_uses_priority_tenant_queue(mocker, proxy) send_mock.assert_called_once_with("file-1") -def test_dispatch_no_billing_uses_priority_direct_queue(mocker, proxy) -> None: +def test_dispatch_no_billing_uses_priority_direct_queue(mocker: MockerFixture, proxy) -> None: upload_mock = mocker.patch.object(proxy, "_upload_invoke_entities", return_value="file-1") send_mock = mocker.patch.object(proxy, "_send_to_priority_direct_queue") @@ -95,7 +95,7 @@ def test_dispatch_no_billing_uses_priority_direct_queue(mocker, proxy) -> None: send_mock.assert_called_once_with("file-1") -def test_dispatch_raises_on_empty_upload_file_id(mocker, proxy) -> None: +def test_dispatch_raises_on_empty_upload_file_id(mocker: MockerFixture, proxy) -> None: mocker.patch.object(proxy, "_upload_invoke_entities", return_value="") features = SimpleNamespace(billing=SimpleNamespace(enabled=False, subscription=SimpleNamespace(plan="free"))) @@ -108,7 +108,7 @@ def test_dispatch_raises_on_empty_upload_file_id(mocker, proxy) -> None: # --- _send_to_direct_queue --- -def test_send_to_direct_queue_calls_task_func_delay(mocker, proxy) -> None: +def test_send_to_direct_queue_calls_task_func_delay(mocker: MockerFixture, proxy) -> None: task_func = Mock() proxy._send_to_direct_queue("file-1", task_func) @@ -122,7 +122,7 @@ def test_send_to_direct_queue_calls_task_func_delay(mocker, proxy) -> None: # --- _send_to_tenant_queue --- -def test_send_to_tenant_queue_pushes_when_task_key_exists(mocker, proxy) -> None: +def test_send_to_tenant_queue_pushes_when_task_key_exists(mocker: MockerFixture, proxy) -> None: proxy._tenant_isolated_task_queue.get_task_key.return_value = "existing-key" task_func = Mock() @@ -132,7 +132,7 @@ def test_send_to_tenant_queue_pushes_when_task_key_exists(mocker, proxy) -> None task_func.delay.assert_not_called() -def test_send_to_tenant_queue_sets_waiting_time_and_calls_delay(mocker, proxy) -> None: +def test_send_to_tenant_queue_sets_waiting_time_and_calls_delay(mocker: MockerFixture, proxy) -> None: proxy._tenant_isolated_task_queue.get_task_key.return_value = None task_func = Mock() @@ -148,7 +148,7 @@ def test_send_to_tenant_queue_sets_waiting_time_and_calls_delay(mocker, proxy) - # --- _upload_invoke_entities --- -def test_upload_invoke_entities_returns_file_id(mocker, proxy) -> None: +def test_upload_invoke_entities_returns_file_id(mocker: MockerFixture, proxy) -> None: upload_file = SimpleNamespace(id="uploaded-file-1") file_service_cls = mocker.patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") file_service_cls.return_value.upload_text.return_value = upload_file diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py index 7d30645d38a..1e15a72f476 100644 --- a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py @@ -7,15 +7,16 @@ from unittest.mock import MagicMock, patch import pytest +from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup -def make_run(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None): - run = MagicMock() - run.tenant_id = tenant_id - run.id = run_id - run.created_at = created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC) - return run +def make_ref(tenant_id: str = "t1", run_id: str = "r1", created_at: datetime.datetime | None = None): + return WorkflowRunCleanupRef( + id=run_id, + tenant_id=tenant_id, + created_at=created_at or datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC), + ) @pytest.fixture @@ -341,28 +342,28 @@ class TestRunDeleteMode: return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) def test_no_rows_stops_immediately(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() def test_all_paid_skips_delete(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_cleanup(mock_repo) # billing disabled -> all free; but let's override _filter_free_tenants to return empty c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() def test_runs_deleted_successfully(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.delete_runs_with_related.return_value = { + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []] + mock_repo.delete_runs_with_related_by_ids.return_value = { "runs": 1, "node_executions": 0, "offloads": 0, @@ -376,12 +377,12 @@ class TestRunDeleteMode: cfg.BILLING_ENABLED = False with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"): c.run() - mock_repo.delete_runs_with_related.assert_called_once() + mock_repo.delete_runs_with_related_by_ids.assert_called_once() def test_delete_exception_reraises(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.delete_runs_with_related.side_effect = RuntimeError("db error") + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref]] + mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error") c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False @@ -389,7 +390,7 @@ class TestRunDeleteMode: c.run() def test_summary_with_window_start(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 cfg.BILLING_ENABLED = False @@ -421,9 +422,10 @@ class TestRunDryRunMode: ) def test_dry_run_no_delete_called(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] - mock_repo.count_runs_with_related.return_value = { + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], [ref], []] + mock_repo.count_runs_with_related_by_ids.return_value = { + "runs": 1, "node_executions": 2, "offloads": 0, "app_logs": 0, @@ -435,11 +437,11 @@ class TestRunDryRunMode: with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.delete_runs_with_related.assert_not_called() - mock_repo.count_runs_with_related.assert_called_once() + mock_repo.delete_runs_with_related_by_ids.assert_not_called() + mock_repo.count_runs_with_related_by_ids.assert_called_once() def test_dry_run_summary_with_window_start(self, mock_repo): - mock_repo.get_runs_batch_by_time_range.return_value = [] + mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 cfg.BILLING_ENABLED = False @@ -454,14 +456,14 @@ class TestRunDryRunMode: c.run() def test_dry_run_all_paid_skips_count(self, mock_repo): - run = make_run("t1") - mock_repo.get_runs_batch_by_time_range.side_effect = [[run], []] + ref = make_ref("t1") + mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_dry_cleanup(mock_repo) c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.BILLING_ENABLED = False c.run() - mock_repo.count_runs_with_related.assert_not_called() + mock_repo.count_runs_with_related_by_ids.assert_not_called() # --------------------------------------------------------------------------- @@ -492,7 +494,7 @@ class TestTriggerLogMethods: # --------------------------------------------------------------------------- -# _count_node_executions / _delete_node_executions +# _count_node_executions_by_run_ids / _delete_node_executions_by_run_ids # --------------------------------------------------------------------------- @@ -500,25 +502,23 @@ class TestNodeExecutionMethods: def test_count_node_executions(self, cleanup): session = MagicMock() session.get_bind.return_value = MagicMock() - runs = [make_run("t1", "r1")] with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory" ) as factory: repo = factory.create_api_workflow_node_execution_repository.return_value repo.count_by_runs.return_value = (10, 2) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"): - result = cleanup._count_node_executions(session, runs) + result = cleanup._count_node_executions_by_run_ids(session, ["r1"]) assert result == (10, 2) def test_delete_node_executions(self, cleanup): session = MagicMock() session.get_bind.return_value = MagicMock() - runs = [make_run("t1", "r1")] with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory" ) as factory: repo = factory.create_api_workflow_node_execution_repository.return_value repo.delete_by_runs.return_value = (5, 1) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"): - result = cleanup._delete_node_executions(session, runs) + result = cleanup._delete_node_executions_by_run_ids(session, ["r1"]) assert result == (5, 1) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index db79cf4cb55..3b5c6cc9bd6 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -1,5 +1,6 @@ import json from datetime import datetime, timedelta +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -793,7 +794,9 @@ class TestTenantService: mock_db_dependencies["db"].session.add = MagicMock() # Execute test - result = TenantService.create_tenant_member(mock_tenant, mock_account, "normal") + result = TenantService.create_tenant_member( + mock_tenant, mock_account, mock_db_dependencies["db"].session, "normal" + ) # Verify member was created with correct parameters assert result is not None @@ -828,8 +831,8 @@ class TestTenantService: tenant_id="tenant-456", account_id="operator-123", role="owner" ) - # scalar calls: permission check, ta lookup, remaining count - mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, 0] + # scalar calls: permission check, ta lookup, owner_id lookup, remaining count + mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, "operator-123", 0] with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: mock_sync.return_value = True @@ -868,8 +871,8 @@ class TestTenantService: tenant_id="tenant-456", account_id="operator-123", role="owner" ) - # scalar calls: permission check, ta lookup, remaining count = 1 - mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, 1] + # scalar calls: permission check, ta lookup, owner_id lookup, remaining count = 1 + mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, "operator-123", 1] with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: mock_sync.return_value = True @@ -899,8 +902,8 @@ class TestTenantService: tenant_id="tenant-456", account_id="operator-123", role="owner" ) - # scalar calls: permission check, ta lookup (no count needed for active member) - mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta] + # scalar calls: permission check, ta lookup, owner_id lookup (no count for active member) + mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, "operator-123"] with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: mock_sync.return_value = True @@ -974,6 +977,43 @@ class TestTenantService: assert mock_target_join.role == "admin" self._assert_database_operations_called(mock_db) + def test_create_owner_tenant_if_not_exist_rbac_enabled_assigns_owner_role( + self, mock_db_dependencies, mock_external_service_dependencies + ): + mock_account = TestAccountAssociatedDataFactory.create_account_mock(account_id="user-rbac", name="RBAC User") + mock_external_service_dependencies[ + "feature_service" + ].get_system_features.return_value.is_allow_create_workspace = True + mock_external_service_dependencies[ + "feature_service" + ].get_system_features.return_value.license.workspaces.is_available.return_value = True + + mock_tenant = MagicMock() + mock_tenant.id = "tenant-rbac" + mock_tenant.name = "RBAC User's Workspace" + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.TenantService.create_tenant", return_value=mock_tenant), + patch("services.account_service.TenantService.create_tenant_member"), + patch( + "services.account_service.AccountService._resolve_legacy_role_id", + return_value="rbac-owner-id", + ), + patch("services.account_service.RBACService") as mock_rbac_service, + patch("services.account_service.tenant_was_created.send"), + ): + mock_db_dependencies["db"].session.scalar.return_value = None + + TenantService.create_owner_tenant_if_not_exist(mock_account, is_setup=True) + + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id="tenant-rbac", + account_id="user-rbac", + member_account_id="user-rbac", + role_ids=["rbac-owner-id"], + ) + def test_admin_can_update_admin_member_role(self): """Test admin can update another non-owner member, including an admin.""" mock_tenant = MagicMock() @@ -1103,6 +1143,81 @@ class TestTenantService: with pytest.raises(NoPermissionError): TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + def test_rbac_member_can_remove_non_owner_member(self): + """Test RBAC workspace.member.manage allows removing 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_permissions = MagicMock() + mock_permissions.workspace = MagicMock(permission_keys=["workspace.member.manage"]) + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), + patch("services.account_service.AccountService.is_rbac_workspace_owner", return_value=False), + ): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_rbac_member_cannot_remove_without_permission(self): + """Test RBAC permission check rejects removal without workspace.member.manage.""" + 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_permissions = MagicMock() + mock_permissions.workspace = MagicMock(permission_keys=["workspace.role.manage"]) + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), + ): + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_rbac_member_cannot_remove_owner_member(self): + """Test RBAC permission check rejects removing 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_permissions = MagicMock() + mock_permissions.workspace = MagicMock(permission_keys=["workspace.member.manage"]) + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), + patch("services.account_service.AccountService.is_rbac_workspace_owner", return_value=True), + ): + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_get_rbac_workspace_owner_account_id(self): + mock_roles = MagicMock() + mock_roles.data = [SimpleNamespace(account_id="owner-account")] + mock_rbac_roles = MagicMock() + mock_rbac_roles.members.return_value = mock_roles + + with ( + patch( + "services.account_service.AccountService._resolve_legacy_role_id", + return_value="owner-role-id", + ), + patch("services.account_service.RBACService.Roles", mock_rbac_roles), + ): + owner_account_id = AccountService.get_rbac_workspace_owner_account_id("tenant-1", "acct-1") + + assert owner_account_id == "owner-account" + call = mock_rbac_roles.members.call_args + assert call.kwargs["tenant_id"] == "tenant-1" + assert call.kwargs["account_id"] == "acct-1" + assert call.kwargs["role_id"] == "owner-role-id" + assert call.kwargs["options"].page_number == 1 + assert call.kwargs["options"].results_per_page == 1 + class TestRegisterService: """ @@ -1370,7 +1485,9 @@ class TestRegisterService: 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") + mock_create_member.assert_called_once_with( + mock_tenant, mock_account, mock_db_dependencies["db"].session, role="owner" + ) mock_event.send.assert_called_once_with(mock_tenant) self._assert_database_operations_called(mock_db_dependencies["db"]) @@ -1750,15 +1867,19 @@ class TestRegisterService: ) mock_lookup.assert_called_once_with(mixed_email) mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, None, "add") - mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "normal") + mock_create_member.assert_called_once_with( + mock_tenant, mock_new_account, mock_db_dependencies["db"].session, "normal" + ) mock_switch_tenant.assert_called_once_with(mock_new_account, mock_tenant.id) - mock_generate_token.assert_called_once_with(mock_tenant, mock_new_account) + mock_generate_token.assert_called_once_with( + mock_tenant, mock_new_account, "normal", requires_setup=True + ) mock_task_dependencies.delay.assert_called_once() def test_invite_new_member_existing_account( self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies ): - """Test inviting a new member who already has an account.""" + """Test inviting a pending account that is not in the tenant yet.""" # Setup test data mock_tenant = MagicMock() mock_tenant.id = "tenant-456" @@ -1795,11 +1916,54 @@ class TestRegisterService: # Verify results assert result == "invite-token-123" - mock_create_member.assert_called_once_with(mock_tenant, mock_existing_account, "normal") - mock_generate_token.assert_called_once_with(mock_tenant, mock_existing_account) + mock_create_member.assert_called_once_with( + mock_tenant, mock_existing_account, mock_db_dependencies["db"].session, "normal" + ) + mock_generate_token.assert_called_once_with( + mock_tenant, mock_existing_account, "normal", requires_setup=True + ) mock_task_dependencies.delay.assert_called_once() mock_lookup.assert_called_once_with("existing@example.com") + def test_invite_existing_active_account_requires_acceptance_before_joining( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """Existing active accounts outside the tenant receive an invite without immediate membership.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_tenant.name = "Test Workspace" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter") + mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="existing-user-456", email="existing@example.com", status="active" + ) + + with patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup: + mock_lookup.return_value = mock_existing_account + mock_db_dependencies["db"].session.scalar.return_value = None + + with ( + patch("services.account_service.TenantService.check_member_permission") as mock_check_permission, + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.RegisterService.generate_invite_token") as mock_generate_token, + ): + mock_generate_token.return_value = "invite-token-123" + + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="existing@example.com", + language="en-US", + role="admin", + inviter=mock_inviter, + ) + + assert result == "invite-token-123" + mock_check_permission.assert_called_once_with(mock_tenant, mock_inviter, mock_existing_account, "add") + mock_create_member.assert_not_called() + mock_generate_token.assert_called_once_with( + mock_tenant, mock_existing_account, "admin", requires_setup=False + ) + mock_task_dependencies.delay.assert_called_once() + def test_invite_new_member_already_in_tenant(self, mock_db_dependencies, mock_redis_dependencies): """Test inviting a member who is already in the tenant.""" # Setup test data @@ -1848,6 +2012,193 @@ class TestRegisterService: inviter=None, ) + # ==================== RBAC Member Invitation Tests ==================== + + def test_invite_new_member_rbac_enabled_new_account( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """When RBAC is enabled, create the member join and replace RBAC member roles.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-789" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter") + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = None + mock_config.RBAC_ENABLED = True + + mock_new_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="new-user-rbac", email="rbac@example.com", name="rbacuser", status="pending" + ) + with ( + patch("services.account_service.RegisterService.register") as mock_register, + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.TenantService.switch_tenant"), + patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"), + patch("services.account_service.RBACService") as mock_rbac_service, + ): + mock_register.return_value = mock_new_account + + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="rbac@example.com", + language="en-US", + role="rbac-role-id-123", + inviter=mock_inviter, + ) + + assert result == "rbac-token" + mock_create_member.assert_called_once_with( + mock_tenant, mock_new_account, mock_db_dependencies["db"].session, TenantAccountRole.NORMAL.value + ) + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id=str(mock_tenant.id), + account_id=mock_inviter.id, + member_account_id=mock_new_account.id, + role_ids=["rbac-role-id-123"], + ) + + def test_invite_new_member_rbac_enabled_existing_account( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """When RBAC is enabled and account exists, create the member join and replace RBAC member roles.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-789" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter") + mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="existing-rbac", email="existing-rbac@example.com", status="pending" + ) + + mock_db_dependencies["db"].session.scalar.return_value = None + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = mock_existing_account + mock_config.RBAC_ENABLED = True + + with ( + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"), + patch("services.account_service.RBACService") as mock_rbac_service, + ): + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="existing-rbac@example.com", + language="en-US", + role="rbac-role-id-456", + inviter=mock_inviter, + ) + + assert result == "rbac-token" + mock_create_member.assert_called_once_with( + mock_tenant, + mock_existing_account, + mock_db_dependencies["db"].session, + TenantAccountRole.NORMAL.value, + ) + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id=str(mock_tenant.id), + account_id=mock_inviter.id, + member_account_id=mock_existing_account.id, + role_ids=["rbac-role-id-456"], + ) + + def test_invite_new_member_rbac_enabled_existing_active_account_adds_role_before_signin_response( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """Existing active accounts still need an RBAC membership before the API returns the signin URL.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-789" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter") + mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="existing-rbac", email="existing-rbac@example.com", status=AccountStatus.ACTIVE + ) + + mock_db_dependencies["db"].session.scalar.return_value = None + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = mock_existing_account + mock_config.RBAC_ENABLED = True + + with ( + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.RBACService") as mock_rbac_service, + ): + with pytest.raises(AccountAlreadyInTenantError): + RegisterService.invite_new_member( + tenant=mock_tenant, + email="existing-rbac@example.com", + language="en-US", + role="rbac-role-id-456", + inviter=mock_inviter, + ) + + mock_create_member.assert_called_once_with( + mock_tenant, + mock_existing_account, + mock_db_dependencies["db"].session, + TenantAccountRole.NORMAL.value, + ) + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id=str(mock_tenant.id), + account_id=mock_inviter.id, + member_account_id=mock_existing_account.id, + role_ids=["rbac-role-id-456"], + ) + mock_task_dependencies.delay.assert_not_called() + + def test_invite_new_member_rbac_disabled_uses_legacy_role( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """When RBAC is disabled, create_tenant_member should be called and MemberRoles.replace should NOT.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-legacy" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-789", name="Inviter") + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = None + mock_config.RBAC_ENABLED = False + + mock_new_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="legacy-user", email="legacy@example.com", name="legacyuser", status="pending" + ) + with ( + patch("services.account_service.RegisterService.register") as mock_register, + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.TenantService.switch_tenant"), + patch("services.account_service.RegisterService.generate_invite_token", return_value="legacy-token"), + patch("services.account_service.RBACService") as mock_rbac_service, + ): + mock_register.return_value = mock_new_account + + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="legacy@example.com", + language="en-US", + role="editor", + inviter=mock_inviter, + ) + + assert result == "legacy-token" + mock_create_member.assert_called_once_with( + mock_tenant, mock_new_account, mock_db_dependencies["db"].session, "editor" + ) + mock_rbac_service.MemberRoles.replace.assert_not_called() + # ==================== Token Management Tests ==================== def test_generate_invite_token_success(self, mock_redis_dependencies): @@ -1864,7 +2215,7 @@ class TestRegisterService: mock_uuid.return_value = "test-uuid-123" # Execute test - result = RegisterService.generate_invite_token(mock_tenant, mock_account) + result = RegisterService.generate_invite_token(mock_tenant, mock_account, "admin", requires_setup=True) # Verify results assert result == "test-uuid-123" @@ -1877,6 +2228,8 @@ class TestRegisterService: assert stored_data["account_id"] == "user-123" assert stored_data["email"] == "test@example.com" assert stored_data["workspace_id"] == "tenant-456" + assert stored_data["role"] == "admin" + assert stored_data["requires_setup"] is True def test_is_valid_invite_token_valid(self, mock_redis_dependencies): """Test checking valid invite token.""" @@ -1943,9 +2296,8 @@ class TestRegisterService: } mock_get_invitation_by_token.return_value = invitation_data - # Mock scalar for tenant lookup, execute for account+role lookup - mock_db_dependencies["db"].session.scalar.return_value = mock_tenant - mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal") + # Mock scalar for tenant lookup, then account lookup. + mock_db_dependencies["db"].session.scalar.side_effect = [mock_tenant, mock_account] # Execute test result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123") @@ -2001,9 +2353,8 @@ class TestRegisterService: } mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode() - # Mock scalar for tenant, execute for account+role - mock_db_dependencies["db"].session.scalar.return_value = mock_tenant - mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found + # Mock scalar for tenant lookup, then account lookup. + mock_db_dependencies["db"].session.scalar.side_effect = [mock_tenant, None] # Execute test result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123") @@ -2029,9 +2380,8 @@ class TestRegisterService: } mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode() - # Mock scalar for tenant, execute for account+role - mock_db_dependencies["db"].session.scalar.return_value = mock_tenant - mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal") + # Mock scalar for tenant lookup, then account lookup. + mock_db_dependencies["db"].session.scalar.side_effect = [mock_tenant, mock_account] # Execute test result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123") diff --git a/api/tests/unit_tests/services/test_agent_app_feature_service.py b/api/tests/unit_tests/services/test_agent_app_feature_service.py index a8553b62a86..3d9337d79fb 100644 --- a/api/tests/unit_tests/services/test_agent_app_feature_service.py +++ b/api/tests/unit_tests/services/test_agent_app_feature_service.py @@ -12,7 +12,6 @@ from typing import Any import pytest -from services import agent_app_feature_service as svc_mod from services.agent_app_feature_service import AgentAppFeatureConfigService TENANT_ID = "11111111-1111-1111-1111-111111111111" @@ -89,9 +88,8 @@ class _FakeWriteSession: class TestUpdateFeatures: - def test_persists_new_app_model_config_version(self, monkeypatch): + def test_persists_new_app_model_config_version(self): session = _FakeWriteSession() - monkeypatch.setattr(svc_mod.db, "session", session) app_model = SimpleNamespace( tenant_id=TENANT_ID, id="app-1", app_model_config_id=None, updated_by=None, updated_at=None ) @@ -101,6 +99,7 @@ class TestUpdateFeatures: app_model=app_model, # type: ignore[arg-type] account=account, # type: ignore[arg-type] config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}}, + session=session, ) # New row carries the features but no Soul-owned model/prompt/agent_mode. diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py index 9f8170bfd11..09cde917946 100644 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -66,6 +66,7 @@ def _tables() -> Generator[None, None, None]: yield with session_factory.create_session() as session: session.execute(delete(AgentDriveFile)) + session.execute(delete(UploadFile)) session.execute(delete(ToolFile)) session.execute(delete(Agent)) session.commit() @@ -493,6 +494,20 @@ def test_download_url_signs_external_audience(): assert url == "https://signed.example/x" # console downloads are for browsers: external signing, never the internal URL assert resolver.call_args.kwargs["for_external"] is True + assert resolver.call_args.kwargs["as_attachment"] is True + + +def test_upload_file_download_url_uses_attachment_filename(): + upload_file_id = _seed_upload_file(name="report.pdf") + _commit_upload("files/report.pdf", upload_file_id) + + with patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls: + runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf" + url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf") + + assert url == "https://files.example/report.pdf" + assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True + assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True def test_manifest_items_carry_created_at_for_inspector(): diff --git a/api/tests/unit_tests/services/test_annotation_service.py b/api/tests/unit_tests/services/test_annotation_service.py index 5054010e89d..55912cc1c1f 100644 --- a/api/tests/unit_tests/services/test_annotation_service.py +++ b/api/tests/unit_tests/services/test_annotation_service.py @@ -2,6 +2,7 @@ Unit tests for services.annotation_service """ +import logging from io import BytesIO from types import SimpleNamespace from typing import Any, cast @@ -1049,7 +1050,9 @@ class TestAppAnnotationServiceBatchImport: mock_redis.setnx.assert_called_once_with("app_annotation_batch_import_uuid-3", "waiting") mock_task.delay.assert_called_once() - def test_batch_import_app_annotations_should_cleanup_active_job_on_unexpected_exception(self) -> None: + def test_batch_import_app_annotations_should_cleanup_active_job_on_unexpected_exception( + self, caplog: pytest.LogCaptureFixture + ) -> None: """Test unexpected runtime errors trigger cleanup and return wrapped error.""" # Arrange file = _make_file(b"question,answer\nq,a\n") @@ -1067,7 +1070,6 @@ class TestAppAnnotationServiceBatchImport: patch("services.annotation_service.redis_client") as mock_redis, patch("services.annotation_service.uuid.uuid4", return_value="uuid-4"), patch("services.annotation_service.naive_utc_now", return_value=SimpleNamespace(timestamp=lambda: 1)), - patch("services.annotation_service.logger") as mock_logger, patch( "configs.dify_config", new=SimpleNamespace(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1), @@ -1078,12 +1080,15 @@ class TestAppAnnotationServiceBatchImport: mock_redis.zrem.side_effect = RuntimeError("cleanup-failed") # Act - result = AppAnnotationService.batch_import_app_annotations(app.id, file) + with caplog.at_level(logging.DEBUG): + result = AppAnnotationService.batch_import_app_annotations(app.id, file) # Assert assert result["error_msg"] == "An error occurred while processing the file: boom" mock_redis.zrem.assert_called_once_with(f"annotation_import_active:{tenant_id}", "uuid-4") - mock_logger.debug.assert_called_once() + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "DEBUG" + assert "Failed to clean up active job tracking during error handling" in caplog.records[0].message class TestAppAnnotationServiceHitHistoryAndSettings: diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index bb764112640..c57fb6ed775 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -3,7 +3,11 @@ from __future__ import annotations from types import SimpleNamespace from unittest.mock import MagicMock, patch +import pytest +from sqlalchemy.exc import IntegrityError + from models.model import App +from services.agent.errors import AgentNameConflictError from services.app_service import AppService @@ -168,6 +172,7 @@ class TestAgentAppType: mode=AppMode.AGENT, name="Old", description="old", + role="draft", icon_type=IconType.EMOJI, icon="robot", icon_background="#fff", @@ -178,6 +183,7 @@ class TestAgentAppType: backing_agent = SimpleNamespace( name="Old", description="old", + role="draft", icon_type=AgentIconType.EMOJI, icon="robot", icon_background="#fff", @@ -195,6 +201,7 @@ class TestAgentAppType: { "name": "Iris", "description": "agent app", + "role": "research assistant", "icon_type": "image", "icon": "file-id", "icon_background": "#123456", @@ -206,12 +213,167 @@ class TestAgentAppType: assert updated_app.name == "Iris" assert backing_agent.name == "Iris" assert backing_agent.description == "agent app" + assert backing_agent.role == "research assistant" assert backing_agent.icon_type == AgentIconType.IMAGE assert backing_agent.icon == "file-id" assert backing_agent.icon_background == "#123456" assert backing_agent.updated_by == "account-2" assert backing_agent.updated_at == updated_app.updated_at + def test_update_agent_app_preserves_role_when_args_omit_it(self): + from models.agent import AgentIconType + from models.model import AppMode, IconType + from services.app_service import AppService + + app = SimpleNamespace( + id="app-1", + tenant_id="tenant-1", + mode=AppMode.AGENT, + name="Old", + description="old", + role="draft", + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#fff", + use_icon_as_answer_icon=False, + max_active_requests=None, + created_by="account-1", + ) + backing_agent = SimpleNamespace( + name="Old", + description="old", + role="research assistant", + icon_type=AgentIconType.EMOJI, + icon="robot", + icon_background="#fff", + updated_by=None, + updated_at=None, + ) + + with ( + patch("services.app_service.db") as mock_db, + patch("services.app_service.current_user", SimpleNamespace(id="account-2")), + ): + mock_db.session.scalar.return_value = backing_agent + AppService().update_app( + app, # type: ignore[arg-type] + { + "name": "Iris", + "description": "agent app", + "icon_type": "image", + "icon": "file-id", + "icon_background": "#123456", + "use_icon_as_answer_icon": False, + "max_active_requests": 0, + }, + ) + + assert backing_agent.role == "research assistant" + + def test_update_agent_app_clears_role_when_args_set_empty_string(self): + from models.agent import AgentIconType + from models.model import AppMode, IconType + from services.app_service import AppService + + app = SimpleNamespace( + id="app-1", + tenant_id="tenant-1", + mode=AppMode.AGENT, + name="Old", + description="old", + role="draft", + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#fff", + use_icon_as_answer_icon=False, + max_active_requests=None, + created_by="account-1", + ) + backing_agent = SimpleNamespace( + name="Old", + description="old", + role="research assistant", + icon_type=AgentIconType.EMOJI, + icon="robot", + icon_background="#fff", + updated_by=None, + updated_at=None, + ) + + with ( + patch("services.app_service.db") as mock_db, + patch("services.app_service.current_user", SimpleNamespace(id="account-2")), + ): + mock_db.session.scalar.return_value = backing_agent + AppService().update_app( + app, # type: ignore[arg-type] + { + "name": "Iris", + "description": "agent app", + "role": "", + "icon_type": "image", + "icon": "file-id", + "icon_background": "#123456", + "use_icon_as_answer_icon": False, + "max_active_requests": 0, + }, + ) + + assert backing_agent.role == "" + + def test_update_agent_app_duplicate_name_rolls_back_and_raises_conflict(self): + from models.agent import AgentIconType + from models.model import AppMode, IconType + from services.app_service import AppService + + app = SimpleNamespace( + id="app-1", + tenant_id="tenant-1", + mode=AppMode.AGENT, + name="Old", + description="old", + role="draft", + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#fff", + use_icon_as_answer_icon=False, + max_active_requests=None, + created_by="account-1", + ) + backing_agent = SimpleNamespace( + name="Old", + description="old", + role="research assistant", + icon_type=AgentIconType.EMOJI, + icon="robot", + icon_background="#fff", + updated_by=None, + updated_at=None, + ) + + with ( + patch("services.app_service.db") as mock_db, + patch("services.app_service.current_user", SimpleNamespace(id="account-2")), + ): + mock_db.session.scalar.return_value = backing_agent + mock_db.session.commit.side_effect = IntegrityError("duplicate", None, None) + with pytest.raises(AgentNameConflictError): + AppService().update_app( + app, # type: ignore[arg-type] + { + "name": "Existing Agent", + "description": "agent app", + "role": "research assistant", + "icon_type": "emoji", + "icon": "robot", + "icon_background": "#fff", + "use_icon_as_answer_icon": False, + "max_active_requests": 0, + }, + ) + + mock_db.session.rollback.assert_called_once() + def test_delete_agent_app_archives_backing_agent(self): from models.agent import AgentStatus from models.model import AppMode diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 67d1cc02913..f244b69407f 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -14,6 +14,7 @@ Tests follow the Arrange-Act-Assert pattern for clarity. """ import json +import logging from unittest.mock import MagicMock, patch import httpx @@ -170,7 +171,9 @@ class TestBillingServiceSendRequest: @pytest.mark.parametrize( "status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND] ) - def test_delete_request_non_200_with_valid_json(self, mock_httpx_request, mock_billing_config, status_code): + def test_delete_request_non_200_with_valid_json( + self, mock_httpx_request, mock_billing_config, status_code, caplog: pytest.LogCaptureFixture + ): """Test DELETE request with non-200 status code raises ValueError. DELETE now checks status code and raises ValueError for non-200 responses. @@ -184,13 +187,11 @@ class TestBillingServiceSendRequest: mock_httpx_request.return_value = mock_response # Act & Assert - with patch("services.billing_service.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="services.billing_service"): with pytest.raises(ValueError) as exc_info: BillingService._send_request("DELETE", "/test", json={"key": "value"}) assert "Unable to process delete request" in str(exc_info.value) - # Verify error logging - mock_logger.error.assert_called_once() - assert "DELETE response" in str(mock_logger.error.call_args) + assert "DELETE response" in caplog.text @pytest.mark.parametrize( "status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND] @@ -213,7 +214,9 @@ class TestBillingServiceSendRequest: @pytest.mark.parametrize( "status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND] ) - def test_delete_request_non_200_with_invalid_json(self, mock_httpx_request, mock_billing_config, status_code): + def test_delete_request_non_200_with_invalid_json( + self, mock_httpx_request, mock_billing_config, status_code, caplog: pytest.LogCaptureFixture + ): """Test DELETE request with non-200 status code raises ValueError before JSON parsing. DELETE now checks status code before calling response.json(), so ValueError is raised @@ -227,13 +230,11 @@ class TestBillingServiceSendRequest: mock_httpx_request.return_value = mock_response # Act & Assert - with patch("services.billing_service.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="services.billing_service"): with pytest.raises(ValueError) as exc_info: BillingService._send_request("DELETE", "/test", json={"key": "value"}) assert "Unable to process delete request" in str(exc_info.value) - # Verify error logging - mock_logger.error.assert_called_once() - assert "DELETE response" in str(mock_logger.error.call_args) + assert "DELETE response" in caplog.text def test_retry_on_request_error(self, mock_httpx_request, mock_billing_config): """Test that _send_request retries on httpx.RequestError.""" @@ -1511,7 +1512,7 @@ class TestBillingServiceSubscriptionOperations: assert isinstance(result["tenant-1"]["expiration_date"], int) assert result["tenant-1"]["expiration_date"] == 1735689600 - def test_get_plan_bulk_with_invalid_tenant_plan_skipped(self, mock_send_request): + def test_get_plan_bulk_with_invalid_tenant_plan_skipped(self, mock_send_request, caplog: pytest.LogCaptureFixture): """Test bulk plan retrieval when one tenant has invalid plan data (should skip that tenant).""" # Arrange tenant_ids = ["tenant-valid-1", "tenant-invalid", "tenant-valid-2"] @@ -1526,7 +1527,7 @@ class TestBillingServiceSubscriptionOperations: } # Act - with patch("services.billing_service.logger") as mock_logger: + with caplog.at_level(logging.ERROR, logger="services.billing_service"): result = BillingService.get_plan_bulk(tenant_ids) # Assert - should only contain valid tenants @@ -1542,10 +1543,11 @@ class TestBillingServiceSubscriptionOperations: assert result["tenant-valid-2"]["expiration_date"] == 1767225600 # Verify exception was logged for the invalid tenant - mock_logger.exception.assert_called_once() - log_call_args = mock_logger.exception.call_args[0] - assert "get_plan_bulk: failed to validate subscription plan for tenant" in log_call_args[0] - assert "tenant-invalid" in log_call_args[1] + exception_records = [r for r in caplog.records if r.levelname == "ERROR"] + assert len(exception_records) == 1 + formatted = exception_records[0].getMessage() + assert "get_plan_bulk: failed to validate subscription plan for tenant" in formatted + assert "tenant-invalid" in formatted def test_get_expired_subscription_cleanup_whitelist_success(self, mock_send_request): """Test successful retrieval of expired subscription cleanup whitelist.""" diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index 6bf78d34117..60488beb248 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -3,38 +3,27 @@ from typing import Any import pytest +from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.billing_service import SubscriptionPlan from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup -class FakeRun: - def __init__( - self, - run_id: str, - tenant_id: str, - created_at: datetime.datetime, - app_id: str = "app-1", - workflow_id: str = "wf-1", - triggered_from: str = "workflow-run", - ) -> None: - self.id = run_id - self.tenant_id = tenant_id - self.app_id = app_id - self.workflow_id = workflow_id - self.triggered_from = triggered_from - self.created_at = created_at +def make_ref(run_id: str, tenant_id: str, created_at: datetime.datetime) -> WorkflowRunCleanupRef: + return WorkflowRunCleanupRef(id=run_id, tenant_id=tenant_id, created_at=created_at) class FakeRepo: def __init__( self, - batches: list[list[FakeRun]], + batches: list[list[WorkflowRunCleanupRef]], delete_result: dict[str, int] | None = None, count_result: dict[str, int] | None = None, ) -> None: self.batches = batches - self.call_idx = 0 + self.candidate_call_idx = 0 + self.last_candidate_batch: list[WorkflowRunCleanupRef] = [] + self.cleanup_ref_calls: list[dict[str, object]] = [] self.deleted: list[list[str]] = [] self.counted: list[list[str]] = [] self.delete_result = delete_result or { @@ -56,7 +45,7 @@ class FakeRepo: "pause_reasons": 0, } - def get_runs_batch_by_time_range( + def get_cleanup_refs_batch_by_time_range( self, start_from: datetime.datetime | None, end_before: datetime.datetime, @@ -65,27 +54,50 @@ class FakeRepo: run_types=None, tenant_ids=None, workflow_ids=None, - ) -> list[FakeRun]: - if self.call_idx >= len(self.batches): + upper_bound: tuple[datetime.datetime, str] | None = None, + ) -> list[WorkflowRunCleanupRef]: + self.cleanup_ref_calls.append( + { + "start_from": start_from, + "end_before": end_before, + "last_seen": last_seen, + "batch_size": batch_size, + "run_types": run_types, + "tenant_ids": tenant_ids, + "workflow_ids": workflow_ids, + "upper_bound": upper_bound, + } + ) + if tenant_ids is not None or upper_bound is not None: + refs = self.last_candidate_batch + if tenant_ids is not None: + tenant_id_set = set(tenant_ids) + refs = [ref for ref in refs if ref.tenant_id in tenant_id_set] + if upper_bound is not None: + refs = [ref for ref in refs if (ref.created_at, ref.id) <= upper_bound] + return refs[:batch_size] + + if self.candidate_call_idx >= len(self.batches): return [] - batch = self.batches[self.call_idx] - self.call_idx += 1 + batch = self.batches[self.candidate_call_idx] + self.candidate_call_idx += 1 + self.last_candidate_batch = batch return batch - def delete_runs_with_related( - self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None + def delete_runs_with_related_by_ids( + self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None ) -> dict[str, int]: - self.deleted.append([run.id for run in runs]) + self.deleted.append(list(run_ids)) result = self.delete_result.copy() - result["runs"] = len(runs) + result["runs"] = len(run_ids) return result - def count_runs_with_related( - self, runs: list[FakeRun], count_node_executions=None, count_trigger_logs=None + def count_runs_with_related_by_ids( + self, run_ids: list[str], count_node_executions=None, count_trigger_logs=None ) -> dict[str, int]: - self.counted.append([run.id for run in runs]) + self.counted.append(list(run_ids)) result = self.count_result.copy() - result["runs"] = len(runs) + result["runs"] = len(run_ids) return result @@ -218,8 +230,8 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: repo = FakeRepo( batches=[ [ - FakeRun("run-free", "t_free", cutoff), - FakeRun("run-paid", "t_paid", cutoff), + make_ref("run-free", "t_free", cutoff), + make_ref("run-paid", "t_paid", cutoff), ] ] ) @@ -240,11 +252,43 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: cleanup.run() assert repo.deleted == [["run-free"]] + assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] + + +def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.MonkeyPatch) -> None: + cutoff = datetime.datetime.now() + repo = FakeRepo( + batches=[ + [ + make_ref("run-free", "t_free", cutoff), + make_ref("run-paid", "t_paid", cutoff), + ] + ] + ) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + billing_calls: list[list[str]] = [] + + def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]: + billing_calls.append(tenant_ids) + return { + "t_free": plan_info("sandbox", -1), + "t_paid": plan_info("team", -1), + } + + monkeypatch.setattr(cleanup_module.BillingService, "get_plan_bulk_with_cache", staticmethod(fake_bulk)) + + cleanup.run() + + assert billing_calls == [["t_free", "t_paid"]] + assert repo.cleanup_ref_calls[1]["tenant_ids"] == ["t_free"] + assert repo.deleted == [["run-free"]] def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() - repo = FakeRepo(batches=[[FakeRun("run-paid", "t_paid", cutoff)]]) + repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) @@ -257,6 +301,53 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None cleanup.run() assert repo.deleted == [] + assert len(repo.cleanup_ref_calls) == 2 + + +def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) -> None: + cutoff = datetime.datetime.now() + repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr( + cleanup_module.BillingService, + "get_plan_bulk_with_cache", + staticmethod(lambda tenant_ids: {tenant_id: plan_info("team", 1893456000) for tenant_id in tenant_ids}), + ) + batch_calls: list[dict[str, object]] = [] + monkeypatch.setattr(cleanup._metrics, "record_batch", lambda **kwargs: batch_calls.append(kwargs)) + + cleanup.run() + + assert repo.deleted == [] + assert repo.counted == [] + assert batch_calls[0]["batch_rows"] == 1 + assert batch_calls[0]["targeted_runs"] == 0 + assert batch_calls[0]["skipped_runs"] == 1 + assert batch_calls[0]["deleted_runs"] == 0 + + +def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest.MonkeyPatch) -> None: + first_created_at = datetime.datetime(2024, 1, 1, 0, 0, 0) + second_created_at = datetime.datetime(2024, 1, 1, 0, 1, 0) + repo = FakeRepo( + batches=[ + [ + make_ref("run-free-1", "t_free", first_created_at), + make_ref("run-free-2", "t_free", second_created_at), + ] + ] + ) + cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2) + + monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + + cleanup.run() + + assert repo.cleanup_ref_calls[1]["last_seen"] is None + assert repo.cleanup_ref_calls[1]["upper_bound"] == (second_created_at, "run-free-2") + assert repo.cleanup_ref_calls[2]["last_seen"] == (second_created_at, "run-free-2") def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None: @@ -268,7 +359,7 @@ def test_run_exits_on_empty_batch(monkeypatch: pytest.MonkeyPatch) -> None: def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() repo = FakeRepo( - batches=[[FakeRun("run-free", "t_free", cutoff)]], + batches=[[make_ref("run-free", "t_free", cutoff)]], delete_result={ "runs": 0, "node_executions": 2, @@ -300,13 +391,13 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: class FailingRepo(FakeRepo): - def delete_runs_with_related( - self, runs: list[FakeRun], delete_node_executions=None, delete_trigger_logs=None + def delete_runs_with_related_by_ids( + self, run_ids: list[str], delete_node_executions=None, delete_trigger_logs=None ) -> dict[str, int]: raise RuntimeError("delete failed") cutoff = datetime.datetime.now() - repo = FailingRepo(batches=[[FakeRun("run-free", "t_free", cutoff)]]) + repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) @@ -323,7 +414,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: cutoff = datetime.datetime.now() repo = FakeRepo( - batches=[[FakeRun("run-free", "t_free", cutoff)]], + batches=[[make_ref("run-free", "t_free", cutoff)]], count_result={ "runs": 0, "node_executions": 2, diff --git a/api/tests/unit_tests/services/test_dataset_service_dataset.py b/api/tests/unit_tests/services/test_dataset_service_dataset.py index 3d08b6fd096..46f32f93e8a 100644 --- a/api/tests/unit_tests/services/test_dataset_service_dataset.py +++ b/api/tests/unit_tests/services/test_dataset_service_dataset.py @@ -15,6 +15,7 @@ from .dataset_service_test_helpers import ( ProviderTokenNotInitError, RagPipelineDatasetCreateEntity, SimpleNamespace, + TenantAccountRole, _make_knowledge_configuration, _make_retrieval_model, _make_session_context, @@ -167,6 +168,154 @@ class TestDatasetServiceValidation: DatasetService.check_is_multimodal_model("tenant-1", "provider", "embedding-model") +class TestDatasetServiceRetrievalPermissions: + """Unit tests for dataset list permission branching.""" + + def test_get_datasets_filters_by_maintainer_and_rbac_overrides(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + patch( + "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=SimpleNamespace(workspace=SimpleNamespace(permission_keys=[])), + ), + ): + DatasetService.get_datasets( + page=1, + per_page=20, + tenant_id="tenant-1", + user=user, + accessible_dataset_ids=["dataset-shared"], + include_own_datasets=True, + ) + + select_stmt = mock_db.paginate.call_args.kwargs["select"] + visibility_clause = str(select_stmt._where_criteria[1]) + assert "maintainer" in visibility_clause + assert "IN" in visibility_clause + + def test_get_datasets_filters_only_by_rbac_overrides_without_manage_own_permission(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + patch( + "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=SimpleNamespace(workspace=SimpleNamespace(permission_keys=[])), + ), + ): + DatasetService.get_datasets( + page=1, + per_page=20, + tenant_id="tenant-1", + user=user, + accessible_dataset_ids=["dataset-shared"], + ) + + select_stmt = mock_db.paginate.call_args.kwargs["select"] + visibility_clause = str(select_stmt._where_criteria[1]) + assert "maintainer" not in visibility_clause + assert "IN" in visibility_clause + + def test_get_datasets_by_ids_applies_rbac_visibility(self): + mock_db = MagicMock() + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + ): + DatasetService.get_datasets_by_ids( + ["dataset-requested", "dataset-shared"], + "tenant-1", + user=user, + accessible_dataset_ids=["dataset-shared", "dataset-not-requested"], + include_own_datasets=True, + ) + + select_stmt = mock_db.paginate.call_args.kwargs["select"] + visibility_clause = str(select_stmt._where_criteria[-1]) + assert "maintainer" in visibility_clause + assert "IN" in visibility_clause + visibility_params = select_stmt._where_criteria[-1].compile().params + assert ["dataset-shared"] in visibility_params.values() + list_params = [value for value in visibility_params.values() if isinstance(value, list)] + assert all("dataset-not-requested" not in value for value in list_params) + + def test_get_datasets_rbac_include_all_uses_workspace_permission(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + mock_permissions = SimpleNamespace(workspace=SimpleNamespace(permission_keys=["dataset.create_and_management"])) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + patch( + "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=mock_permissions, + ), + ): + DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=user, include_all=True) + + mock_db.session.scalars.assert_called_once() + mock_db.paginate.assert_called_once() + select_stmt = mock_db.paginate.call_args.kwargs["select"] + assert len(select_stmt._where_criteria) == 1 + + def test_get_datasets_rbac_without_user_returns_empty_result(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + ): + DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=None) + + mock_db.session.scalars.assert_not_called() + mock_db.paginate.assert_called_once() + select_stmt = mock_db.paginate.call_args.kwargs["select"] + assert len(select_stmt._where_criteria) == 2 + + def test_get_datasets_legacy_owner_include_all_keeps_full_access(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.OWNER) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", False), + ): + DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=user, include_all=True) + + mock_db.session.scalars.assert_called_once() + mock_db.paginate.assert_called_once() + select_stmt = mock_db.paginate.call_args.kwargs["select"] + assert len(select_stmt._where_criteria) == 1 + + class TestDatasetServiceCreationAndUpdate: """Unit tests for dataset creation and update helpers.""" diff --git a/api/tests/unit_tests/services/test_external_dataset_service.py b/api/tests/unit_tests/services/test_external_dataset_service.py index fdea0ba8690..248a2b1f68a 100644 --- a/api/tests/unit_tests/services/test_external_dataset_service.py +++ b/api/tests/unit_tests/services/test_external_dataset_service.py @@ -21,6 +21,7 @@ from services.entities.external_knowledge_entities.external_knowledge_entities i ExternalKnowledgeApiSetting, ) from services.errors.dataset import DatasetNameDuplicateError +from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError from services.external_knowledge_service import ExternalDatasetService @@ -136,14 +137,14 @@ class ExternalDatasetServiceTestDataFactory: @pytest.fixture def factory(): """Provide the test data factory to all tests.""" - return ExternalDatasetServiceTestDataFactory + return ExternalDatasetServiceTestDataFactory() class TestExternalDatasetServiceGetAPIs: """Test get_external_knowledge_apis operations - comprehensive coverage.""" @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_success_basic(self, mock_db, factory): + def test_get_external_knowledge_apis_success_basic(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test successful retrieval of external knowledge APIs with pagination.""" # Arrange tenant_id = "tenant-123" @@ -170,7 +171,9 @@ class TestExternalDatasetServiceGetAPIs: mock_db.paginate.assert_called_once() @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_with_search_filter(self, mock_db, factory): + def test_get_external_knowledge_apis_with_search_filter( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test retrieval with search filter.""" # Arrange tenant_id = "tenant-123" @@ -194,7 +197,7 @@ class TestExternalDatasetServiceGetAPIs: assert result_items[0].name == "Production API" @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_empty_results(self, mock_db, factory): + def test_get_external_knowledge_apis_empty_results(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test retrieval with no results.""" # Arrange mock_pagination = MagicMock() @@ -212,7 +215,9 @@ class TestExternalDatasetServiceGetAPIs: assert result_total == 0 @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_large_result_set(self, mock_db, factory): + def test_get_external_knowledge_apis_large_result_set( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test retrieval with large result set.""" # Arrange apis = [factory.create_external_knowledge_api_mock(api_id=f"api-{i}") for i in range(100)] @@ -232,7 +237,9 @@ class TestExternalDatasetServiceGetAPIs: assert result_total == 100 @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_pagination_last_page(self, mock_db, factory): + def test_get_external_knowledge_apis_pagination_last_page( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test last page pagination with partial results.""" # Arrange apis = [factory.create_external_knowledge_api_mock(api_id=f"api-{i}") for i in range(95, 100)] @@ -252,7 +259,9 @@ class TestExternalDatasetServiceGetAPIs: assert result_total == 100 @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_case_insensitive_search(self, mock_db, factory): + def test_get_external_knowledge_apis_case_insensitive_search( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test case-insensitive search functionality.""" # Arrange apis = [ @@ -275,7 +284,9 @@ class TestExternalDatasetServiceGetAPIs: assert result_total == 2 @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_special_characters_search(self, mock_db, factory): + def test_get_external_knowledge_apis_special_characters_search( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test search with special characters.""" # Arrange apis = [factory.create_external_knowledge_api_mock(name="API-v2.0 (beta)")] @@ -294,7 +305,9 @@ class TestExternalDatasetServiceGetAPIs: assert len(result_items) == 1 @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_max_per_page_limit(self, mock_db, factory): + def test_get_external_knowledge_apis_max_per_page_limit( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test that max_per_page limit is enforced.""" # Arrange apis = [factory.create_external_knowledge_api_mock(api_id=f"api-{i}") for i in range(100)] @@ -314,7 +327,9 @@ class TestExternalDatasetServiceGetAPIs: assert call_args.kwargs["max_per_page"] == 100 @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_apis_ordered_by_created_at_desc(self, mock_db, factory): + def test_get_external_knowledge_apis_ordered_by_created_at_desc( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test that results are ordered by created_at descending.""" # Arrange apis = [ @@ -339,7 +354,7 @@ class TestExternalDatasetServiceGetAPIs: class TestExternalDatasetServiceValidateAPIList: """Test validate_api_list operations.""" - def test_validate_api_list_success_with_all_fields(self, factory): + def test_validate_api_list_success_with_all_fields(self, factory: ExternalDatasetServiceTestDataFactory): """Test successful validation with all required fields.""" # Arrange api_settings = {"endpoint": "https://api.example.com", "api_key": "test-key-123"} @@ -347,7 +362,7 @@ class TestExternalDatasetServiceValidateAPIList: # Act & Assert - should not raise ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_missing_endpoint(self, factory): + def test_validate_api_list_missing_endpoint(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when endpoint is missing.""" # Arrange api_settings = {"api_key": "test-key"} @@ -356,7 +371,7 @@ class TestExternalDatasetServiceValidateAPIList: with pytest.raises(ValueError, match="endpoint is required"): ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_empty_endpoint(self, factory): + def test_validate_api_list_empty_endpoint(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when endpoint is empty string.""" # Arrange api_settings = {"endpoint": "", "api_key": "test-key"} @@ -365,7 +380,7 @@ class TestExternalDatasetServiceValidateAPIList: with pytest.raises(ValueError, match="endpoint is required"): ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_missing_api_key(self, factory): + def test_validate_api_list_missing_api_key(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when API key is missing.""" # Arrange api_settings = {"endpoint": "https://api.example.com"} @@ -374,7 +389,7 @@ class TestExternalDatasetServiceValidateAPIList: with pytest.raises(ValueError, match="api_key is required"): ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_empty_api_key(self, factory): + def test_validate_api_list_empty_api_key(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when API key is empty string.""" # Arrange api_settings = {"endpoint": "https://api.example.com", "api_key": ""} @@ -383,7 +398,7 @@ class TestExternalDatasetServiceValidateAPIList: with pytest.raises(ValueError, match="api_key is required"): ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_empty_dict(self, factory): + def test_validate_api_list_empty_dict(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when settings are empty dict.""" # Arrange api_settings = {} @@ -392,7 +407,7 @@ class TestExternalDatasetServiceValidateAPIList: with pytest.raises(ValueError, match="api list is empty"): ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_none_value(self, factory): + def test_validate_api_list_none_value(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when settings are None.""" # Arrange api_settings = None @@ -401,7 +416,7 @@ class TestExternalDatasetServiceValidateAPIList: with pytest.raises(ValueError, match="api list is empty"): ExternalDatasetService.validate_api_list(api_settings) - def test_validate_api_list_with_extra_fields(self, factory): + def test_validate_api_list_with_extra_fields(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation succeeds with extra fields present.""" # Arrange api_settings = { @@ -420,7 +435,9 @@ class TestExternalDatasetServiceCreateAPI: @patch("services.external_knowledge_service.db") @patch("services.external_knowledge_service.ExternalDatasetService.check_endpoint_and_api_key") - def test_create_external_knowledge_api_success_full(self, mock_check, mock_db, factory): + def test_create_external_knowledge_api_success_full( + self, mock_check, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test successful creation with all fields.""" # Arrange tenant_id = "tenant-123" @@ -446,7 +463,9 @@ class TestExternalDatasetServiceCreateAPI: @patch("services.external_knowledge_service.db") @patch("services.external_knowledge_service.ExternalDatasetService.check_endpoint_and_api_key") - def test_create_external_knowledge_api_minimal_fields(self, mock_check, mock_db, factory): + def test_create_external_knowledge_api_minimal_fields( + self, mock_check, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test creation with minimal required fields.""" # Arrange args = { @@ -462,7 +481,9 @@ class TestExternalDatasetServiceCreateAPI: assert result.description == "" @patch("services.external_knowledge_service.db") - def test_create_external_knowledge_api_missing_settings(self, mock_db, factory): + def test_create_external_knowledge_api_missing_settings( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test creation fails when settings are missing.""" # Arrange args = {"name": "Test API", "description": "Test"} @@ -472,7 +493,7 @@ class TestExternalDatasetServiceCreateAPI: ExternalDatasetService.create_external_knowledge_api("tenant-123", "user-123", args) @patch("services.external_knowledge_service.db") - def test_create_external_knowledge_api_none_settings(self, mock_db, factory): + def test_create_external_knowledge_api_none_settings(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test creation fails when settings are explicitly None.""" # Arrange args = {"name": "Test API", "settings": None} @@ -483,7 +504,9 @@ class TestExternalDatasetServiceCreateAPI: @patch("services.external_knowledge_service.db") @patch("services.external_knowledge_service.ExternalDatasetService.check_endpoint_and_api_key") - def test_create_external_knowledge_api_settings_json_serialization(self, mock_check, mock_db, factory): + def test_create_external_knowledge_api_settings_json_serialization( + self, mock_check, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test that settings are properly JSON serialized.""" # Arrange settings = { @@ -503,7 +526,9 @@ class TestExternalDatasetServiceCreateAPI: @patch("services.external_knowledge_service.db") @patch("services.external_knowledge_service.ExternalDatasetService.check_endpoint_and_api_key") - def test_create_external_knowledge_api_unicode_handling(self, mock_check, mock_db, factory): + def test_create_external_knowledge_api_unicode_handling( + self, mock_check, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test proper handling of Unicode characters in name and description.""" # Arrange args = { @@ -521,7 +546,9 @@ class TestExternalDatasetServiceCreateAPI: @patch("services.external_knowledge_service.db") @patch("services.external_knowledge_service.ExternalDatasetService.check_endpoint_and_api_key") - def test_create_external_knowledge_api_long_description(self, mock_check, mock_db, factory): + def test_create_external_knowledge_api_long_description( + self, mock_check, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test creation with very long description.""" # Arrange long_description = "A" * 1000 @@ -543,7 +570,7 @@ class TestExternalDatasetServiceCheckEndpoint: """Test check_endpoint_and_api_key operations - extensive coverage.""" @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_success_https(self, mock_proxy, factory): + def test_check_endpoint_success_https(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test successful validation with HTTPS endpoint.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -557,7 +584,7 @@ class TestExternalDatasetServiceCheckEndpoint: mock_proxy.post.assert_called_once() @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_success_http(self, mock_proxy, factory): + def test_check_endpoint_success_http(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test successful validation with HTTP endpoint.""" # Arrange settings = {"endpoint": "http://api.example.com", "api_key": "test-key"} @@ -569,7 +596,7 @@ class TestExternalDatasetServiceCheckEndpoint: # Act & Assert - should not raise ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_missing_endpoint_key(self, factory): + def test_check_endpoint_missing_endpoint_key(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when endpoint key is missing.""" # Arrange settings = {"api_key": "test-key"} @@ -578,7 +605,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="endpoint is required"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_empty_endpoint_string(self, factory): + def test_check_endpoint_empty_endpoint_string(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when endpoint is empty string.""" # Arrange settings = {"endpoint": "", "api_key": "test-key"} @@ -587,7 +614,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="endpoint is required"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_whitespace_endpoint(self, factory): + def test_check_endpoint_whitespace_endpoint(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when endpoint is only whitespace.""" # Arrange settings = {"endpoint": " ", "api_key": "test-key"} @@ -596,7 +623,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="invalid endpoint"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_missing_api_key_key(self, factory): + def test_check_endpoint_missing_api_key_key(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when api_key key is missing.""" # Arrange settings = {"endpoint": "https://api.example.com"} @@ -605,7 +632,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="api_key is required"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_empty_api_key_string(self, factory): + def test_check_endpoint_empty_api_key_string(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when api_key is empty string.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": ""} @@ -614,7 +641,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="api_key is required"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_no_scheme_url(self, factory): + def test_check_endpoint_no_scheme_url(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails for URL without http:// or https://.""" # Arrange settings = {"endpoint": "api.example.com", "api_key": "test-key"} @@ -623,7 +650,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="invalid endpoint.*must start with http"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_invalid_scheme(self, factory): + def test_check_endpoint_invalid_scheme(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails for URL with invalid scheme.""" # Arrange settings = {"endpoint": "ftp://api.example.com", "api_key": "test-key"} @@ -632,7 +659,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="failed to connect to the endpoint"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_no_netloc(self, factory): + def test_check_endpoint_no_netloc(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails for URL without network location.""" # Arrange settings = {"endpoint": "http://", "api_key": "test-key"} @@ -641,7 +668,7 @@ class TestExternalDatasetServiceCheckEndpoint: with pytest.raises(ValueError, match="invalid endpoint"): ExternalDatasetService.check_endpoint_and_api_key(settings) - def test_check_endpoint_malformed_url(self, factory): + def test_check_endpoint_malformed_url(self, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails for malformed URL.""" # Arrange settings = {"endpoint": "https:///invalid", "api_key": "test-key"} @@ -651,7 +678,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_connection_timeout(self, mock_proxy, factory): + def test_check_endpoint_connection_timeout(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails on connection timeout.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -662,7 +689,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_network_error(self, mock_proxy, factory): + def test_check_endpoint_network_error(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails on network error.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -673,7 +700,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_502_bad_gateway(self, mock_proxy, factory): + def test_check_endpoint_502_bad_gateway(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails with 502 Bad Gateway.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -687,7 +714,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_404_not_found(self, mock_proxy, factory): + def test_check_endpoint_404_not_found(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails with 404 Not Found.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -701,7 +728,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_403_forbidden(self, mock_proxy, factory): + def test_check_endpoint_403_forbidden(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails with 403 Forbidden (auth failure).""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "wrong-key"} @@ -715,7 +742,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_other_4xx_codes_pass(self, mock_proxy, factory): + def test_check_endpoint_other_4xx_codes_pass(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test that other 4xx codes don't raise exceptions.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -729,7 +756,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_5xx_codes_except_502_pass(self, mock_proxy, factory): + def test_check_endpoint_5xx_codes_except_502_pass(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test that 5xx codes except 502 don't raise exceptions.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key"} @@ -743,7 +770,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_with_port_number(self, mock_proxy, factory): + def test_check_endpoint_with_port_number(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation with endpoint including port number.""" # Arrange settings = {"endpoint": "https://api.example.com:8443", "api_key": "test-key"} @@ -756,7 +783,7 @@ class TestExternalDatasetServiceCheckEndpoint: ExternalDatasetService.check_endpoint_and_api_key(settings) @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_with_path(self, mock_proxy, factory): + def test_check_endpoint_with_path(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test validation with endpoint including path.""" # Arrange settings = {"endpoint": "https://api.example.com/v1/api", "api_key": "test-key"} @@ -772,7 +799,9 @@ class TestExternalDatasetServiceCheckEndpoint: assert "/retrieval" in call_args[0][0] @patch("services.external_knowledge_service.ssrf_proxy") - def test_check_endpoint_authorization_header_format(self, mock_proxy, factory): + def test_check_endpoint_authorization_header_format( + self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory + ): """Test that Authorization header is properly formatted.""" # Arrange settings = {"endpoint": "https://api.example.com", "api_key": "test-key-123"} @@ -794,7 +823,7 @@ class TestExternalDatasetServiceGetAPI: """Test get_external_knowledge_api operations.""" @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_api_success(self, mock_db, factory): + def test_get_external_knowledge_api_success(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test successful retrieval of external knowledge API.""" # Arrange api_id = "api-123" @@ -810,7 +839,7 @@ class TestExternalDatasetServiceGetAPI: assert result.id == api_id @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_api_not_found(self, mock_db, factory): + def test_get_external_knowledge_api_not_found(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test error when API is not found.""" # Arrange mock_db.session.scalar.return_value = None @@ -825,7 +854,9 @@ class TestExternalDatasetServiceUpdateAPI: @patch("services.external_knowledge_service.naive_utc_now") @patch("services.external_knowledge_service.db") - def test_update_external_knowledge_api_success_all_fields(self, mock_db, mock_now, factory): + def test_update_external_knowledge_api_success_all_fields( + self, mock_db, mock_now, factory: ExternalDatasetServiceTestDataFactory + ): """Test successful update with all fields.""" # Arrange api_id = "api-123" @@ -855,7 +886,9 @@ class TestExternalDatasetServiceUpdateAPI: mock_db.session.commit.assert_called_once() @patch("services.external_knowledge_service.db") - def test_update_external_knowledge_api_preserve_hidden_api_key(self, mock_db, factory): + def test_update_external_knowledge_api_preserve_hidden_api_key( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test that hidden API key is preserved from existing settings.""" # Arrange api_id = "api-123" @@ -882,7 +915,7 @@ class TestExternalDatasetServiceUpdateAPI: assert settings["api_key"] == "original-secret-key" @patch("services.external_knowledge_service.db") - def test_update_external_knowledge_api_not_found(self, mock_db, factory): + def test_update_external_knowledge_api_not_found(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test error when API is not found.""" # Arrange mock_db.session.scalar.return_value = None @@ -894,7 +927,9 @@ class TestExternalDatasetServiceUpdateAPI: ExternalDatasetService.update_external_knowledge_api("tenant-123", "user-123", "api-123", args) @patch("services.external_knowledge_service.db") - def test_update_external_knowledge_api_tenant_mismatch(self, mock_db, factory): + def test_update_external_knowledge_api_tenant_mismatch( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when tenant ID doesn't match.""" # Arrange mock_db.session.scalar.return_value = None @@ -906,7 +941,7 @@ class TestExternalDatasetServiceUpdateAPI: ExternalDatasetService.update_external_knowledge_api("wrong-tenant", "user-123", "api-123", args) @patch("services.external_knowledge_service.db") - def test_update_external_knowledge_api_name_only(self, mock_db, factory): + def test_update_external_knowledge_api_name_only(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test updating only the name field.""" # Arrange existing_api = factory.create_external_knowledge_api_mock( @@ -929,7 +964,7 @@ class TestExternalDatasetServiceDeleteAPI: """Test delete_external_knowledge_api operations.""" @patch("services.external_knowledge_service.db") - def test_delete_external_knowledge_api_success(self, mock_db, factory): + def test_delete_external_knowledge_api_success(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test successful deletion of external knowledge API.""" # Arrange api_id = "api-123" @@ -947,7 +982,7 @@ class TestExternalDatasetServiceDeleteAPI: mock_db.session.commit.assert_called_once() @patch("services.external_knowledge_service.db") - def test_delete_external_knowledge_api_not_found(self, mock_db, factory): + def test_delete_external_knowledge_api_not_found(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test error when API is not found.""" # Arrange mock_db.session.scalar.return_value = None @@ -957,7 +992,9 @@ class TestExternalDatasetServiceDeleteAPI: ExternalDatasetService.delete_external_knowledge_api("tenant-123", "api-123") @patch("services.external_knowledge_service.db") - def test_delete_external_knowledge_api_tenant_mismatch(self, mock_db, factory): + def test_delete_external_knowledge_api_tenant_mismatch( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when tenant ID doesn't match.""" # Arrange mock_db.session.scalar.return_value = None @@ -971,7 +1008,9 @@ class TestExternalDatasetServiceAPIUseCheck: """Test external_knowledge_api_use_check operations.""" @patch("services.external_knowledge_service.db") - def test_external_knowledge_api_use_check_in_use_single(self, mock_db, factory): + def test_external_knowledge_api_use_check_in_use_single( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test API use check when API has one binding.""" # Arrange api_id = "api-123" @@ -988,7 +1027,9 @@ class TestExternalDatasetServiceAPIUseCheck: assert "tenant_id" in str(mock_db.session.scalar.call_args.args[0]) @patch("services.external_knowledge_service.db") - def test_external_knowledge_api_use_check_in_use_multiple(self, mock_db, factory): + def test_external_knowledge_api_use_check_in_use_multiple( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test API use check with multiple bindings.""" # Arrange api_id = "api-123" @@ -1004,7 +1045,7 @@ class TestExternalDatasetServiceAPIUseCheck: assert count == 10 @patch("services.external_knowledge_service.db") - def test_external_knowledge_api_use_check_not_in_use(self, mock_db, factory): + def test_external_knowledge_api_use_check_not_in_use(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test API use check when API is not in use.""" # Arrange api_id = "api-123" @@ -1024,7 +1065,7 @@ class TestExternalDatasetServiceGetBinding: """Test get_external_knowledge_binding_with_dataset_id operations.""" @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_binding_success(self, mock_db, factory): + def test_get_external_knowledge_binding_success(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test successful retrieval of external knowledge binding.""" # Arrange tenant_id = "tenant-123" @@ -1042,7 +1083,7 @@ class TestExternalDatasetServiceGetBinding: assert result.tenant_id == tenant_id @patch("services.external_knowledge_service.db") - def test_get_external_knowledge_binding_not_found(self, mock_db, factory): + def test_get_external_knowledge_binding_not_found(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test error when binding is not found.""" # Arrange mock_db.session.scalar.return_value = None @@ -1056,7 +1097,9 @@ class TestExternalDatasetServiceDocumentValidate: """Test document_create_args_validate operations.""" @patch("services.external_knowledge_service.db") - def test_document_create_args_validate_success_all_params(self, mock_db, factory): + def test_document_create_args_validate_success_all_params( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test successful validation with all required parameters.""" # Arrange tenant_id = "tenant-123" @@ -1080,7 +1123,9 @@ class TestExternalDatasetServiceDocumentValidate: ExternalDatasetService.document_create_args_validate(tenant_id, api_id, process_parameter) @patch("services.external_knowledge_service.db") - def test_document_create_args_validate_missing_required_param(self, mock_db, factory): + def test_document_create_args_validate_missing_required_param( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test validation fails when required parameter is missing.""" # Arrange tenant_id = "tenant-123" @@ -1099,7 +1144,7 @@ class TestExternalDatasetServiceDocumentValidate: ExternalDatasetService.document_create_args_validate(tenant_id, api_id, process_parameter) @patch("services.external_knowledge_service.db") - def test_document_create_args_validate_api_not_found(self, mock_db, factory): + def test_document_create_args_validate_api_not_found(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test validation fails when API is not found.""" # Arrange mock_db.session.scalar.return_value = None @@ -1109,7 +1154,9 @@ class TestExternalDatasetServiceDocumentValidate: ExternalDatasetService.document_create_args_validate("tenant-123", "api-123", {}) @patch("services.external_knowledge_service.db") - def test_document_create_args_validate_no_custom_parameters(self, mock_db, factory): + def test_document_create_args_validate_no_custom_parameters( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test validation succeeds when no custom parameters defined.""" # Arrange settings = {} @@ -1121,7 +1168,9 @@ class TestExternalDatasetServiceDocumentValidate: ExternalDatasetService.document_create_args_validate("tenant-123", "api-123", {}) @patch("services.external_knowledge_service.db") - def test_document_create_args_validate_optional_params_not_required(self, mock_db, factory): + def test_document_create_args_validate_optional_params_not_required( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test that optional parameters don't cause validation failure.""" # Arrange settings = { @@ -1145,7 +1194,7 @@ class TestExternalDatasetServiceProcessAPI: """Test process_external_api operations - comprehensive HTTP method coverage.""" @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_get_request(self, mock_proxy, factory): + def test_process_external_api_get_request(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test processing GET request.""" # Arrange settings = factory.create_api_setting_mock(request_method="get") @@ -1161,7 +1210,9 @@ class TestExternalDatasetServiceProcessAPI: mock_proxy.get.assert_called_once() @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_post_request_with_data(self, mock_proxy, factory): + def test_process_external_api_post_request_with_data( + self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory + ): """Test processing POST request with data.""" # Arrange settings = factory.create_api_setting_mock(request_method="post", params={"key": "value", "data": "test"}) @@ -1179,7 +1230,7 @@ class TestExternalDatasetServiceProcessAPI: assert "data" in call_kwargs @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_put_request(self, mock_proxy, factory): + def test_process_external_api_put_request(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test processing PUT request.""" # Arrange settings = factory.create_api_setting_mock(request_method="put") @@ -1195,7 +1246,7 @@ class TestExternalDatasetServiceProcessAPI: mock_proxy.put.assert_called_once() @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_delete_request(self, mock_proxy, factory): + def test_process_external_api_delete_request(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test processing DELETE request.""" # Arrange settings = factory.create_api_setting_mock(request_method="delete") @@ -1211,7 +1262,7 @@ class TestExternalDatasetServiceProcessAPI: mock_proxy.delete.assert_called_once() @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_patch_request(self, mock_proxy, factory): + def test_process_external_api_patch_request(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test processing PATCH request.""" # Arrange settings = factory.create_api_setting_mock(request_method="patch") @@ -1227,7 +1278,7 @@ class TestExternalDatasetServiceProcessAPI: mock_proxy.patch.assert_called_once() @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_head_request(self, mock_proxy, factory): + def test_process_external_api_head_request(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test processing HEAD request.""" # Arrange settings = factory.create_api_setting_mock(request_method="head") @@ -1242,7 +1293,7 @@ class TestExternalDatasetServiceProcessAPI: assert result == mock_response mock_proxy.head.assert_called_once() - def test_process_external_api_invalid_method(self, factory): + def test_process_external_api_invalid_method(self, factory: ExternalDatasetServiceTestDataFactory): """Test error for invalid HTTP method.""" # Arrange settings = factory.create_api_setting_mock(request_method="INVALID") @@ -1252,7 +1303,7 @@ class TestExternalDatasetServiceProcessAPI: ExternalDatasetService.process_external_api(settings, None) @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_with_files(self, mock_proxy, factory): + def test_process_external_api_with_files(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test processing request with file uploads.""" # Arrange settings = factory.create_api_setting_mock(request_method="post") @@ -1271,7 +1322,7 @@ class TestExternalDatasetServiceProcessAPI: assert call_kwargs["files"] == files @patch("services.external_knowledge_service.ssrf_proxy") - def test_process_external_api_follow_redirects(self, mock_proxy, factory): + def test_process_external_api_follow_redirects(self, mock_proxy, factory: ExternalDatasetServiceTestDataFactory): """Test that follow_redirects is enabled.""" # Arrange settings = factory.create_api_setting_mock(request_method="get") @@ -1290,7 +1341,7 @@ class TestExternalDatasetServiceProcessAPI: class TestExternalDatasetServiceAssemblingHeaders: """Test assembling_headers operations - comprehensive authorization coverage.""" - def test_assembling_headers_bearer_token(self, factory): + def test_assembling_headers_bearer_token(self, factory: ExternalDatasetServiceTestDataFactory): """Test assembling headers with Bearer token.""" # Arrange authorization = factory.create_authorization_mock(token_type="bearer", api_key="secret-key-123") @@ -1301,7 +1352,7 @@ class TestExternalDatasetServiceAssemblingHeaders: # Assert assert result["Authorization"] == "Bearer secret-key-123" - def test_assembling_headers_basic_auth(self, factory): + def test_assembling_headers_basic_auth(self, factory: ExternalDatasetServiceTestDataFactory): """Test assembling headers with Basic authentication.""" # Arrange authorization = factory.create_authorization_mock(token_type="basic", api_key="credentials") @@ -1312,7 +1363,7 @@ class TestExternalDatasetServiceAssemblingHeaders: # Assert assert result["Authorization"] == "Basic credentials" - def test_assembling_headers_custom_auth(self, factory): + def test_assembling_headers_custom_auth(self, factory: ExternalDatasetServiceTestDataFactory): """Test assembling headers with custom authentication.""" # Arrange authorization = factory.create_authorization_mock(token_type="custom", api_key="custom-token") @@ -1323,7 +1374,7 @@ class TestExternalDatasetServiceAssemblingHeaders: # Assert assert result["Authorization"] == "custom-token" - def test_assembling_headers_custom_header_name(self, factory): + def test_assembling_headers_custom_header_name(self, factory: ExternalDatasetServiceTestDataFactory): """Test assembling headers with custom header name.""" # Arrange authorization = factory.create_authorization_mock(token_type="bearer", api_key="key-123", header="X-API-Key") @@ -1335,7 +1386,7 @@ class TestExternalDatasetServiceAssemblingHeaders: assert result["X-API-Key"] == "Bearer key-123" assert "Authorization" not in result - def test_assembling_headers_with_existing_headers(self, factory): + def test_assembling_headers_with_existing_headers(self, factory: ExternalDatasetServiceTestDataFactory): """Test assembling headers preserves existing headers.""" # Arrange authorization = factory.create_authorization_mock(token_type="bearer", api_key="key") @@ -1354,7 +1405,7 @@ class TestExternalDatasetServiceAssemblingHeaders: assert result["X-Custom"] == "value" assert result["User-Agent"] == "TestAgent/1.0" - def test_assembling_headers_empty_existing_headers(self, factory): + def test_assembling_headers_empty_existing_headers(self, factory: ExternalDatasetServiceTestDataFactory): """Test assembling headers with empty existing headers dict.""" # Arrange authorization = factory.create_authorization_mock(token_type="bearer", api_key="key") @@ -1367,7 +1418,7 @@ class TestExternalDatasetServiceAssemblingHeaders: assert result["Authorization"] == "Bearer key" assert len(result) == 1 - def test_assembling_headers_missing_api_key(self, factory): + def test_assembling_headers_missing_api_key(self, factory: ExternalDatasetServiceTestDataFactory): """Test error when API key is missing.""" # Arrange config = AuthorizationConfig(api_key=None, type="bearer", header="Authorization") @@ -1377,7 +1428,7 @@ class TestExternalDatasetServiceAssemblingHeaders: with pytest.raises(ValueError, match="api_key is required"): ExternalDatasetService.assembling_headers(authorization) - def test_assembling_headers_missing_config(self, factory): + def test_assembling_headers_missing_config(self, factory: ExternalDatasetServiceTestDataFactory): """Test error when config is missing.""" # Arrange authorization = Authorization(type="api-key", config=None) @@ -1386,7 +1437,7 @@ class TestExternalDatasetServiceAssemblingHeaders: with pytest.raises(ValueError, match="authorization config is required"): ExternalDatasetService.assembling_headers(authorization) - def test_assembling_headers_default_header_name(self, factory): + def test_assembling_headers_default_header_name(self, factory: ExternalDatasetServiceTestDataFactory): """Test that default header name is Authorization when not specified.""" # Arrange config = AuthorizationConfig(api_key="key", type="bearer", header=None) @@ -1402,7 +1453,7 @@ class TestExternalDatasetServiceAssemblingHeaders: class TestExternalDatasetServiceGetSettings: """Test get_external_knowledge_api_settings operations.""" - def test_get_external_knowledge_api_settings_success(self, factory): + def test_get_external_knowledge_api_settings_success(self, factory: ExternalDatasetServiceTestDataFactory): """Test successful parsing of API settings.""" # Arrange settings = { @@ -1427,7 +1478,7 @@ class TestExternalDatasetServiceCreateDataset: """Test create_external_dataset operations.""" @patch("services.external_knowledge_service.db") - def test_create_external_dataset_success_full(self, mock_db, factory): + def test_create_external_dataset_success_full(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test successful creation of external dataset with all fields.""" # Arrange tenant_id = "tenant-123" @@ -1456,7 +1507,9 @@ class TestExternalDatasetServiceCreateDataset: mock_db.session.commit.assert_called_once() @patch("services.external_knowledge_service.db") - def test_create_external_dataset_duplicate_name_error(self, mock_db, factory): + def test_create_external_dataset_duplicate_name_error( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when dataset name already exists.""" # Arrange existing_dataset = factory.create_dataset_mock(name="Duplicate Dataset") @@ -1470,7 +1523,7 @@ class TestExternalDatasetServiceCreateDataset: ExternalDatasetService.create_external_dataset("tenant-123", "user-123", args) @patch("services.external_knowledge_service.db") - def test_create_external_dataset_api_not_found_error(self, mock_db, factory): + def test_create_external_dataset_api_not_found_error(self, mock_db, factory: ExternalDatasetServiceTestDataFactory): """Test error when external knowledge API is not found.""" # Arrange mock_db.session.scalar.side_effect = [None, None] @@ -1482,7 +1535,9 @@ class TestExternalDatasetServiceCreateDataset: ExternalDatasetService.create_external_dataset("tenant-123", "user-123", args) @patch("services.external_knowledge_service.db") - def test_create_external_dataset_missing_knowledge_id_error(self, mock_db, factory): + def test_create_external_dataset_missing_knowledge_id_error( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when external_knowledge_id is missing.""" # Arrange api = factory.create_external_knowledge_api_mock() @@ -1496,7 +1551,9 @@ class TestExternalDatasetServiceCreateDataset: ExternalDatasetService.create_external_dataset("tenant-123", "user-123", args) @patch("services.external_knowledge_service.db") - def test_create_external_dataset_missing_api_id_error(self, mock_db, factory): + def test_create_external_dataset_missing_api_id_error( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when external_knowledge_api_id is missing.""" # Arrange api = factory.create_external_knowledge_api_mock() @@ -1515,7 +1572,9 @@ class TestExternalDatasetServiceFetchRetrieval: @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_success_with_results(self, mock_db, mock_process, factory): + def test_fetch_external_knowledge_retrieval_success_with_results( + self, mock_db, mock_process, factory: ExternalDatasetServiceTestDataFactory + ): """Test successful external knowledge retrieval with results.""" # Arrange tenant_id = "tenant-123" @@ -1552,29 +1611,35 @@ class TestExternalDatasetServiceFetchRetrieval: assert result[1]["score"] == 0.8 @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_binding_not_found_error(self, mock_db, factory): + def test_fetch_external_knowledge_retrieval_binding_not_found_error( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when external knowledge binding is not found.""" # Arrange mock_db.session.scalar.return_value = None # Act & Assert - with pytest.raises(ValueError, match="external knowledge binding not found"): + with pytest.raises(ExternalKnowledgeRetrievalError, match="external knowledge binding not found"): ExternalDatasetService.fetch_external_knowledge_retrieval("tenant-123", "dataset-123", "query", {}) @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_cross_tenant_api_template_error(self, mock_db, factory): + def test_fetch_external_knowledge_retrieval_cross_tenant_api_template_error( + self, mock_db, factory: ExternalDatasetServiceTestDataFactory + ): """Test error when a binding points to an API template outside the dataset tenant.""" # Arrange binding = factory.create_external_knowledge_binding_mock() mock_db.session.scalar.side_effect = [binding, None] # Act & Assert - with pytest.raises(ValueError, match="external api template not found"): + with pytest.raises(ExternalKnowledgeRetrievalError, match="external api template not found"): ExternalDatasetService.fetch_external_knowledge_retrieval("tenant-123", "dataset-123", "query", {}) @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_empty_results(self, mock_db, mock_process, factory): + def test_fetch_external_knowledge_retrieval_empty_results( + self, mock_db, mock_process, factory: ExternalDatasetServiceTestDataFactory + ): """Test retrieval with empty results.""" # Arrange binding = factory.create_external_knowledge_binding_mock() @@ -1597,7 +1662,9 @@ class TestExternalDatasetServiceFetchRetrieval: @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_with_score_threshold(self, mock_db, mock_process, factory): + def test_fetch_external_knowledge_retrieval_with_score_threshold( + self, mock_db, mock_process, factory: ExternalDatasetServiceTestDataFactory + ): """Test retrieval with score threshold enabled.""" # Arrange binding = factory.create_external_knowledge_binding_mock() @@ -1629,7 +1696,9 @@ class TestExternalDatasetServiceFetchRetrieval: @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_non_200_status_raises_exception(self, mock_db, mock_process, factory): + def test_fetch_external_knowledge_retrieval_non_200_status_raises_exception( + self, mock_db, mock_process, factory: ExternalDatasetServiceTestDataFactory + ): """Test that non-200 status code raises Exception with response text.""" # Arrange binding = factory.create_external_knowledge_binding_mock() @@ -1643,7 +1712,7 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(Exception, match="Internal Server Error: Database connection failed"): + with pytest.raises(ExternalKnowledgeRetrievalError, match="Internal Server Error: Database connection failed"): ExternalDatasetService.fetch_external_knowledge_retrieval( "tenant-123", "dataset-123", "query", {"top_k": 5} ) @@ -1664,7 +1733,7 @@ class TestExternalDatasetServiceFetchRetrieval: @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") def test_fetch_external_knowledge_retrieval_various_error_status_codes( - self, mock_db, mock_process, factory, status_code, error_message + self, mock_db, mock_process, factory: ExternalDatasetServiceTestDataFactory, status_code, error_message ): """Test that various error status codes raise exceptions with response text.""" # Arrange @@ -1684,12 +1753,14 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(ValueError, match=re.escape(error_message)): + with pytest.raises(ExternalKnowledgeRetrievalError, match=re.escape(error_message)): ExternalDatasetService.fetch_external_knowledge_retrieval(tenant_id, dataset_id, "query", {"top_k": 5}) @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") @patch("services.external_knowledge_service.db") - def test_fetch_external_knowledge_retrieval_empty_response_text(self, mock_db, mock_process, factory): + def test_fetch_external_knowledge_retrieval_empty_response_text( + self, mock_db, mock_process, factory: ExternalDatasetServiceTestDataFactory + ): """Test exception with empty response text.""" # Arrange binding = factory.create_external_knowledge_binding_mock() @@ -1703,7 +1774,79 @@ class TestExternalDatasetServiceFetchRetrieval: mock_process.return_value = mock_response # Act & Assert - with pytest.raises(ValueError): + with pytest.raises(ExternalKnowledgeRetrievalError): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_invalid_json_response(self, mock_db, mock_process, factory): + """Test malformed JSON success responses are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + mock_process.return_value = mock_response + + with pytest.raises(ExternalKnowledgeRetrievalError, match="invalid external knowledge response"): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_invalid_success_payload_shape(self, mock_db, mock_process, factory): + """Test malformed success payload shapes are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = ["not-a-dict"] + mock_process.return_value = mock_response + + with pytest.raises(ExternalKnowledgeRetrievalError, match="invalid external knowledge response"): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_invalid_records_shape(self, mock_db, mock_process, factory): + """Test non-list records payloads are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"records": {"unexpected": "shape"}} + mock_process.return_value = mock_response + + with pytest.raises(ExternalKnowledgeRetrievalError, match="invalid external knowledge response"): + ExternalDatasetService.fetch_external_knowledge_retrieval( + "tenant-123", "dataset-123", "query", {"top_k": 5} + ) + + @patch("services.external_knowledge_service.ExternalDatasetService.process_external_api") + @patch("services.external_knowledge_service.db") + def test_fetch_external_knowledge_retrieval_wraps_transport_errors(self, mock_db, mock_process, factory): + """Test transport/runtime failures are normalized to external retrieval errors.""" + binding = factory.create_external_knowledge_binding_mock() + api = factory.create_external_knowledge_api_mock() + + mock_db.session.scalar.side_effect = [binding, api] + mock_process.side_effect = RuntimeError("connection reset by peer") + + with pytest.raises(ExternalKnowledgeRetrievalError, match="connection reset by peer"): ExternalDatasetService.fetch_external_knowledge_retrieval( "tenant-123", "dataset-123", "query", {"top_k": 5} ) diff --git a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py new file mode 100644 index 00000000000..1c3b4fdbc1f --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py @@ -0,0 +1,37 @@ +import pytest + +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, SystemFeatureModel + + +@pytest.mark.parametrize( + ("enterprise_info", "initial", "expected"), + [ + # Enterprise reports the feature on -> mirrored through. + ({"EnableAppDeploy": True}, False, True), + # Enterprise may turn it off; the read runs after the hardcoded default + # and overrides it (forward-compat with a future entitlement gate). + ({"EnableAppDeploy": False}, True, False), + # Old enterprise without the key -> the existing value is left untouched. + ({}, True, True), + ], + ids=["enabled", "override_off", "missing_keeps_default"], +) +def test_fulfill_params_from_enterprise_enable_app_deploy( + monkeypatch: pytest.MonkeyPatch, + enterprise_info: dict, + initial: bool, + expected: bool, +): + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod(lambda: enterprise_info), + ) + + features = SystemFeatureModel() + features.enable_app_deploy = initial + + FeatureService._fulfill_params_from_enterprise(features) + + assert features.enable_app_deploy is expected diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 2e6ca7dbb9c..b81fb823949 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -375,19 +375,19 @@ class TestFileService: file_service.delete_file("file_id") # Should return without doing anything - @patch("services.file_service.db") - def test_get_upload_files_by_ids_empty(self, mock_db): - result = FileService.get_upload_files_by_ids("tenant_id", []) + def test_get_upload_files_by_ids_empty(self): + session = MagicMock() + result = FileService.get_upload_files_by_ids(session, "tenant_id", []) assert result == {} - @patch("services.file_service.db") - def test_get_upload_files_by_ids(self, mock_db): + def test_get_upload_files_by_ids(self): upload_file = MagicMock(spec=UploadFile) upload_file.id = "550e8400-e29b-41d4-a716-446655440000" upload_file.tenant_id = "tenant_id" - mock_db.session.scalars().all.return_value = [upload_file] + session = MagicMock() + session.scalars().all.return_value = [upload_file] - result = FileService.get_upload_files_by_ids("tenant_id", ["550e8400-e29b-41d4-a716-446655440000"]) + result = FileService.get_upload_files_by_ids(session, "tenant_id", ["550e8400-e29b-41d4-a716-446655440000"]) assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file def test_sanitize_zip_entry_name(self): 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 deleted file mode 100644 index fa0a6552042..00000000000 --- a/api/tests/unit_tests/services/test_hit_testing_service_dump_records.py +++ /dev/null @@ -1,100 +0,0 @@ -from datetime import datetime -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 - segment = payload.get("segment") - if isinstance(segment, dict): - record.segment = Mock() - record.segment.id = segment.get("id") - record.segment.document_id = segment.get("document_id") - record.segment.created_at = datetime(2024, 1, 1, 0, 0, 0) - else: - record.segment = None - 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": {"id": "segment-1", "document_id": None}, "score": 0.95}) - record.segment.document_id = None - - assert HitTestingService._dump_retrieval_records([record]) == [ - {"segment": {"id": "segment-1", "document_id": None}, "score": 0.95} - ] - - def test_dump_retrieval_records_injects_documents(self): - 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_with_document]) - - assert result[0]["segment"]["document"] == { - "id": "document-1", - "data_source_type": "upload_file", - "name": "guide.md", - "doc_type": None, - "doc_metadata": None, - } - - assert result[0]["segment"]["created_at"] == datetime(2024, 1, 1, 0, 0, 0) - - 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_file_upload_service.py b/api/tests/unit_tests/services/test_human_input_file_upload_service.py index c39453557cd..08e0ad139c8 100644 --- a/api/tests/unit_tests/services/test_human_input_file_upload_service.py +++ b/api/tests/unit_tests/services/test_human_input_file_upload_service.py @@ -15,7 +15,7 @@ from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormSt from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin from models.base import Base -from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.human_input import ( HumanInputForm, HumanInputFormRecipient, @@ -107,7 +107,7 @@ def _create_waiting_form( end_user = EndUser( tenant_id=tenant_id, app_id=app_id, - type="web_app", + type=EndUserType.BROWSER, is_anonymous=False, session_id="session-1", external_user_id="external-1", @@ -210,7 +210,7 @@ def test_issue_upload_token_persists_token_without_technical_end_user( assert token_model.form_id == form_id assert token_model.recipient_id == recipient_id assert token_model.token == token.upload_token - assert session.scalar(select(EndUser).where(EndUser.type == "human-input")) is None + assert session.scalar(select(EndUser).limit(1)) is None def test_validate_upload_token_returns_account_owner_and_record_file_link(session_maker) -> None: 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 01d918cd897..4c4abbbb8ec 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -81,7 +81,7 @@ def sample_form_record(): ) -def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factory): +def test_enqueue_resume_dispatches_task_for_workflow(mocker: MockerFixture, mock_session_factory): session_factory, session = mock_session_factory service = HumanInputService(session_factory) @@ -108,7 +108,9 @@ def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factor assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id" -def test_ensure_form_active_respects_global_timeout(monkeypatch, sample_form_record, mock_session_factory): +def test_ensure_form_active_respects_global_timeout( + monkeypatch, sample_form_record: HumanInputFormRecord, mock_session_factory +): session_factory, _ = mock_session_factory service = HumanInputService(session_factory) expired_record = dataclasses.replace( @@ -122,7 +124,7 @@ def test_ensure_form_active_respects_global_timeout(monkeypatch, sample_form_rec service.ensure_form_active(Form(expired_record)) -def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_factory): +def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker: MockerFixture, mock_session_factory): session_factory, session = mock_session_factory service = HumanInputService(session_factory) @@ -149,7 +151,7 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_f assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id" -def test_enqueue_resume_skips_unsupported_app_mode(mocker, mock_session_factory): +def test_enqueue_resume_skips_unsupported_app_mode(mocker: MockerFixture, mock_session_factory): session_factory, session = mock_session_factory service = HumanInputService(session_factory) @@ -174,7 +176,9 @@ def test_enqueue_resume_skips_unsupported_app_mode(mocker, mock_session_factory) resume_task.apply_async.assert_not_called() -def test_get_form_definition_by_token_for_console_uses_repository(sample_form_record, mock_session_factory): +def test_get_form_definition_by_token_for_console_uses_repository( + sample_form_record: HumanInputFormRecord, mock_session_factory +): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE) @@ -215,7 +219,9 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str) return context.dumps().encode() -def test_resolve_form_inputs_uses_runtime_select_options(sample_form_record, mock_session_factory, mocker): +def test_resolve_form_inputs_uses_runtime_select_options( + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture +): session_factory, _ = mock_session_factory configured_input = SelectInputConfig( output_variable_name="decision", @@ -253,7 +259,7 @@ def test_resolve_form_inputs_uses_runtime_select_options(sample_form_record, moc def test_submit_form_by_token_calls_repository_and_enqueue( - sample_form_record, mock_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture ): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) @@ -312,7 +318,7 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form( def test_submit_form_by_token_skips_enqueue_for_delivery_test( - sample_form_record, mock_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture ): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) @@ -337,7 +343,7 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test( def test_submit_form_by_token_passes_submission_user_id( - sample_form_record, mock_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture ): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) @@ -360,7 +366,7 @@ def test_submit_form_by_token_passes_submission_user_id( enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) -def test_submit_form_by_token_invalid_action(sample_form_record, mock_session_factory): +def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, mock_session_factory): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = dataclasses.replace(sample_form_record) @@ -378,7 +384,7 @@ def test_submit_form_by_token_invalid_action(sample_form_record, mock_session_fa repo.mark_submitted.assert_not_called() -def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_factory): +def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, mock_session_factory): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) @@ -522,7 +528,7 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload repo.mark_submitted.assert_not_called() -def test_form_properties(sample_form_record): +def test_form_properties(sample_form_record: HumanInputFormRecord): form = Form(sample_form_record) assert form.id == "form-id" assert form.workflow_run_id == "workflow-run-id" @@ -559,7 +565,7 @@ def test_get_form_by_token_none(mock_session_factory): assert service.get_form_by_token("invalid") is None -def test_get_form_definition_by_token_mismatch(sample_form_record, mock_session_factory): +def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, mock_session_factory): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record @@ -569,7 +575,7 @@ def test_get_form_definition_by_token_mismatch(sample_form_record, mock_session_ assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None -def test_get_form_definition_by_token_success(sample_form_record, mock_session_factory): +def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, mock_session_factory): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record @@ -580,7 +586,9 @@ def test_get_form_definition_by_token_success(sample_form_record, mock_session_f assert form.id == sample_form_record.form_id -def test_get_form_definition_by_token_for_console_mismatch(sample_form_record, mock_session_factory): +def test_get_form_definition_by_token_for_console_mismatch( + sample_form_record: HumanInputFormRecord, mock_session_factory +): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP @@ -599,7 +607,9 @@ 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: MockerFixture): +def test_submit_form_by_token_no_workflow_run_id( + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture +): session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record @@ -615,7 +625,7 @@ def test_submit_form_by_token_no_workflow_run_id(sample_form_record, mock_sessio enqueue_spy.assert_not_called() -def test_ensure_form_active_errors(sample_form_record, mock_session_factory): +def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, mock_session_factory): session_factory, _ = mock_session_factory service = HumanInputService(session_factory) @@ -637,7 +647,7 @@ def test_ensure_form_active_errors(sample_form_record, mock_session_factory): service.ensure_form_active(Form(expired_time_record)) -def test_ensure_not_submitted_raises(sample_form_record, mock_session_factory): +def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, mock_session_factory): session_factory, _ = mock_session_factory service = HumanInputService(session_factory) submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now()) @@ -646,7 +656,7 @@ def test_ensure_not_submitted_raises(sample_form_record, mock_session_factory): service._ensure_not_submitted(Form(submitted_record)) -def test_enqueue_resume_workflow_not_found(mocker, mock_session_factory): +def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, mock_session_factory): session_factory, _ = mock_session_factory service = HumanInputService(session_factory) @@ -662,7 +672,7 @@ def test_enqueue_resume_workflow_not_found(mocker, mock_session_factory): assert "WorkflowRun not found" in str(excinfo.value) -def test_enqueue_resume_app_not_found(mocker, mock_session_factory): +def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factory): session_factory, session = mock_session_factory service = HumanInputService(session_factory) @@ -683,7 +693,9 @@ def test_enqueue_resume_app_not_found(mocker, mock_session_factory): logger_spy.error.assert_called_once() -def test_is_globally_expired_zero_timeout(monkeypatch, sample_form_record, mock_session_factory): +def test_is_globally_expired_zero_timeout( + monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, mock_session_factory +): session_factory, _ = mock_session_factory service = HumanInputService(session_factory) @@ -691,7 +703,9 @@ def test_is_globally_expired_zero_timeout(monkeypatch, sample_form_record, mock_ assert service._is_globally_expired(Form(sample_form_record)) is False -def test_submit_form_by_token_normalizes_select_and_files(sample_form_record, mock_session_factory, mocker) -> None: +def test_submit_form_by_token_normalizes_select_and_files( + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture +) -> None: session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( @@ -772,7 +786,9 @@ def test_submit_form_by_token_normalizes_select_and_files(sample_form_record, mo enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) -def test_submit_form_by_token_invalid_select_value(sample_form_record, mock_session_factory) -> None: +def test_submit_form_by_token_invalid_select_value( + sample_form_record: HumanInputFormRecord, mock_session_factory +) -> None: session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( @@ -799,7 +815,9 @@ def test_submit_form_by_token_invalid_select_value(sample_form_record, mock_sess ) -def test_submit_form_by_token_invalid_file_list_item(sample_form_record, mock_session_factory) -> None: +def test_submit_form_by_token_invalid_file_list_item( + sample_form_record: HumanInputFormRecord, mock_session_factory +) -> None: session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( @@ -824,7 +842,9 @@ def test_submit_form_by_token_invalid_file_list_item(sample_form_record, mock_se ) -def test_submit_form_by_token_rejects_cross_tenant_file(sample_form_record, mock_session_factory, mocker) -> None: +def test_submit_form_by_token_rejects_cross_tenant_file( + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture +) -> None: session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( @@ -855,7 +875,9 @@ def test_submit_form_by_token_rejects_cross_tenant_file(sample_form_record, mock repo.mark_submitted.assert_not_called() -def test_submit_form_by_token_rejects_cross_tenant_file_list(sample_form_record, mock_session_factory, mocker) -> None: +def test_submit_form_by_token_rejects_cross_tenant_file_list( + sample_form_record: HumanInputFormRecord, mock_session_factory, mocker: MockerFixture +) -> None: session_factory, _ = mock_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( diff --git a/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py new file mode 100644 index 00000000000..287d787ad70 --- /dev/null +++ b/api/tests/unit_tests/services/test_knowledge_retrieval_inner_service.py @@ -0,0 +1,218 @@ +"""Unit tests for the inner knowledge retrieval service.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMetadata +from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest +from services.errors.knowledge_retrieval import ( + InnerKnowledgeRetrieveAppNotFoundError, + InnerKnowledgeRetrieveAppTenantMismatchError, + InnerKnowledgeRetrieveDatasetNotFoundError, + InnerKnowledgeRetrieveDatasetTenantMismatchError, +) +from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService + + +def _build_request(**overrides): + payload = { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "workflow", + }, + "dataset_ids": ["dataset-1", "dataset-2"], + "query": "how to reset password", + "retrieval": { + "mode": "multiple", + "top_k": 4, + "score_threshold": 0.25, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": { + "provider": "cohere", + "model": "rerank-english-v3.0", + }, + }, + "metadata_filtering": { + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "contains", + "value": "pricing", + } + ], + }, + }, + "attachment_ids": ["attachment-1"], + } + payload.update(overrides) + return InnerKnowledgeRetrieveRequest.model_validate(payload) + + +def _build_source() -> Source: + return Source( + metadata=SourceMetadata( + dataset_id="dataset-1", + dataset_name="Docs", + document_id="document-1", + document_name="FAQ.md", + data_source_type="upload_file", + ), + title="FAQ.md", + files=[], + content="Reset your password from settings.", + summary=None, + ) + + +class TestInnerKnowledgeRetrievalService: + @patch("services.knowledge_retrieval_inner_service.DatasetRetrieval") + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_maps_multiple_request_and_skips_enable_api_check(self, mock_db, mock_rag_cls): + request = _build_request() + mock_app = MagicMock(id="app-1", tenant_id="tenant-1") + dataset_1 = MagicMock(id="dataset-1", tenant_id="tenant-1", enable_api=False) + dataset_2 = MagicMock(id="dataset-2", tenant_id="tenant-1", enable_api=True) + mock_db.session.scalar.return_value = mock_app + mock_db.session.scalars.return_value.all.return_value = [dataset_1, dataset_2] + + rag = MagicMock() + rag.knowledge_retrieval.return_value = [_build_source()] + rag.llm_usage = { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "prompt_unit_price": "0", + "completion_unit_price": "0", + "prompt_price_unit": "0.001", + "completion_price_unit": "0.001", + "prompt_price": "0", + "completion_price": "0", + "total_price": "0", + "currency": "USD", + "latency": 0, + } + mock_rag_cls.return_value = rag + + response = InnerKnowledgeRetrievalService().retrieve(request) + + rag_request = rag.knowledge_retrieval.call_args.kwargs["request"] + assert rag_request.tenant_id == "tenant-1" + assert rag_request.app_id == "app-1" + assert rag_request.user_id == "user-1" + assert rag_request.dataset_ids == ["dataset-1", "dataset-2"] + assert rag_request.query == "how to reset password" + assert rag_request.retrieval_mode == "multiple" + assert rag_request.top_k == 4 + assert rag_request.score_threshold == 0.25 + assert rag_request.reranking_model == { + "reranking_provider_name": "cohere", + "reranking_model_name": "rerank-english-v3.0", + } + assert rag_request.metadata_filtering_mode == "manual" + assert rag_request.metadata_filtering_conditions is not None + metadata_conditions = rag_request.metadata_filtering_conditions.model_dump(mode="python") + assert metadata_conditions["logical_operator"] == "and" + assert metadata_conditions["conditions"] is not None + assert metadata_conditions["conditions"][0]["name"] == "category" + assert rag_request.attachment_ids == ["attachment-1"] + assert response.results[0].title == "FAQ.md" + assert response.usage.currency == "USD" + + @patch("services.knowledge_retrieval_inner_service.DatasetRetrieval") + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_maps_single_request(self, mock_db, mock_rag_cls): + request = _build_request( + dataset_ids=["dataset-1"], + retrieval={ + "mode": "single", + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0}, + }, + }, + metadata_filtering={ + "mode": "automatic", + "model_config": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0}, + }, + }, + attachment_ids=[], + ) + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1") + mock_db.session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")] + + rag = MagicMock() + rag.knowledge_retrieval.return_value = [] + rag.llm_usage = { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + "prompt_unit_price": "0", + "completion_unit_price": "0", + "prompt_price_unit": "0.001", + "completion_price_unit": "0.001", + "prompt_price": "0", + "completion_price": "0", + "total_price": "0", + "currency": "USD", + "latency": 1, + } + mock_rag_cls.return_value = rag + + InnerKnowledgeRetrievalService().retrieve(request) + + rag_request = rag.knowledge_retrieval.call_args.kwargs["request"] + assert rag_request.retrieval_mode == "single" + assert rag_request.model_provider == "openai" + assert rag_request.model_name == "gpt-4o-mini" + assert rag_request.model_mode == "chat" + assert rag_request.completion_params == {"temperature": 0} + assert rag_request.metadata_filtering_mode == "automatic" + assert rag_request.metadata_model_config is not None + assert rag_request.metadata_model_config.provider == "openai" + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_app_missing(self, mock_db): + mock_db.session.scalar.return_value = None + + with pytest.raises(InnerKnowledgeRetrieveAppNotFoundError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_app_belongs_to_other_tenant(self, mock_db): + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-2") + + with pytest.raises(InnerKnowledgeRetrieveAppTenantMismatchError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_dataset_missing(self, mock_db): + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1") + mock_db.session.scalars.return_value.all.return_value = [MagicMock(id="dataset-1", tenant_id="tenant-1")] + + with pytest.raises(InnerKnowledgeRetrieveDatasetNotFoundError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) + + @patch("services.knowledge_retrieval_inner_service.db") + def test_retrieve_raises_when_dataset_belongs_to_other_tenant(self, mock_db): + mock_db.session.scalar.return_value = MagicMock(id="app-1", tenant_id="tenant-1") + mock_db.session.scalars.return_value.all.return_value = [ + MagicMock(id="dataset-1", tenant_id="tenant-1"), + MagicMock(id="dataset-2", tenant_id="tenant-2"), + ] + + with pytest.raises(InnerKnowledgeRetrieveDatasetTenantMismatchError): + InnerKnowledgeRetrievalService().retrieve(_build_request()) diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index 005dcec886e..6588c8a8de6 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -90,7 +90,7 @@ class TestMessageServicePaginationByFirstId: return TestMessageServiceFactory() # Test 01: No user provided - def test_pagination_by_first_id_no_user(self, factory): + def test_pagination_by_first_id_no_user(self, factory: TestMessageServiceFactory): """Test pagination returns empty result when no user is provided.""" # Arrange app = factory.create_app_mock() @@ -111,7 +111,7 @@ class TestMessageServicePaginationByFirstId: assert result.has_more is False # Test 02: No conversation_id provided - def test_pagination_by_first_id_no_conversation(self, factory): + def test_pagination_by_first_id_no_conversation(self, factory: TestMessageServiceFactory): """Test pagination returns empty result when no conversation_id is provided.""" # Arrange app = factory.create_app_mock() @@ -137,7 +137,7 @@ class TestMessageServicePaginationByFirstId: @patch("services.message_service.db") @patch("services.message_service.ConversationService") def test_pagination_by_first_id_without_first_id_desc( - self, mock_conversation_service, mock_db, mock_create_repo, factory + self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory ): """Test basic pagination without first_id in descending order.""" # Arrange @@ -180,7 +180,7 @@ class TestMessageServicePaginationByFirstId: @patch("services.message_service.db") @patch("services.message_service.ConversationService") def test_pagination_by_first_id_without_first_id_asc( - self, mock_conversation_service, mock_db, mock_create_repo, factory + self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory ): """Test basic pagination without first_id in ascending order.""" # Arrange @@ -222,7 +222,9 @@ class TestMessageServicePaginationByFirstId: @patch("services.message_service._create_execution_extra_content_repository") @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_with_first_id(self, mock_conversation_service, mock_db, mock_create_repo, factory): + def test_pagination_by_first_id_with_first_id( + self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory + ): """Test pagination with first_id to get messages before a specific message.""" # Arrange app = factory.create_app_mock() @@ -265,7 +267,9 @@ class TestMessageServicePaginationByFirstId: # Test 06: First message not found @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_first_message_not_exists(self, mock_conversation_service, mock_db, factory): + def test_pagination_by_first_id_first_message_not_exists( + self, mock_conversation_service, mock_db, factory: TestMessageServiceFactory + ): """Test error handling when first_id doesn't exist.""" # Arrange app = factory.create_app_mock() @@ -290,7 +294,9 @@ class TestMessageServicePaginationByFirstId: @patch("services.message_service._create_execution_extra_content_repository") @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_has_more_true(self, mock_conversation_service, mock_db, mock_create_repo, factory): + def test_pagination_by_first_id_has_more_true( + self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory + ): """Test has_more flag is True when results exceed limit.""" # Arrange app = factory.create_app_mock() @@ -327,7 +333,9 @@ class TestMessageServicePaginationByFirstId: # Test 08: Empty conversation @patch("services.message_service.db") @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_empty_conversation(self, mock_conversation_service, mock_db, factory): + def test_pagination_by_first_id_empty_conversation( + self, mock_conversation_service, mock_db, factory: TestMessageServiceFactory + ): """Test pagination with conversation that has no messages.""" # Arrange app = factory.create_app_mock() @@ -370,7 +378,7 @@ class TestMessageServicePaginationByLastId: return TestMessageServiceFactory() # Test 09: No user provided - def test_pagination_by_last_id_no_user(self, factory): + def test_pagination_by_last_id_no_user(self, factory: TestMessageServiceFactory): """Test pagination returns empty result when no user is provided.""" # Arrange app = factory.create_app_mock() @@ -391,7 +399,7 @@ class TestMessageServicePaginationByLastId: # Test 10: Basic pagination without last_id @patch("services.message_service.db") - def test_pagination_by_last_id_without_last_id(self, mock_db, factory): + def test_pagination_by_last_id_without_last_id(self, mock_db, factory: TestMessageServiceFactory): """Test basic pagination without last_id.""" # Arrange app = factory.create_app_mock() @@ -422,7 +430,7 @@ class TestMessageServicePaginationByLastId: # Test 11: Pagination with last_id @patch("services.message_service.db") - def test_pagination_by_last_id_with_last_id(self, mock_db, factory): + def test_pagination_by_last_id_with_last_id(self, mock_db, factory: TestMessageServiceFactory): """Test pagination with last_id to get messages after a specific message.""" # Arrange app = factory.create_app_mock() @@ -459,7 +467,7 @@ class TestMessageServicePaginationByLastId: # Test 12: Last message not found @patch("services.message_service.db") - def test_pagination_by_last_id_last_message_not_exists(self, mock_db, factory): + def test_pagination_by_last_id_last_message_not_exists(self, mock_db, factory: TestMessageServiceFactory): """Test error handling when last_id doesn't exist.""" # Arrange app = factory.create_app_mock() @@ -479,7 +487,9 @@ class TestMessageServicePaginationByLastId: # Test 13: Pagination with conversation_id filter @patch("services.message_service.ConversationService") @patch("services.message_service.db") - def test_pagination_by_last_id_with_conversation_filter(self, mock_db, mock_conversation_service, factory): + def test_pagination_by_last_id_with_conversation_filter( + self, mock_db, mock_conversation_service, factory: TestMessageServiceFactory + ): """Test pagination filtered by conversation_id.""" # Arrange app = factory.create_app_mock() @@ -515,7 +525,7 @@ class TestMessageServicePaginationByLastId: # Test 14: Pagination with include_ids filter @patch("services.message_service.db") - def test_pagination_by_last_id_with_include_ids(self, mock_db, factory): + def test_pagination_by_last_id_with_include_ids(self, mock_db, factory: TestMessageServiceFactory): """Test pagination filtered by include_ids.""" # Arrange app = factory.create_app_mock() @@ -545,7 +555,7 @@ class TestMessageServicePaginationByLastId: # Test 15: Has_more flag when results exceed limit @patch("services.message_service.db") - def test_pagination_by_last_id_has_more_true(self, mock_db, factory): + def test_pagination_by_last_id_has_more_true(self, mock_db, factory: TestMessageServiceFactory): """Test has_more flag is True when results exceed limit.""" # Arrange app = factory.create_app_mock() @@ -592,7 +602,7 @@ class TestMessageServiceUtilities: # Test 17: attach_message_extra_contents with messages @patch("services.message_service._create_execution_extra_content_repository") - def test_attach_message_extra_contents_with_messages(self, mock_create_repo, factory): + def test_attach_message_extra_contents_with_messages(self, mock_create_repo, factory: TestMessageServiceFactory): """Test attach_message_extra_contents correctly attaches content.""" # Arrange messages = [factory.create_message_mock(message_id="msg-1"), factory.create_message_mock(message_id="msg-2")] @@ -618,7 +628,9 @@ class TestMessageServiceUtilities: # Test 18: attach_message_extra_contents with index out of bounds @patch("services.message_service._create_execution_extra_content_repository") - def test_attach_message_extra_contents_index_out_of_bounds(self, mock_create_repo, factory): + def test_attach_message_extra_contents_index_out_of_bounds( + self, mock_create_repo, factory: TestMessageServiceFactory + ): """Test attach_message_extra_contents handles missing content lists.""" # Arrange messages = [factory.create_message_mock(message_id="msg-1")] @@ -659,7 +671,7 @@ class TestMessageServiceGetMessage: # Test 20: get_message success for EndUser @patch("services.message_service.db") - def test_get_message_end_user_success(self, mock_db, factory): + def test_get_message_end_user_success(self, mock_db, factory: TestMessageServiceFactory): """Test get_message returns message for EndUser.""" # Arrange app = factory.create_app_mock() @@ -676,7 +688,7 @@ class TestMessageServiceGetMessage: # Test 21: get_message success for Account (Admin) @patch("services.message_service.db") - def test_get_message_account_success(self, mock_db, factory): + def test_get_message_account_success(self, mock_db, factory: TestMessageServiceFactory): """Test get_message returns message for Account.""" # Arrange from models import Account @@ -696,7 +708,7 @@ class TestMessageServiceGetMessage: # Test 22: get_message not found @patch("services.message_service.db") - def test_get_message_not_found(self, mock_db, factory): + def test_get_message_not_found(self, mock_db, factory: TestMessageServiceFactory): """Test get_message raises MessageNotExistsError when not found.""" # Arrange app = factory.create_app_mock() @@ -720,7 +732,7 @@ class TestMessageServiceFeedback: # Test 23: create_feedback - new feedback for EndUser @patch("services.message_service.db") @patch.object(MessageService, "get_message") - def test_create_feedback_new_end_user(self, mock_get_message, mock_db, factory): + def test_create_feedback_new_end_user(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): """Test creating new feedback for an end user.""" # Arrange app = factory.create_app_mock() @@ -748,7 +760,7 @@ class TestMessageServiceFeedback: # Test 24: create_feedback - update feedback for Account @patch("services.message_service.db") @patch.object(MessageService, "get_message") - def test_create_feedback_update_account(self, mock_get_message, mock_db, factory): + def test_create_feedback_update_account(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): """Test updating existing feedback for an account.""" # Arrange from models import Account, MessageFeedback @@ -779,7 +791,7 @@ class TestMessageServiceFeedback: # Test 25: create_feedback - delete feedback (rating is None) @patch("services.message_service.db") @patch.object(MessageService, "get_message") - def test_create_feedback_delete(self, mock_get_message, mock_db, factory): + def test_create_feedback_delete(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): """Test deleting feedback by passing rating=None.""" # Arrange app = factory.create_app_mock() @@ -805,7 +817,7 @@ class TestMessageServiceFeedback: # Test 26: get_all_messages_feedbacks @patch("services.message_service.db") - def test_get_all_messages_feedbacks(self, mock_db, factory): + def test_get_all_messages_feedbacks(self, mock_db, factory: TestMessageServiceFactory): """Test get_all_messages_feedbacks returns list of dicts.""" # Arrange app = factory.create_app_mock() @@ -830,7 +842,7 @@ class TestMessageServiceSuggestedQuestions: return TestMessageServiceFactory() # Test 27: get_suggested_questions_after_answer - user is None - def test_get_suggested_questions_user_none(self, factory): + def test_get_suggested_questions_user_none(self, factory: TestMessageServiceFactory): app = factory.create_app_mock() with pytest.raises(ValueError, match="user cannot be None"): MessageService.get_suggested_questions_after_answer( @@ -856,7 +868,7 @@ class TestMessageServiceSuggestedQuestions: mock_config_manager, mock_workflow_service, mock_model_manager, - factory, + factory: TestMessageServiceFactory, ): """Test successful suggested questions generation in Advanced Chat mode.""" from core.app.entities.app_invoke_entities import InvokeFrom @@ -896,14 +908,14 @@ class TestMessageServiceSuggestedQuestions: @patch("services.message_service.ConversationService") def test_get_suggested_questions_chat_app_success( self, - mock_conversation_service, - mock_get_message, - mock_trace_manager, - mock_llm_gen, - mock_memory, - mock_model_manager, - mock_db, - factory, + mock_conversation_service: MagicMock, + mock_get_message: MagicMock, + mock_trace_manager: MagicMock, + mock_llm_gen: MagicMock, + mock_memory: MagicMock, + mock_model_manager: MagicMock, + mock_db: MagicMock, + factory: TestMessageServiceFactory, ): """Test successful suggested questions generation in basic Chat mode.""" # Arrange @@ -942,14 +954,14 @@ class TestMessageServiceSuggestedQuestions: @patch("services.message_service.ConversationService") def test_get_suggested_questions_chat_app_uses_frontend_model_and_prompt( self, - mock_conversation_service, - mock_get_message, - mock_trace_manager, - mock_llm_gen, - mock_memory, - mock_model_manager, - mock_db, - factory, + mock_conversation_service: MagicMock, + mock_get_message: MagicMock, + mock_trace_manager: MagicMock, + mock_llm_gen: MagicMock, + mock_memory: MagicMock, + mock_model_manager: MagicMock, + mock_db: MagicMock, + factory: TestMessageServiceFactory, ): """Test suggested question generation uses frontend configured model and prompt.""" from core.app.entities.app_invoke_entities import InvokeFrom @@ -1015,14 +1027,14 @@ class TestMessageServiceSuggestedQuestions: @patch("services.message_service.ConversationService") def test_get_suggested_questions_chat_app_invalid_frontend_model_fallback_to_default( self, - mock_conversation_service, - mock_get_message, - mock_trace_manager, - mock_llm_gen, - mock_memory, - mock_model_manager, - mock_db, - factory, + mock_conversation_service: MagicMock, + mock_get_message: MagicMock, + mock_trace_manager: MagicMock, + mock_llm_gen: MagicMock, + mock_memory: MagicMock, + mock_model_manager: MagicMock, + mock_db: MagicMock, + factory: TestMessageServiceFactory, ): """Test invalid frontend configured model falls back to tenant default model.""" app = factory.create_app_mock(mode=AppMode.CHAT) @@ -1066,14 +1078,14 @@ class TestMessageServiceSuggestedQuestions: @patch("services.message_service.ConversationService") def test_get_suggested_questions_chat_app_uses_compatible_override_model_config( self, - mock_conversation_service, - mock_get_message, - mock_trace_manager, - mock_llm_gen, - mock_memory, - mock_model_manager, - mock_db, - factory, + mock_conversation_service: MagicMock, + mock_get_message: MagicMock, + mock_trace_manager: MagicMock, + mock_llm_gen: MagicMock, + mock_memory: MagicMock, + mock_model_manager: MagicMock, + mock_db: MagicMock, + factory: TestMessageServiceFactory, ): """Test legacy override configs are normalized before suggested questions reads them.""" app = factory.create_app_mock(mode=AppMode.CHAT) @@ -1174,7 +1186,12 @@ class TestMessageServiceSuggestedQuestions: @patch.object(MessageService, "get_message") @patch("services.message_service.ConversationService") def test_get_suggested_questions_disabled_error( - self, mock_conversation_service, mock_get_message, mock_config_manager, mock_workflow_service, factory + self, + mock_conversation_service, + mock_get_message, + mock_config_manager, + mock_workflow_service, + factory: TestMessageServiceFactory, ): """Test SuggestedQuestionsAfterAnswerDisabledError is raised when feature is disabled.""" # Arrange 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 806be013497..a8a976f4b07 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -215,12 +215,13 @@ class TestModelProviderServiceDelegation: get_provider_config_mock.assert_called_once_with("tenant-1", "openai") provider_method = getattr(provider_configuration, provider_method_name) - if isinstance(provider_call_kwargs, tuple): - provider_method.assert_called_once_with(*provider_call_kwargs) - elif isinstance(provider_call_kwargs, dict): - provider_method.assert_called_once_with(**provider_call_kwargs) - else: - provider_method.assert_called_once_with(provider_call_kwargs) + match provider_call_kwargs: + case tuple(): + provider_method.assert_called_once_with(*provider_call_kwargs) + case dict(): + provider_method.assert_called_once_with(**provider_call_kwargs) + case _: + provider_method.assert_called_once_with(provider_call_kwargs) if method_name == "get_provider_credential": assert result == {"token": "abc"} diff --git a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py index cfc685e4cbd..305045cb6ee 100644 --- a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py @@ -1,5 +1,6 @@ import json -from unittest.mock import Mock, patch +import logging +from unittest.mock import MagicMock, Mock, patch import pytest @@ -129,7 +130,7 @@ class TestRagPipelineTaskProxy: assert proxy._rag_pipeline_invoke_entities[2].pipeline_id == "pipeline-3" @patch("services.rag_pipeline.rag_pipeline_task_proxy.FeatureService") - def test_features_property(self, mock_feature_service): + def test_features_property(self, mock_feature_service: MagicMock): """Test cached_property features.""" # Arrange mock_features = RagPipelineTaskProxyTestDataFactory.create_mock_features() @@ -148,7 +149,7 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_upload_invoke_entities(self, mock_db, mock_file_service_class): + def test_upload_invoke_entities(self, mock_db: MagicMock, mock_file_service_class: MagicMock): """Test _upload_invoke_entities method.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -180,7 +181,9 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_upload_invoke_entities_with_multiple_entities(self, mock_db, mock_file_service_class): + def test_upload_invoke_entities_with_multiple_entities( + self, mock_db: MagicMock, mock_file_service_class: MagicMock + ): """Test _upload_invoke_entities method with multiple entities.""" # Arrange entities = [ @@ -208,7 +211,7 @@ class TestRagPipelineTaskProxy: assert parsed_json[1]["pipeline_id"] == "pipeline-2" @patch("services.rag_pipeline.rag_pipeline_task_proxy.rag_pipeline_run_task") - def test_send_to_direct_queue(self, mock_task): + def test_send_to_direct_queue(self, mock_task: MagicMock): """Test _send_to_direct_queue method.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -228,7 +231,7 @@ class TestRagPipelineTaskProxy: ) @patch("services.rag_pipeline.rag_pipeline_task_proxy.rag_pipeline_run_task") - def test_send_to_tenant_queue_with_existing_task_key(self, mock_task): + def test_send_to_tenant_queue_with_existing_task_key(self, mock_task: MagicMock): """Test _send_to_tenant_queue when task key exists.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -247,7 +250,7 @@ class TestRagPipelineTaskProxy: mock_task.delay.assert_not_called() @patch("services.rag_pipeline.rag_pipeline_task_proxy.rag_pipeline_run_task") - def test_send_to_tenant_queue_without_task_key(self, mock_task): + def test_send_to_tenant_queue_without_task_key(self, mock_task: MagicMock): """Test _send_to_tenant_queue when no task key exists.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -270,7 +273,7 @@ class TestRagPipelineTaskProxy: proxy._tenant_isolated_task_queue.push_tasks.assert_not_called() @patch("services.rag_pipeline.rag_pipeline_task_proxy.rag_pipeline_run_task") - def test_send_to_default_tenant_queue(self, mock_task): + def test_send_to_default_tenant_queue(self, mock_task: MagicMock): """Test _send_to_default_tenant_queue method.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -284,7 +287,7 @@ class TestRagPipelineTaskProxy: proxy._send_to_tenant_queue.assert_called_once_with(upload_file_id, mock_task) @patch("services.rag_pipeline.rag_pipeline_task_proxy.priority_rag_pipeline_run_task") - def test_send_to_priority_tenant_queue(self, mock_task): + def test_send_to_priority_tenant_queue(self, mock_task: MagicMock): """Test _send_to_priority_tenant_queue method.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -298,7 +301,7 @@ class TestRagPipelineTaskProxy: proxy._send_to_tenant_queue.assert_called_once_with(upload_file_id, mock_task) @patch("services.rag_pipeline.rag_pipeline_task_proxy.priority_rag_pipeline_run_task") - def test_send_to_priority_direct_queue(self, mock_task): + def test_send_to_priority_direct_queue(self, mock_task: MagicMock): """Test _send_to_priority_direct_queue method.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -314,7 +317,9 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FeatureService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_dispatch_with_billing_enabled_sandbox_plan(self, mock_db, mock_file_service_class, mock_feature_service): + def test_dispatch_with_billing_enabled_sandbox_plan( + self, mock_db: MagicMock, mock_file_service_class: MagicMock, mock_feature_service: MagicMock + ): """Test _dispatch method when billing is enabled with sandbox plan.""" # Arrange mock_features = RagPipelineTaskProxyTestDataFactory.create_mock_features( @@ -364,7 +369,9 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FeatureService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_dispatch_with_billing_disabled(self, mock_db, mock_file_service_class, mock_feature_service): + def test_dispatch_with_billing_disabled( + self, mock_db: MagicMock, mock_file_service_class: MagicMock, mock_feature_service: MagicMock + ): """Test _dispatch method when billing is disabled.""" # Arrange mock_features = RagPipelineTaskProxyTestDataFactory.create_mock_features(billing_enabled=False) @@ -385,7 +392,7 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_dispatch_with_empty_upload_file_id(self, mock_db, mock_file_service_class): + def test_dispatch_with_empty_upload_file_id(self, mock_db: MagicMock, mock_file_service_class: MagicMock): """Test _dispatch method when upload_file_id is empty.""" # Arrange proxy = RagPipelineTaskProxyTestDataFactory.create_rag_pipeline_task_proxy() @@ -403,7 +410,9 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FeatureService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_dispatch_edge_case_empty_plan(self, mock_db, mock_file_service_class, mock_feature_service): + def test_dispatch_edge_case_empty_plan( + self, mock_db: MagicMock, mock_file_service_class: MagicMock, mock_feature_service: MagicMock + ): """Test _dispatch method with empty plan string.""" # Arrange mock_features = RagPipelineTaskProxyTestDataFactory.create_mock_features(billing_enabled=True, plan="") @@ -425,7 +434,9 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FeatureService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_dispatch_edge_case_none_plan(self, mock_db, mock_file_service_class, mock_feature_service): + def test_dispatch_edge_case_none_plan( + self, mock_db: MagicMock, mock_file_service_class: MagicMock, mock_feature_service: MagicMock + ): """Test _dispatch method with None plan.""" # Arrange mock_features = RagPipelineTaskProxyTestDataFactory.create_mock_features(billing_enabled=True, plan=None) @@ -447,7 +458,9 @@ class TestRagPipelineTaskProxy: @patch("services.rag_pipeline.rag_pipeline_task_proxy.FeatureService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService") @patch("services.rag_pipeline.rag_pipeline_task_proxy.db") - def test_delay_method(self, mock_db, mock_file_service_class, mock_feature_service): + def test_delay_method( + self, mock_db: MagicMock, mock_file_service_class: MagicMock, mock_feature_service: MagicMock + ): """Test delay method integration.""" # Arrange mock_features = RagPipelineTaskProxyTestDataFactory.create_mock_features( @@ -468,16 +481,14 @@ class TestRagPipelineTaskProxy: # Assert proxy._dispatch.assert_called_once() - @patch("services.rag_pipeline.rag_pipeline_task_proxy.logger") - def test_delay_method_with_empty_entities(self, mock_logger): + def test_delay_method_with_empty_entities(self, caplog: pytest.LogCaptureFixture): """Test delay method with empty rag_pipeline_invoke_entities.""" # Arrange proxy = RagPipelineTaskProxy("tenant-123", "user-456", []) # Act - proxy.delay() + with caplog.at_level(logging.WARNING, logger="services.rag_pipeline.rag_pipeline_task_proxy"): + proxy.delay() # Assert - mock_logger.warning.assert_called_once_with( - "Received empty rag pipeline invoke entities, no tasks delivered: %s %s", "tenant-123", "user-456" - ) + assert "Received empty rag pipeline invoke entities, no tasks delivered: tenant-123 user-456" in caplog.text diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py index 4866c570fdf..c155d3f8330 100644 --- a/api/tests/unit_tests/services/test_snippet_dsl_service.py +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -73,7 +73,7 @@ def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None: assert result.error == "Invalid URL scheme, only http and https are allowed" -def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch) -> None: +def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None: service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", @@ -90,7 +90,7 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch) -> assert result.error == "Failed to fetch YAML from URL: 404" -def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch) -> None: +def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.MonkeyPatch) -> None: service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3) monkeypatch.setattr( @@ -108,7 +108,7 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch) -> None: assert "YAML content size exceeds maximum limit" in result.error -def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch) -> None: +def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: pytest.MonkeyPatch) -> None: service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr( "services.snippet_dsl_service.ssrf_proxy.get", @@ -125,7 +125,7 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch) - assert result.error == "Failed to fetch YAML from URL: network down" -def test_import_snippet_rejects_oversized_yaml_content(monkeypatch) -> None: +def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.MonkeyPatch) -> None: service = SnippetDslService(session=SimpleNamespace()) monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3) diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index 7cbe773e419..16e1e58baae 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -94,14 +94,15 @@ def test_validate_snippet_graph_forbidden_nodes_raises_with_node_details() -> No def test_get_snippets_returns_empty_when_tag_filter_has_no_targets(monkeypatch: pytest.MonkeyPatch) -> None: + session = _SessionWithoutNameLookup() get_target_ids = Mock(return_value=[]) monkeypatch.setattr("services.snippet_service.TagService.get_target_ids_by_tag_ids", get_target_ids) service = SnippetService.__new__(SnippetService) - result = service.get_snippets(tenant_id="tenant-1", tag_ids=["tag-1"]) + result = service.get_snippets(tenant_id="tenant-1", session=session, tag_ids=["tag-1"]) assert result == ([], 0, False) - get_target_ids.assert_called_once_with("snippet", "tenant-1", ["tag-1"], match_all=True) + get_target_ids.assert_called_once_with("snippet", "tenant-1", ["tag-1"], session, match_all=True) def test_get_snippets_applies_filters_and_paginates(monkeypatch: pytest.MonkeyPatch) -> None: @@ -124,6 +125,7 @@ def test_get_snippets_applies_filters_and_paginates(monkeypatch: pytest.MonkeyPa result, total, has_more = service.get_snippets( tenant_id="tenant-1", + session=session, page=2, limit=2, keyword="search", @@ -135,7 +137,7 @@ def test_get_snippets_applies_filters_and_paginates(monkeypatch: pytest.MonkeyPa assert result == snippets[:2] assert total == 3 assert has_more is True - get_target_ids.assert_called_once_with("snippet", "tenant-1", ["tag-1"], match_all=True) + get_target_ids.assert_called_once_with("snippet", "tenant-1", ["tag-1"], session, match_all=True) session.scalar.assert_called_once() session.scalars.assert_called_once() @@ -527,7 +529,7 @@ def test_delete_snippet_removes_related_records() -> None: session.delete.assert_called_once_with(snippet) -def test_delete_draft_variable_files_removes_storage_objects(monkeypatch) -> None: +def test_delete_draft_variable_files_removes_storage_objects(monkeypatch: pytest.MonkeyPatch) -> None: from extensions.ext_storage import storage snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") @@ -552,7 +554,7 @@ def test_delete_draft_variable_files_removes_storage_objects(monkeypatch) -> Non assert "workflow_draft_variable_files" in executed_sql -def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch) -> None: +def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch: pytest.MonkeyPatch) -> None: from configs import dify_config snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") diff --git a/api/tests/unit_tests/services/test_tag_service.py b/api/tests/unit_tests/services/test_tag_service.py index 282b32a7e55..73df7cc2673 100644 --- a/api/tests/unit_tests/services/test_tag_service.py +++ b/api/tests/unit_tests/services/test_tag_service.py @@ -1,6 +1,7 @@ from types import SimpleNamespace import pytest +from pytest_mock import MockerFixture from werkzeug.exceptions import NotFound from models.enums import TagType @@ -8,19 +9,19 @@ from services.tag_service import TagBindingCreatePayload, TagBindingDeletePayloa @pytest.fixture -def current_user(mocker): +def current_user(mocker: MockerFixture): user = SimpleNamespace(id="user-1", current_tenant_id="tenant-1") mocker.patch("services.tag_service.current_user", user) return user @pytest.fixture -def db_session(mocker): - mock_db = mocker.patch("services.tag_service.db") +def db_session(mocker: MockerFixture): + mock_db = mocker.Mock() return mock_db.session -def test_save_tag_binding_only_creates_bindings_for_valid_snippet_tags(mocker, current_user, db_session): +def test_save_tag_binding_only_creates_bindings_for_valid_snippet_tags(mocker: MockerFixture, current_user, db_session): mocker.patch("services.tag_service.TagService.check_target_exists") db_session.scalars.return_value.all.return_value = ["tag-1"] db_session.scalar.return_value = None @@ -30,7 +31,8 @@ def test_save_tag_binding_only_creates_bindings_for_valid_snippet_tags(mocker, c tag_ids=["tag-1", "tag-from-other-tenant"], target_id="snippet-1", type=TagType.SNIPPET, - ) + ), + db_session, ) db_session.add.assert_called_once() @@ -42,7 +44,7 @@ def test_save_tag_binding_only_creates_bindings_for_valid_snippet_tags(mocker, c db_session.commit.assert_called_once() -def test_delete_tag_binding_limits_deletion_to_valid_snippet_tags(mocker, current_user, db_session): +def test_delete_tag_binding_limits_deletion_to_valid_snippet_tags(mocker: MockerFixture, current_user, db_session): mocker.patch("services.tag_service.TagService.check_target_exists") db_session.execute.return_value = SimpleNamespace(rowcount=1) @@ -51,14 +53,15 @@ def test_delete_tag_binding_limits_deletion_to_valid_snippet_tags(mocker, curren tag_ids=["tag-1", "tag-from-other-tenant"], target_id="snippet-1", type=TagType.SNIPPET, - ) + ), + db_session, ) db_session.execute.assert_called_once() db_session.commit.assert_called_once() -def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker, current_user, db_session): +def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker: MockerFixture, current_user, db_session): mocker.patch("services.tag_service.TagService.check_target_exists") db_session.execute.return_value = SimpleNamespace(rowcount=0) @@ -67,7 +70,8 @@ def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker, current tag_ids=["tag-1"], target_id="snippet-1", type=TagType.SNIPPET, - ) + ), + db_session, ) db_session.execute.assert_called_once() @@ -75,7 +79,7 @@ def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker, current def test_get_target_ids_by_tag_ids_returns_empty_without_query_for_empty_input(db_session): - result = TagService.get_target_ids_by_tag_ids(TagType.SNIPPET, "tenant-1", []) + result = TagService.get_target_ids_by_tag_ids(TagType.SNIPPET, "tenant-1", [], db_session) assert result == [] db_session.scalars.assert_not_called() @@ -84,7 +88,7 @@ def test_get_target_ids_by_tag_ids_returns_empty_without_query_for_empty_input(d def test_check_target_exists_accepts_existing_snippet(current_user, db_session): db_session.scalar.return_value = SimpleNamespace(id="snippet-1") - TagService.check_target_exists("snippet", "snippet-1") + TagService.check_target_exists("snippet", "snippet-1", db_session) db_session.scalar.assert_called_once() @@ -93,11 +97,11 @@ def test_check_target_exists_raises_when_snippet_missing(current_user, db_sessio db_session.scalar.return_value = None with pytest.raises(NotFound, match="Snippet not found"): - TagService.check_target_exists("snippet", "missing-snippet") + TagService.check_target_exists("snippet", "missing-snippet", db_session) def test_check_target_exists_raises_for_invalid_binding_type(current_user, db_session): with pytest.raises(NotFound, match="Invalid binding type"): - TagService.check_target_exists("unknown", "target-1") + TagService.check_target_exists("unknown", "target-1", db_session) db_session.scalar.assert_not_called() 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 4da4af2d939..a47d946bab0 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import contextlib import json +import logging from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -252,27 +253,28 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached mock_session: MagicMock, provider_id: TriggerProviderID, provider_controller: MagicMock, + caplog, ) -> None: # Arrange _patch_redis_lock(mocker) mock_session.scalar.return_value = TriggerProviderService.__MAX_TRIGGER_PROVIDER_COUNT__ _mock_get_trigger_provider(mocker, provider_controller) - mock_logger = mocker.patch("services.trigger.trigger_provider_service.logger") # Act + Assert - with pytest.raises(ValueError, match="Maximum number of providers"): - TriggerProviderService.add_trigger_subscription( - tenant_id="tenant-1", - user_id="user-1", - name="main", - provider_id=provider_id, - endpoint_id="endpoint-1", - credential_type=CredentialType.API_KEY, - parameters={}, - properties={}, - credentials={}, - ) - mock_logger.exception.assert_called_once() + with caplog.at_level(logging.ERROR, logger="services.trigger.trigger_provider_service"): + with pytest.raises(ValueError, match="Maximum number of providers"): + TriggerProviderService.add_trigger_subscription( + tenant_id="tenant-1", + user_id="user-1", + name="main", + provider_id=provider_id, + endpoint_id="endpoint-1", + credential_type=CredentialType.API_KEY, + parameters={}, + properties={}, + credentials={}, + ) + assert sum(1 for r in caplog.records if r.levelno >= logging.ERROR) == 1 def test_add_trigger_subscription_should_raise_error_when_name_exists( @@ -558,6 +560,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials( return_value=(cred_enc, cache), ) mocker.patch.object(TriggerProviderService, "get_oauth_client", return_value={"client_id": "id"}) + mock_delete_cache = mocker.patch("services.trigger.trigger_provider_service.delete_cache_for_subscription") refreshed = SimpleNamespace(credentials={"access_token": "new"}, expires_at=12345) oauth_handler = MagicMock() oauth_handler.refresh_credentials.return_value = refreshed @@ -571,7 +574,12 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials( assert subscription.credentials == {"access_token": "new"} assert subscription.credential_expires_at == 12345 - cache.delete.assert_called_once() + cache.delete.assert_not_called() + mock_delete_cache.assert_called_once_with( + tenant_id="tenant-1", + provider_id=str(provider_id), + subscription_id="sub-1", + ) def test_refresh_subscription_should_raise_error_when_subscription_missing( diff --git a/api/tests/unit_tests/services/test_vector_service.py b/api/tests/unit_tests/services/test_vector_service.py index a78a033f4d3..e6cc59144b3 100644 --- a/api/tests/unit_tests/services/test_vector_service.py +++ b/api/tests/unit_tests/services/test_vector_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from dataclasses import dataclass from typing import Any from unittest.mock import MagicMock @@ -268,7 +269,7 @@ def test_create_segments_vector_parent_child_uses_default_embedding_model_when_p def test_create_segments_vector_parent_child_missing_document_logs_warning_and_continues( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: dataset = _make_dataset(doc_form=vector_service_module.IndexStructureType.PARENT_CHILD_INDEX) segment = _make_segment() @@ -280,18 +281,16 @@ def test_create_segments_vector_parent_child_missing_document_logs_warning_and_c _mock_parent_child_queries(dataset_document=None, processing_rule=processing_rule), ) - logger_mock = MagicMock() - monkeypatch.setattr(vector_service_module, "logger", logger_mock) - index_processor = MagicMock() factory_instance = MagicMock() factory_instance.init_index_processor.return_value = index_processor monkeypatch.setattr(vector_service_module, "IndexProcessorFactory", MagicMock(return_value=factory_instance)) - VectorService.create_segments_vector( - None, [segment], dataset, vector_service_module.IndexStructureType.PARENT_CHILD_INDEX - ) - logger_mock.warning.assert_called_once() + with caplog.at_level(logging.WARNING, logger="services.vector_service"): + VectorService.create_segments_vector( + None, [segment], dataset, vector_service_module.IndexStructureType.PARENT_CHILD_INDEX + ) + assert "Expected DatasetDocument record to exist, but none was found" in caplog.text index_processor.load.assert_not_called() @@ -615,7 +614,7 @@ def test_update_multimodel_vector_commits_when_no_upload_files_found(monkeypatch def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_upload_files( - monkeypatch: pytest.MonkeyPatch, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True) segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}]) @@ -630,12 +629,10 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up monkeypatch.setattr(vector_service_module, "delete", MagicMock()) monkeypatch.setattr(vector_service_module, "select", MagicMock()) - logger_mock = MagicMock() - monkeypatch.setattr(vector_service_module, "logger", logger_mock) + with caplog.at_level(logging.WARNING, logger="services.vector_service"): + VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1", "missing"], dataset=dataset) - VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1", "missing"], dataset=dataset) - - logger_mock.warning.assert_called_once() + assert "Upload file not found for attachment_id" in caplog.text db_mock.session.add_all.assert_called_once() bindings = db_mock.session.add_all.call_args.args[0] assert len(bindings) == 1 @@ -673,7 +670,9 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops db_mock.session.commit.assert_called_once() -def test_update_multimodel_vector_rolls_back_and_reraises_on_error(monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_multimodel_vector_rolls_back_and_reraises_on_error( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True) segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}]) @@ -688,11 +687,11 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(monkeypatch: monkeypatch.setattr(vector_service_module, "delete", MagicMock()) monkeypatch.setattr(vector_service_module, "select", MagicMock()) - logger_mock = MagicMock() - monkeypatch.setattr(vector_service_module, "logger", logger_mock) + with caplog.at_level(logging.ERROR, logger="services.vector_service"): + with pytest.raises(RuntimeError, match="boom"): + VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset) - with pytest.raises(RuntimeError, match="boom"): - VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset) - - logger_mock.exception.assert_called_once() + exception_records = [r for r in caplog.records if r.levelname == "ERROR"] + assert len(exception_records) == 1 + assert "Failed to update multimodal vector for segment" in exception_records[0].getMessage() db_mock.session.rollback.assert_called_once() diff --git a/api/tests/unit_tests/services/test_workflow_generator_service.py b/api/tests/unit_tests/services/test_workflow_generator_service.py index e1d3a488be3..184cd5f29e5 100644 --- a/api/tests/unit_tests/services/test_workflow_generator_service.py +++ b/api/tests/unit_tests/services/test_workflow_generator_service.py @@ -30,10 +30,10 @@ class TestWorkflowGeneratorService: @patch("services.workflow_generator_service.format_tool_catalogue") def test_forwards_model_instance_and_catalogue_text_to_generator( self, - mock_format_catalogue, - mock_build_catalogue, - mock_model_manager, - mock_workflow_generator, + mock_format_catalogue: MagicMock, + mock_build_catalogue: MagicMock, + mock_model_manager: MagicMock, + mock_workflow_generator: MagicMock, ): """Happy path: model_instance + catalogue text + payload flow through.""" # Arrange @@ -110,10 +110,10 @@ class TestWorkflowGeneratorService: @patch("services.workflow_generator_service.format_tool_catalogue") def test_defaults_ideal_output_to_empty_string( self, - mock_format_catalogue, - mock_build_catalogue, - mock_model_manager, - mock_workflow_generator, + mock_format_catalogue: MagicMock, + mock_build_catalogue: MagicMock, + mock_model_manager: MagicMock, + mock_workflow_generator: MagicMock, ): """Callers can omit ideal_output; the runner should still receive "".""" mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock() @@ -142,10 +142,10 @@ class TestWorkflowGeneratorService: @patch("services.workflow_generator_service.format_tool_catalogue") def test_forwards_current_graph_for_refine( self, - mock_format_catalogue, - mock_build_catalogue, - mock_model_manager, - mock_workflow_generator, + mock_format_catalogue: MagicMock, + mock_build_catalogue: MagicMock, + mock_model_manager: MagicMock, + mock_workflow_generator: MagicMock, ): """The cmd+k `/refine` path passes the existing draft graph through to the runner.""" mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock() @@ -175,10 +175,10 @@ class TestWorkflowGeneratorService: @patch("services.workflow_generator_service.format_tool_catalogue") def test_defaults_current_graph_to_none_for_create( self, - mock_format_catalogue, - mock_build_catalogue, - mock_model_manager, - mock_workflow_generator, + mock_format_catalogue: MagicMock, + mock_build_catalogue: MagicMock, + mock_model_manager: MagicMock, + mock_workflow_generator: MagicMock, ): """Omitting current_graph (the `/create` path) forwards None to the runner.""" mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock() diff --git a/api/tests/unit_tests/tasks/test_enterprise_telemetry_task.py b/api/tests/unit_tests/tasks/test_enterprise_telemetry_task.py index b48c69a146b..02131445eb6 100644 --- a/api/tests/unit_tests/tasks/test_enterprise_telemetry_task.py +++ b/api/tests/unit_tests/tasks/test_enterprise_telemetry_task.py @@ -35,7 +35,7 @@ def test_process_enterprise_telemetry_success(sample_envelope_json): assert call_args.event_id == "test-event-123" -def test_process_enterprise_telemetry_invalid_json(caplog): +def test_process_enterprise_telemetry_invalid_json(caplog: pytest.LogCaptureFixture): invalid_json = "not valid json" process_enterprise_telemetry(invalid_json) @@ -43,7 +43,7 @@ def test_process_enterprise_telemetry_invalid_json(caplog): assert "Failed to process enterprise telemetry envelope" in caplog.text -def test_process_enterprise_telemetry_handler_exception(sample_envelope_json, caplog): +def test_process_enterprise_telemetry_handler_exception(sample_envelope_json, caplog: pytest.LogCaptureFixture): with patch("tasks.enterprise_telemetry_task.EnterpriseMetricHandler") as mock_handler_class: mock_handler = MagicMock() mock_handler.handle.side_effect = Exception("Handler error") @@ -54,7 +54,7 @@ def test_process_enterprise_telemetry_handler_exception(sample_envelope_json, ca assert "Failed to process enterprise telemetry envelope" in caplog.text -def test_process_enterprise_telemetry_validation_error(caplog): +def test_process_enterprise_telemetry_validation_error(caplog: pytest.LogCaptureFixture): invalid_envelope = json.dumps( { "case": "INVALID_CASE", diff --git a/api/tests/unit_tests/tasks/test_mail_send_task.py b/api/tests/unit_tests/tasks/test_mail_send_task.py index 2b14deaf6df..a7af192d3a7 100644 --- a/api/tests/unit_tests/tasks/test_mail_send_task.py +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -372,7 +372,7 @@ class TestMailTaskRetryLogic: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - def test_mail_task_logs_success(self, mock_mail, mock_email_service, caplog): + def test_mail_task_logs_success(self, mock_mail, mock_email_service, caplog: pytest.LogCaptureFixture): """Test that successful mail sends are logged properly.""" # Arrange mock_mail.is_inited.return_value = True @@ -398,7 +398,7 @@ class TestMailTaskRetryLogic: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - def test_mail_task_logs_failure(self, mock_mail, mock_email_service, caplog): + def test_mail_task_logs_failure(self, mock_mail, mock_email_service, caplog: pytest.LogCaptureFixture): """Test that failed mail sends are logged with exception details.""" # Arrange mock_mail.is_inited.return_value = True @@ -580,7 +580,9 @@ class TestInnerEmailTask: @patch("tasks.mail_inner_task.get_email_i18n_service") @patch("tasks.mail_inner_task.mail") @patch("tasks.mail_inner_task._render_template_with_strategy") - def test_inner_email_task_logs_failure(self, mock_render, mock_mail, mock_email_service, caplog): + def test_inner_email_task_logs_failure( + self, mock_render, mock_mail, mock_email_service, caplog: pytest.LogCaptureFixture + ): """Test inner email task logs failures properly.""" # Arrange mock_mail.is_inited.return_value = True @@ -899,7 +901,9 @@ class TestPerformanceAndTiming: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") @patch("tasks.mail_register_task.time") - def test_mail_task_tracks_execution_time(self, mock_time, mock_mail, mock_email_service, caplog): + def test_mail_task_tracks_execution_time( + self, mock_time, mock_mail, mock_email_service, caplog: pytest.LogCaptureFixture + ): """Test that mail tasks track and log execution time.""" # Arrange mock_mail.is_inited.return_value = True @@ -1465,7 +1469,9 @@ class TestLoggingAndMonitoring: @patch("tasks.mail_register_task.get_email_i18n_service") @patch("tasks.mail_register_task.mail") - def test_mail_task_logs_recipient_information(self, mock_mail, mock_email_service, caplog): + def test_mail_task_logs_recipient_information( + self, mock_mail, mock_email_service, caplog: pytest.LogCaptureFixture + ): """ Test that mail tasks log recipient information for audit trails. @@ -1487,7 +1493,9 @@ class TestLoggingAndMonitoring: @patch("tasks.mail_inner_task.get_email_i18n_service") @patch("tasks.mail_inner_task.mail") - def test_inner_email_task_logs_subject_for_tracking(self, mock_mail, mock_email_service, caplog): + def test_inner_email_task_logs_subject_for_tracking( + self, mock_mail, mock_email_service, caplog: pytest.LogCaptureFixture + ): """ Test that inner email task logs subject for tracking purposes. diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 3fb673198b3..9fc94547468 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -50,8 +50,7 @@ class TestDeleteDraftVariableOffloadData: assert result == 0 mock_conn.execute.assert_not_called() - @patch("tasks.remove_app_and_related_data_task.logging") - def test_delete_draft_variable_offload_data_database_failure(self, mock_logging): + def test_delete_draft_variable_offload_data_database_failure(self, caplog: pytest.LogCaptureFixture): """Test handling of database operation failures.""" mock_conn = MagicMock() file_ids = ["file-1"] @@ -60,13 +59,14 @@ class TestDeleteDraftVariableOffloadData: mock_conn.execute.side_effect = Exception("Database error") # Execute function - should not raise, but log error - result = _delete_draft_variable_offload_data(mock_conn, file_ids) + with caplog.at_level(logging.ERROR): + result = _delete_draft_variable_offload_data(mock_conn, file_ids) # Should return 0 when error occurs assert result == 0 # Verify error was logged - mock_logging.exception.assert_called_once_with("Error deleting draft variable offload data:") + assert "Error deleting draft variable offload data:" in caplog.text class TestDeleteWorkflowArchiveLogs: @@ -114,7 +114,9 @@ class TestDeleteAppStars: class TestDeleteArchivedWorkflowRunFiles: @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - def test_delete_archived_workflow_run_files_not_configured(self, mock_get_storage, caplog): + def test_delete_archived_workflow_run_files_not_configured( + self, mock_get_storage, caplog: pytest.LogCaptureFixture + ): mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("missing config") with caplog.at_level(logging.INFO, logger="tasks.remove_app_and_related_data_task"): @@ -123,7 +125,7 @@ class TestDeleteArchivedWorkflowRunFiles: assert caplog.text.count("Archive storage not configured") == 1 @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - def test_delete_archived_workflow_run_files_list_failure(self, mock_get_storage, caplog): + def test_delete_archived_workflow_run_files_list_failure(self, mock_get_storage, caplog: pytest.LogCaptureFixture): storage = MagicMock() storage.list_objects.side_effect = Exception("list failed") mock_get_storage.return_value = storage @@ -136,7 +138,7 @@ class TestDeleteArchivedWorkflowRunFiles: assert "Failed to list archive files for app app-1" in caplog.text @patch("tasks.remove_app_and_related_data_task.get_archive_storage") - def test_delete_archived_workflow_run_files_success(self, mock_get_storage, caplog): + def test_delete_archived_workflow_run_files_success(self, mock_get_storage, caplog: pytest.LogCaptureFixture): storage = MagicMock() storage.list_objects.return_value = ["key-1", "key-2"] mock_get_storage.return_value = storage diff --git a/api/tests/unit_tests/utils/position_helper/test_position_helper.py b/api/tests/unit_tests/utils/position_helper/test_position_helper.py index dbd8f05098a..dc94dbdb3d0 100644 --- a/api/tests/unit_tests/utils/position_helper/test_position_helper.py +++ b/api/tests/unit_tests/utils/position_helper/test_position_helper.py @@ -1,3 +1,4 @@ +from pathlib import Path from textwrap import dedent import pytest @@ -6,7 +7,7 @@ from core.helper.position_helper import get_position_map, is_filtered, pin_posit @pytest.fixture -def prepare_example_positions_yaml(tmp_path, monkeypatch) -> str: +def prepare_example_positions_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: monkeypatch.chdir(tmp_path) tmp_path.joinpath("example_positions.yaml").write_text( dedent( @@ -25,7 +26,7 @@ def prepare_example_positions_yaml(tmp_path, monkeypatch) -> str: @pytest.fixture -def prepare_empty_commented_positions_yaml(tmp_path, monkeypatch) -> str: +def prepare_empty_commented_positions_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: monkeypatch.chdir(tmp_path) tmp_path.joinpath("example_positions_all_commented.yaml").write_text( dedent( diff --git a/api/tests/unit_tests/utils/yaml/test_yaml_utils.py b/api/tests/unit_tests/utils/yaml/test_yaml_utils.py index 9e2b0659c04..fd4e4a3664d 100644 --- a/api/tests/unit_tests/utils/yaml/test_yaml_utils.py +++ b/api/tests/unit_tests/utils/yaml/test_yaml_utils.py @@ -1,3 +1,4 @@ +from pathlib import Path from textwrap import dedent import pytest @@ -11,7 +12,7 @@ NON_EXISTING_YAML_FILE = "non_existing_file.yaml" @pytest.fixture -def prepare_example_yaml_file(tmp_path, monkeypatch) -> str: +def prepare_example_yaml_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: monkeypatch.chdir(tmp_path) file_path = tmp_path.joinpath(EXAMPLE_YAML_FILE) file_path.write_text( @@ -34,7 +35,7 @@ def prepare_example_yaml_file(tmp_path, monkeypatch) -> str: @pytest.fixture -def prepare_invalid_yaml_file(tmp_path, monkeypatch) -> str: +def prepare_invalid_yaml_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: monkeypatch.chdir(tmp_path) file_path = tmp_path.joinpath(INVALID_YAML_FILE) file_path.write_text( diff --git a/api/uv.lock b/api/uv.lock index 3445ec78321..048d991a49e 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1293,7 +1293,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" }, - { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.1" }, + { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" }, { name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, @@ -1636,7 +1636,7 @@ requires-dist = [ { name = "gmpy2", specifier = ">=2.3.0,<3.0.0" }, { name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, - { name = "graphon", specifier = "==0.5.1" }, + { name = "graphon", specifier = "==0.5.2" }, { name = "gunicorn", specifier = ">=26.0.0,<27.0.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, @@ -2987,7 +2987,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.5.1" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -3008,9 +3008,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/fa/432fa802bcb13f7f51dc323ddef92594b15333eafef181d937ffa554116e/graphon-0.5.1.tar.gz", hash = "sha256:ca38cc62ef3fbc2f3072b68235bcb41e32a6369a1753b46418c1d761c57125fe", size = 269741, upload-time = "2026-06-11T03:01:38.197Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/16/f183da187414c335be67f52f6a1b7c2a33bf0b1d5090eda7e6c92d42d94a/graphon-0.5.2.tar.gz", hash = "sha256:d66a9edcd883766bd50e94f84a691c92ce536ea60e721552089e83ac8e94bf68", size = 269773, upload-time = "2026-06-16T04:06:22.074Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/c5/61e8634b89c320af9453083213e8be436071634dbc69cb14b5fe646763e4/graphon-0.5.1-py3-none-any.whl", hash = "sha256:70b49c244a46fb6e338905210cc895bd67584d9ab1412f6ba3cd4ed284010091", size = 381866, upload-time = "2026-06-11T03:01:36.693Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e6/36a3981cd44e7a40a7cd7d374e26f01e02dd49410c5fbbd7df248750d5fb/graphon-0.5.2-py3-none-any.whl", hash = "sha256:11f89399e67ed1ddd2ce1c336accd9c4ad5b8fe2741f9167e6085af0b325cd14", size = 381908, upload-time = "2026-06-16T04:06:20.453Z" }, ] [[package]] diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts index 30a5d213b7d..932998d9afe 100644 --- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts +++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts @@ -37,6 +37,7 @@ import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest' import { ZERO } from '@/util/uuid.js' import { assertErrorEnvelope, + assertExitCode, assertNoAnsi, assertNonZeroExit, } from '../../helpers/assert.js' @@ -215,6 +216,147 @@ describe('E2E / error message standards (spec 5.3)', () => { expect(result.stderr).not.toContain(sentValue) }) + // ── 5.70d-h ErrorBody contract — error.server structure and rendering priorities ── + // PR #37285 introduces canonical ErrorBody on every /openapi/v1 non-2xx response. + // CLI strict-parses via zErrorBody.safeParse; success → full struct at error.server. + // + // V2 rendering priorities (format.ts, verified against codebase): + // header code : server?.code ?? cliCode — server wins, CLI fallback + // hint : cliHint ?? server?.hint — CLI wins, server fallback (V2 correction) + // details : server?.details[] — " - loc: msg (type)" per entry, no -v + + it('[P0] 5.70d JSON envelope contains error.server with canonical code/status/message', async () => { + // Trigger: describe app ZERO — server returns canonical 404 ErrorBody + // { code:"not_found", status:404, message:"app not found" }. + // zErrorBody.safeParse succeeds → error.server is populated on the current server. + const result = await fx.r(['describe', 'app', ZERO, '-o', 'json']) + assertNonZeroExit(result) + const envelope = JSON.parse(result.stderr.trim()) as { + error: { code: string, server?: { code: string, status: number, message: string } } + } + expect(envelope.error.server, 'error.server must be present when server returns canonical ErrorBody').toBeDefined() + expect(typeof envelope.error.server?.code, 'error.server.code must be a string').toBe('string') + expect(envelope.error.server?.code.length).toBeGreaterThan(0) + expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe('number') + expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe('string') + expect(envelope.error.server?.message.length).toBeGreaterThan(0) + }) + + it('[P1] 5.70e @accepts query validation returns canonical 422 with details array', async () => { + // Trigger: direct fetch to GET /apps?page=not-integer — @accepts(query=AppListQuery) + // validates page as int and emits canonical 422 ErrorBody with details[]. + // Direct fetch is used because the CLI validates --page as integer client-side + // (would exit 2 before hitting the server); this pins the server-side contract. + const res = await fetch( + `${E.host.replace(/\/$/, '')}/openapi/v1/apps?workspace_id=${E.workspaceId}&page=not-an-integer`, + { headers: { Authorization: `Bearer ${E.token}` }, signal: AbortSignal.timeout(8_000) }, + ) + expect(res.status).toBe(422) + const body = await res.json() as { + code?: string + status?: number + details?: Array<{ type: string, loc: Array, msg: string }> + } + expect(body.code).toBe('invalid_param') + expect(body.status).toBe(422) + expect(Array.isArray(body.details), 'details must be an array').toBe(true) + expect(body.details!.length).toBeGreaterThan(0) + const entry = body.details![0]! + expect(typeof entry.type).toBe('string') + expect(typeof entry.msg).toBe('string') + expect(Array.isArray(entry.loc)).toBe(true) + }) + + it('[P1] 5.70g rendering priority — header code: server code wins over CLI classification code', async () => { + // renderHuman: headerCode = server?.code ?? e.code (server wins, V2 unchanged) + // When canonical ErrorBody is parsed, the server semantic code replaces the CLI + // classification code ("server_4xx_other") in the human-readable output header. + // Trigger: describe app ZERO → canonical 404; header starts with "not_found:". + const result = await fx.r(['describe', 'app', ZERO]) + assertNonZeroExit(result) + expect(result.stderr.trimStart()).not.toMatch(/^server_4xx_other:/) + expect(result.stderr.trimStart()).toMatch(/^not_found:/) + }) + + it('[P1] 5.70g2 rendering priority — hint: CLI hint wins over server hint (V2 correction)', async () => { + // renderHuman: hint = cliHint ?? server?.hint (CLI wins — V2 spec correction) + // V1 incorrectly documented "server wins"; V2 aligns with codebase: CLI wins. + // Test: 401 AuthExpired — classifyResponse sets c.hint = AUTH_LOGIN_HINT before + // serverError is parsed; CLI hint takes precedence over any server-provided hint. + // Verified on current server (no @accepts deployment required). + const unauthTmp = await withTempConfig() + try { + const result = await run(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir }) + assertExitCode(result, 4) + const envelope = JSON.parse(result.stderr.trim()) as { error: { hint?: string } } + expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch(/auth login/i) + } + finally { + await unauthTmp.cleanup() + } + }) + + it('[P1] 5.70h JSON envelope: error.code = CLI classification; error.server.code = server semantic code', async () => { + // toEnvelope() sets error.code from HTTP status bucket (e.g. "server_4xx_other") + // while the server's semantic code is separate in error.server.code. + // Agents can branch on error.server.code without parsing human-readable text. + // Trigger: describe app ZERO → canonical 404; error.code="server_4xx_other", + // error.server.code="not_found" — always distinct when ErrorBody is present. + const result = await fx.r(['describe', 'app', ZERO, '-o', 'json']) + assertNonZeroExit(result) + const envelope = JSON.parse(result.stderr.trim()) as { + error: { code: string, server?: { code: string } } + } + expect(envelope.error.code).toBe('server_4xx_other') + expect(envelope.error.server?.code).toBeDefined() + expect(envelope.error.server?.code).not.toBe('server_4xx_other') + }) + // ── 5.70i / 5.70j PR #37285 boundary contract ─────────────────────────── + + it('[P1] 5.70i unknown /openapi/v1 route returns canonical 404 ErrorBody without route suggestions', async () => { + // PR #37285: ExternalApi._help_on_404 suppresses flask-restx route enumeration. + // Previously, an unknown path under /openapi/v1/ returned flask-restx's default + // 404 with a "Did you mean /openapi/v1/apps?" suggestion, leaking the route table. + // After the fix it must return a canonical ErrorBody and contain no suggestions. + const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, { + headers: { Authorization: `Bearer ${E.token}` }, + signal: AbortSignal.timeout(8_000), + }) + expect(res.status).toBe(404) + const body = await res.json() as Record + // canonical ErrorBody fields must be present + expect(typeof body.code, '404 body must have a string code field').toBe('string') + expect(body.status, '404 body must have status: 404').toBe(404) + // no flask-restx route enumeration in the response + const raw = JSON.stringify(body) + expect(raw).not.toMatch(/did you mean/i) + expect(raw).not.toMatch(/you might want/i) + }) + + it('[P1] 5.70j device-flow 4xx uses RFC 8628 format, not ErrorBody — zErrorBody parse fails gracefully', async () => { + // PR #37285 explicitly excludes RFC 8628 device-flow endpoints from the + // ErrorBody contract. This test pins that contract: + // - The device/token endpoint returns RFC 8628 {error: string} on failure, + // not the canonical {code, status, message} shape. + // - When the CLI's classifyResponse encounters this, zErrorBody.safeParse + // returns failure → serverError = undefined → generic status-based message, + // no error.server field, no crash. + const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/oauth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: 'fake-invalid-device-code-e2e-test', client_id: 'difyctl' }), + signal: AbortSignal.timeout(8_000), + }) + // device flow errors are 4xx (400 bad_request or 401 expired_token etc.) + expect(res.status).toBeGreaterThanOrEqual(400) + expect(res.status).toBeLessThan(500) + const body = await res.json() as Record + // RFC 8628 shape: has 'error' string, must NOT have ErrorBody 'code'/'status' pair + expect(typeof body.error, 'RFC 8628 body must have a string error field').toBe('string') + expect(body).not.toHaveProperty('status') + // zErrorBody.safeParse would fail → CLI sets serverError = undefined → generic message + }) + // ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ──────── it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => { diff --git a/dify-agent/Dockerfile b/dify-agent/Dockerfile new file mode 100644 index 00000000000..6d56811981d --- /dev/null +++ b/dify-agent/Dockerfile @@ -0,0 +1,68 @@ +# Dedicated image for the standalone Dify Agent backend server. +# +# It is laid out to match how the service is deployed today (see the +# agent_backend compose service): the virtualenv lives at /app/api/.venv and +# the server is started with +# cd /app/api && .venv/bin/uvicorn dify_agent.server.app:app --host 0.0.0.0 --port 5050 +# +# Unlike the dify-api image (which only installs the base `dify-agent` +# dependency), this image installs the `[server]` extra, so jwcrypto, +# shell-session-manager, fastapi, uvicorn, etc. are present and the server can +# actually start. dify-api is intentionally left lean. + +# base image +FROM python:3.12-slim-bookworm AS base + +WORKDIR /app/api + +# Install uv +ENV UV_VERSION=0.8.9 + +RUN pip install --no-cache-dir uv==${UV_VERSION} + + +FROM base AS packages + +# The build context is the repository root (see build-push.yml), so paths are +# repo-root relative. dify-agent ships its own uv.lock that resolves the server +# extra, so this builds standalone without the api project. +COPY dify-agent/pyproject.toml dify-agent/uv.lock dify-agent/README.md ./ +COPY dify-agent/src ./src +# Trust the checked-in lock during image builds and install the server extra. +RUN uv sync --frozen --no-dev --no-editable --extra server + + +# production stage +FROM base AS production + +ENV TZ=UTC +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 +ENV PYTHONIOENCODING=utf-8 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app/api + +# Create non-root user (uid matches the dify-api image convention) +ARG dify_uid=1001 +RUN groupadd -r -g ${dify_uid} dify && \ + useradd -r -u ${dify_uid} -g ${dify_uid} -s /bin/bash dify + +# Copy the resolved virtualenv. The dify-agent package is installed +# non-editable, so the source is already baked into site-packages. +ENV VIRTUAL_ENV=/app/api/.venv +COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV} +ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" + +# storage is bind-mounted at runtime; pre-create it so the dify user can write. +RUN mkdir -p /app/api/storage && chown -R dify:dify /app/api + +ARG COMMIT_SHA +ENV COMMIT_SHA=${COMMIT_SHA} + +EXPOSE 5050 + +USER dify + +CMD ["uvicorn", "dify_agent.server.app:app", "--host", "0.0.0.0", "--port", "5050"] diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 915114d2338..03f2525ace7 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -20,7 +20,7 @@ dify-agent-stub-server = "dify_agent.agent_stub.server.cli:main" grpc = ["grpclib[protobuf]>=0.4.9,<0.5.0", "protobuf>=6.33.5,<7.0.0"] server = [ "fastapi==0.136.0", - "graphon==0.5.1", + "graphon==0.5.2", "jsonschema>=4.23.0,<5.0.0", "jwcrypto>=1.5.6,<2", "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_drive.py b/dify-agent/src/dify_agent/agent_stub/cli/_drive.py new file mode 100644 index 00000000000..19611592c0e --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/cli/_drive.py @@ -0,0 +1,380 @@ +"""CLI helpers for sandbox-visible Agent Stub drive commands. + +Drive commands stay in the sandbox-facing CLI because they orchestrate existing +control-plane and signed data-plane helpers. The Agent Stub server authenticates +and injects trusted drive scope; this module only formats manifest output, +downloads signed URLs into a local drive base (including safe auto-extraction of +downloaded skill archives), and uploads local files before committing their +ToolFile ids back into the drive. +""" + +from __future__ import annotations + +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from uuid import uuid4 +from zipfile import BadZipFile, ZIP_DEFLATED, ZipFile, ZipInfo + +from dify_agent.agent_stub.cli._env import read_agent_stub_environment +from dify_agent.agent_stub.cli._files import upload_tool_file_resource_from_environment +from dify_agent.agent_stub.client._agent_stub import ( + download_file_bytes_from_signed_url_sync, + request_agent_stub_drive_commit_sync, + request_agent_stub_drive_manifest_sync, +) +from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitItem, + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveFileRef, + AgentStubDriveItem, + AgentStubDriveManifestResponse, +) + +_SKILL_MD_FILENAME = "SKILL.md" +_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip" +_SKIP_DIR_NAMES = frozenset( + {".git", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".venv", "node_modules"} +) +_SKIP_FILE_NAMES = frozenset({".DS_Store", _SKILL_ARCHIVE_FILENAME}) + + +@dataclass(frozen=True, slots=True) +class _DriveUploadItem: + """Prepared local upload paired with its destination drive key.""" + + local_path: Path + drive_key: str + + +def list_drive_from_environment(prefix: str, json_output: bool) -> str | AgentStubDriveManifestResponse: + """List drive items through the Agent Stub using the current environment. + + Args: + prefix: Optional drive-key prefix forwarded to the manifest request. + json_output: When ``True``, return the validated manifest response model. + When ``False``, return one human-readable tab-separated line per item + containing size, mime type, hash, and key. + + Returns: + Either ``AgentStubDriveManifestResponse`` for JSON callers or a formatted + string for human-facing CLI output. + + Side effects: + Calls the Agent Stub drive manifest control-plane endpoint with + ``include_download_url=False`` so list output does not allocate signed + download URLs. + """ + + environment = read_agent_stub_environment() + response = request_agent_stub_drive_manifest_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + prefix=prefix, + include_download_url=False, + ) + if json_output: + return response + return _format_manifest(response) + + +def pull_drive_from_environment(prefix: str, drive_base: str = "/mnt/drive") -> list[Path]: + """Pull drive files into one local drive base via signed download URLs. + + Args: + prefix: Optional drive-key prefix forwarded to the manifest request. + drive_base: Local base directory that receives downloaded drive files. + + Returns: + A list of written local paths under ``drive_base``. + + Observable behavior: + Requests a manifest with ``include_download_url=True``, requires every + returned item to include ``download_url``, downloads bytes directly from + those signed URLs, blocks path traversal by resolving each destination + under the resolved drive base, writes through a temporary sibling file + before replacing the final path, validates byte length when the manifest + includes ``size``, and automatically extracts + ``.DIFY-SKILL-FULL.zip`` archives into their containing skill + directory with the same path-safety checks. Archive extraction is staged + under a temporary directory and only moved into place after the full + archive validates successfully. + + The return value remains the list of downloaded paths only; extracted + files are materialized on disk but are not added to the returned list. + + Raises: + AgentStubValidationError: if a manifest item omits ``download_url``, a + destination would escape the drive base, or a downloaded skill + archive contains unsafe entries such as absolute paths, traversal + entries, or symlink entries. + AgentStubTransferError: if a downloaded payload does not match declared + size metadata or a downloaded skill archive is corrupt / not a valid + zip file. + """ + + environment = read_agent_stub_environment() + response = request_agent_stub_drive_manifest_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + prefix=prefix, + include_download_url=True, + ) + base_path = Path(drive_base).expanduser().resolve() + base_path.mkdir(parents=True, exist_ok=True) + written_paths: list[Path] = [] + for item in response.items: + download_url = item.download_url + if not isinstance(download_url, str) or not download_url: + raise AgentStubValidationError(f"drive manifest item is missing download_url: {item.key}") + destination = _resolve_drive_destination(base_path, item.key) + payload = download_file_bytes_from_signed_url_sync(download_url=download_url) + if item.size is not None and len(payload) != item.size: + raise AgentStubTransferError(f"downloaded drive file size mismatch for {item.key}") + destination.parent.mkdir(parents=True, exist_ok=True) + temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") + temp_path.write_bytes(payload) + temp_path.replace(destination) + written_paths.append(destination) + if destination.name == _SKILL_ARCHIVE_FILENAME: + _extract_skill_archive(destination) + return written_paths + + +def push_drive_from_environment(local_path: str, drive_path: str, recursive: bool) -> AgentStubDriveCommitResponse: + """Upload local files through the Agent Stub and commit them into the drive. + + Args: + local_path: Source file or directory in the sandbox filesystem. + drive_path: Destination drive key or drive-key prefix. + recursive: Select directory mode. ``False`` standardizes skill + directories, while ``True`` uploads raw directory contents. + + Returns: + The validated drive commit response returned by the Agent Stub. + + Mode split: + * If ``local_path`` is a file, upload that file and commit exactly one + ``tool_file`` binding to ``drive_path``. + * If ``local_path`` is a directory and ``recursive`` is ``False``, + require ``SKILL.md`` and standardize the upload into + ``/SKILL.md`` plus ``/.DIFY-SKILL-FULL.zip``. + * If ``local_path`` is a directory and ``recursive`` is ``True``, upload + each regular file under ``drive_path/`` without skill + standardization. + + Observable safety behavior: + Rejects missing local paths, rejects recursive directory pushes with no + regular files, and rejects symlinked or escaping paths while preparing + directory uploads or skill archives. + """ + + source_path = Path(local_path).expanduser().resolve() + if source_path.is_file(): + return _commit_uploaded_items([_prepare_uploaded_file(source_path, drive_path)]) + if not source_path.is_dir(): + raise AgentStubValidationError(f"local path not found: {source_path}") + if recursive: + upload_items = [ + _prepare_uploaded_file(path, _join_drive_key(drive_path, relative_path)) + for path, relative_path in _iter_regular_files(source_path) + ] + if not upload_items: + raise AgentStubValidationError(f"directory has no regular files: {source_path}") + return _commit_uploaded_items(upload_items) + return _push_skill_directory(source_path, drive_path) + + +def _push_skill_directory(source_path: Path, drive_path: str) -> AgentStubDriveCommitResponse: + skill_md_path = source_path / _SKILL_MD_FILENAME + if not skill_md_path.is_file(): + raise AgentStubValidationError(f"non-recursive drive push requires {_SKILL_MD_FILENAME}: {source_path}") + with TemporaryDirectory() as temp_dir: + archive_path = Path(temp_dir) / _SKILL_ARCHIVE_FILENAME + _build_skill_archive(source_path, archive_path) + return _commit_uploaded_items( + [ + _prepare_uploaded_file(skill_md_path.resolve(), _join_drive_key(drive_path, _SKILL_MD_FILENAME)), + _prepare_uploaded_file(archive_path, _join_drive_key(drive_path, _SKILL_ARCHIVE_FILENAME)), + ] + ) + + +def _prepare_uploaded_file(local_path: Path, drive_key: str) -> _DriveUploadItem: + return _DriveUploadItem(local_path=local_path, drive_key=drive_key) + + +def _commit_uploaded_items(items: list[_DriveUploadItem]) -> AgentStubDriveCommitResponse: + environment = read_agent_stub_environment() + commit_items: list[AgentStubDriveCommitItem] = [] + for item in items: + uploaded_file = upload_tool_file_resource_from_environment(path=str(item.local_path)) + commit_items.append( + AgentStubDriveCommitItem( + key=item.drive_key, + file_ref=AgentStubDriveFileRef(kind="tool_file", id=uploaded_file.tool_file_id), + ) + ) + return request_agent_stub_drive_commit_sync( + url=environment.url, + auth_jwe=environment.auth_jwe, + request=AgentStubDriveCommitRequest(items=commit_items), + ) + + +def _format_manifest(response: AgentStubDriveManifestResponse) -> str: + return "\n".join(_format_manifest_item(item) for item in response.items) + + +def _format_manifest_item(item: AgentStubDriveItem) -> str: + size = str(item.size) if item.size is not None else "-" + mime_type = item.mime_type or "-" + item_hash = item.hash or "-" + return f"{size}\t{mime_type}\t{item_hash}\t{item.key}" + + +def _resolve_drive_destination(base_path: Path, drive_key: str) -> Path: + destination = (base_path / Path(drive_key)).resolve() + try: + destination.relative_to(base_path) + except ValueError as exc: + raise AgentStubValidationError(f"drive key resolves outside the drive base: {drive_key}") from exc + return destination + + +def _iter_regular_files(root_path: Path) -> list[tuple[Path, str]]: + """Return all regular files under one directory, rejecting unsafe symlinks.""" + + return _iter_regular_files_with_skip_filter(root_path, skip_filtered=False) + + +def _iter_skill_archive_files(root_path: Path) -> list[tuple[Path, str]]: + """Return regular files for skill packaging, excluding transient content.""" + + return _iter_regular_files_with_skip_filter(root_path, skip_filtered=True) + + +def _iter_regular_files_with_skip_filter(root_path: Path, *, skip_filtered: bool) -> list[tuple[Path, str]]: + root_resolved = root_path.resolve() + collected: list[tuple[Path, str]] = [] + for candidate in sorted(root_path.rglob("*")): + if skip_filtered and _should_skip_path(candidate, root_path): + continue + if candidate.is_symlink(): + raise AgentStubValidationError(f"drive push does not support symlinked files: {candidate}") + if not candidate.is_file(): + continue + resolved_candidate = candidate.resolve() + try: + relative_path = resolved_candidate.relative_to(root_resolved) + except ValueError as exc: + raise AgentStubValidationError( + f"drive push file resolves outside the source directory: {candidate}" + ) from exc + collected.append((resolved_candidate, relative_path.as_posix())) + return collected + + +def _should_skip_path(candidate: Path, root_path: Path) -> bool: + relative_path = candidate.relative_to(root_path) + if any(part in _SKIP_DIR_NAMES for part in relative_path.parts): + return True + return candidate.name in _SKIP_FILE_NAMES + + +def _build_skill_archive(source_path: Path, archive_path: Path) -> None: + with ZipFile(archive_path, mode="w", compression=ZIP_DEFLATED) as archive: + for file_path, relative_path in _iter_skill_archive_files(source_path): + archive.write(file_path, arcname=relative_path) + + +def _extract_skill_archive(archive_path: Path) -> None: + """Safely extract one downloaded skill archive into its containing directory. + + Extraction is staged under a temporary directory created inside the target + skill directory. Every entry is validated and materialized into staging + first, and only after the full archive succeeds are staged files moved into + their final locations under the skill directory. Existing files at those + final locations are overwritten in place by the extracted archive content. + + Error mapping is intentionally stable for CLI callers: unsafe archive entry + names raise ``AgentStubValidationError``, while malformed archives and zip + parsing / archive I/O failures are translated into ``AgentStubTransferError``. + """ + + target_dir = archive_path.parent.resolve() + try: + with TemporaryDirectory(dir=target_dir, prefix=".dify-skill-extract-") as staging_dir_name: + staging_dir = Path(staging_dir_name).resolve() + with ZipFile(archive_path) as archive: + for zip_info in archive.infolist(): + destination = _resolve_zip_entry_destination(staging_dir, zip_info.filename) + if _is_zip_symlink(zip_info): + raise AgentStubValidationError( + f"skill archive contains unsupported symlink entry: {zip_info.filename}" + ) + if zip_info.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + continue + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(zip_info) as source_file: + temp_path = destination.with_name(f"{destination.name}.tmp-{uuid4().hex}") + temp_path.write_bytes(source_file.read()) + temp_path.replace(destination) + for staged_path in sorted(staging_dir.rglob("*")): + if staged_path.is_dir(): + continue + relative_path = staged_path.relative_to(staging_dir) + destination = (target_dir / relative_path).resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + staged_path.replace(destination) + except AgentStubValidationError: + raise + except (BadZipFile, OSError) as exc: + raise AgentStubTransferError(f"downloaded skill archive is invalid: {archive_path.name}") from exc + + +def _resolve_zip_entry_destination(target_dir: Path, entry_name: str) -> Path: + """Resolve one zip entry path under a target skill directory. + + Zip metadata may contain POSIX or backslash-separated names, so entry names + are normalized to forward slashes before validation. The resolved entry must + not be absolute, empty, ``.`` / ``..`` based, or otherwise escape the target + skill directory after resolution. + """ + + normalized_name = entry_name.replace("\\", "/") + pure_path = PurePosixPath(normalized_name) + if not normalized_name or normalized_name.startswith("/") or pure_path.is_absolute(): + raise AgentStubValidationError(f"skill archive contains unsafe absolute path: {entry_name}") + if any(part in {"", ".", ".."} for part in pure_path.parts): + raise AgentStubValidationError(f"skill archive contains unsafe path traversal entry: {entry_name}") + destination = (target_dir / Path(*pure_path.parts)).resolve() + try: + destination.relative_to(target_dir) + except ValueError as exc: + raise AgentStubValidationError( + f"skill archive entry resolves outside the skill directory: {entry_name}" + ) from exc + return destination + + +def _is_zip_symlink(zip_info: ZipInfo) -> bool: + file_mode = zip_info.external_attr >> 16 + return stat.S_ISLNK(file_mode) + + +def _join_drive_key(base_key: str, child_key: str) -> str: + stripped_base = base_key.rstrip("/") + stripped_child = child_key.lstrip("/") + return f"{stripped_base}/{stripped_child}" if stripped_base else stripped_child + + +__all__ = [ + "list_drive_from_environment", + "pull_drive_from_environment", + "push_drive_from_environment", +] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_files.py b/dify-agent/src/dify_agent/agent_stub/cli/_files.py index b46bc7409c2..dccc6d36c44 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_files.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_files.py @@ -36,6 +36,14 @@ class DownloadedFileResult: path: Path +@dataclass(frozen=True, slots=True) +class UploadedToolFileResource: + """Lower-level upload result carrying both public mapping and ToolFile id.""" + + mapping: UploadedToolFileMapping + tool_file_id: str + + def upload_file_from_environment(*, path: str) -> UploadedToolFileMapping: """Upload one sandbox-local file through the Agent Stub control plane. @@ -44,6 +52,23 @@ def upload_file_from_environment(*, path: str) -> UploadedToolFileMapping: canonical Agent output file mapping without synthesizing reference format. """ + return upload_tool_file_resource_from_environment(path=path).mapping + + +def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFileResource: + """Upload one sandbox-local file and preserve both reference and ToolFile id. + + This lower-level helper backs ``drive push``. The signed upload data-plane + response must include both the canonical Dify ``reference`` used by public + CLI output and the raw ToolFile ``id`` required by drive commit payloads. + + Raises: + AgentStubValidationError: if ``path`` does not resolve to a local file. + AgentStubTransferError: if the signed upload response omits either the + canonical ``reference`` or the raw ToolFile ``id``, or if the + canonical reference is malformed. + """ + source_path = Path(path).expanduser().resolve() if not source_path.is_file(): raise AgentStubValidationError(f"local file not found: {source_path}") @@ -64,7 +89,7 @@ def upload_file_from_environment(*, path: str) -> UploadedToolFileMapping: file_obj=file_obj, mimetype=mime_type, ) - return _normalize_uploaded_tool_file(payload) + return _normalize_uploaded_tool_file_resource(payload) def download_file_from_environment( @@ -101,13 +126,19 @@ def download_file_from_environment( return DownloadedFileResult(path=destination) -def _normalize_uploaded_tool_file(payload: dict[str, object]) -> UploadedToolFileMapping: +def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> UploadedToolFileResource: reference = payload.get("reference") if not isinstance(reference, str) or not reference: raise AgentStubTransferError("signed file upload response is missing reference") if not is_canonical_dify_file_reference(reference): raise AgentStubTransferError("signed file upload response has invalid canonical reference") - return UploadedToolFileMapping(reference=reference) + tool_file_id = payload.get("id") + if not isinstance(tool_file_id, str) or not tool_file_id: + raise AgentStubTransferError("signed file upload response is missing id") + return UploadedToolFileResource( + mapping=UploadedToolFileMapping(reference=reference), + tool_file_id=tool_file_id, + ) def _deduplicate_destination_path(path: Path) -> Path: @@ -134,6 +165,8 @@ def _sanitize_download_filename(filename: str) -> str: __all__ = [ "DownloadedFileResult", "UploadedToolFileMapping", + "UploadedToolFileResource", "download_file_from_environment", "upload_file_from_environment", + "upload_tool_file_resource_from_environment", ] diff --git a/dify-agent/src/dify_agent/agent_stub/cli/main.py b/dify-agent/src/dify_agent/agent_stub/cli/main.py index 87bf870f7cd..466a53339e7 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/main.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -1,11 +1,11 @@ """Typer entry point for the client-safe ``dify-agent`` console script. -The CLI supports an explicit ``connect`` command and treats unknown bare -commands as Agent Stub forwards. When the injected Agent Stub environment -variables are missing, that path intentionally surfaces a clear missing-env -error instead of Typer's generic unknown-command message. The module depends -only on client-safe code so importing the console entry point does not pull in -FastAPI, Redis, shellctl, or JWE runtime dependencies. +The CLI supports explicit ``connect``, ``file``, and ``drive`` commands and +treats unknown bare commands as Agent Stub forwards. When the injected Agent +Stub environment variables are missing, that path intentionally surfaces a +clear missing-env error instead of Typer's generic unknown-command message. The +module depends only on client-safe code so importing the console entry point +does not pull in FastAPI, Redis, shellctl, or JWE runtime dependencies. """ from __future__ import annotations @@ -16,6 +16,11 @@ import typer from typer.main import get_command from dify_agent.agent_stub.cli._agent_stub import connect_from_environment +from dify_agent.agent_stub.cli._drive import ( + list_drive_from_environment, + pull_drive_from_environment, + push_drive_from_environment, +) from dify_agent.agent_stub.cli._env import MissingAgentStubEnvironmentError, has_agent_stub_environment from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment from dify_agent.agent_stub.client._errors import AgentStubClientError @@ -27,8 +32,10 @@ app = typer.Typer( no_args_is_help=True, ) file_app = typer.Typer(help="Upload or download workflow files through the Agent Stub.") +drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.") app.add_typer(file_app, name="file") -_KNOWN_ROOT_COMMANDS = frozenset({"connect", "file"}) +app.add_typer(drive_app, name="drive") +_KNOWN_ROOT_COMMANDS = frozenset({"connect", "drive", "file"}) @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) @@ -60,6 +67,34 @@ def download( ) +@drive_app.command("list") +def drive_list( + path_prefix: str = typer.Argument("", metavar="PATH_PREFIX"), + json_output: bool = typer.Option(False, "--json", help="Emit the drive manifest as JSON."), +) -> None: + """List drive files visible to the current sandbox execution.""" + _run_drive_list(path_prefix=path_prefix, json_output=json_output) + + +@drive_app.command("pull") +def drive_pull( + path_prefix: str = typer.Argument("", metavar="PATH_PREFIX"), + drive_base: str = typer.Option("/mnt/drive", "--drive-base", help="Local base directory for pulled drive files."), +) -> None: + """Pull drive files into one local directory tree.""" + _run_drive_pull(path_prefix=path_prefix, drive_base=drive_base) + + +@drive_app.command("push") +def drive_push( + local_path: str = typer.Argument(..., metavar="LOCAL_PATH"), + drive_path: str = typer.Argument(..., metavar="DRIVE_PATH"), + recursive: bool = typer.Option(False, "-r", "--recursive", help="Recursively upload directory contents."), +) -> None: + """Upload one local file or directory into the agent drive.""" + _run_drive_push(local_path=local_path, drive_path=drive_path, recursive=recursive) + + def main(argv: list[str] | None = None) -> None: """Run the ``dify-agent`` CLI with optional argv injection for tests.""" args = list(sys.argv[1:] if argv is None else argv) @@ -155,4 +190,46 @@ def _run_file_download(*, transfer_method: str, reference_or_url: str, directory typer.echo(str(response.path)) +def _run_drive_list(*, path_prefix: str, json_output: bool) -> None: + try: + response = list_drive_from_environment(prefix=path_prefix, json_output=json_output) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + if json_output: + if isinstance(response, str): + raise RuntimeError("drive list JSON output expected a manifest response") + typer.echo(response.model_dump_json()) + return + typer.echo(response) + + +def _run_drive_pull(*, path_prefix: str, drive_base: str) -> None: + try: + response = pull_drive_from_environment(prefix=path_prefix, drive_base=drive_base) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + for path in response: + typer.echo(str(path)) + + +def _run_drive_push(*, local_path: str, drive_path: str, recursive: bool) -> None: + try: + response = push_drive_from_environment(local_path=local_path, drive_path=drive_path, recursive=recursive) + except MissingAgentStubEnvironmentError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(2) from exc + except AgentStubClientError as exc: + typer.echo(str(exc), err=True) + raise SystemExit(1) from exc + typer.echo(response.model_dump_json()) + + __all__ = ["app", "main"] diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py index 9c546cdbd05..34b54cabaca 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py @@ -8,12 +8,18 @@ from pydantic import JsonValue from dify_agent.agent_stub.client._agent_stub_http import ( connect_agent_stub_http_sync, download_file_bytes_from_signed_url_sync, + request_agent_stub_drive_commit_http_sync, + request_agent_stub_drive_manifest_http_sync, request_agent_stub_file_download_http_sync, request_agent_stub_file_upload_http_sync, upload_file_to_signed_url_sync, ) from dify_agent.agent_stub.client._errors import AgentStubValidationError -from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, parse_agent_stub_endpoint +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitRequest, + AgentStubFileMapping, + parse_agent_stub_endpoint, +) def connect_agent_stub_sync( @@ -106,6 +112,60 @@ def request_agent_stub_file_download_sync( ) +def request_agent_stub_drive_manifest_sync( + *, + url: str, + auth_jwe: str, + prefix: str, + include_download_url: bool, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Request one drive manifest through the HTTP Agent Stub transport. + + Drive operations are intentionally HTTP-only in this stage. Callers must + provide an ``http://`` or ``https://`` Agent Stub URL; ``grpc://`` endpoints + raise ``AgentStubValidationError`` instead of attempting transport fallback. + """ + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub drive operations require an HTTP Agent Stub URL") + return request_agent_stub_drive_manifest_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + prefix=prefix, + include_download_url=include_download_url, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + +def request_agent_stub_drive_commit_sync( + *, + url: str, + auth_jwe: str, + request: AgentStubDriveCommitRequest, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +): + """Commit one drive batch through the HTTP Agent Stub transport. + + Drive operations are intentionally HTTP-only in this stage. Callers must + provide an ``http://`` or ``https://`` Agent Stub URL; ``grpc://`` endpoints + raise ``AgentStubValidationError`` instead of attempting transport fallback. + """ + endpoint = _parse_endpoint(url) + if endpoint.is_grpc: + raise AgentStubValidationError("Agent Stub drive operations require an HTTP Agent Stub URL") + return request_agent_stub_drive_commit_http_sync( + base_url=endpoint.url, + auth_jwe=auth_jwe, + request=request, + timeout=timeout, + sync_http_client=sync_http_client, + ) + + def _parse_endpoint(url: str): try: return parse_agent_stub_endpoint(url) @@ -116,6 +176,8 @@ def _parse_endpoint(url: str): __all__ = [ "connect_agent_stub_sync", "download_file_bytes_from_signed_url_sync", + "request_agent_stub_drive_commit_sync", + "request_agent_stub_drive_manifest_sync", "request_agent_stub_file_download_sync", "request_agent_stub_file_upload_sync", "upload_file_to_signed_url_sync", diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py index f3ea57d3cd6..9c80e2d060d 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py @@ -24,12 +24,17 @@ from dify_agent.agent_stub.client._errors import ( from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectRequest, AgentStubConnectResponse, + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveManifestResponse, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, AgentStubFileMapping, AgentStubFileUploadRequest, AgentStubFileUploadResponse, agent_stub_connections_url, + agent_stub_drive_commit_url, + agent_stub_drive_manifest_url, agent_stub_file_download_request_url, agent_stub_file_upload_request_url, ) @@ -124,6 +129,56 @@ def request_agent_stub_file_download_http_sync( ) +def request_agent_stub_drive_manifest_http_sync( + *, + base_url: str, + auth_jwe: str, + prefix: str, + include_download_url: bool, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> AgentStubDriveManifestResponse: + """Request one drive manifest from the HTTP Agent Stub endpoint.""" + + response = _get_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="drive manifest request", + endpoint_url_factory=agent_stub_drive_manifest_url, + params={ + "prefix": prefix, + "include_download_url": str(include_download_url).lower(), + }, + timeout=timeout, + sync_http_client=sync_http_client, + ) + return _parse_success_response( + response=response, response_model=AgentStubDriveManifestResponse, label="drive manifest" + ) + + +def request_agent_stub_drive_commit_http_sync( + *, + base_url: str, + auth_jwe: str, + request: AgentStubDriveCommitRequest, + timeout: float | httpx.Timeout = 30.0, + sync_http_client: httpx.Client | None = None, +) -> AgentStubDriveCommitResponse: + """Commit one drive batch through the HTTP Agent Stub endpoint.""" + + response = _post_agent_stub_json( + base_url=base_url, + auth_jwe=auth_jwe, + endpoint_name="drive commit request", + endpoint_url_factory=agent_stub_drive_commit_url, + request_body=request.model_dump_json(exclude_none=True), + timeout=timeout, + sync_http_client=sync_http_client, + ) + return _parse_success_response(response=response, response_model=AgentStubDriveCommitResponse, label="drive commit") + + def upload_file_to_signed_url_sync( *, upload_url: str, @@ -229,6 +284,38 @@ def _post_agent_stub_json( client.close() +def _get_agent_stub_json( + *, + base_url: str, + auth_jwe: str, + endpoint_name: str, + endpoint_url_factory: Callable[[str], str], + params: dict[str, str], + timeout: float | httpx.Timeout, + sync_http_client: httpx.Client | None, +) -> httpx.Response: + try: + endpoint_url = endpoint_url_factory(base_url) + except ValueError as exc: + raise AgentStubValidationError("invalid Agent Stub base URL") from exc + owns_client = sync_http_client is None + client = sync_http_client or httpx.Client(timeout=timeout, follow_redirects=True) + try: + return client.get( + endpoint_url, + params=params, + headers={"Authorization": f"Bearer {auth_jwe}"}, + timeout=timeout, + ) + except httpx.TimeoutException as exc: + raise AgentStubClientError(f"Agent Stub {endpoint_name} timed out") from exc + except httpx.RequestError as exc: + raise AgentStubClientError(f"Agent Stub {endpoint_name} request failed: {exc}") from exc + finally: + if owns_client: + client.close() + + def _parse_success_response[T: BaseModel]( *, response: httpx.Response, @@ -257,6 +344,8 @@ def _parse_json_payload(response: httpx.Response, *, invalid_json_message: str) __all__ = [ "connect_agent_stub_http_sync", "download_file_bytes_from_signed_url_sync", + "request_agent_stub_drive_commit_http_sync", + "request_agent_stub_drive_manifest_http_sync", "request_agent_stub_file_download_http_sync", "request_agent_stub_file_upload_http_sync", "upload_file_to_signed_url_sync", diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py index c9b4d429e14..18ea874a2a6 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py @@ -6,6 +6,12 @@ from .agent_stub import ( AGENT_STUB_URL_ENV_VAR, AgentStubConnectRequest, AgentStubConnectResponse, + AgentStubDriveCommitItem, + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveFileRef, + AgentStubDriveItem, + AgentStubDriveManifestResponse, AgentStubEndpoint, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, @@ -14,6 +20,8 @@ from .agent_stub import ( AgentStubFileUploadResponse, AgentStubURLScheme, agent_stub_connections_url, + agent_stub_drive_commit_url, + agent_stub_drive_manifest_url, agent_stub_file_download_request_url, agent_stub_file_upload_request_url, is_canonical_dify_file_reference, @@ -27,6 +35,12 @@ __all__ = [ "AGENT_STUB_URL_ENV_VAR", "AgentStubConnectRequest", "AgentStubConnectResponse", + "AgentStubDriveCommitItem", + "AgentStubDriveCommitRequest", + "AgentStubDriveCommitResponse", + "AgentStubDriveFileRef", + "AgentStubDriveItem", + "AgentStubDriveManifestResponse", "AgentStubEndpoint", "AgentStubFileDownloadRequest", "AgentStubFileDownloadResponse", @@ -35,6 +49,8 @@ __all__ = [ "AgentStubFileUploadResponse", "AgentStubURLScheme", "agent_stub_connections_url", + "agent_stub_drive_commit_url", + "agent_stub_drive_manifest_url", "agent_stub_file_download_request_url", "agent_stub_file_upload_request_url", "is_canonical_dify_file_reference", diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py index 1a98778f9e0..16d5fa79532 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py @@ -115,6 +115,16 @@ def agent_stub_file_download_request_url(base_url: str) -> str: return f"{_require_http_base_url(base_url)}/files/download-request" +def agent_stub_drive_manifest_url(base_url: str) -> str: + """Return the stable HTTP drive-manifest endpoint URL for one base URL.""" + return f"{_require_http_base_url(base_url)}/drive/manifest" + + +def agent_stub_drive_commit_url(base_url: str) -> str: + """Return the stable HTTP drive-commit endpoint URL for one base URL.""" + return f"{_require_http_base_url(base_url)}/drive/commit" + + def is_canonical_dify_file_reference(reference: str) -> bool: """Return whether one string matches Dify's opaque file reference format.""" prefix = "dify-file-ref:" @@ -210,6 +220,65 @@ class AgentStubFileDownloadResponse(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class AgentStubDriveFileRef(BaseModel): + """Trusted file reference used by Agent Stub drive commit requests.""" + + kind: Literal["upload_file", "tool_file"] + id: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubDriveCommitItem(BaseModel): + """One drive key to file binding committed through the Agent Stub.""" + + key: str + file_ref: AgentStubDriveFileRef + value_owned_by_drive: bool = True + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubDriveCommitRequest(BaseModel): + """Request body for one Agent Stub drive commit batch.""" + + items: list[AgentStubDriveCommitItem] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubDriveItem(BaseModel): + """One manifest or commit item returned by the Agent Stub drive API.""" + + key: str + size: int | None = None + hash: str | None = None + mime_type: str | None = None + file_kind: Literal["upload_file", "tool_file"] + file_id: str + created_at: int | None = None + download_url: str | None = None + value_owned_by_drive: bool | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubDriveManifestResponse(BaseModel): + """Response body for one Agent Stub drive manifest request.""" + + items: list[AgentStubDriveItem] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubDriveCommitResponse(BaseModel): + """Response body for one Agent Stub drive commit request.""" + + items: list[AgentStubDriveItem] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + def _require_http_base_url(base_url: str) -> str: endpoint = parse_agent_stub_endpoint(base_url) if not endpoint.is_http: @@ -228,6 +297,12 @@ __all__ = [ "AgentStubConnectRequest", "AgentStubConnectResponse", "AgentStubEndpoint", + "AgentStubDriveCommitItem", + "AgentStubDriveCommitRequest", + "AgentStubDriveCommitResponse", + "AgentStubDriveFileRef", + "AgentStubDriveItem", + "AgentStubDriveManifestResponse", "AgentStubFileDownloadRequest", "AgentStubFileDownloadResponse", "AgentStubFileMapping", @@ -235,6 +310,8 @@ __all__ = [ "AgentStubFileUploadResponse", "AgentStubURLScheme", "agent_stub_connections_url", + "agent_stub_drive_commit_url", + "agent_stub_drive_manifest_url", "agent_stub_file_download_request_url", "agent_stub_file_upload_request_url", "is_canonical_dify_file_reference", diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py new file mode 100644 index 00000000000..463cbd4a706 --- /dev/null +++ b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_drive.py @@ -0,0 +1,190 @@ +"""Server-side Dify API client for Agent Stub drive endpoints. + +The Agent Stub drive API is an HTTP-only control plane over the existing Dify +agent drive inner APIs. Sandbox callers never send trusted tenant, agent, or +user ids directly; this module receives an authenticated ``AgentStubPrincipal``, +derives ``agent-`` from execution context, injects trusted identity +fields into the Dify inner request, and normalizes transport, HTTP, JSON, and +schema failures into ``AgentStubDriveRequestError`` for the route layer. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +import httpx +from pydantic import ValidationError + +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveManifestResponse, +) +from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +class AgentStubDriveRequestHandler(Protocol): + """Trusted control-plane bridge from sandbox drive calls to Dify inner APIs.""" + + async def get_manifest( + self, + *, + principal: AgentStubPrincipal, + prefix: str, + include_download_url: bool, + ) -> AgentStubDriveManifestResponse: ... + + async def commit( + self, + *, + principal: AgentStubPrincipal, + request: AgentStubDriveCommitRequest, + ) -> AgentStubDriveCommitResponse: ... + + +class AgentStubDriveRequestError(RuntimeError): + """Raised when the Agent Stub cannot complete one drive control-plane call.""" + + status_code: int + detail: object + + def __init__(self, status_code: int, detail: object) -> None: + self.status_code = status_code + self.detail = detail + super().__init__(str(detail)) + + +@dataclass(slots=True) +class DifyApiAgentStubDriveRequestHandler: + """Call Dify API inner drive endpoints on behalf of authenticated sandboxes. + + Manifest requests require ``tenant_id`` and ``agent_id`` from execution + context and forward query parameters to + ``/inner/api/drive/agent-/manifest``. Commit requests additionally + require ``user_id`` and post a raw JSON payload to + ``/inner/api/drive/agent-/commit``. Dify drive endpoints return + raw ``{"items": [...]}`` payloads instead of plugin-style ``data`` envelopes, + so this module validates the raw success payload directly. + """ + + dify_api_base_url: str + dify_api_inner_api_key: str + timeout: httpx.Timeout | float = 30.0 + + async def get_manifest( + self, + *, + principal: AgentStubPrincipal, + prefix: str, + include_download_url: bool, + ) -> AgentStubDriveManifestResponse: + """Request one drive manifest from Dify's inner drive manifest endpoint.""" + execution_context = self._require_agent_context(principal.execution_context) + payload = await self._get_inner_api( + f"/inner/api/drive/{self._drive_ref(execution_context)}/manifest", + { + "tenant_id": execution_context.tenant_id, + "prefix": prefix, + "include_download_url": str(include_download_url).lower(), + }, + ) + try: + return AgentStubDriveManifestResponse.model_validate(payload) + except ValidationError as exc: + raise AgentStubDriveRequestError(502, "Dify API drive manifest response is invalid") from exc + + async def commit( + self, + *, + principal: AgentStubPrincipal, + request: AgentStubDriveCommitRequest, + ) -> AgentStubDriveCommitResponse: + """Commit one drive batch through Dify's inner drive commit endpoint.""" + execution_context = self._require_user_context(self._require_agent_context(principal.execution_context)) + payload = await self._post_inner_api( + f"/inner/api/drive/{self._drive_ref(execution_context)}/commit", + { + "tenant_id": execution_context.tenant_id, + "user_id": execution_context.user_id, + "items": [item.model_dump(mode="json", exclude_none=True) for item in request.items], + }, + ) + try: + return AgentStubDriveCommitResponse.model_validate(payload) + except ValidationError as exc: + raise AgentStubDriveRequestError(502, "Dify API drive commit response is invalid") from exc + + def _require_agent_context( + self, execution_context: DifyExecutionContextLayerConfig + ) -> DifyExecutionContextLayerConfig: + if execution_context.agent_id is None: + raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations") + return execution_context + + def _require_user_context( + self, execution_context: DifyExecutionContextLayerConfig + ) -> DifyExecutionContextLayerConfig: + if execution_context.user_id is None: + raise AgentStubDriveRequestError(400, "execution context user_id is required for drive commit") + return execution_context + + @staticmethod + def _drive_ref(execution_context: DifyExecutionContextLayerConfig) -> str: + agent_id = execution_context.agent_id + if agent_id is None: + raise AgentStubDriveRequestError(400, "execution context agent_id is required for drive operations") + return f"agent-{agent_id}" + + async def _get_inner_api(self, path: str, params: Mapping[str, str]) -> object: + url = f"{self.dify_api_base_url.rstrip('/')}{path}" + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client: + try: + response = await client.get( + url, + params=dict(params), + headers={"X-Inner-Api-Key": self.dify_api_inner_api_key}, + ) + except httpx.TimeoutException as exc: + raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc + except httpx.RequestError as exc: + raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc + return self._normalize_payload(response) + + async def _post_inner_api(self, path: str, payload: Mapping[str, Any]) -> object: + url = f"{self.dify_api_base_url.rstrip('/')}{path}" + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True, trust_env=False) as client: + try: + response = await client.post( + url, + json=dict(payload), + headers={"X-Inner-Api-Key": self.dify_api_inner_api_key}, + ) + except httpx.TimeoutException as exc: + raise AgentStubDriveRequestError(504, "Dify API drive request timed out") from exc + except httpx.RequestError as exc: + raise AgentStubDriveRequestError(502, f"Dify API drive request failed: {exc}") from exc + return self._normalize_payload(response) + + def _normalize_payload(self, response: httpx.Response) -> object: + raw_payload = self._parse_json(response) + if response.is_error: + detail = raw_payload.get("detail", raw_payload) if isinstance(raw_payload, dict) else raw_payload + raise AgentStubDriveRequestError(response.status_code, detail) + return raw_payload + + @staticmethod + def _parse_json(response: httpx.Response) -> object: + try: + return response.json() + except ValueError as exc: + raise AgentStubDriveRequestError(502, "Dify API drive request returned invalid JSON") from exc + + +__all__ = [ + "AgentStubDriveRequestError", + "AgentStubDriveRequestHandler", + "DifyApiAgentStubDriveRequestHandler", +] diff --git a/dify-agent/src/dify_agent/agent_stub/server/app.py b/dify-agent/src/dify_agent/agent_stub/server/app.py index fa329cd4765..8467dea79f4 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/app.py +++ b/dify-agent/src/dify_agent/agent_stub/server/app.py @@ -2,8 +2,9 @@ The standalone stub server is only a convenience wrapper around the shared router. It reuses the main ``ServerSettings`` model and derives the Agent Stub -token codec and optional file-request bridge from the same helper methods that -the standard run server uses before mounting ``create_agent_stub_router(...)``. +token codec plus optional file and drive request bridges from the same helper +methods that the standard run server uses before mounting +``create_agent_stub_router(...)``. """ from __future__ import annotations @@ -22,6 +23,7 @@ def create_agent_stub_app(settings: ServerSettings | None = None) -> FastAPI: create_agent_stub_router( token_codec=resolved_settings.create_agent_stub_token_codec(), file_request_handler=resolved_settings.create_agent_stub_file_request_handler(), + drive_request_handler=resolved_settings.create_agent_stub_drive_request_handler(), ) ) return app diff --git a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py index 89c9b2e42da..ef747bd6d0e 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py +++ b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py @@ -8,11 +8,15 @@ from uuid import uuid4 from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectResponse, + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveManifestResponse, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, AgentStubFileUploadRequest, AgentStubFileUploadResponse, ) +from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal, AgentStubTokenCodec, AgentStubTokenError @@ -43,11 +47,12 @@ class AgentStubControlPlaneService: HTTP and gRPC adapters validate or decode transport payloads before calling this service, so this layer focuses only on shared auth, connection-id - generation, and file-request delegation. + generation, plus file and drive request delegation. """ token_codec: AgentStubTokenCodec | None file_request_handler: AgentStubFileRequestHandler | None = None + drive_request_handler: AgentStubDriveRequestHandler | None = None connection_id_factory: Callable[[], str] = field(default=lambda: str(uuid4())) async def connect(self, *, authorization: str | None) -> AgentStubConnectResponse: @@ -83,6 +88,39 @@ class AgentStubControlPlaneService: except AgentStubFileRequestError as exc: raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + async def get_drive_manifest( + self, + *, + prefix: str, + include_download_url: bool, + authorization: str | None, + ) -> AgentStubDriveManifestResponse: + """Authenticate and delegate one drive manifest request.""" + principal = self._authenticate(authorization) + handler = self._require_drive_request_handler() + try: + return await handler.get_manifest( + principal=principal, + prefix=prefix, + include_download_url=include_download_url, + ) + except AgentStubDriveRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def commit_drive( + self, + *, + request: AgentStubDriveCommitRequest, + authorization: str | None, + ) -> AgentStubDriveCommitResponse: + """Authenticate and delegate one drive commit request.""" + principal = self._authenticate(authorization) + handler = self._require_drive_request_handler() + try: + return await handler.commit(principal=principal, request=request) + except AgentStubDriveRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + def _authenticate(self, authorization: str | None) -> AgentStubPrincipal: token_codec = self.token_codec if token_codec is None: @@ -97,6 +135,11 @@ class AgentStubControlPlaneService: raise AgentStubConfigurationError(503, "Agent Stub file API is not configured") return self.file_request_handler + def _require_drive_request_handler(self) -> AgentStubDriveRequestHandler: + if self.drive_request_handler is None: + raise AgentStubConfigurationError(503, "Agent Stub drive API is not configured") + return self.drive_request_handler + __all__ = [ "AgentStubAuthenticationError", diff --git a/dify-agent/src/dify_agent/agent_stub/server/router.py b/dify-agent/src/dify_agent/agent_stub/server/router.py index dadde9b053a..fa506ab0188 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/router.py +++ b/dify-agent/src/dify_agent/agent_stub/server/router.py @@ -1,17 +1,18 @@ """Embeddable router factory for Dify Agent stub endpoints. Both the standalone stub server and the standard run server mount the same -router so the Agent Stub protocol, token validation, and file-control-plane -behavior stay identical regardless of hosting mode. The factory is intentionally -settings-agnostic: callers must pass already constructed token-codec and file -handler dependencies rather than having this module read environment variables -or import server settings directly. +router so the Agent Stub protocol, token validation, and file/drive +control-plane behavior stay identical regardless of hosting mode. The factory is +intentionally settings-agnostic: callers must pass already constructed +token-codec and request-handler dependencies rather than having this module read +environment variables or import server settings directly. """ from __future__ import annotations from fastapi import APIRouter +from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec @@ -21,9 +22,10 @@ def create_agent_stub_router( *, token_codec: AgentStubTokenCodec | None, file_request_handler: AgentStubFileRequestHandler | None = None, + drive_request_handler: AgentStubDriveRequestHandler | None = None, ) -> APIRouter: """Build the embeddable stub router from pre-built server dependencies.""" - return create_agent_stub_http_router(token_codec, file_request_handler) + return create_agent_stub_http_router(token_codec, file_request_handler, drive_request_handler) __all__ = ["create_agent_stub_router"] diff --git a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py index cdadb738642..72077c88545 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py @@ -2,8 +2,8 @@ The router is a thin HTTP adapter around ``AgentStubControlPlaneService``. It keeps FastAPI-specific request parsing and HTTPException translation here while -sharing auth, DTO validation, connection-id generation, and file delegation with -the gRPC transport. +sharing auth, DTO validation, connection-id generation, and file/drive +delegation with the gRPC transport. """ from __future__ import annotations @@ -13,11 +13,15 @@ from fastapi import APIRouter, Header, HTTPException from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectRequest, AgentStubConnectResponse, + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveManifestResponse, AgentStubFileDownloadRequest, AgentStubFileDownloadResponse, AgentStubFileUploadRequest, AgentStubFileUploadResponse, ) +from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestHandler from dify_agent.agent_stub.server.control_plane import AgentStubControlPlaneError, AgentStubControlPlaneService from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec @@ -26,10 +30,11 @@ from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec def create_agent_stub_http_router( token_codec: AgentStubTokenCodec | None, file_request_handler: AgentStubFileRequestHandler | None = None, + drive_request_handler: AgentStubDriveRequestHandler | None = None, ) -> APIRouter: """Create HTTP routes bound to the application's Agent Stub dependencies.""" router = APIRouter(prefix="/agent-stub", tags=["agent-stub"]) - service = AgentStubControlPlaneService(token_codec, file_request_handler) + service = AgentStubControlPlaneService(token_codec, file_request_handler, drive_request_handler) @router.post("/connections", response_model=AgentStubConnectResponse) async def create_connection( @@ -62,6 +67,31 @@ def create_agent_stub_http_router( except AgentStubControlPlaneError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + @router.get("/drive/manifest", response_model=AgentStubDriveManifestResponse) + async def get_drive_manifest( + prefix: str = "", + include_download_url: bool = False, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> AgentStubDriveManifestResponse: + try: + return await service.get_drive_manifest( + prefix=prefix, + include_download_url=include_download_url, + authorization=authorization, + ) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.post("/drive/commit", response_model=AgentStubDriveCommitResponse) + async def commit_drive( + request: AgentStubDriveCommitRequest, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> AgentStubDriveCommitResponse: + try: + return await service.commit_drive(request=request, authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return router diff --git a/dify-agent/src/dify_agent/client/_client.py b/dify-agent/src/dify_agent/client/_client.py index 0ab8dc0dcf3..7c9a76e604f 100644 --- a/dify-agent/src/dify_agent/client/_client.py +++ b/dify-agent/src/dify_agent/client/_client.py @@ -46,7 +46,7 @@ from dify_agent.protocol import ( _ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel) _TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"} _TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled"} -_FUNCTION_TOOL_RESULT_PAYLOAD_KEY: str | None = None +_function_tool_result_payload_key_cache: str | None = None class DifyAgentClientError(RuntimeError): @@ -176,13 +176,13 @@ def _function_tool_result_payload_key() -> str: during local development or rolling deploys, so the client normalizes the remote frame into the local schema before Pydantic validation. """ - global _FUNCTION_TOOL_RESULT_PAYLOAD_KEY - if _FUNCTION_TOOL_RESULT_PAYLOAD_KEY is not None: - return _FUNCTION_TOOL_RESULT_PAYLOAD_KEY + global _function_tool_result_payload_key_cache + if _function_tool_result_payload_key_cache is not None: + return _function_tool_result_payload_key_cache parameters = list(inspect.signature(FunctionToolResultEvent).parameters) - _FUNCTION_TOOL_RESULT_PAYLOAD_KEY = "part" if parameters and parameters[0] == "part" else "result" - return _FUNCTION_TOOL_RESULT_PAYLOAD_KEY + _function_tool_result_payload_key_cache = "part" if parameters and parameters[0] == "part" else "result" + return _function_tool_result_payload_key_cache def _normalize_run_event_payload_for_local_pydantic_ai(payload: Any) -> Any: 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 index 48e6c5508d8..bdd9d947565 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py @@ -32,7 +32,7 @@ class DifyPluginLLMDeps(LayerDeps): class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]): """Layer that creates the Dify plugin-daemon Pydantic AI model.""" - type_id: ClassVar[str] = DIFY_PLUGIN_LLM_LAYER_TYPE_ID + type_id: ClassVar[str | None] = DIFY_PLUGIN_LLM_LAYER_TYPE_ID config: DifyPluginLLMLayerConfig diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py index 5ed4a5ea330..dd6c0c3cfb2 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py @@ -58,7 +58,7 @@ class DifyPluginToolsDeps(LayerDeps): class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerConfig]): """Layer that resolves Dify plugin tools into Pydantic AI tools.""" - type_id: ClassVar[str] = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID + type_id: ClassVar[str | None] = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID config: DifyPluginToolsLayerConfig diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py index 3d5efb23d40..42f7011a51c 100644 --- a/dify-agent/src/dify_agent/layers/drive/layer.py +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -21,7 +21,7 @@ from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveL class DifyDriveLayer(PlainLayer[NoLayerDeps, DifyDriveLayerConfig, EmptyRuntimeState]): """Config-only carrier of the drive Skills & Files manifest.""" - type_id: ClassVar[str] = DIFY_DRIVE_LAYER_TYPE_ID + type_id: ClassVar[str | None] = DIFY_DRIVE_LAYER_TYPE_ID config: DifyDriveLayerConfig diff --git a/dify-agent/src/dify_agent/layers/execution_context/__init__.py b/dify-agent/src/dify_agent/layers/execution_context/__init__.py index f1534bceffd..aaf031501b3 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/__init__.py +++ b/dify-agent/src/dify_agent/layers/execution_context/__init__.py @@ -2,7 +2,8 @@ Implementation layers live in sibling modules and require server-side runtime dependencies. Keep this package root import-safe for client code that only -needs to build run requests. +needs to build run requests. Knowledge layers read ``user_from`` from the same +DTO, but that runtime implementation still lives in sibling modules. """ from dify_agent.layers.execution_context.configs import ( diff --git a/dify-agent/src/dify_agent/layers/execution_context/configs.py b/dify-agent/src/dify_agent/layers/execution_context/configs.py index 2b042add7b5..21abe9b9481 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/configs.py +++ b/dify-agent/src/dify_agent/layers/execution_context/configs.py @@ -4,9 +4,11 @@ This layer carries both Dify product execution context (tenant, user, workflow, invoke source) and Agent backend runtime mode. The product-facing fields are used by trusted server-side boundaries such as the Agent Stub when they need to reconstruct Dify API file-access scope without granting the sandbox any -direct inner-API credentials. Server-only plugin-daemon settings are injected -by the runtime provider factory and therefore do not appear in this public -schema. +direct inner-API credentials. Knowledge-base layers also read ``user_from`` from +this shared config so the inner Dify API can distinguish platform-user and +end-user searches without making that caller identity model-controlled. +Server-only plugin-daemon settings are injected by the runtime provider factory +and therefore do not appear in this public schema. """ from typing import ClassVar, Final, Literal, TypeAlias @@ -42,7 +44,7 @@ class DifyExecutionContextLayerConfig(LayerConfig): tenant_id: str user_id: str | None = None - user_from: DifyExecutionContextUserFrom + user_from: DifyExecutionContextUserFrom | None = None app_id: str | None = None workflow_id: str | None = None workflow_run_id: str | None = None diff --git a/dify-agent/src/dify_agent/layers/execution_context/layer.py b/dify-agent/src/dify_agent/layers/execution_context/layer.py index 06ef41ecf42..55a1920f535 100644 --- a/dify-agent/src/dify_agent/layers/execution_context/layer.py +++ b/dify-agent/src/dify_agent/layers/execution_context/layer.py @@ -1,7 +1,8 @@ """Runtime Dify execution-context layer. The public config carries Dify-owned execution identifiers plus the tenant/user -daemon context needed by plugin-backed business layers. Server-only daemon URL +daemon context needed by plugin-backed business layers and the caller identity +needed by knowledge-base layers. Server-only daemon URL and API key are 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 @@ -29,7 +30,7 @@ from dify_agent.layers.execution_context.configs import ( class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, DifyExecutionContextLayerConfig, EmptyRuntimeState]): """Layer that carries Dify execution context without owning live resources.""" - type_id: ClassVar[str] = DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID + type_id: ClassVar[str | None] = DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID config: DifyExecutionContextLayerConfig daemon_url: str diff --git a/dify-agent/src/dify_agent/layers/knowledge/__init__.py b/dify-agent/src/dify_agent/layers/knowledge/__init__.py new file mode 100644 index 00000000000..569512d8004 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/__init__.py @@ -0,0 +1,27 @@ +"""Client-safe exports for Dify knowledge-base layer DTOs and type ids. + +Implementation layers and HTTP clients live in sibling modules so this package +root stays import-safe for callers that only need to construct run requests. +""" + +from dify_agent.layers.knowledge.configs import ( + DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + DifyKnowledgeBaseLayerConfig, + DifyKnowledgeMetadataCondition, + DifyKnowledgeMetadataConditions, + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeModelConfig, + DifyKnowledgeRerankingModelConfig, + DifyKnowledgeRetrievalConfig, +) + +__all__ = [ + "DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID", + "DifyKnowledgeBaseLayerConfig", + "DifyKnowledgeMetadataCondition", + "DifyKnowledgeMetadataConditions", + "DifyKnowledgeMetadataFilteringConfig", + "DifyKnowledgeModelConfig", + "DifyKnowledgeRerankingModelConfig", + "DifyKnowledgeRetrievalConfig", +] diff --git a/dify-agent/src/dify_agent/layers/knowledge/client.py b/dify-agent/src/dify_agent/layers/knowledge/client.py new file mode 100644 index 00000000000..b80e363190a --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/client.py @@ -0,0 +1,214 @@ +"""Async client for the Dify API inner knowledge retrieval endpoint. + +This wrapper owns only request/response mapping and error normalization for +``POST /inner/api/knowledge/retrieve``. The shared ``httpx.AsyncClient`` is +supplied by the FastAPI lifespan/runtime and must stay open for the caller. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import ClassVar + +import httpx +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from dify_agent.layers.knowledge.configs import ( + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeRetrievalConfig, +) + + +class DifyKnowledgeBaseClientError(RuntimeError): + """Raised when the inner knowledge retrieval HTTP boundary fails.""" + + status_code: int | None + error_code: str | None + retryable: bool + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + error_code: str | None = None, + retryable: bool, + ) -> None: + self.status_code = status_code + self.error_code = error_code + self.retryable = retryable + super().__init__(message) + + +class _DifyKnowledgeCaller(BaseModel): + tenant_id: str + user_id: str + app_id: str + user_from: str + invoke_from: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class _DifyKnowledgeRetrieveRequest(BaseModel): + caller: _DifyKnowledgeCaller + dataset_ids: list[str] + query: str + retrieval: dict[str, JsonValue] + metadata_filtering: dict[str, JsonValue] + attachment_ids: list[str] = Field(default_factory=list) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeResultMetadata(BaseModel): + source: str | None = Field(default=None, alias="_source") + dataset_id: str | None = None + dataset_name: str | None = None + document_id: str | None = None + document_name: str | None = None + score: float | int | str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="allow", populate_by_name=True) + + +class DifyKnowledgeResult(BaseModel): + metadata: DifyKnowledgeResultMetadata + title: str | None = None + files: list[JsonValue] | None = None + content: str | None = None + summary: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRetrieveResponse(BaseModel): + results: list[DifyKnowledgeResult] + usage: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +@dataclass(slots=True) +class DifyKnowledgeBaseClient: + """Boundary client for the Dify API inner knowledge retrieval endpoint.""" + + base_url: str + api_key: str = field(repr=False) + http_client: httpx.AsyncClient = field(repr=False) + + def __post_init__(self) -> None: + self.base_url = self.base_url.rstrip("/") + + async def retrieve( + self, + *, + tenant_id: str, + user_id: str, + app_id: str, + user_from: str, + invoke_from: str, + dataset_ids: list[str], + query: str, + retrieval: DifyKnowledgeRetrievalConfig, + metadata_filtering: DifyKnowledgeMetadataFilteringConfig, + ) -> DifyKnowledgeRetrieveResponse: + """Call the inner API and return parsed retrieval results. + + Raises: + DifyKnowledgeBaseClientError: For HTTP, transport, or response-shape + failures. Only ``429``, ``502``, and transport/timeout failures + are marked retryable because the model may continue gracefully in + those temporary-unavailable cases. + """ + request_payload = _DifyKnowledgeRetrieveRequest( + caller=_DifyKnowledgeCaller( + tenant_id=tenant_id, + user_id=user_id, + app_id=app_id, + user_from=user_from, + invoke_from=invoke_from, + ), + dataset_ids=dataset_ids, + query=query, + retrieval=retrieval.to_request_payload(), + metadata_filtering=metadata_filtering.to_request_payload(), + ) + + try: + response = await self.http_client.post( + f"{self.base_url}/inner/api/knowledge/retrieve", + headers={ + "X-Inner-Api-Key": self.api_key, + "Content-Type": "application/json", + }, + json=request_payload.model_dump(mode="json", by_alias=True), + ) + except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc: + raise DifyKnowledgeBaseClientError( + f"Knowledge base search is misconfigured: {exc}", + retryable=False, + ) from exc + except httpx.TimeoutException as exc: + raise DifyKnowledgeBaseClientError( + "Knowledge base search timed out.", + retryable=True, + ) from exc + except httpx.RequestError as exc: + raise DifyKnowledgeBaseClientError( + f"Knowledge base search request failed: {exc}", + retryable=True, + ) from exc + + if response.status_code >= 400: + raise _build_http_error(response) + + try: + return DifyKnowledgeRetrieveResponse.model_validate_json(response.text) + except ValidationError as exc: + raise DifyKnowledgeBaseClientError( + "Invalid knowledge retrieval response from Dify API.", + status_code=response.status_code, + error_code="invalid_response", + retryable=False, + ) from exc + + +def _build_http_error(response: httpx.Response) -> DifyKnowledgeBaseClientError: + detail = _decode_error_detail(response) + retryable = response.status_code in {429, 502} + message = detail["message"] or f"HTTP {response.status_code}" + return DifyKnowledgeBaseClientError( + message, + status_code=response.status_code, + error_code=detail["error_code"], + retryable=retryable, + ) + + +def _decode_error_detail(response: httpx.Response) -> dict[str, str | None]: + raw_body = response.text + try: + payload = response.json() + except json.JSONDecodeError: + payload = None + + if isinstance(payload, dict): + error_code = payload.get("code") + message = payload.get("message") + return { + "error_code": error_code if isinstance(error_code, str) else None, + "message": message if isinstance(message, str) and message else raw_body or f"HTTP {response.status_code}", + } + + return {"error_code": None, "message": raw_body or f"HTTP {response.status_code}"} + + +__all__ = [ + "DifyKnowledgeBaseClient", + "DifyKnowledgeBaseClientError", + "DifyKnowledgeResult", + "DifyKnowledgeResultMetadata", + "DifyKnowledgeRetrieveResponse", +] diff --git a/dify-agent/src/dify_agent/layers/knowledge/configs.py b/dify-agent/src/dify_agent/layers/knowledge/configs.py new file mode 100644 index 00000000000..9ada075d1cc --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/configs.py @@ -0,0 +1,200 @@ +"""Client-safe DTOs for the Dify knowledge-base Agenton layer. + +The public layer config exposes only static retrieval controls: dataset ids, +retrieval strategy, metadata filtering, and observation-size limits. The agent +model itself should only ever see a single ``query`` tool argument; tenant/ +app/user context comes from the execution-context layer and the actual +retrieval is delegated to the Dify API inner endpoint. Tool naming is not +caller-configurable: the runtime always exposes the same stable knowledge-base +search tool. +""" + +from __future__ import annotations + +from typing import ClassVar, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator + +from agenton.layers import LayerConfig + +type DifyKnowledgeMetadataComparisonOperator = Literal[ + "contains", + "not contains", + "start with", + "end with", + "is", + "is not", + "empty", + "not empty", + "in", + "not in", + "=", + "≠", + ">", + "<", + "≥", + "≤", + "before", + "after", +] + +DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID: Final[str] = "dify.knowledge_base" + + +class DifyKnowledgeModelConfig(BaseModel): + """Static model configuration forwarded to the inner retrieval API.""" + + provider: str + name: str + mode: str + completion_params: dict[str, JsonValue] = Field(default_factory=dict) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRerankingModelConfig(BaseModel): + """Reranking model settings for multiple-mode retrieval.""" + + provider: str + model: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeRetrievalConfig(BaseModel): + """Static retrieval controls mirrored into the inner API request.""" + + mode: Literal["multiple", "single"] + top_k: int | None = Field(default=None, ge=1) + score_threshold: float = 0.0 + reranking_mode: str = "reranking_model" + reranking_enable: bool = True + reranking_model: DifyKnowledgeRerankingModelConfig | None = None + weights: dict[str, JsonValue] | None = None + model: DifyKnowledgeModelConfig | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> DifyKnowledgeRetrievalConfig: + if self.mode == "multiple" and self.top_k is None: + raise ValueError("retrieval.top_k is required for multiple mode") + if self.mode == "single" and self.model is None: + raise ValueError("retrieval.model is required for single mode") + return self + + def to_request_payload(self) -> dict[str, JsonValue]: + """Serialize the retrieval config into the inner API request shape.""" + payload: dict[str, JsonValue] = { + "mode": self.mode, + "score_threshold": self.score_threshold, + "reranking_mode": self.reranking_mode, + "reranking_enable": self.reranking_enable, + } + if self.mode == "multiple": + payload["top_k"] = self.top_k + payload["reranking_model"] = ( + self.reranking_model.model_dump(mode="json") if self.reranking_model is not None else None + ) + payload["weights"] = self.weights + else: + payload["model"] = self.model.model_dump(mode="json") if self.model is not None else None + return payload + + +class DifyKnowledgeMetadataCondition(BaseModel): + """One manual metadata filter clause.""" + + name: str + comparison_operator: DifyKnowledgeMetadataComparisonOperator + value: str | list[str] | int | float | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeMetadataConditions(BaseModel): + """Boolean composition for manual metadata filtering.""" + + logical_operator: Literal["and", "or"] = "and" + conditions: list[DifyKnowledgeMetadataCondition] + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class DifyKnowledgeMetadataFilteringConfig(BaseModel): + """Static metadata filtering controls for the inner API request.""" + + mode: Literal["disabled", "automatic", "manual"] = "disabled" + metadata_model_config: DifyKnowledgeModelConfig | None = Field(default=None, alias="model_config") + conditions: DifyKnowledgeMetadataConditions | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", populate_by_name=True) + + @model_validator(mode="after") + def validate_mode_specific_fields(self) -> DifyKnowledgeMetadataFilteringConfig: + if self.mode == "automatic" and self.metadata_model_config is None: + raise ValueError("metadata_filtering.model_config is required for automatic mode") + if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions): + raise ValueError("metadata_filtering.conditions is required for manual mode") + return self + + def to_request_payload(self) -> dict[str, JsonValue]: + """Serialize metadata filtering using the inner API request field names.""" + if self.mode == "disabled": + return {"mode": self.mode} + + payload: dict[str, JsonValue] = {"mode": self.mode} + if self.metadata_model_config is not None: + payload["model_config"] = self.metadata_model_config.model_dump(mode="json") + if self.conditions is not None: + payload["conditions"] = self.conditions.model_dump(mode="json") + return payload + + +class DifyKnowledgeBaseLayerConfig(LayerConfig): + """Public config for one model-visible knowledge search tool. + + The model only gets to choose whether to call the tool and what ``query`` + to send. Dataset ids, retrieval settings, metadata filtering, and caller + context remain config/runtime concerns outside the model-visible tool + schema. The tool name and description are fixed by the layer runtime and do + not appear in the public config DTO. + """ + + dataset_ids: list[str] + retrieval: DifyKnowledgeRetrievalConfig + metadata_filtering: DifyKnowledgeMetadataFilteringConfig = Field( + default_factory=DifyKnowledgeMetadataFilteringConfig + ) + max_result_content_chars: int = Field(default=2000, ge=1) + max_observation_chars: int = Field(default=12000, ge=1) + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @field_validator("dataset_ids") + @classmethod + def validate_dataset_ids(cls, value: list[str]) -> list[str]: + if not value: + raise ValueError("dataset_ids must contain at least one item") + normalized_ids = [item.strip() for item in value] + if any(not item for item in normalized_ids): + raise ValueError("dataset_ids must not contain blank items") + return normalized_ids + + @model_validator(mode="after") + def validate_observation_limits(self) -> DifyKnowledgeBaseLayerConfig: + if self.max_observation_chars < self.max_result_content_chars: + raise ValueError("max_observation_chars must be greater than or equal to max_result_content_chars") + return self + + +__all__ = [ + "DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID", + "DifyKnowledgeBaseLayerConfig", + "DifyKnowledgeMetadataCondition", + "DifyKnowledgeMetadataConditions", + "DifyKnowledgeMetadataFilteringConfig", + "DifyKnowledgeModelConfig", + "DifyKnowledgeRerankingModelConfig", + "DifyKnowledgeRetrievalConfig", +] diff --git a/dify-agent/src/dify_agent/layers/knowledge/layer.py b/dify-agent/src/dify_agent/layers/knowledge/layer.py new file mode 100644 index 00000000000..16605a9ceb3 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/knowledge/layer.py @@ -0,0 +1,285 @@ +"""Dify knowledge-base layer exposing one model-visible search tool. + +The layer depends on ``DifyExecutionContextLayer`` for tenant/app/user/invoke +identity, keeps retrieval controls in config only, and borrows a lifespan-owned +HTTP client for each tool invocation. It never owns live clients or stores +retrieved source content in layer state. Tool identity is intentionally fixed at +runtime: callers cannot rename the knowledge tool or override its description +through public layer config because the model-visible surface must stay stable +across API-side Agent Soul mappings. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import ClassVar, cast + +import httpx +from pydantic_ai import RunContext, Tool +from pydantic_ai.tools import ToolDefinition +from typing_extensions import Self, override + +from agenton.layers import LayerDeps, PlainLayer +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.client import ( + DifyKnowledgeBaseClient, + DifyKnowledgeBaseClientError, + DifyKnowledgeRetrieveResponse, +) +from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig + +logger = logging.getLogger(__name__) + +# Fixed model-visible tool identity. These stay module-private on purpose so the +# public DTO cannot grow a parallel naming contract that diverges from the +# runtime knowledge-search surface. +_KNOWLEDGE_BASE_TOOL_NAME = "knowledge_base_search" +_KNOWLEDGE_BASE_TOOL_DESCRIPTION = "Search configured knowledge bases for information relevant to the query." +BLANK_QUERY_OBSERVATION = "knowledge base search requires a non-empty query" +NO_RESULTS_OBSERVATION = "No relevant knowledge base results were found." +TEMPORARY_UNAVAILABLE_OBSERVATION = ( + "Knowledge base search is temporarily unavailable. Please continue without it if possible." +) +QUERY_TOOL_SCHEMA = { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query for the configured knowledge bases.", + } + }, + "required": ["query"], + "additionalProperties": False, +} + + +class DifyKnowledgeBaseDeps(LayerDeps): + """Dependencies required by ``DifyKnowledgeBaseLayer``.""" + + execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable] + + +@dataclass(slots=True) +class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig]): + """Layer that resolves one config-scoped knowledge search tool.""" + + type_id: ClassVar[str | None] = DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID + + config: DifyKnowledgeBaseLayerConfig + dify_api_inner_url: str + dify_api_inner_api_key: str + + @classmethod + @override + def from_config(cls, config: DifyKnowledgeBaseLayerConfig) -> Self: + """Reject construction without server-injected Dify API settings.""" + del config + raise TypeError( + "DifyKnowledgeBaseLayer requires server-side Dify API settings and must use a provider factory." + ) + + @classmethod + def from_config_with_settings( + cls, + config: DifyKnowledgeBaseLayerConfig, + *, + dify_api_inner_url: str, + dify_api_inner_api_key: str, + ) -> Self: + """Create the layer from public config plus server-only API settings.""" + return cls( + config=DifyKnowledgeBaseLayerConfig.model_validate(config), + dify_api_inner_url=dify_api_inner_url, + dify_api_inner_api_key=dify_api_inner_api_key, + ) + + async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + """Build one Pydantic AI tool that exposes only ``query`` to the model. + + Knowledge tools depend on execution-context identity that is optional for + other run types but mandatory here: ``tenant_id``, ``user_id``, + ``user_from``, ``app_id``, and ``invoke_from`` must all be present before + any HTTP request is attempted. Tool execution then follows a strict + observation policy: + + - blank ``query`` returns a local validation observation; + - retryable client failures (timeouts, connection failures, HTTP + ``429``/``502``) become a temporary-unavailable observation; + - non-retryable client failures are raised so the run fails fast. + """ + if http_client.is_closed: + raise RuntimeError("DifyKnowledgeBaseLayer.get_tools() requires an open shared HTTP client.") + + execution_context = self.deps.execution_context.config + caller = _build_caller_context(execution_context) + client = DifyKnowledgeBaseClient( + base_url=self.dify_api_inner_url, + api_key=self.dify_api_inner_api_key, + http_client=http_client, + ) + + async def knowledge_base_search(_ctx: RunContext[object], query: str) -> str: + normalized_query = query.strip() + if not normalized_query: + return BLANK_QUERY_OBSERVATION + try: + response = await client.retrieve( + tenant_id=caller["tenant_id"], + user_id=caller["user_id"], + app_id=caller["app_id"], + user_from=caller["user_from"], + invoke_from=caller["invoke_from"], + dataset_ids=list(self.config.dataset_ids), + query=normalized_query, + retrieval=self.config.retrieval, + metadata_filtering=self.config.metadata_filtering, + ) + except DifyKnowledgeBaseClientError as exc: + if exc.retryable: + logger.warning( + "knowledge base search temporarily unavailable", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + return TEMPORARY_UNAVAILABLE_OBSERVATION + logger.error( + "knowledge base search failed", + extra={ + "tenant_id": caller["tenant_id"], + "app_id": caller["app_id"], + "invoke_from": caller["invoke_from"], + "error_code": exc.error_code, + "status_code": exc.status_code, + }, + ) + raise + return _format_observation(response, self.config) + + async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition: + return ToolDefinition( + name=tool_def.name, + description=tool_def.description, + parameters_json_schema=QUERY_TOOL_SCHEMA, + strict=tool_def.strict, + sequential=tool_def.sequential, + metadata=tool_def.metadata, + timeout=tool_def.timeout, + defer_loading=tool_def.defer_loading, + kind=tool_def.kind, + return_schema=tool_def.return_schema, + include_return_schema=tool_def.include_return_schema, + ) + + return [ + Tool( + knowledge_base_search, + takes_ctx=True, + name=_KNOWLEDGE_BASE_TOOL_NAME, + description=_KNOWLEDGE_BASE_TOOL_DESCRIPTION, + prepare=prepare_tool_definition, + ) + ] + + +def _build_caller_context(execution_context: object) -> dict[str, str]: + """Extract the inner-API caller identity from execution-context config. + + The public execution-context DTO keeps several fields optional for general + runs, but knowledge retrieval requires all of ``tenant_id``, ``user_id``, + ``user_from``, ``app_id``, and ``invoke_from``. Missing or blank values are + rejected here so misconfigured runs fail before transport rather than being + softened into tool observations. + """ + tenant_id = getattr(execution_context, "tenant_id", None) + user_id = getattr(execution_context, "user_id", None) + user_from = getattr(execution_context, "user_from", None) + app_id = getattr(execution_context, "app_id", None) + invoke_from = getattr(execution_context, "invoke_from", None) + + missing_fields = [ + field_name + for field_name, value in ( + ("tenant_id", tenant_id), + ("user_id", user_id), + ("user_from", user_from), + ("app_id", app_id), + ("invoke_from", invoke_from), + ) + if not isinstance(value, str) or not value.strip() + ] + if missing_fields: + joined_fields = ", ".join(missing_fields) + raise ValueError(f"Dify knowledge base layer requires execution context fields: {joined_fields}") + + normalized_tenant_id = cast(str, tenant_id).strip() + normalized_user_id = cast(str, user_id).strip() + normalized_user_from = cast(str, user_from).strip() + normalized_app_id = cast(str, app_id).strip() + normalized_invoke_from = cast(str, invoke_from).strip() + + return { + "tenant_id": normalized_tenant_id, + "user_id": normalized_user_id, + "user_from": normalized_user_from, + "app_id": normalized_app_id, + "invoke_from": normalized_invoke_from, + } + + +def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKnowledgeBaseLayerConfig) -> str: + """Render inner-API retrieval results into the model-visible tool response. + + The formatting contract is intentionally simple and stable for the model: + + - empty ``results`` returns ``NO_RESULTS_OBSERVATION``; + - non-empty results become a numbered list headed by + ``"Knowledge base search results:"``; + - each item includes title plus dataset/document/score metadata when those + fields are present; + - each content snippet is truncated by ``max_result_content_chars``; + - the final observation is truncated by ``max_observation_chars``. + """ + if not response.results: + return NO_RESULTS_OBSERVATION + + lines = ["Knowledge base search results:"] + for index, result in enumerate(response.results, start=1): + metadata = result.metadata + title = result.title or metadata.document_name or "Untitled" + lines.append(f"{index}. Title: {title}") + if metadata.dataset_name: + lines.append(f" Dataset: {metadata.dataset_name}") + if metadata.document_name: + lines.append(f" Document: {metadata.document_name}") + if metadata.score is not None: + lines.append(f" Score: {metadata.score}") + content = _truncate_text(result.content or result.summary or "", config.max_result_content_chars) + if content: + lines.append(f" Content: {content}") + lines.append("") + + return _truncate_text("\n".join(lines).rstrip(), config.max_observation_chars) + + +def _truncate_text(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + if max_chars <= 3: + return text[:max_chars] + return f"{text[: max_chars - 3]}..." + + +__all__ = [ + "BLANK_QUERY_OBSERVATION", + "DifyKnowledgeBaseDeps", + "DifyKnowledgeBaseLayer", + "NO_RESULTS_OBSERVATION", + "QUERY_TOOL_SCHEMA", + "TEMPORARY_UNAVAILABLE_OBSERVATION", +] diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 81bfcd48e2f..a637dce7767 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -4,23 +4,23 @@ Only explicitly allowed provider type ids are constructible here. The default provider set contains prompt layers, the optional pydantic-ai history layer, the state-free Dify structured output layer, the optional Dify ask-human layer, the Dify execution-context layer, the stateful Dify shell layer, and the Dify -plugin business-layer family: +plugin/knowledge business-layer family: - ``dify.drive`` for the inert Skills & Files drive declaration, - ``dify.execution_context`` for shared tenant/user/run daemon context, - ``dify.shell`` for shellctl-backed shell job control, -- ``dify.plugin.llm`` for plugin-backed model selection, and -- ``dify.plugin.tools`` for prepared plugin tool exposure. +- ``dify.plugin.llm`` for plugin-backed model selection, +- ``dify.plugin.tools`` for prepared plugin tool exposure, and +- ``dify.knowledge_base`` for inner-API-backed knowledge search tools. Public DTOs provide Dify context plus plugin/model/tool data, while server-only -plugin daemon settings are injected through the provider factory for -``DifyExecutionContextLayer`` and the optional shellctl entrypoint/auth token plus -client factory plus optional Agent Stub URL/token issuer are injected for -``DifyShellLayer``. The resulting ``Compositor`` -remains Agenton state-only at the snapshot boundary: live resources such as -HTTP clients are injected by runtime-owned providers, may be held on active -layer instances inside ``resource_context()``, and never enter session -snapshots. +plugin daemon settings and Dify API inner settings are injected through provider +factories. Optional shellctl entrypoint/auth token, client factory, and Agent +Stub URL/token issuer are injected for ``DifyShellLayer``. The resulting +``Compositor`` remains Agenton state-only at the snapshot boundary: live +resources such as HTTP clients are injected by runtime-owned providers, may be +held on active layer instances inside ``resource_context()``, and never enter +session snapshots. """ from collections.abc import Mapping, Sequence @@ -41,6 +41,8 @@ from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.output.output_layer import DifyOutputLayer from dify_agent.layers.shell.configs import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory @@ -52,6 +54,8 @@ def create_default_layer_providers( *, plugin_daemon_url: str = "http://localhost:5002", plugin_daemon_api_key: str = "", + dify_api_inner_url: str = "http://localhost:5001", + dify_api_inner_api_key: str = "", shellctl_entrypoint: str | None = None, shellctl_auth_token: str | None = None, agent_stub_url: str | None = None, @@ -109,6 +113,14 @@ def create_default_layer_providers( ), LayerProvider.from_layer_type(DifyPluginLLMLayer), LayerProvider.from_layer_type(DifyPluginToolsLayer), + LayerProvider.from_factory( + layer_type=DifyKnowledgeBaseLayer, + create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings( + DifyKnowledgeBaseLayerConfig.model_validate(config), + dify_api_inner_url=dify_api_inner_url, + dify_api_inner_api_key=dify_api_inner_api_key, + ), + ), ) diff --git a/dify-agent/src/dify_agent/runtime/run_scheduler.py b/dify-agent/src/dify_agent/runtime/run_scheduler.py index 9dfc93b8465..4186b6afd76 100644 --- a/dify-agent/src/dify_agent/runtime/run_scheduler.py +++ b/dify-agent/src/dify_agent/runtime/run_scheduler.py @@ -69,6 +69,7 @@ class RunScheduler: runner_factory: RunRunnerFactory layer_providers: tuple[LayerProviderInput, ...] plugin_daemon_http_client: httpx.AsyncClient + dify_api_http_client: httpx.AsyncClient _lifecycle_lock: asyncio.Lock def __init__( @@ -76,6 +77,7 @@ class RunScheduler: *, store: RunStore, plugin_daemon_http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, shutdown_grace_seconds: float = 30, layer_providers: tuple[LayerProviderInput, ...] | None = None, runner_factory: RunRunnerFactory | None = None, @@ -85,6 +87,7 @@ class RunScheduler: self.active_tasks = {} self.stopping = False self.plugin_daemon_http_client = plugin_daemon_http_client + self.dify_api_http_client = dify_api_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() @@ -141,6 +144,7 @@ class RunScheduler: request=request, run_id=record.run_id, plugin_daemon_http_client=self.plugin_daemon_http_client, + dify_api_http_client=self.dify_api_http_client, layer_providers=self.layer_providers, ) diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 8a6d7b9bd91..d10b1843e9a 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -19,7 +19,9 @@ publishes that deferred request through the normal ``run_succeeded`` event as ``deferred_tool_call`` instead of a final ``output``. Invalid structured outputs or invalid deferred-tool behavior still trigger normal retries/failures before Dify Agent emits success. Layers still never own the FastAPI lifespan-owned -plugin daemon HTTP client. +plugin daemon or Dify API inner HTTP clients. Successful terminal events contain +both the JSON-safe final output or deferred tool call and the session snapshot; +there are no separate output or snapshot events to correlate. """ from collections.abc import AsyncIterable @@ -38,6 +40,7 @@ from agenton.layers.types import PydanticAITool from dify_agent.layers.ask_human.layer import get_ask_human_layer, validate_ask_human_layer_composition from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.protocol.schemas import ( CreateRunRequest, DIFY_AGENT_MODEL_LAYER_ID, @@ -91,6 +94,7 @@ class AgentRunRunner: run_id: str layer_providers: tuple[LayerProviderInput, ...] plugin_daemon_http_client: httpx.AsyncClient + dify_api_http_client: httpx.AsyncClient def __init__( self, @@ -99,12 +103,14 @@ class AgentRunRunner: request: CreateRunRequest, run_id: str, plugin_daemon_http_client: httpx.AsyncClient, + dify_api_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.dify_api_http_client = dify_api_http_client self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers() async def run(self) -> None: @@ -187,7 +193,11 @@ class AgentRunRunner: ask_human_layer = get_ask_human_layer(run) llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer) model = llm_layer.get_model(http_client=self.plugin_daemon_http_client) - tools = await _resolve_run_tools(run, http_client=self.plugin_daemon_http_client) + tools = await _resolve_run_tools( + run, + plugin_daemon_http_client=self.plugin_daemon_http_client, + dify_api_http_client=self.dify_api_http_client, + ) except (KeyError, TypeError, RuntimeError, ValueError) as exc: raise AgentRunValidationError(str(exc)) from exc @@ -266,14 +276,17 @@ def _resolve_deferred_tool_results(request: CreateRunRequest) -> DeferredToolRes async def _resolve_run_tools( run: Any, *, - http_client: httpx.AsyncClient, + plugin_daemon_http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, ) -> list[PydanticAITool[object]]: - """Return the static compositor tools plus any Dify plugin runtime tools.""" + """Return the static compositor tools plus any Dify runtime tools.""" resolved_tools = list(cast(list[PydanticAITool[object]], run.tools)) for slot in run.slots.values(): layer = slot.layer if isinstance(layer, DifyPluginToolsLayer): - resolved_tools.extend(await layer.get_tools(http_client=http_client)) + resolved_tools.extend(await layer.get_tools(http_client=plugin_daemon_http_client)) + if isinstance(layer, DifyKnowledgeBaseLayer): + resolved_tools.extend(await layer.get_tools(http_client=dify_api_http_client)) _validate_unique_tool_names(resolved_tools) return resolved_tools diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index f4eab601a2e..42b406799bb 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -1,15 +1,16 @@ """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 receive shell-layer server settings through provider -construction rather than reading environment variables themselves. The standard -server always mounts the HTTP Agent Stub router and additionally starts the -optional grpclib Agent Stub server when ``DIFY_AGENT_STUB_URL`` uses ``grpc://``. +The HTTP process owns Redis clients plus separate shared ``httpx.AsyncClient`` +instances for plugin-daemon and Dify API inner calls, 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 clients through the runner and +receive shell-layer server settings through provider construction rather than +reading environment variables themselves. The standard server always mounts the +HTTP Agent Stub router and additionally starts the optional grpclib Agent Stub +server when ``DIFY_AGENT_STUB_URL`` uses ``grpc://``. """ from collections.abc import AsyncGenerator @@ -36,9 +37,12 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: resolved_settings = settings or ServerSettings() agent_stub_token_codec = resolved_settings.create_agent_stub_token_codec() agent_stub_file_request_handler = resolved_settings.create_agent_stub_file_request_handler() + agent_stub_drive_request_handler = resolved_settings.create_agent_stub_drive_request_handler() layer_providers = create_default_layer_providers( plugin_daemon_url=resolved_settings.plugin_daemon_url, plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key, + dify_api_inner_url=resolved_settings.dify_api_inner_url, + dify_api_inner_api_key=resolved_settings.dify_api_inner_api_key or "", shellctl_entrypoint=resolved_settings.shellctl_entrypoint, shellctl_auth_token=resolved_settings.shellctl_auth_token, agent_stub_url=resolved_settings.agent_stub_url, @@ -53,6 +57,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: 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) + dify_api_inner_http_client = create_dify_api_inner_http_client(resolved_settings) store = RedisRunStore( redis, prefix=resolved_settings.redis_prefix, @@ -61,6 +66,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: scheduler = RunScheduler( store=store, plugin_daemon_http_client=plugin_daemon_http_client, + dify_api_http_client=dify_api_inner_http_client, shutdown_grace_seconds=resolved_settings.shutdown_grace_seconds, layer_providers=layer_providers, ) @@ -83,6 +89,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: if grpc_server is not None: await grpc_server.aclose() await scheduler.shutdown() + await dify_api_inner_http_client.aclose() await plugin_daemon_http_client.aclose() await redis.aclose() @@ -100,6 +107,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: create_agent_stub_router( token_codec=agent_stub_token_codec, file_request_handler=agent_stub_file_request_handler, + drive_request_handler=agent_stub_drive_request_handler, ) ) return app @@ -112,17 +120,28 @@ def create_plugin_daemon_http_client(settings: ServerSettings) -> httpx.AsyncCli process and must be closed by the app lifespan after the scheduler has stopped using it. """ + return _create_shared_http_client(settings) + + +def create_dify_api_inner_http_client(settings: ServerSettings) -> httpx.AsyncClient: + """Create the lifespan-owned Dify API inner HTTP client. + + The Dify API inner client intentionally shares the generic outbound HTTP + timeout and connection-pool settings with the plugin daemon client so + operational tuning stays in one place while endpoint URL/API keys remain + distinct server settings. + """ + return _create_shared_http_client(settings) + + +def _create_shared_http_client(settings: ServerSettings) -> httpx.AsyncClient: + """Build one shared HTTP client using generic outbound timeout/pool settings.""" 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, - ), + timeout=settings.create_outbound_http_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, + max_connections=settings.outbound_http_max_connections, + max_keepalive_connections=settings.outbound_http_max_keepalive_connections, + keepalive_expiry=settings.outbound_http_keepalive_expiry, ), trust_env=False, ) @@ -131,4 +150,4 @@ def create_plugin_daemon_http_client(settings: ServerSettings) -> httpx.AsyncCli app = create_app() -__all__ = ["app", "create_app", "create_plugin_daemon_http_client"] +__all__ = ["app", "create_app", "create_dify_api_inner_http_client", "create_plugin_daemon_http_client"] diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index 7c24fbb9f9b..b85fcffdc36 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -1,20 +1,24 @@ """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. The Agent Stub now also uses this main server settings -model directly: the public Agent Stub URL, server secret, optional gRPC bind -override, and optional Dify inner API file-request settings all live here under -the longstanding ``DIFY_AGENT_...`` environment-variable namespace. +Outbound HTTP client settings describe the FastAPI lifespan-owned +``httpx.AsyncClient`` instances shared by local run tasks for plugin-daemon and +Dify API inner calls. Layers and Agenton providers do not own those clients, so +these settings are process resource limits rather than per-run lifecycle knobs. +Endpoint URLs and API keys stay service-specific. The Agent Stub also uses this +settings model directly: the public Agent Stub URL, server secret, optional gRPC +bind override, and optional Dify inner API file/drive request settings all live +here under the longstanding ``DIFY_AGENT_...`` environment-variable namespace. """ +import httpx + from typing import ClassVar from pydantic import AnyHttpUrl, Field, TypeAdapter, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from dify_agent.agent_stub.protocol.agent_stub import normalize_agent_stub_url, parse_agent_stub_endpoint +from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.grpc_bind import normalize_agent_stub_grpc_bind_address from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec, decode_server_secret_key @@ -23,7 +27,7 @@ DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60 class ServerSettings(BaseSettings): - """Environment-backed settings for Redis, scheduling, plugin, and shell access.""" + """Environment-backed settings for Redis, scheduling, outbound HTTP, and shell access.""" redis_url: str = "redis://localhost:6379/0" redis_prefix: str = "dify-agent" @@ -31,6 +35,7 @@ class ServerSettings(BaseSettings): run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1) plugin_daemon_url: str = "http://localhost:5002" plugin_daemon_api_key: str = "" + dify_api_inner_url: str = "http://localhost:5001" dify_api_base_url: str | None = None dify_api_inner_api_key: str | None = None shellctl_entrypoint: str | None = None @@ -38,13 +43,13 @@ class ServerSettings(BaseSettings): agent_stub_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_URL") agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS") server_secret_key: str | None = None - 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) + outbound_http_connect_timeout: float = Field(default=10.0, ge=0) + outbound_http_read_timeout: float = Field(default=600.0, ge=0) + outbound_http_write_timeout: float = Field(default=30.0, ge=0) + outbound_http_pool_timeout: float = Field(default=10.0, ge=0) + outbound_http_max_connections: int = Field(default=100, ge=1) + outbound_http_max_keepalive_connections: int = Field(default=20, ge=0) + outbound_http_keepalive_expiry: float = Field(default=30.0, ge=0) model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( env_prefix="DIFY_AGENT_", @@ -116,7 +121,7 @@ class ServerSettings(BaseSettings): @model_validator(mode="after") def validate_agent_stub_requirements(self) -> "ServerSettings": - """Require the server secret and Dify API file settings in valid pairs.""" + """Require Agent Stub settings while allowing knowledge-only inner API keys.""" if self.agent_stub_url is not None and self.server_secret_key is None: raise ValueError("DIFY_AGENT_SERVER_SECRET_KEY is required when DIFY_AGENT_STUB_URL is set.") if self.agent_stub_grpc_bind_address is not None: @@ -124,8 +129,8 @@ class ServerSettings(BaseSettings): raise ValueError("DIFY_AGENT_STUB_URL is required when DIFY_AGENT_STUB_GRPC_BIND_ADDRESS is set.") if not parse_agent_stub_endpoint(self.agent_stub_url).is_grpc: raise ValueError("DIFY_AGENT_STUB_GRPC_BIND_ADDRESS requires a grpc:// DIFY_AGENT_STUB_URL.") - if (self.dify_api_base_url is None) != (self.dify_api_inner_api_key is None): - raise ValueError("DIFY_AGENT_DIFY_API_BASE_URL and DIFY_AGENT_DIFY_API_INNER_API_KEY must be set together.") + if self.dify_api_base_url is not None and self.dify_api_inner_api_key is None: + raise ValueError("DIFY_AGENT_DIFY_API_INNER_API_KEY is required when DIFY_AGENT_DIFY_API_BASE_URL is set.") return self def create_agent_stub_token_codec(self) -> AgentStubTokenCodec | None: @@ -143,5 +148,28 @@ class ServerSettings(BaseSettings): dify_api_inner_api_key=self.dify_api_inner_api_key, ) + def create_agent_stub_drive_request_handler(self) -> DifyApiAgentStubDriveRequestHandler | None: + """Return the Dify API drive bridge when both Dify API settings are configured. + + Drive manifest and commit requests should honor the same outbound timeout + settings as the server's other trusted Dify API HTTP calls. + """ + if self.dify_api_base_url is None or self.dify_api_inner_api_key is None: + return None + return DifyApiAgentStubDriveRequestHandler( + dify_api_base_url=self.dify_api_base_url, + dify_api_inner_api_key=self.dify_api_inner_api_key, + timeout=self.create_outbound_http_timeout(), + ) + + def create_outbound_http_timeout(self) -> httpx.Timeout: + """Build one shared outbound HTTP timeout object from server settings.""" + return httpx.Timeout( + connect=self.outbound_http_connect_timeout, + read=self.outbound_http_read_timeout, + write=self.outbound_http_write_timeout, + pool=self.outbound_http_pool_timeout, + ) + __all__ = ["DEFAULT_RUN_RETENTION_SECONDS", "ServerSettings"] diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py new file mode 100644 index 00000000000..ff52bb6b4f8 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py @@ -0,0 +1,651 @@ +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +import stat +from zipfile import ZipFile, ZipInfo + +import pytest + +from dify_agent.agent_stub.cli._drive import ( + list_drive_from_environment, + pull_drive_from_environment, + push_drive_from_environment, +) +from dify_agent.agent_stub.cli._files import UploadedToolFileMapping, UploadedToolFileResource +from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitRequest, + AgentStubDriveCommitResponse, + AgentStubDriveItem, + AgentStubDriveManifestResponse, +) + + +def test_list_drive_from_environment_returns_manifest_json_model(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + captured: dict[str, object] = {} + + def fake_manifest(**kwargs): + captured.update(kwargs) + return AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=12, + hash="sha256:abc", + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + ) + ] + ) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + fake_manifest, + ) + + result = list_drive_from_environment(prefix="skills/", json_output=True) + + assert isinstance(result, AgentStubDriveManifestResponse) + assert result.items[0].key == "skills/example/SKILL.md" + assert captured["prefix"] == "skills/" + assert captured["include_download_url"] is False + + +def test_list_drive_from_environment_returns_human_readable_listing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + captured: dict[str, object] = {} + + def fake_manifest(**kwargs): + captured.update(kwargs) + return AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=12, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + ), + AgentStubDriveItem( + key="skills/example/helper.py", + size=None, + hash="sha256:abc", + mime_type=None, + file_kind="tool_file", + file_id="tool-file-2", + ), + ] + ) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + fake_manifest, + ) + + result = list_drive_from_environment(prefix="skills/", json_output=False) + + assert result == ("12\ttext/markdown\t-\tskills/example/SKILL.md\n-\t-\tsha256:abc\tskills/example/helper.py") + assert captured["prefix"] == "skills/" + assert captured["include_download_url"] is False + + +def test_pull_drive_from_environment_writes_files_under_drive_base( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + captured: dict[str, object] = {} + + def fake_manifest(**kwargs): + captured.update(kwargs) + return AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=11, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ) + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + fake_manifest, + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: b"hello world", + ) + + results = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path)) + + assert results == [tmp_path / "skills" / "example" / "SKILL.md"] + assert results[0].read_bytes() == b"hello world" + assert captured["prefix"] == "skills/" + assert captured["include_download_url"] is True + + +def test_pull_drive_from_environment_auto_extracts_skill_archive( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_buffer = BytesIO() + with ZipFile(archive_buffer, mode="w") as archive: + archive.writestr("SKILL.md", "# Example\n") + archive.writestr("nested/helper.py", "print('x')\n") + archive_bytes = archive_buffer.getvalue() + + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/foo/.DIFY-SKILL-FULL.zip", + size=len(archive_bytes), + hash=None, + mime_type="application/zip", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: archive_bytes, + ) + + results = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path)) + + archive_path = tmp_path / "skills" / "foo" / ".DIFY-SKILL-FULL.zip" + assert results == [archive_path] + assert archive_path.read_bytes() == archive_bytes + assert (tmp_path / "skills" / "foo" / "SKILL.md").read_text(encoding="utf-8") == "# Example\n" + assert (tmp_path / "skills" / "foo" / "nested" / "helper.py").read_text(encoding="utf-8") == "print('x')\n" + + +def test_pull_drive_from_environment_rejects_traversal_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="../escape.txt", + size=4, + hash=None, + mime_type="text/plain", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + + with pytest.raises(AgentStubValidationError, match="outside the drive base"): + _ = pull_drive_from_environment(prefix="", drive_base=str(tmp_path)) + + +def test_pull_drive_from_environment_rejects_skill_archive_path_traversal( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_buffer = BytesIO() + with ZipFile(archive_buffer, mode="w") as archive: + archive.writestr("SKILL.md", "# Example\n") + archive.writestr("../escape.txt", "escape") + archive_bytes = archive_buffer.getvalue() + + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/foo/.DIFY-SKILL-FULL.zip", + size=len(archive_bytes), + hash=None, + mime_type="application/zip", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: archive_bytes, + ) + + with pytest.raises(AgentStubValidationError, match="path traversal"): + _ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path)) + assert not (tmp_path / "skills" / "foo" / "SKILL.md").exists() + + +def test_pull_drive_from_environment_rejects_skill_archive_absolute_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_buffer = BytesIO() + with ZipFile(archive_buffer, mode="w") as archive: + archive.writestr("/escape.txt", "escape") + archive_bytes = archive_buffer.getvalue() + + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/foo/.DIFY-SKILL-FULL.zip", + size=len(archive_bytes), + hash=None, + mime_type="application/zip", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: archive_bytes, + ) + + with pytest.raises(AgentStubValidationError, match="absolute path"): + _ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path)) + + +def test_pull_drive_from_environment_rejects_skill_archive_symlink_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_buffer = BytesIO() + with ZipFile(archive_buffer, mode="w") as archive: + symlink_info = ZipInfo("linked.txt") + symlink_info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(symlink_info, "outside.txt") + archive_bytes = archive_buffer.getvalue() + + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/foo/.DIFY-SKILL-FULL.zip", + size=len(archive_bytes), + hash=None, + mime_type="application/zip", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: archive_bytes, + ) + + with pytest.raises(AgentStubValidationError, match="symlink entry"): + _ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path)) + + +def test_pull_drive_from_environment_rejects_invalid_skill_archive( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_bytes = b"not-a-zip" + + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/foo/.DIFY-SKILL-FULL.zip", + size=len(archive_bytes), + hash=None, + mime_type="application/zip", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: archive_bytes, + ) + + with pytest.raises(AgentStubTransferError, match="downloaded skill archive is invalid"): + _ = pull_drive_from_environment(prefix="skills/foo", drive_base=str(tmp_path)) + + +def test_pull_drive_from_environment_rejects_missing_download_url( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=11, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + ) + ] + ), + ) + + with pytest.raises(AgentStubValidationError, match="missing download_url"): + _ = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path)) + + +def test_pull_drive_from_environment_rejects_size_mismatch( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_manifest_sync", + lambda **_kwargs: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=99, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + download_url="https://files.example.com/download", + ) + ] + ), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.download_file_bytes_from_signed_url_sync", + lambda **_kwargs: b"hello world", + ) + + with pytest.raises(AgentStubTransferError, match="size mismatch"): + _ = pull_drive_from_environment(prefix="skills/", drive_base=str(tmp_path)) + + +def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report") + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment", + lambda *, path: UploadedToolFileResource( + mapping=UploadedToolFileMapping(reference="dify-file-ref:tool-file-1"), + tool_file_id="tool-file-1", + ), + ) + captured: dict[str, object] = {} + + def fake_commit(**kwargs): + captured.update(kwargs) + return AgentStubDriveCommitResponse( + items=[ + AgentStubDriveItem( + key="files/report.pdf", + size=6, + hash=None, + mime_type="application/pdf", + file_kind="tool_file", + file_id="tool-file-1", + value_owned_by_drive=True, + ) + ] + ) + + monkeypatch.setattr("dify_agent.agent_stub.cli._drive.request_agent_stub_drive_commit_sync", fake_commit) + + response = push_drive_from_environment(local_path=str(source), drive_path="files/report.pdf", recursive=False) + + assert response.items[0].key == "files/report.pdf" + request = captured["request"] + assert isinstance(request, AgentStubDriveCommitRequest) + assert request.items[0].model_dump(mode="json") == { + "key": "files/report.pdf", + "file_ref": {"kind": "tool_file", "id": "tool-file-1"}, + "value_owned_by_drive": True, + } + + +def test_push_drive_from_environment_requires_skill_md_for_non_recursive_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="SKILL.md"): + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + + +def test_push_drive_from_environment_standardizes_non_recursive_skill_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Example\n", encoding="utf-8") + (skill_dir / "helper.py").write_text("print('x')\n", encoding="utf-8") + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + uploaded_paths: list[str] = [] + + def fake_upload(*, path: str) -> UploadedToolFileResource: + uploaded_paths.append(Path(path).name) + return UploadedToolFileResource( + mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"), + tool_file_id=Path(path).name, + ) + + monkeypatch.setattr("dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment", fake_upload) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_commit_sync", + lambda **kwargs: AgentStubDriveCommitResponse( + items=[ + AgentStubDriveItem( + key=item.key, + size=None, + hash=None, + mime_type=None, + file_kind=item.file_ref.kind, + file_id=item.file_ref.id, + value_owned_by_drive=item.value_owned_by_drive, + ) + for item in kwargs["request"].items + ] + ), + ) + + response = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + + assert set(uploaded_paths) == {"SKILL.md", ".DIFY-SKILL-FULL.zip"} + assert {item.key for item in response.items} == { + "skills/example/SKILL.md", + "skills/example/.DIFY-SKILL-FULL.zip", + } + + +def test_push_drive_from_environment_non_recursive_archive_excludes_transient_entries( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Example\n", encoding="utf-8") + (skill_dir / "helper.py").write_text("print('x')\n", encoding="utf-8") + (skill_dir / ".DIFY-SKILL-FULL.zip").write_bytes(b"old-archive") + git_dir = skill_dir / ".git" + git_dir.mkdir() + (git_dir / "config").write_text("[core]\n", encoding="utf-8") + pycache_dir = skill_dir / "__pycache__" + pycache_dir.mkdir() + (pycache_dir / "helper.pyc").write_bytes(b"compiled") + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + archive_entries: list[str] = [] + + def fake_upload(*, path: str) -> UploadedToolFileResource: + if Path(path).name == ".DIFY-SKILL-FULL.zip": + with ZipFile(path) as archive: + archive_entries.extend(sorted(archive.namelist())) + return UploadedToolFileResource( + mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"), + tool_file_id=Path(path).name, + ) + + monkeypatch.setattr("dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment", fake_upload) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_commit_sync", + lambda **kwargs: AgentStubDriveCommitResponse( + items=[ + AgentStubDriveItem( + key=item.key, + size=None, + hash=None, + mime_type=None, + file_kind=item.file_ref.kind, + file_id=item.file_ref.id, + value_owned_by_drive=item.value_owned_by_drive, + ) + for item in kwargs["request"].items + ] + ), + ) + + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + + assert {"SKILL.md", "helper.py"}.issubset(archive_entries) + assert ".git/config" not in archive_entries + assert "__pycache__/helper.pyc" not in archive_entries + assert ".DIFY-SKILL-FULL.zip" not in archive_entries + + +def test_push_drive_from_environment_non_recursive_rejects_symlinked_archive_entries( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Example\n", encoding="utf-8") + outside = tmp_path / "outside.txt" + outside.write_text("outside", encoding="utf-8") + (skill_dir / "linked.txt").symlink_to(outside) + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="symlink"): + _ = push_drive_from_environment(local_path=str(skill_dir), drive_path="skills/example", recursive=False) + + +def test_push_drive_from_environment_rejects_symlinked_recursive_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "skill" + root.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("outside", encoding="utf-8") + (root / "linked.txt").symlink_to(outside) + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + with pytest.raises(AgentStubValidationError, match="symlink"): + _ = push_drive_from_environment(local_path=str(root), drive_path="skills/example", recursive=True) + + +def test_push_drive_from_environment_recursive_keeps_user_files_that_skill_packaging_skips( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + root = tmp_path / "skill" + root.mkdir() + (root / ".DIFY-SKILL-FULL.zip").write_bytes(b"archive") + node_modules_dir = root / "node_modules" + node_modules_dir.mkdir() + (node_modules_dir / "module.js").write_text("export default 1\n", encoding="utf-8") + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + uploaded_paths: list[str] = [] + + def fake_upload(*, path: str) -> UploadedToolFileResource: + uploaded_paths.append(Path(path).relative_to(root).as_posix()) + return UploadedToolFileResource( + mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"), + tool_file_id=Path(path).name, + ) + + monkeypatch.setattr("dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment", fake_upload) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._drive.request_agent_stub_drive_commit_sync", + lambda **kwargs: AgentStubDriveCommitResponse( + items=[ + AgentStubDriveItem( + key=item.key, + size=None, + hash=None, + mime_type=None, + file_kind=item.file_ref.kind, + file_id=item.file_ref.id, + value_owned_by_drive=item.value_owned_by_drive, + ) + for item in kwargs["request"].items + ] + ), + ) + + response = push_drive_from_environment(local_path=str(root), drive_path="skills/example", recursive=True) + + assert set(uploaded_paths) == {".DIFY-SKILL-FULL.zip", "node_modules/module.js"} + assert {item.key for item in response.items} == { + "skills/example/.DIFY-SKILL-FULL.zip", + "skills/example/node_modules/module.js", + } diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py index 3f2e044be5d..c5b093128fd 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py @@ -6,7 +6,11 @@ from pathlib import Path import pytest -from dify_agent.agent_stub.cli._files import download_file_from_environment, upload_file_from_environment +from dify_agent.agent_stub.cli._files import ( + download_file_from_environment, + upload_file_from_environment, + upload_tool_file_resource_from_environment, +) from dify_agent.agent_stub.client._errors import AgentStubTransferError @@ -36,6 +40,7 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output( captured["file_bytes"] = kwargs["file_obj"].read() kwargs["file_obj"].seek(0) return { + "id": "tool-file-1", "reference": _reference("tool-file-1"), } @@ -57,6 +62,33 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output( } +def test_upload_tool_file_resource_from_environment_preserves_tool_file_id( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report-bytes") + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.request_agent_stub_file_upload_sync", + lambda **_kwargs: type("Response", (), {"upload_url": "https://files.example.com/upload"})(), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync", + lambda **_kwargs: {"id": "tool-file-1", "reference": _reference("tool-file-1")}, + ) + + result = upload_tool_file_resource_from_environment(path=str(source)) + + assert result.mapping.model_dump() == { + "transfer_method": "tool_file", + "reference": _reference("tool-file-1"), + } + assert result.tool_file_id == "tool-file-1" + + def test_download_file_from_environment_saves_bytes_and_renames_on_collision( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -148,8 +180,30 @@ def test_upload_file_from_environment_rejects_non_canonical_reference( ) monkeypatch.setattr( "dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync", - lambda **_kwargs: {"reference": "raw-tool-file-uuid"}, + lambda **_kwargs: {"id": "tool-file-1", "reference": "raw-tool-file-uuid"}, ) with pytest.raises(AgentStubTransferError, match="invalid canonical reference"): _ = upload_file_from_environment(path=str(source)) + + +def test_upload_tool_file_resource_from_environment_rejects_missing_id( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report-bytes") + monkeypatch.setenv("DIFY_AGENT_STUB_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.request_agent_stub_file_upload_sync", + lambda **_kwargs: type("Response", (), {"upload_url": "https://files.example.com/upload"})(), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync", + lambda **_kwargs: {"reference": _reference("tool-file-1")}, + ) + + with pytest.raises(AgentStubTransferError, match="missing id"): + _ = upload_tool_file_resource_from_environment(path=str(source)) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py index b6fa85049b3..8699ac99e13 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py @@ -7,6 +7,11 @@ from pathlib import Path import pytest from dify_agent.agent_stub.cli.main import main +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitResponse, + AgentStubDriveItem, + AgentStubDriveManifestResponse, +) from dify_agent.agent_stub.protocol.agent_stub import AgentStubConnectResponse @@ -194,3 +199,97 @@ def test_cli_file_download_prints_saved_path( captured = capsys.readouterr() assert exc_info.value.code == 0 assert captured.out.strip() == "/tmp/report.pdf" + + +def test_cli_drive_list_prints_manifest_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.list_drive_from_environment", + lambda *, prefix, json_output: AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key=prefix + "example/SKILL.md", + size=12, + hash="sha256:abc", + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + ) + ] + ), + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "list", "skills/", "--json"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert json.loads(captured.out)["items"][0]["key"] == "skills/example/SKILL.md" + + +def test_cli_drive_list_prints_human_readable_listing( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.list_drive_from_environment", + lambda *, prefix, json_output: f"12\ttext/markdown\t-\t{prefix}example/SKILL.md", + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "list", "skills/"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert captured.out.strip() == "12\ttext/markdown\t-\tskills/example/SKILL.md" + + +def test_cli_drive_pull_prints_downloaded_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.pull_drive_from_environment", + lambda *, prefix, drive_base: [Path(drive_base) / prefix / "SKILL.md", Path(drive_base) / prefix / "helper.py"], + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "pull", "skills/example", "--drive-base", "/tmp/drive"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert captured.out.strip().splitlines() == [ + "/tmp/drive/skills/example/SKILL.md", + "/tmp/drive/skills/example/helper.py", + ] + + +def test_cli_drive_push_prints_commit_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr( + "dify_agent.agent_stub.cli.main.push_drive_from_environment", + lambda *, local_path, drive_path, recursive: AgentStubDriveCommitResponse( + items=[ + AgentStubDriveItem( + key=drive_path, + size=12, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id=Path(local_path).name, + value_owned_by_drive=recursive is False, + ) + ] + ), + ) + + with pytest.raises(SystemExit) as exc_info: + main(["drive", "push", "/tmp/report.md", "skills/example/SKILL.md"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert json.loads(captured.out)["items"][0]["key"] == "skills/example/SKILL.md" diff --git a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py index b97dff3e3ec..f62cc4564e9 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/client/test_agent_stub_client.py @@ -11,6 +11,8 @@ import pytest from dify_agent.agent_stub.client._agent_stub import ( connect_agent_stub_sync, download_file_bytes_from_signed_url_sync, + request_agent_stub_drive_commit_sync, + request_agent_stub_drive_manifest_sync, request_agent_stub_file_download_sync, request_agent_stub_file_upload_sync, upload_file_to_signed_url_sync, @@ -23,7 +25,12 @@ from dify_agent.agent_stub.client._errors import ( AgentStubTransferError, AgentStubValidationError, ) -from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitItem, + AgentStubDriveCommitRequest, + AgentStubDriveFileRef, + AgentStubFileMapping, +) def _reference(record_id: str) -> str: @@ -174,6 +181,140 @@ def test_request_agent_stub_file_download_sync_posts_download_request() -> None: assert response.download_url == "https://files.example.com/download" +def test_request_agent_stub_drive_manifest_sync_gets_manifest_request() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == ( + "https://agent.example.com/agent-stub/drive/manifest?prefix=skills%2F&include_download_url=true" + ) + assert request.headers["Authorization"] == "Bearer test-jwe" + return httpx.Response( + 200, + json={ + "items": [ + { + "key": "skills/example/SKILL.md", + "size": 12, + "hash": "sha256:abc", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tool-file-1", + "created_at": 123, + } + ] + }, + ) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + response = request_agent_stub_drive_manifest_sync( + url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + prefix="skills/", + include_download_url=True, + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert response.items[0].key == "skills/example/SKILL.md" + + +def test_request_agent_stub_drive_commit_sync_posts_commit_request() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "https://agent.example.com/agent-stub/drive/commit" + assert request.headers["Authorization"] == "Bearer test-jwe" + assert json.loads(request.content) == { + "items": [ + { + "key": "skills/example/SKILL.md", + "file_ref": {"kind": "tool_file", "id": "tool-file-1"}, + "value_owned_by_drive": True, + } + ] + } + return httpx.Response( + 200, + json={ + "items": [ + { + "key": "skills/example/SKILL.md", + "size": 12, + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tool-file-1", + "value_owned_by_drive": True, + } + ] + }, + ) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + response = request_agent_stub_drive_commit_sync( + url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + request=AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ), + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert response.items[0].file_id == "tool-file-1" + + +def test_request_agent_stub_drive_manifest_sync_maps_invalid_json_to_client_error() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(AgentStubClientError, match="invalid JSON"): + _ = request_agent_stub_drive_manifest_sync( + url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + prefix="", + include_download_url=False, + sync_http_client=http_client, + ) + finally: + http_client.close() + + +def test_request_agent_stub_drive_commit_sync_maps_non_2xx_to_http_error() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(403, json={"detail": "forbidden"}) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with pytest.raises(AgentStubHTTPError, match="403") as exc_info: + _ = request_agent_stub_drive_commit_sync( + url="https://agent.example.com/agent-stub", + auth_jwe="test-jwe", + request=AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ), + sync_http_client=http_client, + ) + finally: + http_client.close() + + assert exc_info.value.detail == "forbidden" + + def test_upload_file_to_signed_url_sync_posts_multipart_file() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.method == "POST" @@ -322,6 +463,32 @@ def test_request_agent_stub_file_download_sync_dispatches_grpc_urls(monkeypatch: assert response.download_url == "https://files.example.com/download" +def test_request_agent_stub_drive_manifest_sync_rejects_grpc_urls() -> None: + with pytest.raises(AgentStubValidationError, match="require an HTTP Agent Stub URL"): + _ = request_agent_stub_drive_manifest_sync( + url="grpc://agent.example.com:9091", + auth_jwe="token", + prefix="skills/", + include_download_url=False, + ) + + +def test_request_agent_stub_drive_commit_sync_rejects_grpc_urls() -> None: + with pytest.raises(AgentStubValidationError, match="require an HTTP Agent Stub URL"): + _ = request_agent_stub_drive_commit_sync( + url="grpc://agent.example.com:9091", + auth_jwe="token", + request=AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ), + ) + + def test_request_agent_stub_file_upload_grpc_sync_attaches_bearer_metadata(monkeypatch: pytest.MonkeyPatch) -> None: import dify_agent.agent_stub.client._agent_stub_grpc as grpc_module diff --git a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py index b2dd3629c10..dccde1b42b5 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/protocol/test_agent_stub_protocol.py @@ -5,10 +5,16 @@ import json from typing import Literal import pytest +from pydantic import ValidationError from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitItem, + AgentStubDriveCommitRequest, + AgentStubDriveFileRef, AgentStubFileMapping, agent_stub_connections_url, + agent_stub_drive_commit_url, + agent_stub_drive_manifest_url, agent_stub_file_download_request_url, agent_stub_file_upload_request_url, normalize_agent_stub_url, @@ -39,6 +45,15 @@ def test_agent_stub_file_request_urls_handle_trailing_slash() -> None: ) +def test_agent_stub_drive_request_urls_handle_trailing_slash() -> None: + assert agent_stub_drive_manifest_url("https://agent.example.com/agent-stub/") == ( + "https://agent.example.com/agent-stub/drive/manifest" + ) + assert agent_stub_drive_commit_url("https://agent.example.com/agent-stub") == ( + "https://agent.example.com/agent-stub/drive/commit" + ) + + def test_normalize_agent_stub_url_rejects_query_and_fragment() -> None: with pytest.raises(ValueError, match="query string or fragment"): _ = normalize_agent_stub_url("https://agent.example.com/agent-stub?x=1") @@ -98,6 +113,25 @@ def test_agent_stub_file_mapping_rejects_remote_url_with_reference() -> None: ) +def test_agent_stub_drive_commit_request_validates_file_refs() -> None: + request = AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ) + + assert request.items[0].file_ref.kind == "tool_file" + + with pytest.raises(ValidationError, match="tool_file"): + _ = AgentStubDriveFileRef(kind="bad_kind", id="tool-file-1") # pyright: ignore[reportArgumentType] + + with pytest.raises(ValidationError, match="file_ref"): + _ = AgentStubDriveCommitItem.model_validate({"key": "skills/example/SKILL.md"}) + + @pytest.mark.parametrize("transfer_method", ["tool_file", "local_file", "datasource_file"]) def test_agent_stub_file_mapping_rejects_non_remote_with_url( transfer_method: Literal["tool_file", "local_file", "datasource_file"], diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py index be8d9e4031c..e1a53a11bb4 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_app.py @@ -39,6 +39,8 @@ def test_create_agent_stub_app_exposes_same_stub_routes_as_module_app() -> None: assert "/agent-stub/connections" in created_paths assert "/agent-stub/files/upload-request" in created_paths assert "/agent-stub/files/download-request" in created_paths + assert "/agent-stub/drive/manifest" in created_paths + assert "/agent-stub/drive/commit" in created_paths assert created_paths == module_paths @@ -88,3 +90,56 @@ def test_create_agent_stub_app_wires_configured_file_handler_for_upload_requests assert response.status_code == 200 assert response.json() == {"upload_url": "https://files.example.com/upload"} + + +def test_create_agent_stub_app_wires_configured_drive_handler_for_manifest_requests(monkeypatch) -> None: + settings = ServerSettings( + agent_stub_url="https://agent.example.com/agent-stub", + server_secret_key=_base64url_secret(b"1" * 32), + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + token_codec = settings.create_agent_stub_token_codec() + assert token_codec is not None + token = token_codec.encode_connection_token( + _execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1 + ) + + original_async_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == ( + "https://api.example.com/inner/api/drive/agent-agent-1/manifest" + "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false" + ) + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + return httpx.Response( + 200, + json={ + "items": [ + { + "key": "skills/example/SKILL.md", + "size": 12, + "hash": "sha256:abc", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tool-file-1", + } + ] + }, + ) + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + client = TestClient(create_agent_stub_app(settings)) + response = client.get( + "/agent-stub/drive/manifest", + headers={"Authorization": f"Bearer {token}"}, + params={"prefix": "skills/"}, + ) + + assert response.status_code == 200 + assert response.json()["items"][0]["key"] == "skills/example/SKILL.md" diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py new file mode 100644 index 00000000000..b0d07f68e00 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_drive.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import asyncio +import json + +import httpx + +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitItem, + AgentStubDriveCommitRequest, + AgentStubDriveFileRef, +) +from dify_agent.agent_stub.server.agent_stub_drive import ( + AgentStubDriveRequestError, + DifyApiAgentStubDriveRequestHandler, +) +from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig + + +def _principal() -> AgentStubPrincipal: + return AgentStubPrincipal( + execution_context=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + workflow_id="workflow-1", + agent_id="agent-1", + agent_mode="workflow_run", + invoke_from="service-api", + ), + session_id="session-1", + scope=["agent_stub:connect"], + token_id="token-1", + ) + + +def _patch_async_client(monkeypatch, handler) -> None: + original_async_client = httpx.AsyncClient + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + +def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_manifest(monkeypatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == ( + "https://api.example.com/inner/api/drive/agent-agent-1/manifest" + "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=true" + ) + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + return httpx.Response( + 200, + json={ + "items": [ + { + "key": "skills/example/SKILL.md", + "size": 12, + "hash": "sha256:abc", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tool-file-1", + "created_at": 123, + "download_url": "https://files.example.com/download", + } + ] + }, + ) + + _patch_async_client(monkeypatch, handler) + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + + async def scenario() -> None: + response = await drive_handler.get_manifest( + principal=_principal(), + prefix="skills/", + include_download_url=True, + ) + assert response.items[0].download_url == "https://files.example.com/download" + + asyncio.run(scenario()) + + +def test_dify_api_agent_stub_drive_handler_injects_execution_context_for_commit(monkeypatch) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "https://api.example.com/inner/api/drive/agent-agent-1/commit" + assert json.loads(request.content) == { + "tenant_id": "tenant-1", + "user_id": "user-1", + "items": [ + { + "key": "skills/example/SKILL.md", + "file_ref": {"kind": "tool_file", "id": "tool-file-1"}, + "value_owned_by_drive": True, + } + ], + } + return httpx.Response( + 200, + json={ + "items": [ + { + "key": "skills/example/SKILL.md", + "size": 12, + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tool-file-1", + "value_owned_by_drive": True, + } + ] + }, + ) + + _patch_async_client(monkeypatch, handler) + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + + async def scenario() -> None: + response = await drive_handler.commit( + principal=_principal(), + request=AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ), + ) + assert response.items[0].value_owned_by_drive is True + + asyncio.run(scenario()) + + +def test_dify_api_agent_stub_drive_handler_rejects_missing_agent_id() -> None: + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + principal = _principal() + principal.execution_context = principal.execution_context.model_copy(update={"agent_id": None}) + + async def scenario() -> None: + try: + await drive_handler.get_manifest(principal=principal, prefix="", include_download_url=False) + except AgentStubDriveRequestError as exc: + assert exc.status_code == 400 + assert "agent_id" in str(exc) + else: + raise AssertionError("expected AgentStubDriveRequestError") + + asyncio.run(scenario()) + + +def test_dify_api_agent_stub_drive_handler_rejects_missing_user_id_for_commit() -> None: + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + principal = _principal() + principal.execution_context = principal.execution_context.model_copy(update={"user_id": None}) + + async def scenario() -> None: + try: + await drive_handler.commit( + principal=principal, + request=AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ), + ) + except AgentStubDriveRequestError as exc: + assert exc.status_code == 400 + assert "user_id" in str(exc) + else: + raise AssertionError("expected AgentStubDriveRequestError") + + asyncio.run(scenario()) + + +def test_dify_api_agent_stub_drive_handler_maps_invalid_json_response(monkeypatch) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="not-json", headers={"Content-Type": "application/json"}) + + _patch_async_client(monkeypatch, handler) + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + + async def scenario() -> None: + try: + await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False) + except AgentStubDriveRequestError as exc: + assert exc.status_code == 502 + assert exc.detail == "Dify API drive request returned invalid JSON" + else: + raise AssertionError("expected AgentStubDriveRequestError") + + asyncio.run(scenario()) + + +def test_dify_api_agent_stub_drive_handler_rejects_malformed_success_payload(monkeypatch) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"unexpected": []}) + + _patch_async_client(monkeypatch, handler) + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + + async def scenario() -> None: + try: + await drive_handler.get_manifest(principal=_principal(), prefix="skills/", include_download_url=False) + except AgentStubDriveRequestError as exc: + assert exc.status_code == 502 + assert exc.detail == "Dify API drive manifest response is invalid" + else: + raise AssertionError("expected AgentStubDriveRequestError") + + asyncio.run(scenario()) + + +def test_dify_api_agent_stub_drive_handler_preserves_non_2xx_detail(monkeypatch) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json={"code": "source_not_found", "message": "missing file"}) + + _patch_async_client(monkeypatch, handler) + drive_handler = DifyApiAgentStubDriveRequestHandler( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + + async def scenario() -> None: + try: + await drive_handler.commit( + principal=_principal(), + request=AgentStubDriveCommitRequest( + items=[ + AgentStubDriveCommitItem( + key="skills/example/SKILL.md", + file_ref=AgentStubDriveFileRef(kind="tool_file", id="tool-file-1"), + ) + ] + ), + ) + except AgentStubDriveRequestError as exc: + assert exc.status_code == 404 + assert exc.detail == {"code": "source_not_found", "message": "missing file"} + else: + raise AssertionError("expected AgentStubDriveRequestError") + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py index fb3fd264f29..ab285cc681a 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py @@ -8,7 +8,14 @@ from typing import cast from fastapi import FastAPI from fastapi.testclient import TestClient -from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileDownloadResponse, AgentStubFileUploadResponse +from dify_agent.agent_stub.protocol.agent_stub import ( + AgentStubDriveCommitResponse, + AgentStubDriveItem, + AgentStubDriveManifestResponse, + AgentStubFileDownloadResponse, + AgentStubFileUploadResponse, +) +from dify_agent.agent_stub.server.agent_stub_drive import AgentStubDriveRequestError, AgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler from dify_agent.agent_stub.server.routes.agent_stub import create_agent_stub_http_router from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec @@ -261,3 +268,137 @@ def test_agent_stub_file_route_preserves_structured_handler_error_details() -> N assert response.status_code == 400 assert response.json()["detail"] == {"detail": "bad request", "code": "inner_api_error"} + + +def test_agent_stub_drive_manifest_route_forwards_authenticated_request() -> None: + codec = _token_codec() + token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) + + class FakeDriveHandler: + async def get_manifest(self, *, principal, prefix, include_download_url): + assert principal.execution_context.user_id == "user-1" + assert prefix == "skills/" + assert include_download_url is True + return AgentStubDriveManifestResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=12, + hash="sha256:abc", + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + created_at=123, + download_url="https://files.example.com/download", + ) + ] + ) + + async def commit(self, *, principal, request): + del principal, request + raise AssertionError("unexpected commit request") + + drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler())) + app = FastAPI() + app.include_router(create_agent_stub_http_router(codec, None, drive_handler)) + client = TestClient(app) + + response = client.get( + "/agent-stub/drive/manifest", + headers={"Authorization": f"Bearer {token}"}, + params={"prefix": "skills/", "include_download_url": "true"}, + ) + + assert response.status_code == 200 + assert response.json()["items"][0]["key"] == "skills/example/SKILL.md" + + +def test_agent_stub_drive_commit_route_forwards_authenticated_request() -> None: + codec = _token_codec() + token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) + + class FakeDriveHandler: + async def commit(self, *, principal, request): + assert principal.execution_context.user_id == "user-1" + assert request.items[0].file_ref.id == "tool-file-1" + return AgentStubDriveCommitResponse( + items=[ + AgentStubDriveItem( + key="skills/example/SKILL.md", + size=12, + hash=None, + mime_type="text/markdown", + file_kind="tool_file", + file_id="tool-file-1", + value_owned_by_drive=True, + ) + ] + ) + + async def get_manifest(self, *, principal, prefix, include_download_url): + del principal, prefix, include_download_url + raise AssertionError("unexpected manifest request") + + drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler())) + app = FastAPI() + app.include_router(create_agent_stub_http_router(codec, None, drive_handler)) + client = TestClient(app) + + response = client.post( + "/agent-stub/drive/commit", + headers={"Authorization": f"Bearer {token}"}, + json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]}, + ) + + assert response.status_code == 200 + assert response.json()["items"][0]["file_id"] == "tool-file-1" + + +def test_agent_stub_drive_routes_return_503_when_drive_api_is_unconfigured() -> None: + codec = _token_codec() + token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) + app = FastAPI() + app.include_router(create_agent_stub_http_router(codec, None, None)) + client = TestClient(app) + + manifest_response = client.get( + "/agent-stub/drive/manifest", + headers={"Authorization": f"Bearer {token}"}, + ) + commit_response = client.post( + "/agent-stub/drive/commit", + headers={"Authorization": f"Bearer {token}"}, + json={"items": [{"key": "skills/example/SKILL.md", "file_ref": {"kind": "tool_file", "id": "tool-file-1"}}]}, + ) + + assert manifest_response.status_code == 503 + assert commit_response.status_code == 503 + assert manifest_response.json()["detail"] == "Agent Stub drive API is not configured" + assert commit_response.json()["detail"] == "Agent Stub drive API is not configured" + + +def test_agent_stub_drive_route_preserves_structured_handler_error_details() -> None: + codec = _token_codec() + token = codec.encode_connection_token(_execution_context(), now=int(time.time()) - 1) + + class FakeDriveHandler: + async def get_manifest(self, *, principal, prefix, include_download_url): + del principal, prefix, include_download_url + raise AgentStubDriveRequestError(400, {"code": "invalid_key", "message": "bad request"}) + + async def commit(self, *, principal, request): + del principal, request + raise AssertionError("unexpected commit request") + + drive_handler = cast(AgentStubDriveRequestHandler, cast(object, FakeDriveHandler())) + app = FastAPI() + app.include_router(create_agent_stub_http_router(codec, None, drive_handler)) + client = TestClient(app) + + response = client.get( + "/agent-stub/drive/manifest", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == {"code": "invalid_key", "message": "bad request"} diff --git a/api/tests/unit_tests/controllers/openapi/__init__.py b/dify-agent/tests/local/dify_agent/layers/knowledge/__init__.py similarity index 100% rename from api/tests/unit_tests/controllers/openapi/__init__.py rename to dify-agent/tests/local/dify_agent/layers/knowledge/__init__.py diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py new file mode 100644 index 00000000000..9e2ca5462f8 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_client.py @@ -0,0 +1,248 @@ +import json +from unittest.mock import AsyncMock + +import httpx +import pytest + +from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClient, DifyKnowledgeBaseClientError +from dify_agent.layers.knowledge.configs import ( + DifyKnowledgeMetadataFilteringConfig, + DifyKnowledgeRetrievalConfig, +) + + +def _retrieval_config() -> DifyKnowledgeRetrievalConfig: + return DifyKnowledgeRetrievalConfig(mode="multiple", top_k=4, score_threshold=0.2) + + +def _metadata_filtering() -> DifyKnowledgeMetadataFilteringConfig: + return DifyKnowledgeMetadataFilteringConfig(mode="disabled") + + +def test_knowledge_client_posts_inner_api_request_with_static_controls() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == "http://dify-api/inner/api/knowledge/retrieve" + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + payload = json.loads(request.content.decode("utf-8")) + assert payload == { + "caller": { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "agent_app", + }, + "dataset_ids": ["dataset-1"], + "query": "reset password", + "retrieval": { + "mode": "multiple", + "top_k": 4, + "score_threshold": 0.2, + "reranking_mode": "reranking_model", + "reranking_enable": True, + "reranking_model": None, + "weights": None, + }, + "metadata_filtering": {"mode": "disabled"}, + "attachment_ids": [], + } + return httpx.Response( + 200, + json={ + "results": [ + { + "metadata": { + "_source": "knowledge", + "dataset_name": "Docs", + "document_name": "FAQ.md", + "score": 0.9, + }, + "title": "FAQ", + "files": [], + "content": "Use the reset link.", + "summary": None, + } + ], + "usage": {}, + }, + ) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", + api_key="inner-secret", + http_client=http_client, + ) + response = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert response.results[0].metadata.dataset_name == "Docs" + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_marks_retryable_http_failures() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response( + 502, json={"code": "external_knowledge_failed", "message": "bad gateway"} + ) + ) + ) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.status_code == 502 + assert exc_info.value.error_code == "external_knowledge_failed" + assert exc_info.value.retryable is True + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_marks_non_retryable_http_failures() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response( + 403, + json={"code": "dataset_tenant_mismatch", "message": "forbidden"}, + ) + ) + ) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.status_code == 403 + assert exc_info.value.error_code == "dataset_tenant_mismatch" + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_rejects_malformed_success_response() -> None: + async def scenario() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"bad": []})) + ) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.error_code == "invalid_response" + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "error_factory", + [ + lambda request: httpx.ReadTimeout("timed out", request=request), + lambda request: httpx.ConnectError("connection failed", request=request), + ], +) +def test_knowledge_client_marks_transport_failures_retryable(error_factory) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise error_factory(request) + + async def scenario() -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.retryable is True + + import asyncio + + asyncio.run(scenario()) + + +def test_knowledge_client_treats_invalid_url_errors_as_non_retryable_configuration_error() -> None: + async def scenario() -> None: + async with httpx.AsyncClient() as http_client: + http_client.post = AsyncMock(side_effect=httpx.UnsupportedProtocol("unsupported protocol")) + client = DifyKnowledgeBaseClient( + base_url="http://dify-api", api_key="inner-secret", http_client=http_client + ) + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + _ = await client.retrieve( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="account", + invoke_from="agent_app", + dataset_ids=["dataset-1"], + query="reset password", + retrieval=_retrieval_config(), + metadata_filtering=_metadata_filtering(), + ) + assert exc_info.value.retryable is False + + import asyncio + + asyncio.run(scenario()) diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py new file mode 100644 index 00000000000..f28939e329b --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_configs.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig + + +def _valid_config() -> dict[str, object]: + return { + "dataset_ids": ["dataset-1"], + "retrieval": { + "mode": "multiple", + "top_k": 4, + }, + } + + +def test_knowledge_base_config_accepts_valid_multiple_mode() -> None: + config = DifyKnowledgeBaseLayerConfig.model_validate(_valid_config()) + + assert config.dataset_ids == ["dataset-1"] + assert config.retrieval.top_k == 4 + assert config.metadata_filtering.mode == "disabled" + + +@pytest.mark.parametrize( + "payload, expected_message", + [ + ({"dataset_ids": [], "retrieval": {"mode": "multiple", "top_k": 4}}, "dataset_ids"), + ({"tool_name": "knowledge_base_search", **_valid_config()}, "Extra inputs are not permitted"), + ({"tool_description": "Search knowledge", **_valid_config()}, "Extra inputs are not permitted"), + ({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "multiple"}}, "top_k"), + ({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "single"}}, "retrieval.model"), + ( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "automatic"}, + }, + "metadata_filtering.model_config", + ), + ( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + "metadata_filtering": {"mode": "manual"}, + }, + "metadata_filtering.conditions", + ), + ], +) +def test_knowledge_base_config_rejects_invalid_inputs(payload: dict[str, object], expected_message: str) -> None: + with pytest.raises(ValidationError, match=expected_message): + _ = DifyKnowledgeBaseLayerConfig.model_validate(payload) + + +def test_knowledge_base_config_rejects_observation_limit_smaller_than_result_limit() -> None: + with pytest.raises(ValidationError, match="max_observation_chars"): + _ = DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + "max_result_content_chars": 50, + "max_observation_chars": 20, + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py b/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py new file mode 100644 index 00000000000..5db74d6f452 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/layers/knowledge/test_layer.py @@ -0,0 +1,417 @@ +import asyncio +import json + +import httpx +import pytest +from pydantic_ai import Tool + +from agenton.compositor import Compositor, LayerNode, LayerProvider +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError +from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import ( + BLANK_QUERY_OBSERVATION, + DifyKnowledgeBaseLayer, + NO_RESULTS_OBSERVATION, + TEMPORARY_UNAVAILABLE_OBSERVATION, +) + + +def _execution_context_config(**overrides: object) -> DifyExecutionContextLayerConfig: + payload: dict[str, object] = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "user_from": "account", + "app_id": "app-1", + "agent_mode": "agent_app", + "invoke_from": "web-app", + } + payload.update(overrides) + return DifyExecutionContextLayerConfig.model_validate(payload) + + +def _knowledge_config(**overrides: object) -> DifyKnowledgeBaseLayerConfig: + payload: dict[str, object] = { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + } + payload.update(overrides) + return DifyKnowledgeBaseLayerConfig.model_validate(payload) + + +def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]: + return LayerProvider.from_factory( + layer_type=DifyExecutionContextLayer, + create=lambda config: DifyExecutionContextLayer.from_config_with_settings( + DifyExecutionContextLayerConfig.model_validate(config), + daemon_url="http://plugin-daemon", + daemon_api_key="daemon-secret", + ), + ) + + +def _knowledge_provider() -> LayerProvider[DifyKnowledgeBaseLayer]: + return LayerProvider.from_factory( + layer_type=DifyKnowledgeBaseLayer, + create=lambda config: DifyKnowledgeBaseLayer.from_config_with_settings( + DifyKnowledgeBaseLayerConfig.model_validate(config), + dify_api_inner_url="http://dify-api", + dify_api_inner_api_key="inner-secret", + ), + ) + + +def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType] + assert isinstance(tool, Tool) + assert tool.name == "knowledge_base_search" + assert tool.description == "Search configured knowledge bases for information relevant to the query." + assert tool_def is not None + assert ( + tool_def.description == "Search configured knowledge bases for information relevant to the query." + ) + assert tool_def.parameters_json_schema == { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query for the configured knowledge bases.", + } + }, + "required": ["query"], + "additionalProperties": False, + } + + asyncio.run(scenario()) + + +def test_knowledge_layer_rejects_blank_query_locally() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": " "}, None) # pyright: ignore[reportArgumentType] + assert result == BLANK_QUERY_OBSERVATION + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ("user_id", None), + ("user_from", None), + ("app_id", None), + ], +) +def test_knowledge_layer_fails_fast_when_execution_context_is_missing_required_fields( + field_name: str, + field_value: object, +) -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient() as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + execution_context_layer = run.get_layer("execution_context", DifyExecutionContextLayer) + setattr(execution_context_layer.config, field_name, field_value) + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + with pytest.raises(ValueError, match=field_name): + _ = await knowledge_layer.get_tools(http_client=http_client) + + asyncio.run(scenario()) + + +def test_knowledge_layer_formats_results_and_truncates_observation() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "results": [ + { + "metadata": { + "_source": "knowledge", + "dataset_name": "Docs", + "document_name": "Guide.md", + "score": 0.9, + }, + "title": "Guide", + "files": [], + "content": "ABCDEFGHIJKL", + "summary": None, + } + ], + "usage": {}, + }, + ) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(max_result_content_chars=8, max_observation_chars=160), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result.startswith("Knowledge base search results:\n1. Title: Guide") + assert "Dataset: Docs" in result + assert "Document: Guide.md" in result + assert "Score: 0.9" in result + assert "Content: ABCDE..." in result + assert len(result) <= 160 + + asyncio.run(scenario()) + + +def test_knowledge_layer_returns_no_results_observation() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"results": [], "usage": {}})) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == NO_RESULTS_OBSERVATION + + asyncio.run(scenario()) + + +def test_knowledge_layer_converts_retryable_failures_into_observation() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response(429, json={"code": "knowledge_rate_limited", "message": "slow down"}) + ) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == TEMPORARY_UNAVAILABLE_OBSERVATION + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "transport_error", + [ + lambda request: httpx.ReadTimeout("timed out", request=request), + lambda request: httpx.ConnectError("connection failed", request=request), + ], +) +def test_knowledge_layer_converts_retryable_transport_failures_into_observation(transport_error) -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise transport_error(request) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == TEMPORARY_UNAVAILABLE_OBSERVATION + + asyncio.run(scenario()) + + +def test_knowledge_layer_raises_non_retryable_client_errors() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _request: httpx.Response(403, json={"code": "dataset_tenant_mismatch", "message": "forbidden"}) + ) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert exc_info.value.status_code == 403 + + asyncio.run(scenario()) + + +def test_knowledge_layer_raises_for_malformed_success_responses() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda _request: httpx.Response(200, json={"bad": []})) + ) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config(), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + with pytest.raises(DifyKnowledgeBaseClientError) as exc_info: + await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert exc_info.value.error_code == "invalid_response" + assert exc_info.value.retryable is False + + asyncio.run(scenario()) + + +def test_knowledge_layer_sends_execution_context_and_static_config_to_inner_api() -> None: + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + assert payload["caller"] == { + "tenant_id": "tenant-1", + "user_id": "user-1", + "app_id": "app-1", + "user_from": "account", + "invoke_from": "web-app", + } + assert payload["dataset_ids"] == ["dataset-1", "dataset-2"] + assert payload["query"] == "reset" + assert payload["retrieval"]["top_k"] == 2 + assert payload["metadata_filtering"] == { + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "contains", + "value": "auth", + } + ], + }, + } + return httpx.Response(200, json={"results": [], "usage": {}}) + + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "knowledge": _knowledge_config( + dataset_ids=["dataset-1", "dataset-2"], + retrieval={"mode": "multiple", "top_k": 2}, + metadata_filtering={ + "mode": "manual", + "conditions": { + "logical_operator": "and", + "conditions": [ + { + "name": "category", + "comparison_operator": "contains", + "value": "auth", + } + ], + }, + }, + ), + } + ) as run: + knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer) + tool = (await knowledge_layer.get_tools(http_client=http_client))[0] + result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType] + assert result == NO_RESULTS_OBSERVATION + + asyncio.run(scenario()) 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 index a4a5ad8429b..e1124560ac6 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_run_scheduler.py @@ -120,6 +120,7 @@ def test_create_run_starts_background_task_and_returns_running() -> None: scheduler = RunScheduler( store=store, plugin_daemon_http_client=client, + dify_api_http_client=client, runner_factory=lambda _record, _request: ControlledRunner(started=started, release=release), ) @@ -144,6 +145,7 @@ def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None: scheduler = RunScheduler( store=store, plugin_daemon_http_client=client, + dify_api_http_client=client, shutdown_grace_seconds=0, runner_factory=lambda _record, _request: ControlledRunner(started=started, release=asyncio.Event()), ) @@ -165,7 +167,7 @@ def test_create_run_accepts_blank_prompt_and_runner_fails_asynchronously() -> No async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) record = await scheduler.create_run(_request(["", " "])) await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) @@ -182,7 +184,7 @@ def test_create_run_accepts_invalid_output_schema_and_runner_fails_asynchronousl async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) record = await scheduler.create_run( _request( @@ -205,7 +207,12 @@ def test_create_run_honors_explicit_empty_layer_providers_by_failing_after_persi async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, layer_providers=()) + scheduler = RunScheduler( + store=store, + plugin_daemon_http_client=client, + dify_api_http_client=client, + layer_providers=(), + ) record = await scheduler.create_run(_request()) await asyncio.wait_for(scheduler.active_tasks[record.run_id], timeout=1) @@ -222,7 +229,7 @@ def test_create_run_accepts_closed_session_snapshot_and_runner_fails_asynchronou async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) request = _request() request.session_snapshot = CompositorSessionSnapshot( layers=[ @@ -248,7 +255,7 @@ def test_create_run_accepts_closed_session_snapshot_and_runner_fails_asynchronou 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) + scheduler = RunScheduler(store=FakeStore(), plugin_daemon_http_client=client, dify_api_http_client=client) await scheduler.shutdown() with pytest.raises(SchedulerStoppingError): @@ -261,7 +268,7 @@ def test_create_run_rejects_invalid_request_after_shutdown_without_persisting() async def scenario() -> None: store = FakeStore() async with httpx.AsyncClient() as client: - scheduler = RunScheduler(store=store, plugin_daemon_http_client=client) + scheduler = RunScheduler(store=store, plugin_daemon_http_client=client, dify_api_http_client=client) await scheduler.shutdown() with pytest.raises(SchedulerStoppingError): @@ -282,6 +289,7 @@ def test_shutdown_waits_for_in_flight_create_to_register_before_cancelling() -> scheduler = RunScheduler( store=store, plugin_daemon_http_client=client, + dify_api_http_client=client, shutdown_grace_seconds=0, runner_factory=lambda _record, _request: ControlledRunner( started=runner_started, release=asyncio.Event() diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index c910b7c3dd9..f5ddeb72367 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -41,6 +41,8 @@ from dify_agent.layers.dify_plugin.configs import ( ) from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( @@ -357,6 +359,7 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa request=request, run_id="run-1", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() assert seen_clients == [client] assert client.is_closed is False @@ -406,6 +409,7 @@ def test_runner_preserves_explicit_json_null_output(monkeypatch: pytest.MonkeyPa request=request, run_id="run-null-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -462,6 +466,7 @@ def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatc request=request, run_id="run-ask-human", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -558,6 +563,7 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc request=request, run_id="run-ask-human-initial", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() initial_terminal = sink.events["run-ask-human-initial"][-1] @@ -582,6 +588,7 @@ def test_runner_resumes_with_deferred_tool_results_and_no_user_prompt(monkeypatc request=resumed_request, run_id="run-ask-human-resume", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -657,6 +664,7 @@ def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pyt request=request, run_id="run-ask-human-turn-1", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() first_terminal = sink.events["run-ask-human-turn-1"][-1] @@ -681,6 +689,7 @@ def test_runner_can_emit_second_deferred_tool_call_after_resume(monkeypatch: pyt request=resumed_request, run_id="run-ask-human-turn-2", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -736,6 +745,7 @@ def test_runner_rejects_deferred_tool_call_without_history_layer(monkeypatch: py request=request, run_id="run-ask-human-no-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -785,6 +795,7 @@ def test_runner_rejects_resume_with_deferred_tool_results_without_history_layer( request=request, run_id="run-ask-human-resume-no-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -823,6 +834,7 @@ def test_runner_rejects_multiple_deferred_tool_calls(monkeypatch: pytest.MonkeyP request=request, run_id="run-ask-human-multi", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -861,6 +873,7 @@ def test_runner_rejects_deferred_approval_requests(monkeypatch: pytest.MonkeyPat request=request, run_id="run-ask-human-approval", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -960,6 +973,7 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo request=request, run_id="run-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -970,6 +984,105 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo assert terminal.data.output == "done" +def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest.MonkeyPatch) -> None: + seen_tools: list[Tool[object]] = [] + + async def knowledge_tool() -> str: + return "knowledge" + + 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] + + async def fake_get_tools(self: DifyKnowledgeBaseLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + assert self.config.dataset_ids == ["dataset-1"] + assert http_client.headers.get("X-Test-Client") == "dify-api" + return [Tool(knowledge_tool, name="knowledge_base_search")] + + class FakeResult: + output: str = "done" + + def new_messages(self) -> list[ModelMessage]: + return [] + + class FakeAgent: + async def run(self, *_args: object, **_kwargs: object) -> FakeResult: + return FakeResult() + + def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent: + del model, output_type + seen_tools.extend(tools) + return FakeAgent() + + monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model) + monkeypatch.setattr(DifyKnowledgeBaseLayer, "get_tools", fake_get_tools) + monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent) + + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + app_id="app-1", + agent_mode="workflow_run", + invoke_from="service-api", + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="dify.plugin.llm", + deps={"execution_context": "execution_context"}, + config=DifyPluginLLMLayerConfig( + plugin_id="langgenius/openai", + model_provider="openai", + model="demo-model", + credentials={"api_key": "secret"}, + ), + ), + RunLayerSpec( + name="knowledge", + type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 4}, + } + ), + ), + ] + ) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with ( + httpx.AsyncClient() as plugin_client, + httpx.AsyncClient(headers={"X-Test-Client": "dify-api"}) as dify_api_client, + ): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-knowledge-tools", + plugin_daemon_http_client=plugin_client, + dify_api_http_client=dify_api_client, + ).run() + + asyncio.run(scenario()) + + assert [tool.name for tool in seen_tools] == ["knowledge_base_search"] + + def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1075,6 +1188,7 @@ def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( request=request, run_id="run-duplicate-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1182,6 +1296,7 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools( request=request, run_id="run-static-dynamic-duplicate-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=layer_providers, ).run() @@ -1297,6 +1412,7 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( request=request, run_id="run-shell-duplicate-tools", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=layer_providers, ).run() @@ -1325,6 +1441,7 @@ def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monk request=_request("current user"), run_id="run-no-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1368,6 +1485,7 @@ def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_onl request=request, run_id="run-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1418,6 +1536,7 @@ def test_runner_with_empty_history_layer_still_sends_system_prompt_and_saves_onl request=request, run_id="run-empty-history", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1468,6 +1587,7 @@ def test_runner_failure_with_history_layer_emits_failed_terminal_event_without_s request=request, run_id="run-history-failure", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1499,6 +1619,7 @@ def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytes request=request, run_id="run-exit", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1559,6 +1680,7 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu request=request, run_id="run-structured-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() first_terminal = sink.events["run-structured-output"][-1] @@ -1572,6 +1694,7 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu request=resumed_request, run_id="run-structured-output-resume", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1645,6 +1768,7 @@ def test_runner_retries_invalid_structured_output_and_eventually_succeeds(monkey request=request, run_id="run-output-retry-success", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1698,6 +1822,7 @@ def test_runner_fails_when_invalid_structured_output_exhausts_retries(monkeypatc request=request, run_id="run-output-retry-failed", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1734,6 +1859,7 @@ def test_runner_rejects_invalid_output_layer_before_model_resolution(monkeypatch request=request, run_id="run-invalid-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1808,6 +1934,7 @@ def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatc request=request, run_id="run-misnamed-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1894,6 +2021,7 @@ def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypat request=request, run_id="run-duplicate-output", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -1965,6 +2093,7 @@ def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_ request=request, run_id="run-wrong-output-type", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2009,6 +2138,7 @@ def test_runner_rejects_misnamed_output_layer_before_provider_checks() -> None: request=request, run_id="run-misnamed-output-before-providers", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=(), ).run() @@ -2033,6 +2163,7 @@ def test_runner_rejects_unknown_on_exit_layer_id() -> None: request=request, run_id="run-unknown-signal", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2053,6 +2184,7 @@ def test_runner_honors_explicit_empty_layer_providers() -> None: request=request, run_id="run-empty-providers", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=(), ).run() @@ -2074,6 +2206,7 @@ def test_runner_fails_empty_user_prompts() -> None: request=request, run_id="run-2", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2094,6 +2227,7 @@ def test_runner_fails_blank_string_user_prompt_list() -> None: request=request, run_id="run-3", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2114,6 +2248,7 @@ def test_runner_requires_llm_layer_id() -> None: request=request, run_id="run-4", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2153,6 +2288,7 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None: request=request, run_id="run-closed-snapshot", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2205,6 +2341,7 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None: request=request, run_id="run-missing-shell-entrypoint", plugin_daemon_http_client=client, + dify_api_http_client=client, ).run() asyncio.run(scenario()) @@ -2282,6 +2419,7 @@ def test_runner_treats_invalid_shell_snapshot_offsets_as_validation_error() -> N request=request, run_id="run-invalid-shell-offset", plugin_daemon_http_client=client, + dify_api_http_client=client, layer_providers=create_default_layer_providers(shellctl_entrypoint="http://shellctl"), ).run() diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 534b42e764e..3983ef43506 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -13,10 +13,12 @@ from shell_session_manager.shellctl.client import ShellctlClient import dify_agent.server.app as app_module from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig +from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.layers.shell import DifyShellLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider -from dify_agent.server.app import create_app, create_plugin_daemon_http_client +from dify_agent.server.app import create_app, create_dify_api_inner_http_client, create_plugin_daemon_http_client from dify_agent.server.settings import ServerSettings from dify_agent.storage.redis_run_store import RedisRunStore @@ -67,6 +69,7 @@ class FakeRunScheduler: shutdown_grace_seconds: float layer_providers: tuple[DifyAgentLayerProvider, ...] plugin_daemon_http_client: FakePluginDaemonHttpClient + dify_api_http_client: FakePluginDaemonHttpClient shutdown_called: bool def __init__( @@ -74,6 +77,7 @@ class FakeRunScheduler: *, store: object, plugin_daemon_http_client: FakePluginDaemonHttpClient, + dify_api_http_client: FakePluginDaemonHttpClient, shutdown_grace_seconds: float, layer_providers: tuple[DifyAgentLayerProvider, ...], ) -> None: @@ -81,6 +85,7 @@ class FakeRunScheduler: self.shutdown_grace_seconds = shutdown_grace_seconds self.layer_providers = layer_providers self.plugin_daemon_http_client = plugin_daemon_http_client + self.dify_api_http_client = dify_api_http_client self.shutdown_called = False self.created.append(self) @@ -160,7 +165,22 @@ class FakeHttpxModule: def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pytest.MonkeyPatch) -> None: - fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) + fake_redis = FakeRedis() + fake_http_client = FakePluginDaemonHttpClient() + fake_dify_api_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 + + def fake_create_dify_api_inner_http_client(_settings: ServerSettings) -> FakePluginDaemonHttpClient: + return fake_dify_api_http_client + + monkeypatch.setattr(app_module, "create_plugin_daemon_http_client", fake_create_plugin_daemon_http_client) + monkeypatch.setattr(app_module, "create_dify_api_inner_http_client", fake_create_dify_api_inner_http_client) settings = ServerSettings( redis_url="redis://example.invalid/0", @@ -169,19 +189,20 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt run_retention_seconds=7, plugin_daemon_url="http://plugin-daemon", plugin_daemon_api_key="daemon-secret", + dify_api_inner_url="http://dify-api", shellctl_entrypoint="http://shellctl", shellctl_auth_token="shell-secret", agent_stub_url="https://agent.example.com/agent-stub", server_secret_key=_base64url_secret(b"1" * 32), dify_api_base_url="https://api.example.com", dify_api_inner_api_key="inner-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, + outbound_http_connect_timeout=1, + outbound_http_read_timeout=2, + outbound_http_write_timeout=3, + outbound_http_pool_timeout=4, + outbound_http_max_connections=5, + outbound_http_max_keepalive_connections=3, + outbound_http_keepalive_expiry=6, ) with TestClient(create_app(settings)): @@ -207,6 +228,18 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt assert isinstance(shell_layer, DifyShellLayer) assert execution_context_layer.daemon_url == "http://plugin-daemon" assert execution_context_layer.daemon_api_key == "daemon-secret" + knowledge_provider = next(provider for provider in layer_providers if provider.type_id == "dify.knowledge_base") + knowledge_layer = knowledge_provider.create_layer( + DifyKnowledgeBaseLayerConfig.model_validate( + { + "dataset_ids": ["dataset-1"], + "retrieval": {"mode": "multiple", "top_k": 2}, + } + ) + ) + assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer) + assert knowledge_layer.dify_api_inner_url == "http://dify-api" + assert knowledge_layer.dify_api_inner_api_key == "inner-secret" assert shell_layer.shellctl_entrypoint == "http://shellctl" assert shell_layer.agent_stub_url == "https://agent.example.com/agent-stub" shellctl_client = shell_layer.shellctl_client_factory("http://shellctl") @@ -216,6 +249,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt http_client = scheduler.plugin_daemon_http_client assert http_client is fake_http_client assert http_client.is_closed is False + assert scheduler.dify_api_http_client is fake_dify_api_http_client + assert scheduler.dify_api_http_client.is_closed is False store = scheduler.store assert isinstance(store, RedisRunStore) assert store.run_retention_seconds == 7 @@ -227,8 +262,13 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt getattr(route, "path", None) == "/agent-stub/files/download-request" for route in create_app(settings).routes ) + assert any( + getattr(route, "path", None) == "/agent-stub/drive/manifest" for route in create_app(settings).routes + ) + assert any(getattr(route, "path", None) == "/agent-stub/drive/commit" for route in create_app(settings).routes) assert FakeRunScheduler.created[0].shutdown_called is True + assert FakeRunScheduler.created[0].dify_api_http_client.is_closed is True assert FakeRunScheduler.created[0].plugin_daemon_http_client.is_closed is True assert fake_redis.closed is True @@ -298,6 +338,64 @@ def test_create_app_wires_authenticated_agent_stub_file_upload_route(monkeypatch assert fake_redis.closed is True +def test_create_app_wires_authenticated_agent_stub_drive_manifest_route(monkeypatch: pytest.MonkeyPatch) -> None: + fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) + settings = ServerSettings( + redis_url="redis://example.invalid/0", + agent_stub_url="https://agent.example.com/agent-stub", + server_secret_key=_base64url_secret(b"1" * 32), + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + ) + token_codec = settings.create_agent_stub_token_codec() + assert token_codec is not None + token = token_codec.encode_connection_token( + _execution_context().model_copy(update={"agent_id": "agent-1"}), now=int(time.time()) - 1 + ) + + original_async_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == ( + "https://api.example.com/inner/api/drive/agent-agent-1/manifest" + "?tenant_id=tenant-1&prefix=skills%2F&include_download_url=false" + ) + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + return httpx.Response( + 200, + json={ + "items": [ + { + "key": "skills/example/SKILL.md", + "size": 12, + "hash": "sha256:abc", + "mime_type": "text/markdown", + "file_kind": "tool_file", + "file_id": "tool-file-1", + } + ] + }, + ) + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_drive.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + + with TestClient(create_app(settings)) as client: + response = client.get( + "/agent-stub/drive/manifest", + headers={"Authorization": f"Bearer {token}"}, + params={"prefix": "skills/"}, + ) + + assert response.status_code == 200 + assert response.json()["items"][0]["key"] == "skills/example/SKILL.md" + assert FakeRunScheduler.created[0].shutdown_called is True + assert fake_http_client.is_closed is True + assert fake_redis.closed is True + + def test_create_app_starts_and_stops_agent_stub_grpc_server_for_grpc_url(monkeypatch: pytest.MonkeyPatch) -> None: fake_redis, fake_http_client = _patch_app_lifecycle(monkeypatch) started: dict[str, object] = {} @@ -326,21 +424,73 @@ def test_create_app_starts_and_stops_agent_stub_grpc_server_for_grpc_url(monkeyp assert fake_redis.closed is True -def test_create_plugin_daemon_http_client_uses_configured_httpx_construction_args( +def test_create_plugin_daemon_http_client_uses_generic_outbound_httpx_construction_args( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(app_module, "httpx", FakeHttpxModule) - client = create_plugin_daemon_http_client(ServerSettings()) + client = create_plugin_daemon_http_client( + ServerSettings( + outbound_http_connect_timeout=1, + outbound_http_read_timeout=2, + outbound_http_write_timeout=3, + outbound_http_pool_timeout=4, + outbound_http_max_connections=5, + outbound_http_max_keepalive_connections=3, + outbound_http_keepalive_expiry=6, + ) + ) 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 client.timeout.connect == 1 + assert client.timeout.read == 2 + assert client.timeout.write == 3 + assert client.timeout.pool == 4 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.limits.max_connections == 5 + assert client.limits.max_keepalive_connections == 3 + assert client.limits.keepalive_expiry == 6 assert client.trust_env is False + + +def test_create_dify_api_inner_http_client_uses_generic_outbound_httpx_construction_args( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(app_module, "httpx", FakeHttpxModule) + + client = create_dify_api_inner_http_client( + ServerSettings( + outbound_http_connect_timeout=1, + outbound_http_read_timeout=2, + outbound_http_write_timeout=3, + outbound_http_pool_timeout=4, + outbound_http_max_connections=5, + outbound_http_max_keepalive_connections=3, + outbound_http_keepalive_expiry=6, + ) + ) + + assert isinstance(client, FakePluginDaemonHttpClient) + assert client.timeout.connect == 1 + assert client.timeout.read == 2 + assert client.timeout.write == 3 + assert client.timeout.pool == 4 + assert isinstance(client.limits, FakeLimits) + assert client.limits.max_connections == 5 + assert client.limits.max_keepalive_connections == 3 + assert client.limits.keepalive_expiry == 6 + assert client.trust_env is False + + +def test_server_settings_use_generic_outbound_http_args_for_shared_clients() -> None: + model_fields = ServerSettings.model_fields + + assert "dify_api_inner_url" in model_fields + assert "dify_api_inner_api_key" in model_fields + assert "outbound_http_connect_timeout" in model_fields + assert "outbound_http_read_timeout" in model_fields + assert "outbound_http_write_timeout" in model_fields + assert "outbound_http_pool_timeout" in model_fields + assert "outbound_http_max_connections" in model_fields + assert "outbound_http_max_keepalive_connections" in model_fields + assert "outbound_http_keepalive_expiry" in model_fields diff --git a/dify-agent/tests/local/dify_agent/server/test_settings.py b/dify-agent/tests/local/dify_agent/server/test_settings.py index 07b8e09f53d..1862e5995e2 100644 --- a/dify-agent/tests/local/dify_agent/server/test_settings.py +++ b/dify-agent/tests/local/dify_agent/server/test_settings.py @@ -2,10 +2,13 @@ from __future__ import annotations from pathlib import Path import secrets +from typing import cast +import httpx import pytest from pydantic import ValidationError +from dify_agent.agent_stub.server.agent_stub_drive import DifyApiAgentStubDriveRequestHandler from dify_agent.agent_stub.server.agent_stub_files import DifyApiAgentStubFileRequestHandler from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec from dify_agent.server.settings import ServerSettings @@ -129,12 +132,13 @@ def test_server_settings_normalizes_dify_api_base_url_from_env(monkeypatch: pyte assert settings.dify_api_inner_api_key == "inner-secret" -def test_server_settings_requires_dify_api_base_url_and_key_together() -> None: - with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_BASE_URL"): +def test_server_settings_requires_inner_api_key_when_dify_api_base_url_is_set() -> None: + with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_INNER_API_KEY"): _ = ServerSettings(dify_api_base_url="https://api.example.com") - with pytest.raises(ValidationError, match="DIFY_AGENT_DIFY_API_BASE_URL"): - _ = ServerSettings(dify_api_inner_api_key="inner-secret") + settings = ServerSettings(dify_api_inner_api_key="inner-secret") + assert settings.dify_api_inner_api_key == "inner-secret" + assert settings.dify_api_base_url is None def test_server_settings_rejects_dify_api_base_url_with_query_or_fragment() -> None: @@ -178,3 +182,29 @@ def test_server_settings_create_agent_stub_file_request_handler_returns_handler_ assert isinstance(handler, DifyApiAgentStubFileRequestHandler) assert handler.dify_api_base_url == "https://api.example.com" assert handler.dify_api_inner_api_key == "inner-secret" + + +def test_server_settings_create_agent_stub_drive_request_handler_returns_none_without_full_settings() -> None: + assert ServerSettings().create_agent_stub_drive_request_handler() is None + + +def test_server_settings_create_agent_stub_drive_request_handler_returns_handler_when_configured() -> None: + settings = ServerSettings( + dify_api_base_url="https://api.example.com", + dify_api_inner_api_key="inner-secret", + outbound_http_connect_timeout=11, + outbound_http_read_timeout=22, + outbound_http_write_timeout=33, + outbound_http_pool_timeout=44, + ) + + handler = settings.create_agent_stub_drive_request_handler() + + assert isinstance(handler, DifyApiAgentStubDriveRequestHandler) + assert handler.dify_api_base_url == "https://api.example.com" + assert handler.dify_api_inner_api_key == "inner-secret" + timeout = cast(httpx.Timeout, handler.timeout) + assert timeout.connect == 11 + assert timeout.read == 22 + assert timeout.write == 33 + assert timeout.pool == 44 diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 0ac1d77615b..ecc2d548574 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -84,6 +84,8 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "dify_agent.layers.ask_human.layer", "dify_agent.layers.dify_plugin.llm_layer", "dify_agent.layers.dify_plugin.tools_layer", + "dify_agent.layers.knowledge.client", + "dify_agent.layers.knowledge.layer", "dify_agent.layers.output.output_layer", "dify_agent.layers.shell.layer", "dify_agent.runtime", @@ -103,6 +105,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "dify_agent.layers.execution_context", "dify_agent.layers.ask_human", "dify_agent.layers.dify_plugin", + "dify_agent.layers.knowledge", "dify_agent.layers.output", "dify_agent.layers.shell", ], @@ -112,6 +115,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", + "assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig']", "assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']", "assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']", ], diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index 23ae6e65e8b..bc76ede8ed7 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -16,7 +16,7 @@ CLIENT_SHARED_DTO_DEPENDENCIES = { SERVER_RUNTIME_DEPENDENCIES = { "fastapi==0.136.0", - "graphon==0.5.1", + "graphon==0.5.2", "jsonschema>=4.23.0,<5.0.0", "jwcrypto>=1.5.6,<2", "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 0ee1bf4f8bf..bd99e89a439 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -628,7 +628,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" }, - { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.1" }, + { name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" }, { name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" }, @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "graphon" -version = "0.5.1" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -829,9 +829,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/fa/432fa802bcb13f7f51dc323ddef92594b15333eafef181d937ffa554116e/graphon-0.5.1.tar.gz", hash = "sha256:ca38cc62ef3fbc2f3072b68235bcb41e32a6369a1753b46418c1d761c57125fe", size = 269741, upload-time = "2026-06-11T03:01:38.197Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/16/f183da187414c335be67f52f6a1b7c2a33bf0b1d5090eda7e6c92d42d94a/graphon-0.5.2.tar.gz", hash = "sha256:d66a9edcd883766bd50e94f84a691c92ce536ea60e721552089e83ac8e94bf68", size = 269773, upload-time = "2026-06-16T04:06:22.074Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/c5/61e8634b89c320af9453083213e8be436071634dbc69cb14b5fe646763e4/graphon-0.5.1-py3-none-any.whl", hash = "sha256:70b49c244a46fb6e338905210cc895bd67584d9ab1412f6ba3cd4ed284010091", size = 381866, upload-time = "2026-06-11T03:01:36.693Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e6/36a3981cd44e7a40a7cd7d374e26f01e02dd49410c5fbbd7df248750d5fb/graphon-0.5.2-py3-none-any.whl", hash = "sha256:11f89399e67ed1ddd2ce1c336accd9c4ad5b8fe2741f9167e6085af0b325cd14", size = 381908, upload-time = "2026-06-16T04:06:20.453Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 8daa82d05a1..5e13db9cbc4 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,6 +1,7 @@ # ------------------------------------------------------------------ # Essential defaults for Docker Compose deployments. # Only include variables required for services to start. +# Do not add optional variables to this file. # # For a default deployment, copy this file to .env and run: # docker compose up -d @@ -150,6 +151,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false # Enable preview features still in development (currently the /create and # /refine slash commands in the "Go to Anything" command palette). NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false +ENABLE_AGENT_V2=false EXPERIMENTAL_ENABLE_VINEXT=false # Storage and default vector store @@ -199,8 +201,6 @@ 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 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 9987483156f..1fbd0ec1169 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -622,9 +622,8 @@ services: # pls clearly modify the squid env vars to fit your network environment. HTTP_PORT: ${SSRF_HTTP_PORT:-3128} COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} - REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194} - SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox} - SANDBOX_PORT: ${SANDBOX_PORT:-8194} + SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-} + SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-} networks: - ssrf_proxy_network - default diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 170e1718565..f9d15675aa0 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -212,12 +212,13 @@ services: # pls clearly modify the squid env vars to fit your network environment. HTTP_PORT: ${SSRF_HTTP_PORT:-3128} COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} - REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194} - SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox} - SANDBOX_PORT: ${SANDBOX_PORT:-8194} + SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-} + SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-} + SSRF_SANDBOX_PROXY_PORT: ${SSRF_SANDBOX_PROXY_PORT:-8194} + SSRF_SANDBOX_PROXY_HOST: ${SSRF_SANDBOX_PROXY_HOST:-sandbox} ports: - "${EXPOSE_SSRF_PROXY_PORT:-3128}:${SSRF_HTTP_PORT:-3128}" - - "${EXPOSE_SANDBOX_PORT:-8194}:${SANDBOX_PORT:-8194}" + - "${EXPOSE_SANDBOX_PORT:-8194}:${SSRF_SANDBOX_PROXY_PORT:-8194}" networks: - ssrf_proxy_network - default diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 2b9f91492eb..af9ccdfd9c2 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -628,9 +628,8 @@ services: # pls clearly modify the squid env vars to fit your network environment. HTTP_PORT: ${SSRF_HTTP_PORT:-3128} COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} - REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194} - SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox} - SANDBOX_PORT: ${SANDBOX_PORT:-8194} + SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-} + SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-} networks: - ssrf_proxy_network - default diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 0cc840d2a4d..26274fe87d2 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -188,8 +188,6 @@ 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 diff --git a/docker/envs/core-services/web.env.example b/docker/envs/core-services/web.env.example index 4c119106316..bd788a1b16c 100644 --- a/docker/envs/core-services/web.env.example +++ b/docker/envs/core-services/web.env.example @@ -25,6 +25,7 @@ ENABLE_WEBSITE_FIRECRAWL=true ENABLE_WEBSITE_WATERCRAWL=true NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false +ENABLE_AGENT_V2=false NEXT_PUBLIC_COOKIE_DOMAIN= NEXT_PUBLIC_BATCH_CONCURRENCY=5 CSP_WHITELIST= diff --git a/docker/envs/infrastructure/ssrf-proxy.env.example b/docker/envs/infrastructure/ssrf-proxy.env.example index 210a7824944..3624bd9fbb4 100644 --- a/docker/envs/infrastructure/ssrf-proxy.env.example +++ b/docker/envs/infrastructure/ssrf-proxy.env.example @@ -6,8 +6,8 @@ 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_PROXY_ALLOW_PRIVATE_IPS= +SSRF_PROXY_ALLOW_PRIVATE_DOMAINS= SSRF_DEFAULT_TIME_OUT=5 SSRF_DEFAULT_CONNECT_TIME_OUT=5 SSRF_DEFAULT_READ_TIME_OUT=5 diff --git a/docker/envs/middleware.env.example b/docker/envs/middleware.env.example index 7b28a77fe3e..3ff8139ad16 100644 --- a/docker/envs/middleware.env.example +++ b/docker/envs/middleware.env.example @@ -111,8 +111,10 @@ SANDBOX_PORT=8194 # ------------------------------ SSRF_HTTP_PORT=3128 SSRF_COREDUMP_DIR=/var/spool/squid -SSRF_REVERSE_PROXY_PORT=8194 -SSRF_SANDBOX_HOST=sandbox +SSRF_PROXY_ALLOW_PRIVATE_IPS= +SSRF_PROXY_ALLOW_PRIVATE_DOMAINS= +SSRF_SANDBOX_PROXY_PORT=8194 +SSRF_SANDBOX_PROXY_HOST=sandbox # ------------------------------ # Environment Variables for weaviate Service @@ -240,4 +242,4 @@ 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 \ No newline at end of file +LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true diff --git a/docker/ssrf_proxy/docker-entrypoint.sh b/docker/ssrf_proxy/docker-entrypoint.sh index 613897bb7db..a19f9818b24 100755 --- a/docker/ssrf_proxy/docker-entrypoint.sh +++ b/docker/ssrf_proxy/docker-entrypoint.sh @@ -26,6 +26,54 @@ tail -F /var/log/squid/error.log 2>/dev/null & tail -F /var/log/squid/store.log 2>/dev/null & tail -F /var/log/squid/cache.log 2>/dev/null & +ALLOW_PRIVATE_CONF=/etc/squid/dify_allow_private.conf +SANDBOX_PROXY_CONF=/etc/squid/dify_sandbox_proxy.conf + +write_optional_private_allowlist() { + local env_name="$1" + local acl_name="$2" + local acl_type="$3" + local raw_values="${!env_name:-}" + + raw_values="${raw_values//,/ }" + + if [ -z "${raw_values//[[:space:]]/}" ]; then + return + fi + + printf 'acl %s %s' "$acl_name" "$acl_type" >> "$ALLOW_PRIVATE_CONF" + for value in $raw_values; do + printf ' %s' "$value" >> "$ALLOW_PRIVATE_CONF" + done + printf '\nhttp_access allow client_localnet %s\n' "$acl_name" >> "$ALLOW_PRIVATE_CONF" +} + +{ + echo "# Generated by docker-entrypoint.sh." + echo "# Allows selected private targets before the default private-network deny rule." +} > "$ALLOW_PRIVATE_CONF" +write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_IPS" "dify_allowed_private_networks" "dst" +write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS" "dify_allowed_private_domains" "dstdomain" + +{ + echo "# Generated by docker-entrypoint.sh." + echo "# Enables the middleware-only sandbox host bridge when configured." +} > "$SANDBOX_PROXY_CONF" + +if [ -n "${SSRF_SANDBOX_PROXY_PORT:-}" ]; then + sandbox_proxy_host="${SSRF_SANDBOX_PROXY_HOST:-sandbox}" + sandbox_proxy_target_port="${SANDBOX_PORT:-8194}" + + { + printf 'http_port %s accel vhost\n' "$SSRF_SANDBOX_PROXY_PORT" + printf 'cache_peer %s parent %s 0 no-query originserver name=dify_sandbox\n' \ + "$sandbox_proxy_host" \ + "$sandbox_proxy_target_port" + printf 'acl dify_sandbox_proxy_port localport %s\n' "$SSRF_SANDBOX_PROXY_PORT" + printf 'http_access allow dify_sandbox_proxy_port\n' + } >> "$SANDBOX_PROXY_CONF" +fi + # Replace environment variables in the template and output to the squid.conf echo "[ENTRYPOINT] replacing environment variables in the template" awk '{ diff --git a/docker/ssrf_proxy/squid.conf.template b/docker/ssrf_proxy/squid.conf.template index fbe9ebc448b..6e30cdba928 100644 --- a/docker/ssrf_proxy/squid.conf.template +++ b/docker/ssrf_proxy/squid.conf.template @@ -1,11 +1,26 @@ -acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) -acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) -acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) -acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines -acl localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) -acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) -acl localnet src fc00::/7 # RFC 4193 local private network range -acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines +acl client_localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) +acl client_localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) +acl client_localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) +acl client_localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines +acl client_localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) +acl client_localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) +acl client_localnet src fc00::/7 # RFC 4193 local private network range +acl client_localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines +acl to_private_networks dst 0.0.0.0/8 +acl to_private_networks dst 10.0.0.0/8 +acl to_private_networks dst 100.64.0.0/10 +acl to_private_networks dst 127.0.0.0/8 +acl to_private_networks dst 169.254.0.0/16 +acl to_private_networks dst 172.16.0.0/12 +acl to_private_networks dst 192.168.0.0/16 +acl to_private_networks dst 224.0.0.0/4 +acl to_private_networks dst 240.0.0.0/4 +acl to_private_networks dst ::/128 +acl to_private_networks dst ::1/128 +acl to_private_networks dst ::ffff:0:0/96 # IPv4-mapped +acl to_private_networks dst ::/96 # deprecated IPv4-compatible +acl to_private_networks dst fc00::/7 +acl to_private_networks dst fe80::/10 acl SSL_ports port 443 # acl SSL_ports port 1025-65535 # Enable the configuration to resolve this issue: https://github.com/langgenius/dify/issues/12792 acl Safe_ports port 80 # http @@ -20,18 +35,23 @@ acl Safe_ports port 591 # filemaker acl Safe_ports port 777 # multiling http acl CONNECT method CONNECT acl allowed_domains dstdomain .marketplace.dify.ai -http_access allow allowed_domains + +http_port ${HTTP_PORT} + http_access deny !Safe_ports http_access deny CONNECT !SSL_ports http_access allow localhost manager http_access deny manager +include /etc/squid/dify_sandbox_proxy.conf +include /etc/squid/dify_allow_private.conf +http_access deny to_private_networks +http_access allow allowed_domains +http_access allow client_localnet http_access allow localhost -include /etc/squid/conf.d/*.conf http_access deny all tcp_outgoing_address 0.0.0.0 ################################## Proxy Server ################################ -http_port ${HTTP_PORT} coredump_dir ${COREDUMP_DIR} refresh_pattern ^ftp: 1440 20% 10080 refresh_pattern ^gopher: 1440 0% 1440 @@ -47,11 +67,7 @@ refresh_pattern . 0 20% 4320 # upstream proxy, set to your own upstream proxy IP to avoid SSRF attacks # cache_peer 172.1.1.1 parent 3128 0 no-query no-digest no-netdb-exchange default -################################## Reverse Proxy To Sandbox ################################ -http_port ${REVERSE_PROXY_PORT} accel vhost -cache_peer ${SANDBOX_HOST} parent ${SANDBOX_PORT} 0 no-query originserver -acl src_all src all -http_access allow src_all +################################## Request Buffer ################################ # Unless the option's size is increased, an error will occur when uploading more than two files. client_request_buffer_max_size 100 MB @@ -103,4 +119,3 @@ access_log daemon:/var/log/squid/access.log dify_log # Access log to track concurrent requests and timeouts logfile_rotate 10 - diff --git a/docker/ssrf_proxy/test_ssrf_proxy_config.sh b/docker/ssrf_proxy/test_ssrf_proxy_config.sh new file mode 100755 index 00000000000..21206a96b7f --- /dev/null +++ b/docker/ssrf_proxy/test_ssrf_proxy_config.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE="${SSRF_PROXY_TEST_IMAGE:-ubuntu/squid:latest}" +CLIENT_IMAGE="${SSRF_PROXY_TEST_CLIENT_IMAGE:-busybox:latest}" +CONTAINER_NAME="${SSRF_PROXY_TEST_CONTAINER:-dify-ssrf-proxy-test-$$}" +SANDBOX_CONTAINER_NAME="${CONTAINER_NAME}-sandbox" +NETWORK_NAME="${SSRF_PROXY_TEST_NETWORK:-dify-ssrf-proxy-test-$$}" +RUN_PUBLIC_CHECK="${SSRF_PROXY_TEST_PUBLIC_CHECK:-true}" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm -f "$SANDBOX_CONTAINER_NAME" >/dev/null 2>&1 || true + docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true +} + +http_code_for() { + local proxy_url="$1" + local target_url="$2" + local output + + output="$( + docker run \ + --rm \ + --network "$NETWORK_NAME" \ + --env "http_proxy=$proxy_url" \ + --env "https_proxy=$proxy_url" \ + "$CLIENT_IMAGE" \ + wget -S -O /dev/null -T 10 "$target_url" 2>&1 || true + )" + + printf '%s\n' "$output" | awk '$1 ~ /^HTTP\// { code = $2 } END { print code }' +} + +direct_http_code_for() { + local target_url="$1" + local output + + output="$( + docker run \ + --rm \ + --network "$NETWORK_NAME" \ + "$CLIENT_IMAGE" \ + wget -S -O /dev/null -T 10 "$target_url" 2>&1 || true + )" + + printf '%s\n' "$output" | awk '$1 ~ /^HTTP\// { code = $2 } END { print code }' +} + +assert_private_target_blocked() { + local proxy_url="$1" + local target_url="$2" + local status_code + + status_code="$(http_code_for "$proxy_url" "$target_url")" + if [[ "$status_code" != "403" ]]; then + echo "Expected $target_url to be blocked with HTTP 403, got ${status_code:-no response}." + docker logs "$CONTAINER_NAME" >&2 || true + exit 1 + fi +} + +assert_public_target_allowed() { + local proxy_url="$1" + local target_url="$2" + local status_code + + status_code="$(http_code_for "$proxy_url" "$target_url")" + if [[ ! "$status_code" =~ ^[234][0-9][0-9]$ || "$status_code" == "403" ]]; then + echo "Expected $target_url to remain reachable, got ${status_code:-no response}." + docker logs "$CONTAINER_NAME" >&2 || true + exit 1 + fi +} + +assert_sandbox_bridge_allowed() { + local target_url="$1" + local status_code + + status_code="$(direct_http_code_for "$target_url")" + if [[ ! "$status_code" =~ ^2[0-9][0-9]$ ]]; then + echo "Expected sandbox host bridge $target_url to remain reachable, got ${status_code:-no response}." + docker logs "$CONTAINER_NAME" >&2 || true + docker logs "$SANDBOX_CONTAINER_NAME" >&2 || true + exit 1 + fi +} + +trap cleanup EXIT +cleanup +docker network create "$NETWORK_NAME" >/dev/null + +docker run \ + --detach \ + --name "$SANDBOX_CONTAINER_NAME" \ + --network "$NETWORK_NAME" \ + --network-alias sandbox \ + "$CLIENT_IMAGE" \ + sh -c "mkdir -p /www && echo ok > /www/health && httpd -f -p 8194 -h /www" \ + >/dev/null + +docker run \ + --detach \ + --name "$CONTAINER_NAME" \ + --entrypoint sh \ + --network "$NETWORK_NAME" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template:ro" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \ + --env HTTP_PORT=3128 \ + --env COREDUMP_DIR=/var/spool/squid \ + --env SSRF_SANDBOX_PROXY_PORT=8194 \ + --env SSRF_SANDBOX_PROXY_HOST=sandbox \ + --env "SSRF_PROXY_ALLOW_PRIVATE_IPS=${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}" \ + --env "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}" \ + "$IMAGE" \ + -c "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh" \ + >/dev/null + +proxy_url="http://$CONTAINER_NAME:3128" +for _ in {1..30}; do + probe_status="$(http_code_for "$proxy_url" "http://127.0.0.1:80/")" + if [[ -n "$probe_status" ]]; then + break + fi + sleep 1 +done + +if [[ -z "${probe_status:-}" ]]; then + echo "Squid proxy did not respond to probes." + docker logs "$CONTAINER_NAME" >&2 || true + exit 1 +fi + +assert_private_target_blocked "$proxy_url" "http://127.0.0.1:80/" +assert_private_target_blocked "$proxy_url" "http://0.1.2.3:80/" +assert_private_target_blocked "$proxy_url" "http://169.254.169.254/latest/meta-data/" + +if [[ "$RUN_PUBLIC_CHECK" == "true" ]]; then + assert_public_target_allowed "$proxy_url" "http://example.com/" +fi + +assert_sandbox_bridge_allowed "http://$CONTAINER_NAME:8194/health" diff --git a/e2e/support/apps.ts b/e2e/support/apps.ts index f035b5f4a1b..3c3af547a35 100644 --- a/e2e/support/apps.ts +++ b/e2e/support/apps.ts @@ -19,6 +19,6 @@ export const openBlankAppCreation = async (page: Page) => { return } - await page.getByRole('button', { name: 'Create' }).click() + await page.getByRole('button', { name: 'Create', exact: true }).click() await page.getByRole('menuitem', { name: 'Create from Blank' }).click() } diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 87230e947ef..7e4521f51cb 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -228,9 +228,6 @@ } }, "web/app/(shareLayout)/webapp-reset-password/page.tsx": { - "no-restricted-globals": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } @@ -252,9 +249,6 @@ } }, "web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx": { - "no-restricted-globals": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } @@ -321,11 +315,6 @@ "count": 1 } }, - "web/app/account/(commonLayout)/delete-account/index.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "web/app/account/oauth/authorize/layout.tsx": { "ts/no-explicit-any": { "count": 1 @@ -378,11 +367,6 @@ "count": 1 } }, - "web/app/components/app-sidebar/index.tsx": { - "no-restricted-globals": { - "count": 1 - } - }, "web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -487,14 +471,6 @@ "count": 1 } }, - "web/app/components/app/app-access-control/access-control-item.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/app-publisher/sections.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -658,9 +634,6 @@ "jsx-a11y/no-static-element-interactions": { "count": 1 }, - "no-restricted-globals": { - "count": 6 - }, "react/set-state-in-effect": { "count": 4 }, @@ -728,11 +701,6 @@ "count": 1 } }, - "web/app/components/app/configuration/dataset-config/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx": { "ts/no-explicit-any": { "count": 1 @@ -845,14 +813,6 @@ "count": 1 } }, - "web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/app/create-app-dialog/app-list/index.tsx": { "no-restricted-imports": { "count": 1 @@ -973,11 +933,6 @@ "count": 1 } }, - "web/app/components/app/overview/trigger-card.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app/overview/workflow-hidden-input-fields.tsx": { "no-restricted-imports": { "count": 1 @@ -1043,6 +998,11 @@ "count": 1 } }, + "web/app/components/apps/new-app-card.tsx": { + "react/set-state-in-effect": { + "count": 3 + } + }, "web/app/components/base/action-button/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -1097,14 +1057,6 @@ "count": 1 } }, - "web/app/components/base/app-icon/index.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/base/audio-btn/audio.ts": { "node/prefer-global/buffer": { "count": 1 @@ -1537,17 +1489,6 @@ "count": 1 } }, - "web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/features/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -1948,7 +1889,7 @@ }, "web/app/components/base/icons/src/vender/solid/arrows/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 3 + "count": 1 } }, "web/app/components/base/icons/src/vender/solid/communication/index.ts": { @@ -2510,11 +2451,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/utils.ts": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/base/prompt-log-modal/index.stories.tsx": { "no-console": { "count": 1 @@ -2671,11 +2607,6 @@ "count": 4 } }, - "web/app/components/billing/billing-page/__tests__/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/billing/header-billing-btn/index.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -2961,14 +2892,6 @@ "count": 3 } }, - "web/app/components/datasets/create/step-two/index.tsx": { - "no-barrel-files/no-barrel-files": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "web/app/components/datasets/create/step-two/preview-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -3038,14 +2961,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/datasets/documents/components/document-list/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -3069,14 +2984,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/components/operations.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/datasets/documents/components/rename-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3326,10 +3233,10 @@ }, "web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts": { "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 4 + "count": 6 }, "react/set-state-in-effect": { - "count": 4 + "count": 6 } }, "web/app/components/datasets/documents/detail/metadata/index.tsx": { @@ -3441,11 +3348,6 @@ "count": 1 } }, - "web/app/components/datasets/list/__tests__/header.spec.tsx": { - "jsx-a11y/label-has-associated-control": { - "count": 1 - } - }, "web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3558,11 +3460,6 @@ "count": 1 } }, - "web/app/components/datasets/settings/permission-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/settings/permission-selector/member-item.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3600,6 +3497,14 @@ "count": 2 } }, + "web/app/components/develop/secret-key/secret-key-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3835,11 +3740,6 @@ "count": 1 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -3862,9 +3762,6 @@ }, "jsx-a11y/no-static-element-interactions": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx": { @@ -3908,11 +3805,6 @@ "count": 7 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx": { "unicorn/prefer-number-properties": { "count": 2 @@ -4013,12 +3905,7 @@ "count": 2 } }, - "web/app/components/header/account-setting/plugin-page/utils.ts": { - "ts/no-explicit-any": { - "count": 4 - } - }, - "web/app/components/header/nav/index.tsx": { + "web/app/components/header/account-setting/permission-group-list.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 }, @@ -4026,8 +3913,11 @@ "count": 1 } }, - "web/app/components/main-nav/components/web-apps-section.tsx": { - "jsx-a11y/no-autofocus": { + "web/app/components/header/nav/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -4254,11 +4144,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx": { - "jsx-a11y/anchor-has-content": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4342,10 +4227,10 @@ }, "web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { - "count": 4 + "count": 5 }, "jsx-a11y/no-static-element-interactions": { - "count": 4 + "count": 5 } }, "web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": { @@ -4762,11 +4647,6 @@ "count": 2 } }, - "web/app/components/signin/countdown.tsx": { - "no-restricted-globals": { - "count": 4 - } - }, "web/app/components/snippet-list/components/snippet-card.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4852,30 +4732,11 @@ "count": 1 } }, - "web/app/components/tools/mcp/__tests__/index.spec.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/tools/mcp/provider-card.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/tools/provider/detail.tsx": { "jsx-a11y/anchor-has-content": { "count": 1 @@ -5020,14 +4881,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/blocks.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/featured-tools.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -5068,11 +4921,6 @@ "count": 2 } }, - "web/app/components/workflow/block-selector/market-place-plugin/item.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/market-place-plugin/list.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -5160,11 +5008,6 @@ "count": 1 } }, - "web/app/components/workflow/candidate-node-main.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/workflow/comment/comment-input.tsx": { "jsx-a11y/no-autofocus": { "count": 1 @@ -5217,14 +5060,6 @@ "count": 2 } }, - "web/app/components/workflow/header/run-and-history.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/workflow/header/scroll-to-selected-node-button.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -5308,11 +5143,6 @@ "count": 1 } }, - "web/app/components/workflow/hooks/use-nodes-interactions.ts": { - "ts/no-explicit-any": { - "count": 8 - } - }, "web/app/components/workflow/hooks/use-serial-async-callback.ts": { "ts/no-explicit-any": { "count": 1 @@ -5712,7 +5542,7 @@ "count": 2 }, "ts/no-explicit-any": { - "count": 6 + "count": 4 } }, "web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/index.tsx": { @@ -5893,11 +5723,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/components.ts": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/workflow/nodes/data-source-empty/hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -6351,11 +6176,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": { - "no-restricted-properties": { - "count": 3 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx": { "react-refresh/only-export-components": { "count": 2 @@ -7068,19 +6888,6 @@ "count": 1 } }, - "web/app/components/workflow/run/agent-log/agent-log-trigger.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/workflow/run/agent-log/index.tsx": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "web/app/components/workflow/run/hooks.ts": { "ts/no-explicit-any": { "count": 1 @@ -7490,9 +7297,6 @@ } }, "web/app/reset-password/page.tsx": { - "no-restricted-globals": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } @@ -7513,24 +7317,11 @@ "count": 1 } }, - "web/app/signin/invite-settings/page.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/signin/layout.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/signin/normal-form.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/signin/one-more-step.tsx": { "no-restricted-imports": { "count": 1 @@ -7649,11 +7440,6 @@ "count": 1 } }, - "web/models/access-control.ts": { - "erasable-syntax-only/enums": { - "count": 2 - } - }, "web/models/app.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -7716,10 +7502,62 @@ "count": 1 } }, - "web/service/access-control.ts": { - "@tanstack/query/exhaustive-deps": { + "web/service/__tests__/use-tools.spec.tsx": { + "no-restricted-imports": { "count": 1 - }, + } + }, + "web/service/access-control/__tests__/use-app-access-control.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-member-roles.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-permission-catalog.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-permission-keys.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-app-access-control.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-member-roles.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-permission-catalog.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-permission-keys.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-workspace-access-rules.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-workspace-roles.ts": { "no-restricted-imports": { "count": 1 } @@ -7763,17 +7601,12 @@ "count": 1 } }, - "web/service/client.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/common.ts": { "no-restricted-imports": { "count": 1 }, "ts/no-explicit-any": { - "count": 27 + "count": 26 } }, "web/service/datasets.ts": { @@ -7977,9 +7810,6 @@ "web/service/use-tools.ts": { "no-restricted-imports": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 } }, "web/service/use-triggers.ts": { @@ -8051,11 +7881,6 @@ "count": 17 } }, - "web/utils/clipboard.ts": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/utils/completion-params.spec.ts": { "ts/no-explicit-any": { "count": 3 diff --git a/eslint.config.mjs b/eslint.config.mjs index 1380ed67d2e..880ef4cdc56 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ import markdownPreferences from 'eslint-plugin-markdown-preferences' const GENERATED_IGNORES = [ '**/storybook-static/', '**/.next/', + '**/.vinext/', 'web/next/', 'web/next-env.d.ts', '**/dist/', diff --git a/packages/contracts/generated/api/console/activate/types.gen.ts b/packages/contracts/generated/api/console/activate/types.gen.ts index 92370b5a75c..6591aba3996 100644 --- a/packages/contracts/generated/api/console/activate/types.gen.ts +++ b/packages/contracts/generated/api/console/activate/types.gen.ts @@ -6,9 +6,9 @@ export type ClientOptions = { export type ActivatePayload = { email?: string | null - interface_language: string - name: string - timezone: string + interface_language?: string | null + name?: string | null + timezone?: string | null token: string workspace_id?: string | null } @@ -23,7 +23,9 @@ export type ActivationCheckResponse = { } export type ActivationCheckData = { + account_status?: string | null email: string | null + requires_setup?: boolean | null workspace_id: string | null workspace_name: string | null } diff --git a/packages/contracts/generated/api/console/activate/zod.gen.ts b/packages/contracts/generated/api/console/activate/zod.gen.ts index 00f85767b7c..40aff1f934f 100644 --- a/packages/contracts/generated/api/console/activate/zod.gen.ts +++ b/packages/contracts/generated/api/console/activate/zod.gen.ts @@ -7,9 +7,9 @@ import * as z from 'zod' */ export const zActivatePayload = z.object({ email: z.string().nullish(), - interface_language: z.string(), - name: z.string().max(30), - timezone: z.string(), + interface_language: z.string().nullish(), + name: z.string().max(30).nullish(), + timezone: z.string().nullish(), token: z.string(), workspace_id: z.string().nullish(), }) @@ -25,7 +25,9 @@ export const zActivationResponse = z.object({ * ActivationCheckData */ export const zActivationCheckData = z.object({ + account_status: z.string().nullish(), email: z.string().nullable(), + requires_setup: z.boolean().nullish(), workspace_id: z.string().nullable(), workspace_name: z.string().nullable(), }) diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index b749f644532..fbfca3be118 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -29,6 +29,14 @@ import { zGetAgentByAgentIdDriveFilesPreviewResponse, zGetAgentByAgentIdDriveFilesQuery, zGetAgentByAgentIdDriveFilesResponse, + zGetAgentByAgentIdLogsByConversationIdMessagesPath, + zGetAgentByAgentIdLogsByConversationIdMessagesQuery, + zGetAgentByAgentIdLogsByConversationIdMessagesResponse, + zGetAgentByAgentIdLogSourcesPath, + zGetAgentByAgentIdLogSourcesResponse, + zGetAgentByAgentIdLogsPath, + zGetAgentByAgentIdLogsQuery, + zGetAgentByAgentIdLogsResponse, zGetAgentByAgentIdMessagesByMessageIdPath, zGetAgentByAgentIdMessagesByMessageIdResponse, zGetAgentByAgentIdPath, @@ -41,6 +49,9 @@ import { zGetAgentByAgentIdSandboxFilesReadQuery, zGetAgentByAgentIdSandboxFilesReadResponse, zGetAgentByAgentIdSandboxFilesResponse, + zGetAgentByAgentIdStatisticsSummaryPath, + zGetAgentByAgentIdStatisticsSummaryQuery, + zGetAgentByAgentIdStatisticsSummaryResponse, zGetAgentByAgentIdVersionsByVersionIdPath, zGetAgentByAgentIdVersionsByVersionIdResponse, zGetAgentByAgentIdVersionsPath, @@ -55,6 +66,9 @@ import { zPostAgentByAgentIdComposerValidateBody, zPostAgentByAgentIdComposerValidatePath, zPostAgentByAgentIdComposerValidateResponse, + zPostAgentByAgentIdCopyBody, + zPostAgentByAgentIdCopyPath, + zPostAgentByAgentIdCopyResponse, zPostAgentByAgentIdFeaturesBody, zPostAgentByAgentIdFeaturesPath, zPostAgentByAgentIdFeaturesResponse, @@ -69,8 +83,7 @@ import { zPostAgentByAgentIdSandboxFilesUploadResponse, zPostAgentByAgentIdSkillsBySlugInferToolsPath, zPostAgentByAgentIdSkillsBySlugInferToolsResponse, - zPostAgentByAgentIdSkillsStandardizePath, - zPostAgentByAgentIdSkillsStandardizeResponse, + zPostAgentByAgentIdSkillsUploadBody, zPostAgentByAgentIdSkillsUploadPath, zPostAgentByAgentIdSkillsUploadResponse, zPostAgentResponse, @@ -233,6 +246,22 @@ export const composer = { validate, } +export const post3 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAgentByAgentIdCopy', + path: '/agent/{agent_id}/copy', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ body: zPostAgentByAgentIdCopyBody, params: zPostAgentByAgentIdCopyPath })) + .output(zPostAgentByAgentIdCopyResponse) + +export const copy = { + post: post3, +} + /** * Time-limited external signed URL for one Agent App drive value */ @@ -314,7 +343,7 @@ export const drive = { /** * Update an Agent App's presentation features (opener, follow-up, citations, ...) */ -export const post3 = oc +export const post4 = oc .route({ description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)', inputStructure: 'detailed', @@ -329,13 +358,13 @@ export const post3 = oc .output(zPostAgentByAgentIdFeaturesResponse) export const features = { - post: post3, + post: post4, } /** * Create or update Agent App message feedback */ -export const post4 = oc +export const post5 = oc .route({ description: 'Create or update Agent App message feedback', inputStructure: 'detailed', @@ -350,7 +379,7 @@ export const post4 = oc .output(zPostAgentByAgentIdFeedbacksResponse) export const feedbacks = { - post: post4, + post: post5, } /** @@ -373,7 +402,7 @@ export const delete_ = oc /** * Commit an uploaded file into the Agent App drive under files/ */ -export const post5 = oc +export const post6 = oc .route({ description: 'Commit an uploaded file into the Agent App drive under files/', inputStructure: 'detailed', @@ -388,13 +417,70 @@ export const post5 = oc export const files2 = { delete: delete_, - post: post5, + post: post6, +} + +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdLogSources', + path: '/agent/{agent_id}/log-sources', + tags: ['console'], + }) + .input(z.object({ params: zGetAgentByAgentIdLogSourcesPath })) + .output(zGetAgentByAgentIdLogSourcesResponse) + +export const logSources = { + get: get9, +} + +export const get10 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdLogsByConversationIdMessages', + path: '/agent/{agent_id}/logs/{conversation_id}/messages', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdLogsByConversationIdMessagesPath, + query: zGetAgentByAgentIdLogsByConversationIdMessagesQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse) + +export const messages = { + get: get10, +} + +export const byConversationId = { + messages, +} + +export const get11 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdLogs', + path: '/agent/{agent_id}/logs', + tags: ['console'], + }) + .input( + z.object({ params: zGetAgentByAgentIdLogsPath, query: zGetAgentByAgentIdLogsQuery.optional() }), + ) + .output(zGetAgentByAgentIdLogsResponse) + +export const logs = { + get: get11, + byConversationId, } /** * Get Agent App message details by ID */ -export const get9 = oc +export const get12 = oc .route({ description: 'Get Agent App message details by ID', inputStructure: 'detailed', @@ -407,17 +493,17 @@ export const get9 = oc .output(zGetAgentByAgentIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get9, + get: get12, } -export const messages = { +export const messages2 = { byMessageId: byMessageId2, } /** * List workflow apps that reference this Agent App's bound Agent (read-only) */ -export const get10 = oc +export const get13 = oc .route({ description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)', inputStructure: 'detailed', @@ -430,13 +516,13 @@ export const get10 = oc .output(zGetAgentByAgentIdReferencingWorkflowsResponse) export const referencingWorkflows = { - get: get10, + get: get13, } /** * Read a text/binary preview file in an Agent App conversation sandbox */ -export const get11 = oc +export const get14 = oc .route({ description: 'Read a text/binary preview file in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -454,13 +540,13 @@ export const get11 = oc .output(zGetAgentByAgentIdSandboxFilesReadResponse) export const read = { - get: get11, + get: get14, } /** * Upload one Agent App sandbox file as a Dify ToolFile mapping */ -export const post6 = oc +export const post7 = oc .route({ description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -478,13 +564,13 @@ export const post6 = oc .output(zPostAgentByAgentIdSandboxFilesUploadResponse) export const upload = { - post: post6, + post: post7, } /** * List a directory in an Agent App conversation sandbox */ -export const get12 = oc +export const get15 = oc .route({ description: 'List a directory in an Agent App conversation sandbox', inputStructure: 'detailed', @@ -502,7 +588,7 @@ export const get12 = oc .output(zGetAgentByAgentIdSandboxFilesResponse) export const files3 = { - get: get12, + get: get15, read, upload, } @@ -512,31 +598,11 @@ export const sandbox = { } /** - * Validate + standardize a Skill into an Agent App drive - */ -export const post7 = oc - .route({ - description: 'Validate + standardize a Skill into an Agent App drive', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAgentByAgentIdSkillsStandardize', - path: '/agent/{agent_id}/skills/standardize', - successStatus: 201, - tags: ['console'], - }) - .input(z.object({ params: zPostAgentByAgentIdSkillsStandardizePath })) - .output(zPostAgentByAgentIdSkillsStandardizeResponse) - -export const standardize = { - post: post7, -} - -/** - * Upload + validate a Skill package for an Agent App + * Upload + standardize a Skill into an Agent App drive */ export const post8 = oc .route({ - description: 'Upload + validate a Skill package for an Agent App', + description: 'Upload + standardize a Skill into an Agent App drive', inputStructure: 'detailed', method: 'POST', operationId: 'postAgentByAgentIdSkillsUpload', @@ -544,7 +610,12 @@ export const post8 = oc successStatus: 201, tags: ['console'], }) - .input(z.object({ params: zPostAgentByAgentIdSkillsUploadPath })) + .input( + z.object({ + body: zPostAgentByAgentIdSkillsUploadBody, + params: zPostAgentByAgentIdSkillsUploadPath, + }), + ) .output(zPostAgentByAgentIdSkillsUploadResponse) export const upload2 = { @@ -591,12 +662,35 @@ export const bySlug = { } export const skills = { - standardize, upload: upload2, bySlug, } -export const get13 = oc +export const get16 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAgentByAgentIdStatisticsSummary', + path: '/agent/{agent_id}/statistics/summary', + tags: ['console'], + }) + .input( + z.object({ + params: zGetAgentByAgentIdStatisticsSummaryPath, + query: zGetAgentByAgentIdStatisticsSummaryQuery.optional(), + }), + ) + .output(zGetAgentByAgentIdStatisticsSummaryResponse) + +export const summary = { + get: get16, +} + +export const statistics = { + summary, +} + +export const get17 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -608,10 +702,10 @@ export const get13 = oc .output(zGetAgentByAgentIdVersionsByVersionIdResponse) export const byVersionId = { - get: get13, + get: get17, } -export const get14 = oc +export const get18 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -623,7 +717,7 @@ export const get14 = oc .output(zGetAgentByAgentIdVersionsResponse) export const versions = { - get: get14, + get: get18, byVersionId, } @@ -639,7 +733,7 @@ export const delete3 = oc .input(z.object({ params: zDeleteAgentByAgentIdPath })) .output(zDeleteAgentByAgentIdResponse) -export const get15 = oc +export const get19 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -663,22 +757,26 @@ export const put2 = oc export const byAgentId = { delete: delete3, - get: get15, + get: get19, put: put2, chatMessages, composer, + copy, drive, features, feedbacks, files: files2, - messages, + logSources, + logs, + messages: messages2, referencingWorkflows, sandbox, skills, + statistics, versions, } -export const get16 = oc +export const get20 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -702,7 +800,7 @@ export const post10 = oc .output(zPostAgentResponse) export const agent = { - get: get16, + get: get20, post: post10, inviteOptions, byAgentId, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index a1779531706..64afc442406 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -4,8 +4,8 @@ export type ClientOptions = { baseUrl: `${string}://${string}/console/api` | (string & {}) } -export type AppPagination = { - data: Array +export type AgentAppPagination = { + data: Array has_more: boolean limit: number page: number @@ -18,11 +18,12 @@ export type AgentAppCreatePayload = { icon_background?: string | null icon_type?: IconType | null name: string - role?: string + role: string } -export type AppDetailWithSite = { +export type AgentAppDetailWithSite = { access_mode?: string | null + active_config_is_published?: boolean api_base_url?: string | null app_id?: string | null bound_agent_id?: string | null @@ -37,10 +38,12 @@ export type AppDetailWithSite = { icon_type?: string | null readonly icon_url: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string + permission_keys?: Array role?: string | null site?: Site | null tags?: Array @@ -66,7 +69,7 @@ export type AgentAppUpdatePayload = { icon_type?: IconType | null max_active_requests?: number | null name: string - role?: string | null + role: string use_icon_as_answer_icon?: boolean | null } @@ -121,6 +124,14 @@ export type AgentComposerValidateResponse = { warnings?: Array } +export type CopyAppPayload = { + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + name?: string | null +} + export type AgentDriveListResponse = { items?: Array } @@ -168,6 +179,27 @@ export type AgentDriveFileCommitResponse = { file: AgentDriveFileResponse } +export type AgentLogSourceListResponse = { + data: Array + groups: Array +} + +export type AgentLogListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + +export type AgentLogMessageListResponse = { + data: Array + has_more: boolean + limit: number + page: number + total: number +} + export type MessageDetailResponse = { agent_thoughts?: Array annotation?: ConversationAnnotation | null @@ -225,11 +257,6 @@ export type SandboxUploadResponse = { path: string } -export type AgentSkillStandardizeResponse = { - manifest: SkillManifest - skill: AgentSkillRefConfig -} - export type AgentSkillUploadResponse = { manifest: SkillManifest skill: AgentSkillRefConfig @@ -241,6 +268,12 @@ export type SkillToolInferenceResult = { reason?: string | null } +export type AgentStatisticSummaryEnvelopeResponse = { + charts: AgentStatisticChartsResponse + source: string + summary: AgentStatisticSummaryResponse +} + export type AgentConfigSnapshotListResponse = { data: Array } @@ -250,15 +283,18 @@ export type AgentConfigSnapshotDetailResponse = { config_snapshot: AgentSoulConfig created_at?: number | null created_by?: string | null + display_version?: number | null id: string revisions?: Array + snapshot_version?: number | null summary?: string | null version: number version_note?: string | null } -export type AppPartial = { +export type AgentAppPartial = { access_mode?: string | null + active_config_is_published?: boolean app_id?: string | null author_name?: string | null bound_agent_id?: string | null @@ -273,10 +309,14 @@ export type AppPartial = { readonly icon_url: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string + permission_keys?: Array + published_reference_count?: number + published_references?: Array role?: string | null tags?: Array updated_at?: number | null @@ -345,6 +385,7 @@ export type WorkflowPartial = { } export type AgentInviteOptionResponse = { + active_config_is_published?: boolean active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null active_config_snapshot_id?: string | null agent_kind: AgentKind @@ -379,7 +420,9 @@ export type AgentConfigSnapshotSummaryResponse = { agent_id?: string | null created_at?: number | null created_by?: string | null + display_version?: number | null id: string + snapshot_version?: number | null summary?: string | null version: number version_note?: string | null @@ -527,6 +570,60 @@ export type AgentDriveFileResponse = { size?: number | null } +export type AgentLogSourceResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_name: string + id: string + node_id?: string | null + type: 'webapp' | 'workflow' + workflow_id?: string | null + workflow_version?: string | null +} + +export type AgentLogSourceGroupResponse = { + label: string + sources?: Array + type: 'webapp' | 'workflow' +} + +export type AgentLogConversationItemResponse = { + conversation_id: string + created_at?: number | null + end_user_id?: string | null + id: string + message_count: number + operation_rate?: number | null + source?: AgentLogSourceResponse | null + status: 'failed' | 'paused' | 'success' + title?: string | null + unread: boolean + updated_at?: number | null + user_rate?: number | null +} + +export type AgentLogMessageItemResponse = { + answer: string + answer_tokens: number + conversation_id: string + created_at?: number | null + currency: string + error?: string | null + from_account_id?: string | null + from_end_user_id?: string | null + id: string + latency: number + message_id: string + message_tokens: number + query: string + status: string + total_price: string + total_tokens: number + updated_at?: number | null +} + export type AgentThought = { chain_id?: string | null created_at?: number | null @@ -641,6 +738,30 @@ export type CliToolSuggestion = { name: string } +export type AgentStatisticChartsResponse = { + average_response_time?: Array + average_session_interactions?: Array + daily_conversations?: Array + daily_end_users?: Array + daily_messages?: Array + token_usage?: Array + tokens_per_second?: Array + user_satisfaction_rate?: Array +} + +export type AgentStatisticSummaryResponse = { + average_response_time: number + average_session_interactions: number + currency: string + tokens_per_second: number + total_conversations: number + total_end_users: number + total_messages: number + total_price: string + total_tokens: number + user_satisfaction_rate: number +} + export type AgentConfigRevisionResponse = { created_at?: number | null created_by?: string | null @@ -662,6 +783,14 @@ export type ModelConfigPartial = { updated_by?: string | null } +export type AgentAppPublishedReferenceResponse = { + app_icon?: string | null + app_icon_background?: string | null + app_icon_type?: string | null + app_id: string + app_name: string +} + export type LlmMode = 'chat' | 'completion' export type AgentKind = 'dify_agent' @@ -757,6 +886,26 @@ export type AgentSoulToolsConfig = { export type DeclaredOutputConfig = { array_item?: DeclaredArrayItem | null check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null failure_strategy?: DeclaredOutputFailureStrategy file?: DeclaredOutputFileConfig | null @@ -930,6 +1079,48 @@ export type EnvSuggestion = { secret_likely?: boolean } +export type AgentAverageResponseTimeStatisticResponse = { + date: string + latency: number +} + +export type AgentAverageSessionInteractionStatisticResponse = { + date: string + interactions: number +} + +export type AgentDailyConversationStatisticResponse = { + conversation_count: number + date: string +} + +export type AgentDailyEndUserStatisticResponse = { + date: string + terminal_count: number +} + +export type AgentDailyMessageStatisticResponse = { + date: string + message_count: number +} + +export type AgentTokenUsageStatisticResponse = { + currency: string + date: string + token_count: number + total_price: string +} + +export type AgentTokensPerSecondStatisticResponse = { + date: string + tps: number +} + +export type AgentUserSatisfactionRateStatisticResponse = { + date: string + rate: number +} + export type AgentConfigRevisionOperation = | 'create_version' | 'save_current_version' @@ -949,6 +1140,7 @@ export type AgentSecretRefConfig = { provider_credential_id?: string | null ref?: string | null type?: string | null + value?: string | null variable?: string | null [key: string]: unknown } @@ -1073,6 +1265,26 @@ export type AgentSoulDifyToolConfig = { } export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null type: DeclaredOutputType } @@ -1214,16 +1426,17 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' export type ValueSourceType = 'constant' | 'variable' -export type AppPaginationWritable = { - data: Array +export type AgentAppPaginationWritable = { + data: Array has_more: boolean limit: number page: number total: number } -export type AppDetailWithSiteWritable = { +export type AgentAppDetailWithSiteWritable = { access_mode?: string | null + active_config_is_published?: boolean api_base_url?: string | null app_id?: string | null bound_agent_id?: string | null @@ -1237,10 +1450,12 @@ export type AppDetailWithSiteWritable = { icon_background?: string | null icon_type?: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string + permission_keys?: Array role?: string | null site?: SiteWritable | null tags?: Array @@ -1251,8 +1466,9 @@ export type AppDetailWithSiteWritable = { workflow?: WorkflowPartial | null } -export type AppPartialWritable = { +export type AgentAppPartialWritable = { access_mode?: string | null + active_config_is_published?: boolean app_id?: string | null author_name?: string | null bound_agent_id?: string | null @@ -1266,10 +1482,14 @@ export type AppPartialWritable = { icon_type?: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string + permission_keys?: Array + published_reference_count?: number + published_references?: Array role?: string | null tags?: Array updated_at?: number | null @@ -1319,7 +1539,7 @@ export type GetAgentData = { } export type GetAgentResponses = { - 200: AppPagination + 200: AgentAppPagination } export type GetAgentResponse = GetAgentResponses[keyof GetAgentResponses] @@ -1337,7 +1557,7 @@ export type PostAgentErrors = { } export type PostAgentResponses = { - 201: AppDetailWithSite + 201: AgentAppDetailWithSite } export type PostAgentResponse = PostAgentResponses[keyof PostAgentResponses] @@ -1391,7 +1611,7 @@ export type GetAgentByAgentIdData = { } export type GetAgentByAgentIdResponses = { - 200: AppDetailWithSite + 200: AgentAppDetailWithSite } export type GetAgentByAgentIdResponse = GetAgentByAgentIdResponses[keyof GetAgentByAgentIdResponses] @@ -1411,7 +1631,7 @@ export type PutAgentByAgentIdErrors = { } export type PutAgentByAgentIdResponses = { - 200: AppDetailWithSite + 200: AgentAppDetailWithSite } export type PutAgentByAgentIdResponse = PutAgentByAgentIdResponses[keyof PutAgentByAgentIdResponses] @@ -1542,6 +1762,27 @@ export type PostAgentByAgentIdComposerValidateResponses = { export type PostAgentByAgentIdComposerValidateResponse = PostAgentByAgentIdComposerValidateResponses[keyof PostAgentByAgentIdComposerValidateResponses] +export type PostAgentByAgentIdCopyData = { + body: CopyAppPayload + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/copy' +} + +export type PostAgentByAgentIdCopyErrors = { + 400: unknown + 403: unknown +} + +export type PostAgentByAgentIdCopyResponses = { + 201: AgentAppDetailWithSite +} + +export type PostAgentByAgentIdCopyResponse + = PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] + export type GetAgentByAgentIdDriveFilesData = { body?: never path: { @@ -1671,6 +1912,79 @@ export type PostAgentByAgentIdFilesResponses = { export type PostAgentByAgentIdFilesResponse = PostAgentByAgentIdFilesResponses[keyof PostAgentByAgentIdFilesResponses] +export type GetAgentByAgentIdLogSourcesData = { + body?: never + path: { + agent_id: string + } + query?: never + url: '/agent/{agent_id}/log-sources' +} + +export type GetAgentByAgentIdLogSourcesResponses = { + 200: AgentLogSourceListResponse +} + +export type GetAgentByAgentIdLogSourcesResponse + = GetAgentByAgentIdLogSourcesResponses[keyof GetAgentByAgentIdLogSourcesResponses] + +export type GetAgentByAgentIdLogsData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + keyword?: string + limit?: number + page?: number + sort_by?: string + sort_order?: string + source?: string + sources?: Array + start?: string + status?: string + statuses?: Array + } + url: '/agent/{agent_id}/logs' +} + +export type GetAgentByAgentIdLogsResponses = { + 200: AgentLogListResponse +} + +export type GetAgentByAgentIdLogsResponse + = GetAgentByAgentIdLogsResponses[keyof GetAgentByAgentIdLogsResponses] + +export type GetAgentByAgentIdLogsByConversationIdMessagesData = { + body?: never + path: { + agent_id: string + conversation_id: string + } + query?: { + end?: string + keyword?: string + limit?: number + page?: number + sort_by?: string + sort_order?: string + source?: string + sources?: Array + start?: string + status?: string + statuses?: Array + } + url: '/agent/{agent_id}/logs/{conversation_id}/messages' +} + +export type GetAgentByAgentIdLogsByConversationIdMessagesResponses = { + 200: AgentLogMessageListResponse +} + +export type GetAgentByAgentIdLogsByConversationIdMessagesResponse + = GetAgentByAgentIdLogsByConversationIdMessagesResponses[keyof GetAgentByAgentIdLogsByConversationIdMessagesResponses] + export type GetAgentByAgentIdMessagesByMessageIdData = { body?: never path: { @@ -1766,28 +2080,10 @@ export type PostAgentByAgentIdSandboxFilesUploadResponses = { export type PostAgentByAgentIdSandboxFilesUploadResponse = PostAgentByAgentIdSandboxFilesUploadResponses[keyof PostAgentByAgentIdSandboxFilesUploadResponses] -export type PostAgentByAgentIdSkillsStandardizeData = { - body?: never - path: { - agent_id: string - } - query?: never - url: '/agent/{agent_id}/skills/standardize' -} - -export type PostAgentByAgentIdSkillsStandardizeErrors = { - 400: unknown -} - -export type PostAgentByAgentIdSkillsStandardizeResponses = { - 201: AgentSkillStandardizeResponse -} - -export type PostAgentByAgentIdSkillsStandardizeResponse - = PostAgentByAgentIdSkillsStandardizeResponses[keyof PostAgentByAgentIdSkillsStandardizeResponses] - export type PostAgentByAgentIdSkillsUploadData = { - body?: never + body: { + file: Blob | File + } path: { agent_id: string } @@ -1840,6 +2136,26 @@ export type PostAgentByAgentIdSkillsBySlugInferToolsResponses = { export type PostAgentByAgentIdSkillsBySlugInferToolsResponse = PostAgentByAgentIdSkillsBySlugInferToolsResponses[keyof PostAgentByAgentIdSkillsBySlugInferToolsResponses] +export type GetAgentByAgentIdStatisticsSummaryData = { + body?: never + path: { + agent_id: string + } + query?: { + end?: string + source?: string + start?: string + } + url: '/agent/{agent_id}/statistics/summary' +} + +export type GetAgentByAgentIdStatisticsSummaryResponses = { + 200: AgentStatisticSummaryEnvelopeResponse +} + +export type GetAgentByAgentIdStatisticsSummaryResponse + = GetAgentByAgentIdStatisticsSummaryResponses[keyof GetAgentByAgentIdStatisticsSummaryResponses] + export type GetAgentByAgentIdVersionsData = { body?: never path: { diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 41e16de3b31..7d6bd6f5eb2 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -92,7 +92,7 @@ export const zAgentAppCreatePayload = z.object({ icon_background: z.string().nullish(), icon_type: zIconType.nullish(), name: z.string().min(1), - role: z.string().max(255).optional().default(''), + role: z.string().min(1).max(255), }) /** @@ -105,10 +105,21 @@ export const zAgentAppUpdatePayload = z.object({ icon_type: zIconType.nullish(), max_active_requests: z.int().nullish(), name: z.string().min(1), - role: z.string().max(255).nullish(), + role: z.string().min(1).max(255), use_icon_as_answer_icon: z.boolean().nullish(), }) +/** + * CopyAppPayload + */ +export const zCopyAppPayload = z.object({ + description: z.string().max(400).nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + name: z.string().nullish(), +}) + /** * DeletedTool */ @@ -176,7 +187,9 @@ export const zAgentConfigSnapshotSummaryResponse = z.object({ agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + display_version: z.int().nullish(), id: z.string(), + snapshot_version: z.int().nullish(), summary: z.string().nullish(), version: z.int(), version_note: z.string().nullish(), @@ -321,6 +334,102 @@ export const zAgentDriveFileCommitResponse = z.object({ file: zAgentDriveFileResponse, }) +/** + * AgentLogSourceResponse + */ +export const zAgentLogSourceResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_name: z.string(), + id: z.string(), + node_id: z.string().nullish(), + type: z.enum(['webapp', 'workflow']), + workflow_id: z.string().nullish(), + workflow_version: z.string().nullish(), +}) + +/** + * AgentLogSourceGroupResponse + */ +export const zAgentLogSourceGroupResponse = z.object({ + label: z.string(), + sources: z.array(zAgentLogSourceResponse).optional(), + type: z.enum(['webapp', 'workflow']), +}) + +/** + * AgentLogSourceListResponse + */ +export const zAgentLogSourceListResponse = z.object({ + data: z.array(zAgentLogSourceResponse), + groups: z.array(zAgentLogSourceGroupResponse), +}) + +/** + * AgentLogConversationItemResponse + */ +export const zAgentLogConversationItemResponse = z.object({ + conversation_id: z.string(), + created_at: z.int().nullish(), + end_user_id: z.string().nullish(), + id: z.string(), + message_count: z.int(), + operation_rate: z.number().nullish(), + source: zAgentLogSourceResponse.nullish(), + status: z.enum(['failed', 'paused', 'success']), + title: z.string().nullish(), + unread: z.boolean(), + updated_at: z.int().nullish(), + user_rate: z.number().nullish(), +}) + +/** + * AgentLogListResponse + */ +export const zAgentLogListResponse = z.object({ + data: z.array(zAgentLogConversationItemResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * AgentLogMessageItemResponse + */ +export const zAgentLogMessageItemResponse = z.object({ + answer: z.string(), + answer_tokens: z.int(), + conversation_id: z.string(), + created_at: z.int().nullish(), + currency: z.string(), + error: z.string().nullish(), + from_account_id: z.string().nullish(), + from_end_user_id: z.string().nullish(), + id: z.string(), + latency: z.number(), + message_id: z.string(), + message_tokens: z.int(), + query: z.string(), + status: z.string(), + total_price: z.string(), + total_tokens: z.int(), + updated_at: z.int().nullish(), +}) + +/** + * AgentLogMessageListResponse + */ +export const zAgentLogMessageListResponse = z.object({ + data: z.array(zAgentLogMessageItemResponse), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + /** * AgentThought */ @@ -442,14 +551,6 @@ export const zAgentSkillRefConfig = z.object({ skill_md_key: z.string().max(512).nullish(), }) -/** - * AgentSkillStandardizeResponse - */ -export const zAgentSkillStandardizeResponse = z.object({ - manifest: zSkillManifest, - skill: zAgentSkillRefConfig, -}) - /** * AgentSkillUploadResponse */ @@ -458,6 +559,22 @@ export const zAgentSkillUploadResponse = z.object({ skill: zAgentSkillRefConfig, }) +/** + * AgentStatisticSummaryResponse + */ +export const zAgentStatisticSummaryResponse = z.object({ + average_response_time: z.number(), + average_session_interactions: z.number(), + currency: z.string(), + tokens_per_second: z.number(), + total_conversations: z.int(), + total_end_users: z.int(), + total_messages: z.int(), + total_price: z.string(), + total_tokens: z.int(), + user_satisfaction_rate: z.number(), +}) + /** * ModelConfigPartial */ @@ -471,10 +588,22 @@ export const zModelConfigPartial = z.object({ }) /** - * AppPartial + * AgentAppPublishedReferenceResponse */ -export const zAppPartial = z.object({ +export const zAgentAppPublishedReferenceResponse = z.object({ + app_icon: z.string().nullish(), + app_icon_background: z.string().nullish(), + app_icon_type: z.string().nullish(), + app_id: z.string(), + app_name: z.string(), +}) + +/** + * AgentAppPartial + */ +export const zAgentAppPartial = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), author_name: z.string().nullish(), bound_agent_id: z.string().nullish(), @@ -489,10 +618,14 @@ export const zAppPartial = z.object({ icon_url: z.string().nullable(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), role: z.string().nullish(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), @@ -502,10 +635,10 @@ export const zAppPartial = z.object({ }) /** - * AppPagination + * AgentAppPagination */ -export const zAppPagination = z.object({ - data: z.array(zAppPartial), +export const zAgentAppPagination = z.object({ + data: z.array(zAgentAppPartial), has_more: z.boolean(), limit: z.int(), page: z.int(), @@ -530,10 +663,11 @@ export const zModelConfig = z.object({ }) /** - * AppDetailWithSite + * AgentAppDetailWithSite */ -export const zAppDetailWithSite = z.object({ +export const zAgentAppDetailWithSite = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), @@ -548,10 +682,12 @@ export const zAppDetailWithSite = z.object({ icon_type: z.string().nullish(), icon_url: z.string().nullable(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), site: zSite.nullish(), tags: z.array(zTag).optional(), @@ -620,6 +756,7 @@ export const zAgentStatus = z.enum(['active', 'archived']) * AgentInviteOptionResponse */ export const zAgentInviteOptionResponse = z.object({ + active_config_is_published: z.boolean().optional().default(false), active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), active_config_snapshot_id: z.string().nullish(), agent_kind: zAgentKind, @@ -882,6 +1019,97 @@ export const zSkillToolInferenceResult = z.object({ reason: z.string().nullish(), }) +/** + * AgentAverageResponseTimeStatisticResponse + */ +export const zAgentAverageResponseTimeStatisticResponse = z.object({ + date: z.string(), + latency: z.number(), +}) + +/** + * AgentAverageSessionInteractionStatisticResponse + */ +export const zAgentAverageSessionInteractionStatisticResponse = z.object({ + date: z.string(), + interactions: z.number(), +}) + +/** + * AgentDailyConversationStatisticResponse + */ +export const zAgentDailyConversationStatisticResponse = z.object({ + conversation_count: z.int(), + date: z.string(), +}) + +/** + * AgentDailyEndUserStatisticResponse + */ +export const zAgentDailyEndUserStatisticResponse = z.object({ + date: z.string(), + terminal_count: z.int(), +}) + +/** + * AgentDailyMessageStatisticResponse + */ +export const zAgentDailyMessageStatisticResponse = z.object({ + date: z.string(), + message_count: z.int(), +}) + +/** + * AgentTokenUsageStatisticResponse + */ +export const zAgentTokenUsageStatisticResponse = z.object({ + currency: z.string(), + date: z.string(), + token_count: z.int(), + total_price: z.string(), +}) + +/** + * AgentTokensPerSecondStatisticResponse + */ +export const zAgentTokensPerSecondStatisticResponse = z.object({ + date: z.string(), + tps: z.number(), +}) + +/** + * AgentUserSatisfactionRateStatisticResponse + */ +export const zAgentUserSatisfactionRateStatisticResponse = z.object({ + date: z.string(), + rate: z.number(), +}) + +/** + * AgentStatisticChartsResponse + */ +export const zAgentStatisticChartsResponse = z.object({ + average_response_time: z.array(zAgentAverageResponseTimeStatisticResponse).optional(), + average_session_interactions: z + .array(zAgentAverageSessionInteractionStatisticResponse) + .optional(), + daily_conversations: z.array(zAgentDailyConversationStatisticResponse).optional(), + daily_end_users: z.array(zAgentDailyEndUserStatisticResponse).optional(), + daily_messages: z.array(zAgentDailyMessageStatisticResponse).optional(), + token_usage: z.array(zAgentTokenUsageStatisticResponse).optional(), + tokens_per_second: z.array(zAgentTokensPerSecondStatisticResponse).optional(), + user_satisfaction_rate: z.array(zAgentUserSatisfactionRateStatisticResponse).optional(), +}) + +/** + * AgentStatisticSummaryEnvelopeResponse + */ +export const zAgentStatisticSummaryEnvelopeResponse = z.object({ + charts: zAgentStatisticChartsResponse, + source: z.string(), + summary: zAgentStatisticSummaryResponse, +}) + /** * AgentConfigRevisionOperation * @@ -1078,6 +1306,25 @@ export const zWorkflowNodeJobMetadata = z.object({ * about. Stage 4 §4.2. */ export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), type: zDeclaredOutputType, }) @@ -1136,6 +1383,7 @@ export const zAgentSecretRefConfig = z.object({ provider_credential_id: z.string().max(255).nullish(), ref: z.string().max(255).nullish(), type: z.string().max(64).nullish(), + value: z.string().max(255).nullish(), variable: z.string().max(255).nullish(), }) @@ -1460,8 +1708,10 @@ export const zAgentConfigSnapshotDetailResponse = z.object({ config_snapshot: zAgentSoulConfig, created_at: z.int().nullish(), created_by: z.string().nullish(), + display_version: z.int().nullish(), id: z.string(), revisions: z.array(zAgentConfigRevisionResponse).optional(), + snapshot_version: z.int().nullish(), summary: z.string().nullish(), version: z.int(), version_note: z.string().nullish(), @@ -1515,6 +1765,25 @@ export const zDeclaredOutputFailureStrategy = z.object({ export const zDeclaredOutputConfig = z.object({ array_item: zDeclaredArrayItem.nullish(), check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), failure_strategy: zDeclaredOutputFailureStrategy.optional(), file: zDeclaredOutputFileConfig.nullish(), @@ -1731,10 +2000,11 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({ }) /** - * AppPartial + * AgentAppPartial */ -export const zAppPartialWritable = z.object({ +export const zAgentAppPartialWritable = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), app_id: z.string().nullish(), author_name: z.string().nullish(), bound_agent_id: z.string().nullish(), @@ -1748,10 +2018,14 @@ export const zAppPartialWritable = z.object({ icon_type: z.string().nullish(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), + published_reference_count: z.int().optional().default(0), + published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), role: z.string().nullish(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), @@ -1761,10 +2035,10 @@ export const zAppPartialWritable = z.object({ }) /** - * AppPagination + * AgentAppPagination */ -export const zAppPaginationWritable = z.object({ - data: z.array(zAppPartialWritable), +export const zAgentAppPaginationWritable = z.object({ + data: z.array(zAgentAppPartialWritable), has_more: z.boolean(), limit: z.int(), page: z.int(), @@ -1791,10 +2065,11 @@ export const zSiteWritable = z.object({ }) /** - * AppDetailWithSite + * AgentAppDetailWithSite */ -export const zAppDetailWithSiteWritable = z.object({ +export const zAgentAppDetailWithSiteWritable = z.object({ access_mode: z.string().nullish(), + active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), bound_agent_id: z.string().nullish(), @@ -1808,10 +2083,12 @@ export const zAppDetailWithSiteWritable = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), site: zSiteWritable.nullish(), tags: z.array(zTag).optional(), @@ -1851,14 +2128,14 @@ export const zGetAgentQuery = z.object({ /** * Agent app list */ -export const zGetAgentResponse = zAppPagination +export const zGetAgentResponse = zAgentAppPagination export const zPostAgentBody = zAgentAppCreatePayload /** * Agent app created successfully */ -export const zPostAgentResponse = zAppDetailWithSite +export const zPostAgentResponse = zAgentAppDetailWithSite export const zGetAgentInviteOptionsQuery = z.object({ app_id: z.string().optional(), @@ -1873,7 +2150,7 @@ export const zGetAgentInviteOptionsQuery = z.object({ export const zGetAgentInviteOptionsResponse = zAgentInviteOptionsResponse export const zDeleteAgentByAgentIdPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -1882,27 +2159,27 @@ export const zDeleteAgentByAgentIdPath = z.object({ export const zDeleteAgentByAgentIdResponse = z.void() export const zGetAgentByAgentIdPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** * Agent app detail */ -export const zGetAgentByAgentIdResponse = zAppDetailWithSite +export const zGetAgentByAgentIdResponse = zAgentAppDetailWithSite export const zPutAgentByAgentIdBody = zAgentAppUpdatePayload export const zPutAgentByAgentIdPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** * Agent app updated successfully */ -export const zPutAgentByAgentIdResponse = zAppDetailWithSite +export const zPutAgentByAgentIdResponse = zAgentAppDetailWithSite export const zGetAgentByAgentIdChatMessagesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zGetAgentByAgentIdChatMessagesQuery = z.object({ @@ -1917,8 +2194,8 @@ export const zGetAgentByAgentIdChatMessagesQuery = z.object({ export const zGetAgentByAgentIdChatMessagesResponse = zMessageInfiniteScrollPaginationResponse export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath = z.object({ - agent_id: z.string(), - message_id: z.string(), + agent_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -1928,7 +2205,7 @@ export const zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse = zSuggestedQuestionsResponse export const zPostAgentByAgentIdChatMessagesByTaskIdStopPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), task_id: z.string(), }) @@ -1938,7 +2215,7 @@ export const zPostAgentByAgentIdChatMessagesByTaskIdStopPath = z.object({ export const zPostAgentByAgentIdChatMessagesByTaskIdStopResponse = zSimpleResultResponse export const zGetAgentByAgentIdComposerPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -1949,7 +2226,7 @@ export const zGetAgentByAgentIdComposerResponse = zAgentAppComposerResponse export const zPutAgentByAgentIdComposerBody = zComposerSavePayload export const zPutAgentByAgentIdComposerPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -1958,7 +2235,7 @@ export const zPutAgentByAgentIdComposerPath = z.object({ export const zPutAgentByAgentIdComposerResponse = zAgentAppComposerResponse export const zGetAgentByAgentIdComposerCandidatesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -1969,7 +2246,7 @@ export const zGetAgentByAgentIdComposerCandidatesResponse = zAgentComposerCandid export const zPostAgentByAgentIdComposerValidateBody = zComposerSavePayload export const zPostAgentByAgentIdComposerValidatePath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -1977,8 +2254,19 @@ export const zPostAgentByAgentIdComposerValidatePath = z.object({ */ export const zPostAgentByAgentIdComposerValidateResponse = zAgentComposerValidateResponse +export const zPostAgentByAgentIdCopyBody = zCopyAppPayload + +export const zPostAgentByAgentIdCopyPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent app copied successfully + */ +export const zPostAgentByAgentIdCopyResponse = zAgentAppDetailWithSite + export const zGetAgentByAgentIdDriveFilesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zGetAgentByAgentIdDriveFilesQuery = z.object({ @@ -1991,7 +2279,7 @@ export const zGetAgentByAgentIdDriveFilesQuery = z.object({ export const zGetAgentByAgentIdDriveFilesResponse = zAgentDriveListResponse export const zGetAgentByAgentIdDriveFilesDownloadPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zGetAgentByAgentIdDriveFilesDownloadQuery = z.object({ @@ -2004,7 +2292,7 @@ export const zGetAgentByAgentIdDriveFilesDownloadQuery = z.object({ export const zGetAgentByAgentIdDriveFilesDownloadResponse = zAgentDriveDownloadResponse export const zGetAgentByAgentIdDriveFilesPreviewPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zGetAgentByAgentIdDriveFilesPreviewQuery = z.object({ @@ -2019,7 +2307,7 @@ export const zGetAgentByAgentIdDriveFilesPreviewResponse = zAgentDrivePreviewRes export const zPostAgentByAgentIdFeaturesBody = zAgentAppFeaturesPayload export const zPostAgentByAgentIdFeaturesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -2030,7 +2318,7 @@ export const zPostAgentByAgentIdFeaturesResponse = zSimpleResultResponse export const zPostAgentByAgentIdFeedbacksBody = zMessageFeedbackPayload export const zPostAgentByAgentIdFeedbacksPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -2039,7 +2327,7 @@ export const zPostAgentByAgentIdFeedbacksPath = z.object({ export const zPostAgentByAgentIdFeedbacksResponse = zSimpleResultResponse export const zDeleteAgentByAgentIdFilesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zDeleteAgentByAgentIdFilesQuery = z.object({ @@ -2054,7 +2342,7 @@ export const zDeleteAgentByAgentIdFilesResponse = zAgentDriveDeleteResponse export const zPostAgentByAgentIdFilesBody = zAgentDriveFilePayload export const zPostAgentByAgentIdFilesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -2062,9 +2350,65 @@ export const zPostAgentByAgentIdFilesPath = z.object({ */ export const zPostAgentByAgentIdFilesResponse = zAgentDriveFileCommitResponse +export const zGetAgentByAgentIdLogSourcesPath = z.object({ + agent_id: z.uuid(), +}) + +/** + * Agent log sources + */ +export const zGetAgentByAgentIdLogSourcesResponse = zAgentLogSourceListResponse + +export const zGetAgentByAgentIdLogsPath = z.object({ + agent_id: z.uuid(), +}) + +export const zGetAgentByAgentIdLogsQuery = z.object({ + end: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + sort_by: z.string().optional().default('updated_at'), + sort_order: z.string().optional().default('desc'), + source: z.string().optional(), + sources: z.array(z.string()).optional(), + start: z.string().optional(), + status: z.string().optional(), + statuses: z.array(z.string()).optional(), +}) + +/** + * Agent logs + */ +export const zGetAgentByAgentIdLogsResponse = zAgentLogListResponse + +export const zGetAgentByAgentIdLogsByConversationIdMessagesPath = z.object({ + agent_id: z.uuid(), + conversation_id: z.uuid(), +}) + +export const zGetAgentByAgentIdLogsByConversationIdMessagesQuery = z.object({ + end: z.string().optional(), + keyword: z.string().optional(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).optional().default(1), + sort_by: z.string().optional().default('updated_at'), + sort_order: z.string().optional().default('desc'), + source: z.string().optional(), + sources: z.array(z.string()).optional(), + start: z.string().optional(), + status: z.string().optional(), + statuses: z.array(z.string()).optional(), +}) + +/** + * Agent log messages + */ +export const zGetAgentByAgentIdLogsByConversationIdMessagesResponse = zAgentLogMessageListResponse + export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ - agent_id: z.string(), - message_id: z.string(), + agent_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -2073,7 +2417,7 @@ export const zGetAgentByAgentIdMessagesByMessageIdPath = z.object({ export const zGetAgentByAgentIdMessagesByMessageIdResponse = zMessageDetailResponse export const zGetAgentByAgentIdReferencingWorkflowsPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -2082,7 +2426,7 @@ export const zGetAgentByAgentIdReferencingWorkflowsPath = z.object({ export const zGetAgentByAgentIdReferencingWorkflowsResponse = zAgentReferencingWorkflowsResponse export const zGetAgentByAgentIdSandboxFilesPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zGetAgentByAgentIdSandboxFilesQuery = z.object({ @@ -2096,7 +2440,7 @@ export const zGetAgentByAgentIdSandboxFilesQuery = z.object({ export const zGetAgentByAgentIdSandboxFilesResponse = zSandboxListResponse export const zGetAgentByAgentIdSandboxFilesReadPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) export const zGetAgentByAgentIdSandboxFilesReadQuery = z.object({ @@ -2112,7 +2456,7 @@ export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse export const zPostAgentByAgentIdSandboxFilesUploadBody = zAgentSandboxUploadPayload export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -2120,26 +2464,21 @@ export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ */ export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse -export const zPostAgentByAgentIdSkillsStandardizePath = z.object({ - agent_id: z.string(), +export const zPostAgentByAgentIdSkillsUploadBody = z.object({ + file: z.custom(), }) -/** - * Skill standardized into drive - */ -export const zPostAgentByAgentIdSkillsStandardizeResponse = zAgentSkillStandardizeResponse - export const zPostAgentByAgentIdSkillsUploadPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** - * Skill validated + * Skill uploaded into drive */ export const zPostAgentByAgentIdSkillsUploadResponse = zAgentSkillUploadResponse export const zDeleteAgentByAgentIdSkillsBySlugPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), slug: z.string(), }) @@ -2149,7 +2488,7 @@ export const zDeleteAgentByAgentIdSkillsBySlugPath = z.object({ export const zDeleteAgentByAgentIdSkillsBySlugResponse = zAgentDriveDeleteResponse export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), slug: z.string(), }) @@ -2158,8 +2497,23 @@ export const zPostAgentByAgentIdSkillsBySlugInferToolsPath = z.object({ */ export const zPostAgentByAgentIdSkillsBySlugInferToolsResponse = zSkillToolInferenceResult +export const zGetAgentByAgentIdStatisticsSummaryPath = z.object({ + agent_id: z.uuid(), +}) + +export const zGetAgentByAgentIdStatisticsSummaryQuery = z.object({ + end: z.string().optional(), + source: z.string().optional(), + start: z.string().optional(), +}) + +/** + * Agent monitoring summary and chart data + */ +export const zGetAgentByAgentIdStatisticsSummaryResponse = zAgentStatisticSummaryEnvelopeResponse + export const zGetAgentByAgentIdVersionsPath = z.object({ - agent_id: z.string(), + agent_id: z.uuid(), }) /** @@ -2168,8 +2522,8 @@ export const zGetAgentByAgentIdVersionsPath = z.object({ export const zGetAgentByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse export const zGetAgentByAgentIdVersionsByVersionIdPath = z.object({ - agent_id: z.string(), - version_id: z.string(), + agent_id: z.uuid(), + version_id: z.uuid(), }) /** 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 9bb0f67728f..468e9d09efc 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 @@ -37,7 +37,7 @@ export const zPostApiBasedExtensionBody = zApiBasedExtensionPayload export const zPostApiBasedExtensionResponse = zApiBasedExtensionResponse export const zDeleteApiBasedExtensionByIdPath = z.object({ - id: z.string(), + id: z.uuid(), }) /** @@ -46,7 +46,7 @@ export const zDeleteApiBasedExtensionByIdPath = z.object({ export const zDeleteApiBasedExtensionByIdResponse = z.void() export const zGetApiBasedExtensionByIdPath = z.object({ - id: z.string(), + id: z.uuid(), }) /** @@ -57,7 +57,7 @@ export const zGetApiBasedExtensionByIdResponse = zApiBasedExtensionResponse export const zPostApiBasedExtensionByIdBody = zApiBasedExtensionPayload export const zPostApiBasedExtensionByIdPath = z.object({ - id: z.string(), + id: z.uuid(), }) /** diff --git a/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts b/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts index 0ebff4365b3..a55ba309823 100644 --- a/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts +++ b/packages/contracts/generated/api/console/api-key-auth/zod.gen.ts @@ -50,7 +50,7 @@ export const zPostApiKeyAuthDataSourceBindingBody = zApiKeyAuthBindingPayload export const zPostApiKeyAuthDataSourceBindingResponse = zSimpleResultResponse export const zDeleteApiKeyAuthDataSourceByBindingIdPath = z.object({ - binding_id: z.string(), + binding_id: z.uuid(), }) /** diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 3952812a5a8..eab3c17eb43 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -271,10 +271,9 @@ import { zPostAppsByAppIdAgentSkillsBySlugInferToolsPath, zPostAppsByAppIdAgentSkillsBySlugInferToolsQuery, zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse, - zPostAppsByAppIdAgentSkillsStandardizePath, - zPostAppsByAppIdAgentSkillsStandardizeQuery, - zPostAppsByAppIdAgentSkillsStandardizeResponse, + zPostAppsByAppIdAgentSkillsUploadBody, zPostAppsByAppIdAgentSkillsUploadPath, + zPostAppsByAppIdAgentSkillsUploadQuery, zPostAppsByAppIdAgentSkillsUploadResponse, zPostAppsByAppIdAnnotationReplyByActionBody, zPostAppsByAppIdAnnotationReplyByActionPath, @@ -939,57 +938,32 @@ export const logs = { } /** - * Upload a Skill, validate it, and standardize it into the app agent's drive + * Upload a Skill, validate it, and commit drive-backed skill files * - * Validate + standardize a Skill into the agent drive (ENG-594) + * Upload + standardize a Skill into the agent drive */ export const post10 = oc .route({ - description: 'Validate + standardize a Skill into the agent drive (ENG-594)', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postAppsByAppIdAgentSkillsStandardize', - path: '/apps/{app_id}/agent/skills/standardize', - successStatus: 201, - summary: 'Upload a Skill, validate it, and standardize it into the app agent\'s drive', - tags: ['console'], - }) - .input( - z.object({ - params: zPostAppsByAppIdAgentSkillsStandardizePath, - query: zPostAppsByAppIdAgentSkillsStandardizeQuery.optional(), - }), - ) - .output(zPostAppsByAppIdAgentSkillsStandardizeResponse) - -export const standardize = { - post: post10, -} - -/** - * Validate an uploaded Skill package and persist the archive - * - * Upload + validate a Skill package (.zip/.skill) and extract its manifest - * Returns a validated skill ref (to bind into the Agent soul config on save) - * plus its manifest. Standardizing into the agent drive is ENG-594. - */ -export const post11 = oc - .route({ - description: - 'Upload + validate a Skill package (.zip/.skill) and extract its manifest\nReturns a validated skill ref (to bind into the Agent soul config on save)\nplus its manifest. Standardizing into the agent drive is ENG-594.', + description: 'Upload + standardize a Skill into the agent drive', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsByAppIdAgentSkillsUpload', path: '/apps/{app_id}/agent/skills/upload', successStatus: 201, - summary: 'Validate an uploaded Skill package and persist the archive', + summary: 'Upload a Skill, validate it, and commit drive-backed skill files', tags: ['console'], }) - .input(z.object({ params: zPostAppsByAppIdAgentSkillsUploadPath })) + .input( + z.object({ + body: zPostAppsByAppIdAgentSkillsUploadBody, + params: zPostAppsByAppIdAgentSkillsUploadPath, + query: zPostAppsByAppIdAgentSkillsUploadQuery.optional(), + }), + ) .output(zPostAppsByAppIdAgentSkillsUploadResponse) export const upload = { - post: post11, + post: post10, } /** @@ -998,7 +972,7 @@ export const upload = { * Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371) * Saving still goes through composer validation. */ -export const post12 = oc +export const post11 = oc .route({ description: 'Infer CLI tool + ENV suggestions from a standardized skill\'s SKILL.md (draft only, ENG-371)\nSaving still goes through composer validation.', @@ -1018,7 +992,7 @@ export const post12 = oc .output(zPostAppsByAppIdAgentSkillsBySlugInferToolsResponse) export const inferTools = { - post: post12, + post: post11, } /** @@ -1048,7 +1022,6 @@ export const bySlug = { } export const skills = { - standardize, upload, bySlug, } @@ -1086,7 +1059,7 @@ export const status = { /** * Enable or disable annotation reply for an app */ -export const post13 = oc +export const post12 = oc .route({ description: 'Enable or disable annotation reply for an app', inputStructure: 'detailed', @@ -1104,7 +1077,7 @@ export const post13 = oc .output(zPostAppsByAppIdAnnotationReplyByActionResponse) export const byAction = { - post: post13, + post: post12, status, } @@ -1134,7 +1107,7 @@ export const annotationSetting = { /** * Update annotation settings for an app */ -export const post14 = oc +export const post13 = oc .route({ description: 'Update annotation settings for an app', inputStructure: 'detailed', @@ -1152,7 +1125,7 @@ export const post14 = oc .output(zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse) export const byAnnotationSettingId = { - post: post14, + post: post13, } export const annotationSettings = { @@ -1162,7 +1135,7 @@ export const annotationSettings = { /** * Batch import annotations from CSV file with rate limiting and security checks */ -export const post15 = oc +export const post14 = oc .route({ description: 'Batch import annotations from CSV file with rate limiting and security checks', inputStructure: 'detailed', @@ -1175,7 +1148,7 @@ export const post15 = oc .output(zPostAppsByAppIdAnnotationsBatchImportResponse) export const batchImport = { - post: post15, + post: post14, } /** @@ -1278,7 +1251,7 @@ export const delete3 = oc /** * Update or delete an annotation */ -export const post16 = oc +export const post15 = oc .route({ description: 'Update or delete an annotation', inputStructure: 'detailed', @@ -1297,7 +1270,7 @@ export const post16 = oc export const byAnnotationId = { delete: delete3, - post: post16, + post: post15, hitHistories, } @@ -1336,7 +1309,7 @@ export const get15 = oc /** * Create a new annotation for an app */ -export const post17 = oc +export const post16 = oc .route({ description: 'Create a new annotation for an app', inputStructure: 'detailed', @@ -1354,7 +1327,7 @@ export const post17 = oc export const annotations = { delete: delete4, get: get15, - post: post17, + post: post16, batchImport, batchImportStatus, count: count2, @@ -1365,7 +1338,7 @@ export const annotations = { /** * Enable or disable app API */ -export const post18 = oc +export const post17 = oc .route({ description: 'Enable or disable app API', inputStructure: 'detailed', @@ -1378,13 +1351,13 @@ export const post18 = oc .output(zPostAppsByAppIdApiEnableResponse) export const apiEnable = { - post: post18, + post: post17, } /** * Transcript audio to text for chat messages */ -export const post19 = oc +export const post18 = oc .route({ description: 'Transcript audio to text for chat messages', inputStructure: 'detailed', @@ -1397,7 +1370,7 @@ export const post19 = oc .output(zPostAppsByAppIdAudioToTextResponse) export const audioToText = { - post: post19, + post: post18, } /** @@ -1487,7 +1460,7 @@ export const byMessageId = { /** * Stop a running chat message generation */ -export const post20 = oc +export const post19 = oc .route({ description: 'Stop a running chat message generation', inputStructure: 'detailed', @@ -1500,7 +1473,7 @@ export const post20 = oc .output(zPostAppsByAppIdChatMessagesByTaskIdStopResponse) export const stop = { - post: post20, + post: post19, } export const byTaskId = { @@ -1594,7 +1567,7 @@ export const completionConversations = { /** * Stop a running completion message generation */ -export const post21 = oc +export const post20 = oc .route({ description: 'Stop a running completion message generation', inputStructure: 'detailed', @@ -1607,7 +1580,7 @@ export const post21 = oc .output(zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse) export const stop2 = { - post: post21, + post: post20, } export const byTaskId2 = { @@ -1617,7 +1590,7 @@ export const byTaskId2 = { /** * Generate completion message for debugging */ -export const post22 = oc +export const post21 = oc .route({ description: 'Generate completion message for debugging', inputStructure: 'detailed', @@ -1635,7 +1608,7 @@ export const post22 = oc .output(zPostAppsByAppIdCompletionMessagesResponse) export const completionMessages = { - post: post22, + post: post21, byTaskId: byTaskId2, } @@ -1670,7 +1643,7 @@ export const conversationVariables = { * Convert expert mode of chatbot app to workflow mode * Convert Completion App to Workflow App */ -export const post23 = oc +export const post22 = oc .route({ description: 'Convert application to workflow mode\nConvert expert mode of chatbot app to workflow mode\nConvert Completion App to Workflow App', @@ -1690,7 +1663,7 @@ export const post23 = oc .output(zPostAppsByAppIdConvertToWorkflowResponse) export const convertToWorkflow = { - post: post23, + post: post22, } /** @@ -1698,7 +1671,7 @@ export const convertToWorkflow = { * * Create a copy of an existing application */ -export const post24 = oc +export const post23 = oc .route({ description: 'Create a copy of an existing application', inputStructure: 'detailed', @@ -1713,7 +1686,7 @@ export const post24 = oc .output(zPostAppsByAppIdCopyResponse) export const copy = { - post: post24, + post: post23, } /** @@ -1767,7 +1740,7 @@ export const export3 = { /** * Create or update message feedback (like/dislike) */ -export const post25 = oc +export const post24 = oc .route({ description: 'Create or update message feedback (like/dislike)', inputStructure: 'detailed', @@ -1780,14 +1753,14 @@ export const post25 = oc .output(zPostAppsByAppIdFeedbacksResponse) export const feedbacks = { - post: post25, + post: post24, export: export3, } /** * Update application icon */ -export const post26 = oc +export const post25 = oc .route({ description: 'Update application icon', inputStructure: 'detailed', @@ -1800,7 +1773,7 @@ export const post26 = oc .output(zPostAppsByAppIdIconResponse) export const icon = { - post: post26, + post: post25, } /** @@ -1831,7 +1804,7 @@ export const messages = { * * Update application model configuration */ -export const post27 = oc +export const post26 = oc .route({ description: 'Update application model configuration', inputStructure: 'detailed', @@ -1847,13 +1820,13 @@ export const post27 = oc .output(zPostAppsByAppIdModelConfigResponse) export const modelConfig = { - post: post27, + post: post26, } /** * Check if app name is available */ -export const post28 = oc +export const post27 = oc .route({ description: 'Check if app name is available', inputStructure: 'detailed', @@ -1866,13 +1839,13 @@ export const post28 = oc .output(zPostAppsByAppIdNameResponse) export const name = { - post: post28, + post: post27, } /** * Publish app to Creators Platform */ -export const post29 = oc +export const post28 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1885,7 +1858,7 @@ export const post29 = oc .output(zPostAppsByAppIdPublishToCreatorsPlatformResponse) export const publishToCreatorsPlatform = { - post: post29, + post: post28, } /** @@ -1906,7 +1879,7 @@ export const get26 = oc /** * Create MCP server configuration for an application */ -export const post30 = oc +export const post29 = oc .route({ description: 'Create MCP server configuration for an application', inputStructure: 'detailed', @@ -1936,14 +1909,14 @@ export const put = oc export const server = { get: get26, - post: post30, + post: post29, put, } /** * Reset access token for application site */ -export const post31 = oc +export const post30 = oc .route({ description: 'Reset access token for application site', inputStructure: 'detailed', @@ -1956,13 +1929,13 @@ export const post31 = oc .output(zPostAppsByAppIdSiteAccessTokenResetResponse) export const accessTokenReset = { - post: post31, + post: post30, } /** * Update application site configuration */ -export const post32 = oc +export const post31 = oc .route({ description: 'Update application site configuration', inputStructure: 'detailed', @@ -1975,14 +1948,14 @@ export const post32 = oc .output(zPostAppsByAppIdSiteResponse) export const site = { - post: post32, + post: post31, accessTokenReset, } /** * Enable or disable app site */ -export const post33 = oc +export const post32 = oc .route({ description: 'Enable or disable app site', inputStructure: 'detailed', @@ -1995,7 +1968,7 @@ export const post33 = oc .output(zPostAppsByAppIdSiteEnableResponse) export const siteEnable = { - post: post33, + post: post32, } /** @@ -2016,7 +1989,7 @@ export const delete7 = oc /** * Star an application for the current account */ -export const post34 = oc +export const post33 = oc .route({ description: 'Star an application for the current account', inputStructure: 'detailed', @@ -2030,7 +2003,7 @@ export const post34 = oc export const star = { delete: delete7, - post: post34, + post: post33, } /** @@ -2263,7 +2236,7 @@ export const voices = { /** * Convert text to speech for chat messages */ -export const post35 = oc +export const post34 = oc .route({ description: 'Convert text to speech for chat messages', inputStructure: 'detailed', @@ -2278,7 +2251,7 @@ export const post35 = oc .output(zPostAppsByAppIdTextToAudioResponse) export const textToAudio = { - post: post35, + post: post34, voices, } @@ -2303,7 +2276,7 @@ export const get36 = oc /** * Update app tracing configuration */ -export const post36 = oc +export const post35 = oc .route({ description: 'Update app tracing configuration', inputStructure: 'detailed', @@ -2317,7 +2290,7 @@ export const post36 = oc export const trace = { get: get36, - post: post36, + post: post35, } /** @@ -2386,7 +2359,7 @@ export const patch = oc * * Create a new tracing configuration for an application */ -export const post37 = oc +export const post36 = oc .route({ description: 'Create a new tracing configuration for an application', inputStructure: 'detailed', @@ -2406,13 +2379,13 @@ export const traceConfig = { delete: delete8, get: get37, patch, - post: post37, + post: post36, } /** * Update app trigger (enable/disable) */ -export const post38 = oc +export const post37 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2430,7 +2403,7 @@ export const post38 = oc .output(zPostAppsByAppIdTriggerEnableResponse) export const triggerEnable = { - post: post38, + post: post37, } /** @@ -2538,7 +2511,7 @@ export const count3 = { * * Stop running workflow task */ -export const post39 = oc +export const post38 = oc .route({ description: 'Stop running workflow task', inputStructure: 'detailed', @@ -2552,7 +2525,7 @@ export const post39 = oc .output(zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse) export const stop3 = { - post: post39, + post: post38, } export const byTaskId3 = { @@ -2655,7 +2628,7 @@ export const read = { /** * Upload one workflow Agent sandbox file as a Dify ToolFile mapping */ -export const post40 = oc +export const post39 = oc .route({ description: 'Upload one workflow Agent sandbox file as a Dify ToolFile mapping', inputStructure: 'detailed', @@ -2673,7 +2646,7 @@ export const post40 = oc .output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse) export const upload2 = { - post: post40, + post: post39, } /** @@ -2824,7 +2797,7 @@ export const byReplyId = { * * Add a reply to a workflow comment */ -export const post41 = oc +export const post40 = oc .route({ description: 'Add a reply to a workflow comment', inputStructure: 'detailed', @@ -2844,7 +2817,7 @@ export const post41 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse) export const replies = { - post: post41, + post: post40, byReplyId, } @@ -2853,7 +2826,7 @@ export const replies = { * * Resolve a workflow comment */ -export const post42 = oc +export const post41 = oc .route({ description: 'Resolve a workflow comment', inputStructure: 'detailed', @@ -2867,7 +2840,7 @@ export const post42 = oc .output(zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse) export const resolve = { - post: post42, + post: post41, } /** @@ -2961,7 +2934,7 @@ export const get50 = oc * * Create a new workflow comment */ -export const post43 = oc +export const post42 = oc .route({ description: 'Create a new workflow comment', inputStructure: 'detailed', @@ -2982,7 +2955,7 @@ export const post43 = oc export const comments = { get: get50, - post: post43, + post: post42, mentionUsers, byCommentId, } @@ -3163,7 +3136,7 @@ export const get57 = oc /** * Update conversation variables for workflow draft */ -export const post44 = oc +export const post43 = oc .route({ description: 'Update conversation variables for workflow draft', inputStructure: 'detailed', @@ -3182,7 +3155,7 @@ export const post44 = oc export const conversationVariables2 = { get: get57, - post: post44, + post: post43, } /** @@ -3206,7 +3179,7 @@ export const get58 = oc /** * Update environment variables for workflow draft */ -export const post45 = oc +export const post44 = oc .route({ description: 'Update environment variables for workflow draft', inputStructure: 'detailed', @@ -3225,13 +3198,13 @@ export const post45 = oc export const environmentVariables = { get: get58, - post: post45, + post: post44, } /** * Update draft workflow features */ -export const post46 = oc +export const post45 = oc .route({ description: 'Update draft workflow features', inputStructure: 'detailed', @@ -3249,7 +3222,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftFeaturesResponse) export const features = { - post: post46, + post: post45, } /** @@ -3257,7 +3230,7 @@ export const features = { * * Test human input delivery for workflow */ -export const post47 = oc +export const post46 = oc .route({ description: 'Test human input delivery for workflow', inputStructure: 'detailed', @@ -3276,7 +3249,7 @@ export const post47 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestResponse) export const deliveryTest = { - post: post47, + post: post46, } /** @@ -3284,7 +3257,7 @@ export const deliveryTest = { * * Get human input form preview for workflow */ -export const post48 = oc +export const post47 = oc .route({ description: 'Get human input form preview for workflow', inputStructure: 'detailed', @@ -3303,7 +3276,7 @@ export const post48 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewResponse) export const preview3 = { - post: post48, + post: post47, } /** @@ -3311,7 +3284,7 @@ export const preview3 = { * * Submit human input form preview for workflow */ -export const post49 = oc +export const post48 = oc .route({ description: 'Submit human input form preview for workflow', inputStructure: 'detailed', @@ -3330,7 +3303,7 @@ export const post49 = oc .output(zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunResponse) export const run5 = { - post: post49, + post: post48, } export const form2 = { @@ -3356,7 +3329,7 @@ export const humanInput2 = { * * Run draft workflow iteration node */ -export const post50 = oc +export const post49 = oc .route({ description: 'Run draft workflow iteration node', inputStructure: 'detailed', @@ -3375,7 +3348,7 @@ export const post50 = oc .output(zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse) export const run6 = { - post: post50, + post: post49, } export const byNodeId6 = { @@ -3395,7 +3368,7 @@ export const iteration2 = { * * Run draft workflow loop node */ -export const post51 = oc +export const post50 = oc .route({ description: 'Run draft workflow loop node', inputStructure: 'detailed', @@ -3414,7 +3387,7 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse) export const run7 = { - post: post51, + post: post50, } export const byNodeId7 = { @@ -3446,7 +3419,7 @@ export const candidates = { get: get59, } -export const post52 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3463,10 +3436,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post52, + post: post51, } -export const post53 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3483,10 +3456,10 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post53, + post: post52, } -export const post54 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3503,7 +3476,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post54, + post: post53, } export const get60 = oc @@ -3566,7 +3539,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post55 = oc +export const post54 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3585,7 +3558,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post55, + post: post54, } /** @@ -3593,7 +3566,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post56 = oc +export const post55 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3607,7 +3580,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post56, + post: post55, } export const trigger = { @@ -3667,7 +3640,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post57 = oc +export const post56 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3686,7 +3659,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post57, + post: post56, } /** @@ -3808,7 +3781,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post58 = oc +export const post57 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3827,7 +3800,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post58, + post: post57, } /** @@ -3835,7 +3808,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post59 = oc +export const post58 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -3854,7 +3827,7 @@ export const post59 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post59, + post: post58, } export const trigger2 = { @@ -4007,7 +3980,7 @@ export const get70 = oc * * Sync draft workflow configuration */ -export const post60 = oc +export const post59 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4027,7 +4000,7 @@ export const post60 = oc export const draft2 = { get: get70, - post: post60, + post: post59, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4063,7 +4036,7 @@ export const get71 = oc /** * Publish workflow */ -export const post61 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4082,7 +4055,7 @@ export const post61 = oc export const publish = { get: get71, - post: post61, + post: post60, } /** @@ -4219,7 +4192,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post62 = oc +export const post61 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4232,7 +4205,7 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post62, + post: post61, } /** @@ -4457,7 +4430,7 @@ export const get79 = oc * * Create a new API key for an app */ -export const post63 = oc +export const post62 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4473,7 +4446,7 @@ export const post63 = oc export const apiKeys = { get: get79, - post: post63, + post: post62, byApiKeyId, } @@ -4531,7 +4504,7 @@ export const get81 = oc * * Create a new application */ -export const post64 = oc +export const post63 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4547,7 +4520,7 @@ export const post64 = oc export const apps = { get: get81, - post: post64, + post: post63, imports, starred, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 81842742f5c..904252c77eb 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -37,11 +37,12 @@ export type AppDetailWithSite = { icon_type?: string | null readonly icon_url: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string - role?: string | null + permission_keys?: Array site?: Site | null tags?: Array tracing?: JsonValue | null @@ -70,6 +71,7 @@ export type Import = { error?: string id: string imported_dsl_version?: string + permission_keys?: Array status: ImportStatus } @@ -212,11 +214,6 @@ export type AgentLogResponse = { meta: AgentLogMetaResponse } -export type AgentSkillStandardizeResponse = { - manifest: SkillManifest - skill: AgentSkillRefConfig -} - export type AgentSkillUploadResponse = { manifest: SkillManifest skill: AgentSkillRefConfig @@ -318,8 +315,10 @@ export type AppDetail = { icon?: string | null icon_background?: string | null id: string + maintainer?: string | null mode_compatible_with_agent: string name: string + permission_keys?: Array tags?: Array tracing?: JsonValue | null updated_at?: number | null @@ -419,6 +418,7 @@ export type ConvertToWorkflowPayload = { export type NewAppResponse = { new_app_id: string + permission_keys?: Array } export type CopyAppPayload = { @@ -1169,11 +1169,12 @@ export type AppPartial = { readonly icon_url: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string - role?: string | null + permission_keys?: Array tags?: Array updated_at?: number | null updated_by?: string | null @@ -1623,6 +1624,9 @@ export type AccountWithRole = { last_login_at?: number | null name: string role: string + roles?: Array<{ + [key: string]: string + }> status: string } @@ -1717,7 +1721,9 @@ export type AgentConfigSnapshotSummaryResponse = { agent_id?: string | null created_at?: number | null created_by?: string | null + display_version?: number | null id: string + snapshot_version?: number | null summary?: string | null version: number version_note?: string | null @@ -1760,6 +1766,26 @@ export type AgentComposerBindingResponse = { export type DeclaredOutputConfig = { array_item?: DeclaredArrayItem | null check?: DeclaredOutputCheckConfig | null + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null failure_strategy?: DeclaredOutputFailureStrategy file?: DeclaredOutputFileConfig | null @@ -2103,6 +2129,26 @@ export type AgentSoulToolsConfig = { export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent' export type DeclaredArrayItem = { + children?: Array<{ + array_item?: { + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + [key: string]: unknown + } + children?: Array<{ + [key: string]: unknown + }> + description?: string | null + file?: { + [key: string]: unknown + } + name: string + required?: boolean + type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' + }> description?: string | null type: DeclaredOutputType } @@ -2305,6 +2351,7 @@ export type AgentSecretRefConfig = { provider_credential_id?: string | null ref?: string | null type?: string | null + value?: string | null variable?: string | null [key: string]: unknown } @@ -2556,11 +2603,12 @@ export type AppDetailWithSiteWritable = { icon_background?: string | null icon_type?: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string - role?: string | null + permission_keys?: Array site?: SiteWritable | null tags?: Array tracing?: JsonValue | null @@ -2608,11 +2656,12 @@ export type AppPartialWritable = { icon_type?: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string - role?: string | null + permission_keys?: Array tags?: Array updated_at?: number | null updated_by?: string | null @@ -3136,34 +3185,16 @@ export type GetAppsByAppIdAgentLogsResponses = { export type GetAppsByAppIdAgentLogsResponse = GetAppsByAppIdAgentLogsResponses[keyof GetAppsByAppIdAgentLogsResponses] -export type PostAppsByAppIdAgentSkillsStandardizeData = { - body?: never +export type PostAppsByAppIdAgentSkillsUploadData = { + body: { + file: Blob | File + } path: { app_id: string } query?: { node_id?: string } - url: '/apps/{app_id}/agent/skills/standardize' -} - -export type PostAppsByAppIdAgentSkillsStandardizeErrors = { - 400: unknown -} - -export type PostAppsByAppIdAgentSkillsStandardizeResponses = { - 201: AgentSkillStandardizeResponse -} - -export type PostAppsByAppIdAgentSkillsStandardizeResponse - = PostAppsByAppIdAgentSkillsStandardizeResponses[keyof PostAppsByAppIdAgentSkillsStandardizeResponses] - -export type PostAppsByAppIdAgentSkillsUploadData = { - body?: never - path: { - app_id: string - } - query?: never url: '/apps/{app_id}/agent/skills/upload' } diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 94e4a8a3aad..4a6f397bcbd 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -270,6 +270,7 @@ export const zConvertToWorkflowPayload = z.object({ */ export const zNewAppResponse = z.object({ new_app_id: z.string(), + permission_keys: z.array(z.string()).optional(), }) /** @@ -906,6 +907,7 @@ export const zImport = z.object({ error: z.string().optional().default(''), id: z.string(), imported_dsl_version: z.string().optional().default(''), + permission_keys: z.array(z.string()).optional(), status: zImportStatus, }) @@ -990,14 +992,6 @@ export const zAgentSkillRefConfig = z.object({ skill_md_key: z.string().max(512).nullish(), }) -/** - * AgentSkillStandardizeResponse - */ -export const zAgentSkillStandardizeResponse = z.object({ - manifest: zSkillManifest, - skill: zAgentSkillRefConfig, -}) - /** * AgentSkillUploadResponse */ @@ -1493,6 +1487,7 @@ export const zAccountWithRole = z.object({ last_login_at: z.int().nullish(), name: z.string(), role: z.string(), + roles: z.array(z.record(z.string(), z.string())).optional(), status: z.string(), }) @@ -1744,7 +1739,9 @@ export const zAgentConfigSnapshotSummaryResponse = z.object({ agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + display_version: z.int().nullish(), id: z.string(), + snapshot_version: z.int().nullish(), summary: z.string().nullish(), version: z.int(), version_note: z.string().nullish(), @@ -1960,11 +1957,12 @@ export const zAppPartial = z.object({ icon_url: z.string().nullable(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), - role: z.string().nullish(), + permission_keys: z.array(z.string()).optional(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -2019,11 +2017,12 @@ export const zAppDetailWithSite = z.object({ icon_type: z.string().nullish(), icon_url: z.string().nullable(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), - role: z.string().nullish(), + permission_keys: z.array(z.string()).optional(), site: zSite.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), @@ -2047,8 +2046,10 @@ export const zAppDetail = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), id: z.string(), + maintainer: z.string().nullish(), mode_compatible_with_agent: z.string(), name: z.string(), + permission_keys: z.array(z.string()).optional(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), @@ -2475,6 +2476,25 @@ export const zAgentComposerBindingResponse = z.object({ * about. Stage 4 §4.2. */ export const zDeclaredArrayItem = z.object({ + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), type: zDeclaredOutputType, }) @@ -2908,6 +2928,7 @@ export const zAgentSecretRefConfig = z.object({ provider_credential_id: z.string().max(255).nullish(), ref: z.string().max(255).nullish(), type: z.string().max(64).nullish(), + value: z.string().max(255).nullish(), variable: z.string().max(255).nullish(), }) @@ -3079,6 +3100,25 @@ export const zDeclaredOutputCheckConfig = z.object({ export const zDeclaredOutputConfig = z.object({ array_item: zDeclaredArrayItem.nullish(), check: zDeclaredOutputCheckConfig.nullish(), + children: z + .array( + z.object({ + array_item: z + .object({ + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), + }) + .optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), + description: z.string().nullish(), + file: z.record(z.string(), z.unknown()).optional(), + name: z.string(), + required: z.boolean().optional(), + type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), + }), + ) + .optional(), description: z.string().nullish(), failure_strategy: zDeclaredOutputFailureStrategy.optional(), file: zDeclaredOutputFileConfig.nullish(), @@ -3450,11 +3490,12 @@ export const zAppPartialWritable = z.object({ icon_type: z.string().nullish(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), - role: z.string().nullish(), + permission_keys: z.array(z.string()).optional(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -3510,11 +3551,12 @@ export const zAppDetailWithSiteWritable = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), - role: z.string().nullish(), + permission_keys: z.array(z.string()).optional(), site: zSiteWritable.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), @@ -3703,7 +3745,7 @@ export const zPostAppsWorkflowsOnlineUsersBody = zWorkflowOnlineUsersPayload export const zPostAppsWorkflowsOnlineUsersResponse = zWorkflowOnlineUsersResponse export const zDeleteAppsByAppIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -3712,7 +3754,7 @@ export const zDeleteAppsByAppIdPath = z.object({ export const zDeleteAppsByAppIdResponse = z.void() export const zGetAppsByAppIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -3723,7 +3765,7 @@ export const zGetAppsByAppIdResponse = zAppDetailWithSite export const zPutAppsByAppIdBody = zUpdateAppPayload export const zPutAppsByAppIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -3732,7 +3774,7 @@ export const zPutAppsByAppIdPath = z.object({ export const zPutAppsByAppIdResponse = zAppDetailWithSite export const zGetAppsByAppIdAdvancedChatWorkflowRunsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAdvancedChatWorkflowRunsQuery = z.object({ @@ -3749,7 +3791,7 @@ export const zGetAppsByAppIdAdvancedChatWorkflowRunsResponse = zAdvancedChatWorkflowRunPaginationResponse export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAdvancedChatWorkflowRunsCountQuery = z.object({ @@ -3768,7 +3810,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -3783,7 +3825,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFo export const zPostAppsByAppIdAdvancedChatWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -3797,7 +3839,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRun = zIterationNodeRunPayload export const zPostAppsByAppIdAdvancedChatWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -3811,7 +3853,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunBody = zLoopNodeRunPayload export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -3824,7 +3866,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftLoopNodesByNodeIdRunRespo export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunBody = zAdvancedChatWorkflowRunPayload export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -3833,7 +3875,7 @@ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunPath = z.object({ export const zPostAppsByAppIdAdvancedChatWorkflowsDraftRunResponse = zGeneratedAppResponse export const zGetAppsByAppIdAgentDriveFilesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({ @@ -3847,7 +3889,7 @@ export const zGetAppsByAppIdAgentDriveFilesQuery = z.object({ export const zGetAppsByAppIdAgentDriveFilesResponse = zAgentDriveListResponse export const zGetAppsByAppIdAgentDriveFilesDownloadPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({ @@ -3861,7 +3903,7 @@ export const zGetAppsByAppIdAgentDriveFilesDownloadQuery = z.object({ export const zGetAppsByAppIdAgentDriveFilesDownloadResponse = zAgentDriveDownloadResponse export const zGetAppsByAppIdAgentDriveFilesPreviewPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({ @@ -3875,7 +3917,7 @@ export const zGetAppsByAppIdAgentDriveFilesPreviewQuery = z.object({ export const zGetAppsByAppIdAgentDriveFilesPreviewResponse = zAgentDrivePreviewResponse export const zDeleteAppsByAppIdAgentFilesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zDeleteAppsByAppIdAgentFilesQuery = z.object({ @@ -3891,7 +3933,7 @@ export const zDeleteAppsByAppIdAgentFilesResponse = zAgentDriveDeleteResponse export const zPostAppsByAppIdAgentFilesBody = zAgentDriveFilePayload export const zPostAppsByAppIdAgentFilesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zPostAppsByAppIdAgentFilesQuery = z.object({ @@ -3904,7 +3946,7 @@ export const zPostAppsByAppIdAgentFilesQuery = z.object({ export const zPostAppsByAppIdAgentFilesResponse = zAgentDriveFileCommitResponse export const zGetAppsByAppIdAgentLogsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAgentLogsQuery = z.object({ @@ -3917,30 +3959,25 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ */ export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse -export const zPostAppsByAppIdAgentSkillsStandardizePath = z.object({ - app_id: z.string(), +export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({ + file: z.custom(), }) -export const zPostAppsByAppIdAgentSkillsStandardizeQuery = z.object({ +export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ + app_id: z.uuid(), +}) + +export const zPostAppsByAppIdAgentSkillsUploadQuery = z.object({ node_id: z.string().optional(), }) /** - * Skill standardized into drive - */ -export const zPostAppsByAppIdAgentSkillsStandardizeResponse = zAgentSkillStandardizeResponse - -export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ - app_id: z.string(), -}) - -/** - * Skill validated + * Skill uploaded into drive */ export const zPostAppsByAppIdAgentSkillsUploadResponse = zAgentSkillUploadResponse export const zDeleteAppsByAppIdAgentSkillsBySlugPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), slug: z.string(), }) @@ -3954,7 +3991,7 @@ export const zDeleteAppsByAppIdAgentSkillsBySlugQuery = z.object({ export const zDeleteAppsByAppIdAgentSkillsBySlugResponse = zAgentDriveDeleteResponse export const zPostAppsByAppIdAgentSkillsBySlugInferToolsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), slug: z.string(), }) @@ -3971,7 +4008,7 @@ export const zPostAppsByAppIdAnnotationReplyByActionBody = zAnnotationReplyPaylo export const zPostAppsByAppIdAnnotationReplyByActionPath = z.object({ action: z.string(), - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -3981,8 +4018,8 @@ export const zPostAppsByAppIdAnnotationReplyByActionResponse = zAnnotationJobSta export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdPath = z.object({ action: z.string(), - app_id: z.string(), - job_id: z.string(), + app_id: z.uuid(), + job_id: z.uuid(), }) /** @@ -3992,7 +4029,7 @@ export const zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationSettingPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4004,8 +4041,8 @@ export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdBody = zAnnotationSettingUpdatePayload export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdPath = z.object({ - annotation_setting_id: z.string(), - app_id: z.string(), + annotation_setting_id: z.uuid(), + app_id: z.uuid(), }) /** @@ -4015,7 +4052,7 @@ export const zPostAppsByAppIdAnnotationSettingsByAnnotationSettingIdResponse = zAnnotationSettingResponse export const zDeleteAppsByAppIdAnnotationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4024,7 +4061,7 @@ export const zDeleteAppsByAppIdAnnotationsPath = z.object({ export const zDeleteAppsByAppIdAnnotationsResponse = z.void() export const zGetAppsByAppIdAnnotationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAnnotationsQuery = z.object({ @@ -4041,7 +4078,7 @@ export const zGetAppsByAppIdAnnotationsResponse = zAnnotationList export const zPostAppsByAppIdAnnotationsBody = zCreateAnnotationPayload export const zPostAppsByAppIdAnnotationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4050,7 +4087,7 @@ export const zPostAppsByAppIdAnnotationsPath = z.object({ export const zPostAppsByAppIdAnnotationsResponse = zAnnotation export const zPostAppsByAppIdAnnotationsBatchImportPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4059,8 +4096,8 @@ export const zPostAppsByAppIdAnnotationsBatchImportPath = z.object({ export const zPostAppsByAppIdAnnotationsBatchImportResponse = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdPath = z.object({ - app_id: z.string(), - job_id: z.string(), + app_id: z.uuid(), + job_id: z.uuid(), }) /** @@ -4070,7 +4107,7 @@ export const zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse = zAnnotationJobStatusResponse export const zGetAppsByAppIdAnnotationsCountPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4079,7 +4116,7 @@ export const zGetAppsByAppIdAnnotationsCountPath = z.object({ export const zGetAppsByAppIdAnnotationsCountResponse = zAnnotationCountResponse export const zGetAppsByAppIdAnnotationsExportPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4088,8 +4125,8 @@ export const zGetAppsByAppIdAnnotationsExportPath = z.object({ export const zGetAppsByAppIdAnnotationsExportResponse = zAnnotationExportList export const zDeleteAppsByAppIdAnnotationsByAnnotationIdPath = z.object({ - annotation_id: z.string(), - app_id: z.string(), + annotation_id: z.uuid(), + app_id: z.uuid(), }) /** @@ -4100,15 +4137,15 @@ export const zDeleteAppsByAppIdAnnotationsByAnnotationIdResponse = z.void() export const zPostAppsByAppIdAnnotationsByAnnotationIdBody = zUpdateAnnotationPayload export const zPostAppsByAppIdAnnotationsByAnnotationIdPath = z.object({ - annotation_id: z.string(), - app_id: z.string(), + annotation_id: z.uuid(), + app_id: z.uuid(), }) export const zPostAppsByAppIdAnnotationsByAnnotationIdResponse = z.union([zAnnotation, z.void()]) export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesPath = z.object({ - annotation_id: z.string(), - app_id: z.string(), + annotation_id: z.uuid(), + app_id: z.uuid(), }) export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesQuery = z.object({ @@ -4125,7 +4162,7 @@ export const zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse export const zPostAppsByAppIdApiEnableBody = zAppApiStatusPayload export const zPostAppsByAppIdApiEnablePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4134,7 +4171,7 @@ export const zPostAppsByAppIdApiEnablePath = z.object({ export const zPostAppsByAppIdApiEnableResponse = zAppDetail export const zPostAppsByAppIdAudioToTextPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4143,7 +4180,7 @@ export const zPostAppsByAppIdAudioToTextPath = z.object({ export const zPostAppsByAppIdAudioToTextResponse = zAudioTranscriptResponse export const zGetAppsByAppIdChatConversationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdChatConversationsQuery = z.object({ @@ -4165,8 +4202,8 @@ export const zGetAppsByAppIdChatConversationsQuery = z.object({ export const zGetAppsByAppIdChatConversationsResponse = zConversationWithSummaryPagination export const zDeleteAppsByAppIdChatConversationsByConversationIdPath = z.object({ - app_id: z.string(), - conversation_id: z.string(), + app_id: z.uuid(), + conversation_id: z.uuid(), }) /** @@ -4175,8 +4212,8 @@ export const zDeleteAppsByAppIdChatConversationsByConversationIdPath = z.object( export const zDeleteAppsByAppIdChatConversationsByConversationIdResponse = z.void() export const zGetAppsByAppIdChatConversationsByConversationIdPath = z.object({ - app_id: z.string(), - conversation_id: z.string(), + app_id: z.uuid(), + conversation_id: z.uuid(), }) /** @@ -4185,7 +4222,7 @@ export const zGetAppsByAppIdChatConversationsByConversationIdPath = z.object({ export const zGetAppsByAppIdChatConversationsByConversationIdResponse = zConversationDetail export const zGetAppsByAppIdChatMessagesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdChatMessagesQuery = z.object({ @@ -4200,8 +4237,8 @@ export const zGetAppsByAppIdChatMessagesQuery = z.object({ export const zGetAppsByAppIdChatMessagesResponse = zMessageInfiniteScrollPaginationResponse export const zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsPath = z.object({ - app_id: z.string(), - message_id: z.string(), + app_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -4211,7 +4248,7 @@ export const zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse = zSuggestedQuestionsResponse export const zPostAppsByAppIdChatMessagesByTaskIdStopPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), task_id: z.string(), }) @@ -4221,7 +4258,7 @@ export const zPostAppsByAppIdChatMessagesByTaskIdStopPath = z.object({ export const zPostAppsByAppIdChatMessagesByTaskIdStopResponse = zSimpleResultResponse export const zGetAppsByAppIdCompletionConversationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdCompletionConversationsQuery = z.object({ @@ -4239,8 +4276,8 @@ export const zGetAppsByAppIdCompletionConversationsQuery = z.object({ export const zGetAppsByAppIdCompletionConversationsResponse = zConversationPagination export const zDeleteAppsByAppIdCompletionConversationsByConversationIdPath = z.object({ - app_id: z.string(), - conversation_id: z.string(), + app_id: z.uuid(), + conversation_id: z.uuid(), }) /** @@ -4249,8 +4286,8 @@ export const zDeleteAppsByAppIdCompletionConversationsByConversationIdPath = z.o export const zDeleteAppsByAppIdCompletionConversationsByConversationIdResponse = z.void() export const zGetAppsByAppIdCompletionConversationsByConversationIdPath = z.object({ - app_id: z.string(), - conversation_id: z.string(), + app_id: z.uuid(), + conversation_id: z.uuid(), }) /** @@ -4262,7 +4299,7 @@ export const zGetAppsByAppIdCompletionConversationsByConversationIdResponse export const zPostAppsByAppIdCompletionMessagesBody = zCompletionMessagePayload export const zPostAppsByAppIdCompletionMessagesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4271,7 +4308,7 @@ export const zPostAppsByAppIdCompletionMessagesPath = z.object({ export const zPostAppsByAppIdCompletionMessagesResponse = zGeneratedAppResponse export const zPostAppsByAppIdCompletionMessagesByTaskIdStopPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), task_id: z.string(), }) @@ -4281,7 +4318,7 @@ export const zPostAppsByAppIdCompletionMessagesByTaskIdStopPath = z.object({ export const zPostAppsByAppIdCompletionMessagesByTaskIdStopResponse = zSimpleResultResponse export const zGetAppsByAppIdConversationVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdConversationVariablesQuery = z.object({ @@ -4296,7 +4333,7 @@ export const zGetAppsByAppIdConversationVariablesResponse = zPaginatedConversati export const zPostAppsByAppIdConvertToWorkflowBody = zConvertToWorkflowPayload export const zPostAppsByAppIdConvertToWorkflowPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4307,7 +4344,7 @@ export const zPostAppsByAppIdConvertToWorkflowResponse = zNewAppResponse export const zPostAppsByAppIdCopyBody = zCopyAppPayload export const zPostAppsByAppIdCopyPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4316,7 +4353,7 @@ export const zPostAppsByAppIdCopyPath = z.object({ export const zPostAppsByAppIdCopyResponse = zAppDetailWithSite export const zGetAppsByAppIdExportPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdExportQuery = z.object({ @@ -4332,7 +4369,7 @@ export const zGetAppsByAppIdExportResponse = zAppExportResponse export const zPostAppsByAppIdFeedbacksBody = zMessageFeedbackPayload export const zPostAppsByAppIdFeedbacksPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4341,7 +4378,7 @@ export const zPostAppsByAppIdFeedbacksPath = z.object({ export const zPostAppsByAppIdFeedbacksResponse = zSimpleResultResponse export const zGetAppsByAppIdFeedbacksExportPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdFeedbacksExportQuery = z.object({ @@ -4361,7 +4398,7 @@ export const zGetAppsByAppIdFeedbacksExportResponse = zTextFileResponse export const zPostAppsByAppIdIconBody = zAppIconPayload export const zPostAppsByAppIdIconPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4370,8 +4407,8 @@ export const zPostAppsByAppIdIconPath = z.object({ export const zPostAppsByAppIdIconResponse = zAppDetail export const zGetAppsByAppIdMessagesByMessageIdPath = z.object({ - app_id: z.string(), - message_id: z.string(), + app_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -4382,7 +4419,7 @@ export const zGetAppsByAppIdMessagesByMessageIdResponse = zMessageDetailResponse export const zPostAppsByAppIdModelConfigBody = zModelConfigRequest export const zPostAppsByAppIdModelConfigPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4393,7 +4430,7 @@ export const zPostAppsByAppIdModelConfigResponse = zSimpleResultResponse export const zPostAppsByAppIdNameBody = zAppNamePayload export const zPostAppsByAppIdNamePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4402,7 +4439,7 @@ export const zPostAppsByAppIdNamePath = z.object({ export const zPostAppsByAppIdNameResponse = zAppDetail export const zPostAppsByAppIdPublishToCreatorsPlatformPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4411,7 +4448,7 @@ export const zPostAppsByAppIdPublishToCreatorsPlatformPath = z.object({ export const zPostAppsByAppIdPublishToCreatorsPlatformResponse = zRedirectUrlResponse export const zGetAppsByAppIdServerPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4422,7 +4459,7 @@ export const zGetAppsByAppIdServerResponse = zAppMcpServerResponse export const zPostAppsByAppIdServerBody = zMcpServerCreatePayload export const zPostAppsByAppIdServerPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4433,7 +4470,7 @@ export const zPostAppsByAppIdServerResponse = zAppMcpServerResponse export const zPutAppsByAppIdServerBody = zMcpServerUpdatePayload export const zPutAppsByAppIdServerPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4444,7 +4481,7 @@ export const zPutAppsByAppIdServerResponse = zAppMcpServerResponse export const zPostAppsByAppIdSiteBody = zAppSiteUpdatePayload export const zPostAppsByAppIdSitePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4455,7 +4492,7 @@ export const zPostAppsByAppIdSiteResponse = zAppSiteResponse export const zPostAppsByAppIdSiteEnableBody = zAppSiteStatusPayload export const zPostAppsByAppIdSiteEnablePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4464,7 +4501,7 @@ export const zPostAppsByAppIdSiteEnablePath = z.object({ export const zPostAppsByAppIdSiteEnableResponse = zAppDetail export const zPostAppsByAppIdSiteAccessTokenResetPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4473,7 +4510,7 @@ export const zPostAppsByAppIdSiteAccessTokenResetPath = z.object({ export const zPostAppsByAppIdSiteAccessTokenResetResponse = zAppSiteResponse export const zDeleteAppsByAppIdStarPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4482,7 +4519,7 @@ export const zDeleteAppsByAppIdStarPath = z.object({ export const zDeleteAppsByAppIdStarResponse = zSimpleResultResponse export const zPostAppsByAppIdStarPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4491,7 +4528,7 @@ export const zPostAppsByAppIdStarPath = z.object({ export const zPostAppsByAppIdStarResponse = zSimpleResultResponse export const zGetAppsByAppIdStatisticsAverageResponseTimePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsAverageResponseTimeQuery = z.object({ @@ -4506,7 +4543,7 @@ export const zGetAppsByAppIdStatisticsAverageResponseTimeResponse = zAverageResponseTimeStatisticResponse export const zGetAppsByAppIdStatisticsAverageSessionInteractionsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsAverageSessionInteractionsQuery = z.object({ @@ -4521,7 +4558,7 @@ export const zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse = zAverageSessionInteractionStatisticResponse export const zGetAppsByAppIdStatisticsDailyConversationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsDailyConversationsQuery = z.object({ @@ -4536,7 +4573,7 @@ export const zGetAppsByAppIdStatisticsDailyConversationsResponse = zDailyConversationStatisticResponse export const zGetAppsByAppIdStatisticsDailyEndUsersPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsDailyEndUsersQuery = z.object({ @@ -4550,7 +4587,7 @@ export const zGetAppsByAppIdStatisticsDailyEndUsersQuery = z.object({ export const zGetAppsByAppIdStatisticsDailyEndUsersResponse = zDailyTerminalStatisticResponse export const zGetAppsByAppIdStatisticsDailyMessagesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsDailyMessagesQuery = z.object({ @@ -4564,7 +4601,7 @@ export const zGetAppsByAppIdStatisticsDailyMessagesQuery = z.object({ export const zGetAppsByAppIdStatisticsDailyMessagesResponse = zDailyMessageStatisticResponse export const zGetAppsByAppIdStatisticsTokenCostsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsTokenCostsQuery = z.object({ @@ -4578,7 +4615,7 @@ export const zGetAppsByAppIdStatisticsTokenCostsQuery = z.object({ export const zGetAppsByAppIdStatisticsTokenCostsResponse = zDailyTokenCostStatisticResponse export const zGetAppsByAppIdStatisticsTokensPerSecondPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsTokensPerSecondQuery = z.object({ @@ -4592,7 +4629,7 @@ export const zGetAppsByAppIdStatisticsTokensPerSecondQuery = z.object({ export const zGetAppsByAppIdStatisticsTokensPerSecondResponse = zTokensPerSecondStatisticResponse export const zGetAppsByAppIdStatisticsUserSatisfactionRatePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdStatisticsUserSatisfactionRateQuery = z.object({ @@ -4609,7 +4646,7 @@ export const zGetAppsByAppIdStatisticsUserSatisfactionRateResponse export const zPostAppsByAppIdTextToAudioBody = zTextToSpeechPayload export const zPostAppsByAppIdTextToAudioPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4618,7 +4655,7 @@ export const zPostAppsByAppIdTextToAudioPath = z.object({ export const zPostAppsByAppIdTextToAudioResponse = zAudioBinaryResponse export const zGetAppsByAppIdTextToAudioVoicesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdTextToAudioVoicesQuery = z.object({ @@ -4631,7 +4668,7 @@ export const zGetAppsByAppIdTextToAudioVoicesQuery = z.object({ export const zGetAppsByAppIdTextToAudioVoicesResponse = zTextToSpeechVoiceListResponse export const zGetAppsByAppIdTracePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4642,7 +4679,7 @@ export const zGetAppsByAppIdTraceResponse = zAppTraceResponse export const zPostAppsByAppIdTraceBody = zAppTracePayload export const zPostAppsByAppIdTracePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4651,7 +4688,7 @@ export const zPostAppsByAppIdTracePath = z.object({ export const zPostAppsByAppIdTraceResponse = zSimpleResultResponse export const zDeleteAppsByAppIdTraceConfigPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zDeleteAppsByAppIdTraceConfigQuery = z.object({ @@ -4664,7 +4701,7 @@ export const zDeleteAppsByAppIdTraceConfigQuery = z.object({ export const zDeleteAppsByAppIdTraceConfigResponse = z.void() export const zGetAppsByAppIdTraceConfigPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdTraceConfigQuery = z.object({ @@ -4679,7 +4716,7 @@ export const zGetAppsByAppIdTraceConfigResponse = zTraceAppConfigResponse export const zPatchAppsByAppIdTraceConfigBody = zTraceConfigPayload export const zPatchAppsByAppIdTraceConfigPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4690,7 +4727,7 @@ export const zPatchAppsByAppIdTraceConfigResponse = zTraceAppConfigResponse export const zPostAppsByAppIdTraceConfigBody = zTraceConfigPayload export const zPostAppsByAppIdTraceConfigPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4701,7 +4738,7 @@ export const zPostAppsByAppIdTraceConfigResponse = zTraceAppConfigResponse export const zPostAppsByAppIdTriggerEnableBody = zParserEnable export const zPostAppsByAppIdTriggerEnablePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4710,7 +4747,7 @@ export const zPostAppsByAppIdTriggerEnablePath = z.object({ export const zPostAppsByAppIdTriggerEnableResponse = zWorkflowTriggerResponse export const zGetAppsByAppIdTriggersPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4719,7 +4756,7 @@ export const zGetAppsByAppIdTriggersPath = z.object({ export const zGetAppsByAppIdTriggersResponse = zWorkflowTriggerListResponse export const zGetAppsByAppIdWorkflowAppLogsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowAppLogsQuery = z.object({ @@ -4742,7 +4779,7 @@ export const zGetAppsByAppIdWorkflowAppLogsQuery = z.object({ export const zGetAppsByAppIdWorkflowAppLogsResponse = zWorkflowAppLogPaginationResponse export const zGetAppsByAppIdWorkflowArchivedLogsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowArchivedLogsQuery = z.object({ @@ -4765,7 +4802,7 @@ export const zGetAppsByAppIdWorkflowArchivedLogsQuery = z.object({ export const zGetAppsByAppIdWorkflowArchivedLogsResponse = zWorkflowArchivedLogPaginationResponse export const zGetAppsByAppIdWorkflowRunsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowRunsQuery = z.object({ @@ -4781,7 +4818,7 @@ export const zGetAppsByAppIdWorkflowRunsQuery = z.object({ export const zGetAppsByAppIdWorkflowRunsResponse = zWorkflowRunPaginationResponse export const zGetAppsByAppIdWorkflowRunsCountPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowRunsCountQuery = z.object({ @@ -4796,7 +4833,7 @@ export const zGetAppsByAppIdWorkflowRunsCountQuery = z.object({ export const zGetAppsByAppIdWorkflowRunsCountResponse = zWorkflowRunCountResponse export const zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), task_id: z.string(), }) @@ -4806,8 +4843,8 @@ export const zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath = z.object({ export const zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse = zSimpleResultResponse export const zGetAppsByAppIdWorkflowRunsByRunIdPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -4816,8 +4853,8 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdPath = z.object({ export const zGetAppsByAppIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse export const zGetAppsByAppIdWorkflowRunsByRunIdExportPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -4826,8 +4863,8 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdExportPath = z.object({ export const zGetAppsByAppIdWorkflowRunsByRunIdExportResponse = zWorkflowRunExportResponse export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -4838,9 +4875,9 @@ export const zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), - workflow_run_id: z.string(), + workflow_run_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesQuery @@ -4857,9 +4894,9 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbox export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), - workflow_run_id: z.string(), + workflow_run_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadQuery @@ -4879,9 +4916,9 @@ export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbo export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), - workflow_run_id: z.string(), + workflow_run_id: z.uuid(), }) /** @@ -4891,7 +4928,7 @@ export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbo = zSandboxUploadResponse export const zGetAppsByAppIdWorkflowCommentsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4902,7 +4939,7 @@ export const zGetAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentBasicList export const zPostAppsByAppIdWorkflowCommentsBody = zWorkflowCommentCreatePayload export const zPostAppsByAppIdWorkflowCommentsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4911,7 +4948,7 @@ export const zPostAppsByAppIdWorkflowCommentsPath = z.object({ export const zPostAppsByAppIdWorkflowCommentsResponse = zWorkflowCommentCreate export const zGetAppsByAppIdWorkflowCommentsMentionUsersPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -4921,7 +4958,7 @@ export const zGetAppsByAppIdWorkflowCommentsMentionUsersResponse = zWorkflowCommentMentionUsersPayload export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), }) @@ -4931,7 +4968,7 @@ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdResponse = z.void() export const zGetAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), }) @@ -4943,7 +4980,7 @@ export const zGetAppsByAppIdWorkflowCommentsByCommentIdResponse = zWorkflowComme export const zPutAppsByAppIdWorkflowCommentsByCommentIdBody = zWorkflowCommentUpdatePayload export const zPutAppsByAppIdWorkflowCommentsByCommentIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), }) @@ -4955,7 +4992,7 @@ export const zPutAppsByAppIdWorkflowCommentsByCommentIdResponse = zWorkflowComme export const zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesBody = zWorkflowCommentReplyPayload export const zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), }) @@ -4966,7 +5003,7 @@ export const zPostAppsByAppIdWorkflowCommentsByCommentIdRepliesResponse = zWorkflowCommentReplyCreate export const zDeleteAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), reply_id: z.string(), }) @@ -4980,7 +5017,7 @@ export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdBody = zWorkflowCommentReplyPayload export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), reply_id: z.string(), }) @@ -4992,7 +5029,7 @@ export const zPutAppsByAppIdWorkflowCommentsByCommentIdRepliesByReplyIdResponse = zWorkflowCommentReplyUpdate export const zPostAppsByAppIdWorkflowCommentsByCommentIdResolvePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), comment_id: z.string(), }) @@ -5002,7 +5039,7 @@ export const zPostAppsByAppIdWorkflowCommentsByCommentIdResolvePath = z.object({ export const zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse = zWorkflowCommentResolve export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsQuery = z.object({ @@ -5017,7 +5054,7 @@ export const zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse = zWorkflowAverageAppInteractionStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsQuery = z.object({ @@ -5032,7 +5069,7 @@ export const zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse = zWorkflowDailyRunsStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsQuery = z.object({ @@ -5047,7 +5084,7 @@ export const zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse = zWorkflowDailyTerminalsStatisticResponse export const zGetAppsByAppIdWorkflowStatisticsTokenCostsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowStatisticsTokenCostsQuery = z.object({ @@ -5062,7 +5099,7 @@ export const zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse = zWorkflowDailyTokenCostStatisticResponse export const zGetAppsByAppIdWorkflowsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowsQuery = z.object({ @@ -5078,7 +5115,7 @@ export const zGetAppsByAppIdWorkflowsQuery = z.object({ export const zGetAppsByAppIdWorkflowsResponse = zWorkflowPaginationResponse export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5088,7 +5125,7 @@ export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse = zDefaultBlockConfigsResponse export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), block_type: z.string(), }) @@ -5103,7 +5140,7 @@ export const zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeRespo = zDefaultBlockConfigResponse export const zGetAppsByAppIdWorkflowsDraftPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5114,7 +5151,7 @@ export const zGetAppsByAppIdWorkflowsDraftResponse = zWorkflowResponse export const zPostAppsByAppIdWorkflowsDraftBody = zSyncDraftWorkflowPayload export const zPostAppsByAppIdWorkflowsDraftPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5123,7 +5160,7 @@ export const zPostAppsByAppIdWorkflowsDraftPath = z.object({ export const zPostAppsByAppIdWorkflowsDraftResponse = zSyncDraftWorkflowResponse export const zGetAppsByAppIdWorkflowsDraftConversationVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5135,7 +5172,7 @@ export const zPostAppsByAppIdWorkflowsDraftConversationVariablesBody = zConversationVariableUpdatePayload export const zPostAppsByAppIdWorkflowsDraftConversationVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5144,7 +5181,7 @@ export const zPostAppsByAppIdWorkflowsDraftConversationVariablesPath = z.object( export const zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse = zSimpleResultResponse export const zGetAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5157,7 +5194,7 @@ export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesBody = zEnvironmentVariableUpdatePayload export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5168,7 +5205,7 @@ export const zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse = zSimpl export const zPostAppsByAppIdWorkflowsDraftFeaturesBody = zWorkflowFeaturesPayload export const zPostAppsByAppIdWorkflowsDraftFeaturesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5180,7 +5217,7 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestBo = zHumanInputDeliveryTestPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdDeliveryTestPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5194,7 +5231,7 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewBod = zHumanInputFormPreviewPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormPreviewPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5208,7 +5245,7 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunBody = zHumanInputFormSubmitPayload export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5221,7 +5258,7 @@ export const zPostAppsByAppIdWorkflowsDraftHumanInputNodesByNodeIdFormRunRespons export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunBody = zIterationNodeRunPayload export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5233,7 +5270,7 @@ export const zPostAppsByAppIdWorkflowsDraftIterationNodesByNodeIdRunResponse = z export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody = zLoopNodeRunPayload export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5243,7 +5280,7 @@ export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ export const zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse = zGeneratedAppResponse export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5256,7 +5293,7 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5267,7 +5304,7 @@ export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = zWorkflowAgentComposerResponse export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5281,7 +5318,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody = zComposerSavePayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5295,7 +5332,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoste = zComposerSavePayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5309,7 +5346,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBod = zComposerSavePayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5320,7 +5357,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateRes = zAgentComposerValidateResponse export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5333,7 +5370,7 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunBody = zDraftWorkflowNodeRunPayload export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5344,7 +5381,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse = zWorkflowRunNodeExecutionResponse export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5354,7 +5391,7 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunPath = z.objec export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse = zGeneratedAppResponse export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5364,7 +5401,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.obje export const zDeleteAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.void() export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), }) @@ -5377,7 +5414,7 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdVariablesResponse export const zPostAppsByAppIdWorkflowsDraftRunBody = zDraftWorkflowRunPayload export const zPostAppsByAppIdWorkflowsDraftRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5386,8 +5423,8 @@ export const zPostAppsByAppIdWorkflowsDraftRunPath = z.object({ export const zPostAppsByAppIdWorkflowsDraftRunResponse = zGeneratedAppResponse export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -5396,8 +5433,8 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsPath = z.object( export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse = zWorkflowRunSnapshotView export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -5407,9 +5444,9 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse = zEventStreamResponse export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), - run_id: z.string(), + run_id: z.uuid(), }) /** @@ -5419,10 +5456,10 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), output_name: z.string(), - run_id: z.string(), + run_id: z.uuid(), }) /** @@ -5432,7 +5469,7 @@ export const zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutput = zOutputPreviewView export const zGetAppsByAppIdWorkflowsDraftSystemVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5443,7 +5480,7 @@ export const zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse = zWorkflowDra export const zPostAppsByAppIdWorkflowsDraftTriggerRunBody = zDraftWorkflowTriggerRunRequest export const zPostAppsByAppIdWorkflowsDraftTriggerRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5454,7 +5491,7 @@ export const zPostAppsByAppIdWorkflowsDraftTriggerRunResponse = zGeneratedAppRes export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllBody = zDraftWorkflowTriggerRunAllPayload export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5463,7 +5500,7 @@ export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllPath = z.object({ export const zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse = zGeneratedAppResponse export const zDeleteAppsByAppIdWorkflowsDraftVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5472,7 +5509,7 @@ export const zDeleteAppsByAppIdWorkflowsDraftVariablesPath = z.object({ export const zDeleteAppsByAppIdWorkflowsDraftVariablesResponse = z.void() export const zGetAppsByAppIdWorkflowsDraftVariablesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowsDraftVariablesQuery = z.object({ @@ -5486,8 +5523,8 @@ export const zGetAppsByAppIdWorkflowsDraftVariablesQuery = z.object({ export const zGetAppsByAppIdWorkflowsDraftVariablesResponse = zWorkflowDraftVariableListWithoutValue export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - app_id: z.string(), - variable_id: z.string(), + app_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -5496,8 +5533,8 @@ export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.objec export const zDeleteAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = z.void() export const zGetAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - app_id: z.string(), - variable_id: z.string(), + app_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -5509,8 +5546,8 @@ export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdBody = zWorkflowDraftVariableUpdatePayload export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - app_id: z.string(), - variable_id: z.string(), + app_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -5519,8 +5556,8 @@ export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdPath = z.object export const zPatchAppsByAppIdWorkflowsDraftVariablesByVariableIdResponse = zWorkflowDraftVariable export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ - app_id: z.string(), - variable_id: z.string(), + app_id: z.uuid(), + variable_id: z.uuid(), }) export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([ @@ -5529,7 +5566,7 @@ export const zPutAppsByAppIdWorkflowsDraftVariablesByVariableIdResetResponse = z ]) export const zGetAppsByAppIdWorkflowsPublishPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5540,7 +5577,7 @@ export const zGetAppsByAppIdWorkflowsPublishResponse = zWorkflowResponse export const zPostAppsByAppIdWorkflowsPublishBody = zPublishWorkflowPayload export const zPostAppsByAppIdWorkflowsPublishPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -5549,8 +5586,8 @@ export const zPostAppsByAppIdWorkflowsPublishPath = z.object({ export const zPostAppsByAppIdWorkflowsPublishResponse = zWorkflowPublishResponse export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -5560,8 +5597,8 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse = zWorkflowRunSnapshotView export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsPath = z.object({ - app_id: z.string(), - run_id: z.string(), + app_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -5571,9 +5608,9 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsRespon = zEventStreamResponse export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), - run_id: z.string(), + run_id: z.uuid(), }) /** @@ -5584,10 +5621,10 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResp export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), node_id: z.string(), output_name: z.string(), - run_id: z.string(), + run_id: z.uuid(), }) /** @@ -5597,7 +5634,7 @@ export const zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdByOu = zOutputPreviewView export const zGetAppsByAppIdWorkflowsTriggersWebhookPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetAppsByAppIdWorkflowsTriggersWebhookQuery = z.object({ @@ -5610,7 +5647,7 @@ export const zGetAppsByAppIdWorkflowsTriggersWebhookQuery = z.object({ export const zGetAppsByAppIdWorkflowsTriggersWebhookResponse = zWebhookTriggerResponse export const zDeleteAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), workflow_id: z.string(), }) @@ -5622,7 +5659,7 @@ export const zDeleteAppsByAppIdWorkflowsByWorkflowIdResponse = z.void() export const zPatchAppsByAppIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload export const zPatchAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), workflow_id: z.string(), }) @@ -5632,7 +5669,7 @@ export const zPatchAppsByAppIdWorkflowsByWorkflowIdPath = z.object({ export const zPatchAppsByAppIdWorkflowsByWorkflowIdResponse = zWorkflowResponse export const zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), workflow_id: z.string(), }) @@ -5642,7 +5679,7 @@ export const zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath = z.object({ export const zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse = zWorkflowRestoreResponse export const zGetAppsByResourceIdApiKeysPath = z.object({ - resource_id: z.string(), + resource_id: z.uuid(), }) /** @@ -5651,7 +5688,7 @@ export const zGetAppsByResourceIdApiKeysPath = z.object({ export const zGetAppsByResourceIdApiKeysResponse = zApiKeyList export const zPostAppsByResourceIdApiKeysPath = z.object({ - resource_id: z.string(), + resource_id: z.uuid(), }) /** @@ -5660,8 +5697,8 @@ export const zPostAppsByResourceIdApiKeysPath = z.object({ export const zPostAppsByResourceIdApiKeysResponse = zApiKeyItem export const zDeleteAppsByResourceIdApiKeysByApiKeyIdPath = z.object({ - api_key_id: z.string(), - resource_id: z.string(), + api_key_id: z.uuid(), + resource_id: z.uuid(), }) /** @@ -5670,7 +5707,7 @@ export const zDeleteAppsByResourceIdApiKeysByApiKeyIdPath = z.object({ export const zDeleteAppsByResourceIdApiKeysByApiKeyIdResponse = z.void() export const zGetAppsByServerIdServerRefreshPath = z.object({ - server_id: z.string(), + server_id: z.uuid(), }) /** 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 e5cf1735c3f..e2774d10f42 100644 --- a/packages/contracts/generated/api/console/data-source/zod.gen.ts +++ b/packages/contracts/generated/api/console/data-source/zod.gen.ts @@ -72,7 +72,7 @@ export const zPatchDataSourceIntegratesResponse = zSimpleResultResponse export const zGetDataSourceIntegratesByBindingIdByActionPath = z.object({ action: z.string(), - binding_id: z.string(), + binding_id: z.uuid(), }) /** @@ -82,7 +82,7 @@ export const zGetDataSourceIntegratesByBindingIdByActionResponse = zDataSourceIn export const zPatchDataSourceIntegratesByBindingIdByActionPath = z.object({ action: z.string(), - binding_id: z.string(), + binding_id: z.uuid(), }) /** diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index a24c6cf92ae..f904ea7d717 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -45,8 +45,10 @@ export type DatasetDetailResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -120,6 +122,7 @@ export type DatasetDetail = { is_published?: boolean name?: string permission?: string + permission_keys?: Array pipeline_id?: string provider?: string retrieval_model_dict?: DatasetRetrievalModel @@ -187,7 +190,7 @@ export type IndexingEstimateResponse = { export type KnowledgeConfig = { data_source?: DataSource | null - doc_form?: string + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' doc_language?: string duplicate?: boolean embedding_model?: string | null @@ -199,7 +202,10 @@ export type KnowledgeConfig = { process_rule?: ProcessRule | null retrieval_model?: RetrievalModel | null summary_index_setting?: { - [key: string]: unknown + enable?: boolean + model_name?: string + model_provider_name?: string + summary_prompt?: string } | null } @@ -261,9 +267,11 @@ export type DatasetDetailWithPartialMembersResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string partial_member_list?: Array | null permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -482,7 +490,9 @@ export type ExternalRetrievalTestResponse export type HitTestingPayload = { attachment_ids?: Array | null external_retrieval_model?: { - [key: string]: unknown + score_threshold?: number + score_threshold_enabled?: boolean + top_k?: number } | null query: string retrieval_model?: RetrievalModel | null @@ -561,9 +571,11 @@ export type DatasetListItemResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string partial_member_list: Array permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -707,7 +719,7 @@ export type ProcessRule = { export type RetrievalModel = { metadata_filtering_conditions?: MetadataFilteringCondition | null reranking_enable: boolean - reranking_mode?: string | null + reranking_mode?: 'reranking_model' | 'weighted_score' | null reranking_model?: RerankingModel | null score_threshold?: number | null score_threshold_enabled: boolean @@ -1035,7 +1047,7 @@ export type WebsiteInfo = { export type PreProcessingRule = { enabled: boolean - id: string + id: 'remove_extra_spaces' | 'remove_stopwords' | 'remove_urls_emails' } export type Segmentation = { @@ -1065,7 +1077,7 @@ export type Condition = { | '≤' | '≥' name: string - value?: string | Array | number | number | null + value?: string | Array | number | null } export type WeightKeywordSetting = { diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index 5a5f9282794..10ceb11cb5d 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -963,8 +963,10 @@ export const zDatasetDetailResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -1004,9 +1006,11 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()).nullish(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -1046,9 +1050,11 @@ export const zDatasetListItemResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -1133,6 +1139,7 @@ export const zDatasetDetail = z.object({ is_published: z.boolean().optional(), name: z.string().optional(), permission: z.string().optional(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().optional(), provider: z.string().optional(), retrieval_model_dict: zDatasetRetrievalModel.optional(), @@ -1176,7 +1183,7 @@ export const zWebsiteInfo = z.object({ */ export const zPreProcessingRule = z.object({ enabled: z.boolean(), - id: z.string(), + id: z.enum(['remove_extra_spaces', 'remove_stopwords', 'remove_urls_emails']), }) /** @@ -1233,7 +1240,7 @@ export const zCondition = z.object({ '≥', ]), name: z.string(), - value: z.union([z.string(), z.array(z.string()), z.int(), z.number()]).nullish(), + value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(), }) /** @@ -1277,7 +1284,7 @@ export const zWeightModel = z.object({ export const zRetrievalModel = z.object({ metadata_filtering_conditions: zMetadataFilteringCondition.nullish(), reranking_enable: z.boolean(), - reranking_mode: z.string().nullish(), + reranking_mode: z.enum(['reranking_model', 'weighted_score']).nullish(), reranking_model: zRerankingModel.nullish(), score_threshold: z.number().nullish(), score_threshold_enabled: z.boolean(), @@ -1291,7 +1298,13 @@ export const zRetrievalModel = z.object({ */ export const zHitTestingPayload = z.object({ attachment_ids: z.array(z.string()).nullish(), - external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), + external_retrieval_model: z + .object({ + score_threshold: z.number().optional(), + score_threshold_enabled: z.boolean().optional(), + top_k: z.int().optional(), + }) + .nullish(), query: z.string().max(250), retrieval_model: zRetrievalModel.nullish(), }) @@ -1451,7 +1464,10 @@ export const zDataSource = z.object({ */ export const zKnowledgeConfig = z.object({ data_source: zDataSource.nullish(), - doc_form: z.string().optional().default('text_model'), + doc_form: z + .enum(['hierarchical_model', 'qa_model', 'text_model']) + .optional() + .default('text_model'), doc_language: z.string().optional().default('English'), duplicate: z.boolean().optional().default(true), embedding_model: z.string().nullish(), @@ -1462,7 +1478,14 @@ export const zKnowledgeConfig = z.object({ original_document_id: z.string().nullish(), process_rule: zProcessRule.nullish(), retrieval_model: zRetrievalModel.nullish(), - summary_index_setting: z.record(z.string(), z.unknown()).nullish(), + summary_index_setting: z + .object({ + enable: z.boolean().optional(), + model_name: z.string().optional(), + model_provider_name: z.string().optional(), + summary_prompt: z.string().optional(), + }) + .nullish(), }) export const zGetDatasetsQuery = z.object({ @@ -1502,7 +1525,7 @@ export const zGetDatasetsApiKeysResponse = zApiKeyList export const zPostDatasetsApiKeysResponse = zApiKeyItem export const zDeleteDatasetsApiKeysByApiKeyIdPath = z.object({ - api_key_id: z.string(), + api_key_id: z.uuid(), }) /** @@ -1511,7 +1534,7 @@ export const zDeleteDatasetsApiKeysByApiKeyIdPath = z.object({ export const zDeleteDatasetsApiKeysByApiKeyIdResponse = z.void() export const zGetDatasetsBatchImportStatusByJobIdPath = z.object({ - job_id: z.string(), + job_id: z.uuid(), }) /** @@ -1522,7 +1545,7 @@ export const zGetDatasetsBatchImportStatusByJobIdResponse = zSegmentBatchImportS export const zPostDatasetsBatchImportStatusByJobIdBody = zBatchImportPayload export const zPostDatasetsBatchImportStatusByJobIdPath = z.object({ - job_id: z.string(), + job_id: z.uuid(), }) /** @@ -1556,7 +1579,7 @@ export const zPostDatasetsExternalKnowledgeApiBody = zExternalKnowledgeApiPayloa export const zPostDatasetsExternalKnowledgeApiResponse = zExternalKnowledgeApiResponse export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.object({ - external_knowledge_api_id: z.string(), + external_knowledge_api_id: z.uuid(), }) /** @@ -1565,7 +1588,7 @@ export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z export const zDeleteDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = z.void() export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.object({ - external_knowledge_api_id: z.string(), + external_knowledge_api_id: z.uuid(), }) /** @@ -1578,7 +1601,7 @@ export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdBody = zExternalKnowledgeApiPayload export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdPath = z.object({ - external_knowledge_api_id: z.string(), + external_knowledge_api_id: z.uuid(), }) /** @@ -1588,7 +1611,7 @@ export const zPatchDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdResponse = zExternalKnowledgeApiResponse export const zGetDatasetsExternalKnowledgeApiByExternalKnowledgeApiIdUseCheckPath = z.object({ - external_knowledge_api_id: z.string(), + external_knowledge_api_id: z.uuid(), }) /** @@ -1647,7 +1670,7 @@ export const zGetDatasetsRetrievalSettingByVectorTypePath = z.object({ export const zGetDatasetsRetrievalSettingByVectorTypeResponse = zRetrievalSettingResponse export const zDeleteDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1656,7 +1679,7 @@ export const zDeleteDatasetsByDatasetIdPath = z.object({ export const zDeleteDatasetsByDatasetIdResponse = z.void() export const zGetDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1667,7 +1690,7 @@ export const zGetDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMembersR export const zPatchDatasetsByDatasetIdBody = zDatasetUpdatePayload export const zPatchDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1676,7 +1699,7 @@ export const zPatchDatasetsByDatasetIdPath = z.object({ export const zPatchDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMembersResponse export const zPostDatasetsByDatasetIdApiKeysByStatusPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), status: z.string(), }) @@ -1686,7 +1709,7 @@ export const zPostDatasetsByDatasetIdApiKeysByStatusPath = z.object({ export const zPostDatasetsByDatasetIdApiKeysByStatusResponse = zSimpleResultResponse export const zGetDatasetsByDatasetIdAutoDisableLogsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1696,7 +1719,7 @@ export const zGetDatasetsByDatasetIdAutoDisableLogsResponse = zAutoDisableLogsRe export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimatePath = z.object({ batch: z.string(), - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1706,7 +1729,7 @@ export const zGetDatasetsByDatasetIdBatchByBatchIndexingEstimateResponse = zOpaq export const zGetDatasetsByDatasetIdBatchByBatchIndexingStatusPath = z.object({ batch: z.string(), - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1715,7 +1738,7 @@ export const zGetDatasetsByDatasetIdBatchByBatchIndexingStatusPath = z.object({ export const zGetDatasetsByDatasetIdBatchByBatchIndexingStatusResponse = zDocumentStatusListResponse export const zDeleteDatasetsByDatasetIdDocumentsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1724,7 +1747,7 @@ export const zDeleteDatasetsByDatasetIdDocumentsPath = z.object({ export const zDeleteDatasetsByDatasetIdDocumentsResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsQuery = z.object({ @@ -1744,7 +1767,7 @@ export const zGetDatasetsByDatasetIdDocumentsResponse = zDocumentWithSegmentsLis export const zPostDatasetsByDatasetIdDocumentsBody = zKnowledgeConfig export const zPostDatasetsByDatasetIdDocumentsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1755,7 +1778,7 @@ export const zPostDatasetsByDatasetIdDocumentsResponse = zDatasetAndDocumentResp export const zPostDatasetsByDatasetIdDocumentsDownloadZipBody = zDocumentBatchDownloadZipPayload export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1766,7 +1789,7 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = zBinaryFileR export const zPostDatasetsByDatasetIdDocumentsGenerateSummaryBody = zGenerateSummaryPayload export const zPostDatasetsByDatasetIdDocumentsGenerateSummaryPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1777,7 +1800,7 @@ export const zPostDatasetsByDatasetIdDocumentsGenerateSummaryResponse = zSimpleR export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData export const zPostDatasetsByDatasetIdDocumentsMetadataPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1787,7 +1810,7 @@ export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = z.void() export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath = z.object({ action: z.string(), - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -1796,8 +1819,8 @@ export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchPath = z.objec export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBatchResponse = zSimpleResultResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1806,8 +1829,8 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery = z.object({ @@ -1820,8 +1843,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery = z.object({ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1830,8 +1853,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadPath = z.object export const zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse = zUrlResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimatePath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1841,8 +1864,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingEstimateRespons = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdIndexingStatusPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1855,8 +1878,8 @@ export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataBody = zDocumentMetadataUpdatePayload export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1866,8 +1889,8 @@ export const zPutDatasetsByDatasetIdDocumentsByDocumentIdMetadataResponse = zSimpleResultMessageResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1876,8 +1899,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncPath = z.obje export const zGetDatasetsByDatasetIdDocumentsByDocumentIdNotionSyncResponse = zSimpleResultResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1887,8 +1910,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPipelineExecutionLogRes = zOpaqueObjectResponse export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPausePath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1897,8 +1920,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPausePath = export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingPauseResponse = z.void() export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumePath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1908,8 +1931,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingResumeRespo export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionPath = z.object({ action: z.string(), - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1921,8 +1944,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdProcessingByActionRes export const zPostDatasetsByDatasetIdDocumentsByDocumentIdRenameBody = zDocumentRenamePayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdRenamePath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1933,8 +1956,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdRenameResponse = zDocu export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentBody = zSegmentCreatePayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -1944,8 +1967,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentResponse = zSeg export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionPath = z.object({ action: z.string(), - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionQuery = z.object({ @@ -1959,8 +1982,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentByActionRespon = zSimpleResultResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.object({ @@ -1973,8 +1996,8 @@ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.ob export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.object({ @@ -1993,8 +2016,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = zConsoleSegmentListResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -2007,8 +2030,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportBod = zBatchImportPayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -2018,9 +2041,9 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBatchImportRes = zSegmentBatchImportStatusResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** @@ -2032,9 +2055,9 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBo = zSegmentUpdatePayload export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** @@ -2045,9 +2068,9 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdRe export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery @@ -2068,9 +2091,9 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** @@ -2084,9 +2107,9 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** @@ -2097,10 +2120,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.uuid(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** @@ -2114,10 +2137,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.uuid(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** @@ -2127,8 +2150,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh = zChildChunkDetailResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -2138,8 +2161,8 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSummaryStatusResponse = zOpaqueObjectResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -2148,7 +2171,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncPath = z.obj export const zGetDatasetsByDatasetIdDocumentsByDocumentIdWebsiteSyncResponse = zSimpleResultResponse export const zGetDatasetsByDatasetIdErrorDocsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2159,7 +2182,7 @@ export const zGetDatasetsByDatasetIdErrorDocsResponse = zErrorDocsResponse export const zPostDatasetsByDatasetIdExternalHitTestingBody = zExternalHitTestingPayload export const zPostDatasetsByDatasetIdExternalHitTestingPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2170,7 +2193,7 @@ export const zPostDatasetsByDatasetIdExternalHitTestingResponse = zExternalRetri export const zPostDatasetsByDatasetIdHitTestingBody = zHitTestingPayload export const zPostDatasetsByDatasetIdHitTestingPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2179,7 +2202,7 @@ export const zPostDatasetsByDatasetIdHitTestingPath = z.object({ export const zPostDatasetsByDatasetIdHitTestingResponse = zHitTestingResponse export const zGetDatasetsByDatasetIdIndexingStatusPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2188,7 +2211,7 @@ export const zGetDatasetsByDatasetIdIndexingStatusPath = z.object({ export const zGetDatasetsByDatasetIdIndexingStatusResponse = zDocumentStatusListResponse export const zGetDatasetsByDatasetIdMetadataPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2199,7 +2222,7 @@ export const zGetDatasetsByDatasetIdMetadataResponse = zDatasetMetadataListRespo export const zPostDatasetsByDatasetIdMetadataBody = zMetadataArgs export const zPostDatasetsByDatasetIdMetadataPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2209,7 +2232,7 @@ export const zPostDatasetsByDatasetIdMetadataResponse = zDatasetMetadataResponse export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ action: z.string(), - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2218,8 +2241,8 @@ export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ export const zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse = z.void() export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ - dataset_id: z.string(), - metadata_id: z.string(), + dataset_id: z.uuid(), + metadata_id: z.uuid(), }) /** @@ -2230,8 +2253,8 @@ export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.void() export const zPatchDatasetsByDatasetIdMetadataByMetadataIdBody = zMetadataUpdatePayload export const zPatchDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ - dataset_id: z.string(), - metadata_id: z.string(), + dataset_id: z.uuid(), + metadata_id: z.uuid(), }) /** @@ -2240,7 +2263,7 @@ export const zPatchDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ export const zPatchDatasetsByDatasetIdMetadataByMetadataIdResponse = zDatasetMetadataResponse export const zGetDatasetsByDatasetIdNotionSyncPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2249,7 +2272,7 @@ export const zGetDatasetsByDatasetIdNotionSyncPath = z.object({ export const zGetDatasetsByDatasetIdNotionSyncResponse = zSimpleResultResponse export const zGetDatasetsByDatasetIdPermissionPartUsersPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2258,7 +2281,7 @@ export const zGetDatasetsByDatasetIdPermissionPartUsersPath = z.object({ export const zGetDatasetsByDatasetIdPermissionPartUsersResponse = zPartialMemberListResponse export const zGetDatasetsByDatasetIdQueriesPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2267,7 +2290,7 @@ export const zGetDatasetsByDatasetIdQueriesPath = z.object({ export const zGetDatasetsByDatasetIdQueriesResponse = zDatasetQueryListResponse export const zGetDatasetsByDatasetIdRelatedAppsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2278,7 +2301,7 @@ export const zGetDatasetsByDatasetIdRelatedAppsResponse = zRelatedAppListRespons export const zPostDatasetsByDatasetIdRetryBody = zDocumentRetryPayload export const zPostDatasetsByDatasetIdRetryPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2287,7 +2310,7 @@ export const zPostDatasetsByDatasetIdRetryPath = z.object({ export const zPostDatasetsByDatasetIdRetryResponse = z.void() export const zGetDatasetsByDatasetIdUseCheckPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2296,7 +2319,7 @@ export const zGetDatasetsByDatasetIdUseCheckPath = z.object({ export const zGetDatasetsByDatasetIdUseCheckResponse = zUsageCheckResponse export const zGetDatasetsByResourceIdApiKeysPath = z.object({ - resource_id: z.string(), + resource_id: z.uuid(), }) /** @@ -2305,7 +2328,7 @@ export const zGetDatasetsByResourceIdApiKeysPath = z.object({ export const zGetDatasetsByResourceIdApiKeysResponse = zApiKeyList export const zPostDatasetsByResourceIdApiKeysPath = z.object({ - resource_id: z.string(), + resource_id: z.uuid(), }) /** @@ -2314,8 +2337,8 @@ export const zPostDatasetsByResourceIdApiKeysPath = z.object({ export const zPostDatasetsByResourceIdApiKeysResponse = zApiKeyItem export const zDeleteDatasetsByResourceIdApiKeysByApiKeyIdPath = z.object({ - api_key_id: z.string(), - resource_id: z.string(), + api_key_id: z.uuid(), + resource_id: z.uuid(), }) /** diff --git a/packages/contracts/generated/api/console/explore/zod.gen.ts b/packages/contracts/generated/api/console/explore/zod.gen.ts index 9346796a86b..fa29f0ac13e 100644 --- a/packages/contracts/generated/api/console/explore/zod.gen.ts +++ b/packages/contracts/generated/api/console/explore/zod.gen.ts @@ -86,7 +86,7 @@ export const zGetExploreAppsLearnDifyQuery = z.object({ export const zGetExploreAppsLearnDifyResponse = zLearnDifyAppListResponse export const zGetExploreAppsByAppIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index 389c79558e3..4454afcdc86 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -69,7 +69,7 @@ export const zGetFilesUploadResponse = zUploadConfig export const zPostFilesUploadResponse = zFileResponse export const zGetFilesByFileIdPreviewPath = z.object({ - file_id: z.string(), + file_id: z.uuid(), }) /** 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 6111cf7e104..75cc4efa2c4 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -68,7 +68,16 @@ export type ConversationInfiniteScrollPagination = { limit: number } -export type ConversationRenamePayload = { +export type ConversationRenamePayload = ( + | { + auto_generate: true + name?: string | null + } + | { + auto_generate?: false + name: string + } +) & { auto_generate?: boolean name?: string | null } @@ -147,7 +156,10 @@ export type AudioBinaryResponse = Blob | File export type WorkflowRunPayload = { files?: Array<{ - [key: string]: unknown + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string }> | null inputs: { [key: string]: unknown 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 055b53936bf..a9a06fe3f7e 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -70,13 +70,22 @@ export const zCompletionMessageExplorePayload = z.object({ retriever_from: z.string().optional().default('explore_app'), }) -/** - * ConversationRenamePayload - */ -export const zConversationRenamePayload = z.object({ - auto_generate: z.boolean().optional().default(false), - name: z.string().nullish(), -}) +export const zConversationRenamePayload = z.intersection( + z.union([ + z.object({ + auto_generate: z.literal(true), + name: z.string().nullish(), + }), + z.object({ + auto_generate: z.literal(false).optional().default(false), + name: z.string().regex(/.*\S.*/), + }), + ]), + z.object({ + auto_generate: z.boolean().optional().default(false), + name: z.string().nullish(), + }), +) /** * ResultResponse @@ -119,7 +128,7 @@ export const zSavedMessageCreatePayload = z.object({ * TextToAudioPayload */ export const zTextToAudioPayload = z.object({ - message_id: z.string().nullish(), + message_id: z.uuid().nullish(), streaming: z.boolean().nullish(), text: z.string().nullish(), voice: z.string().nullish(), @@ -134,7 +143,16 @@ export const zAudioBinaryResponse = z.custom() * WorkflowRunPayload */ export const zWorkflowRunPayload = z.object({ - files: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), inputs: z.record(z.string(), z.unknown()), }) @@ -530,7 +548,7 @@ export const zPostInstalledAppsBody = zInstalledAppCreatePayload export const zPostInstalledAppsResponse = zSimpleMessageResponse export const zDeleteInstalledAppsByInstalledAppIdPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -541,7 +559,7 @@ export const zDeleteInstalledAppsByInstalledAppIdResponse = z.void() export const zPatchInstalledAppsByInstalledAppIdBody = zInstalledAppUpdatePayload export const zPatchInstalledAppsByInstalledAppIdPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -550,7 +568,7 @@ export const zPatchInstalledAppsByInstalledAppIdPath = z.object({ export const zPatchInstalledAppsByInstalledAppIdResponse = zSimpleResultMessageResponse export const zPostInstalledAppsByInstalledAppIdAudioToTextPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -561,7 +579,7 @@ export const zPostInstalledAppsByInstalledAppIdAudioToTextResponse = zAudioTrans export const zPostInstalledAppsByInstalledAppIdChatMessagesBody = zChatMessagePayload export const zPostInstalledAppsByInstalledAppIdChatMessagesPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -570,7 +588,7 @@ export const zPostInstalledAppsByInstalledAppIdChatMessagesPath = z.object({ export const zPostInstalledAppsByInstalledAppIdChatMessagesResponse = zGeneratedAppResponse export const zPostInstalledAppsByInstalledAppIdChatMessagesByTaskIdStopPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), task_id: z.string(), }) @@ -584,7 +602,7 @@ export const zPostInstalledAppsByInstalledAppIdCompletionMessagesBody = zCompletionMessageExplorePayload export const zPostInstalledAppsByInstalledAppIdCompletionMessagesPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -593,7 +611,7 @@ export const zPostInstalledAppsByInstalledAppIdCompletionMessagesPath = z.object export const zPostInstalledAppsByInstalledAppIdCompletionMessagesResponse = zGeneratedAppResponse export const zPostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), task_id: z.string(), }) @@ -604,7 +622,7 @@ export const zPostInstalledAppsByInstalledAppIdCompletionMessagesByTaskIdStopRes = zSimpleResultResponse export const zGetInstalledAppsByInstalledAppIdConversationsPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) export const zGetInstalledAppsByInstalledAppIdConversationsQuery = z.object({ @@ -620,8 +638,8 @@ export const zGetInstalledAppsByInstalledAppIdConversationsResponse = zConversationInfiniteScrollPagination export const zDeleteInstalledAppsByInstalledAppIdConversationsByCIdPath = z.object({ - c_id: z.string(), - installed_app_id: z.string(), + c_id: z.uuid(), + installed_app_id: z.uuid(), }) /** @@ -633,8 +651,8 @@ export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameBody = zConversationRenamePayload export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNamePath = z.object({ - c_id: z.string(), - installed_app_id: z.string(), + c_id: z.uuid(), + installed_app_id: z.uuid(), }) /** @@ -643,8 +661,8 @@ export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNamePath = z.ob export const zPostInstalledAppsByInstalledAppIdConversationsByCIdNameResponse = zSimpleConversation export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinPath = z.object({ - c_id: z.string(), - installed_app_id: z.string(), + c_id: z.uuid(), + installed_app_id: z.uuid(), }) /** @@ -653,8 +671,8 @@ export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinPath = z.ob export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdPinResponse = zResultResponse export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinPath = z.object({ - c_id: z.string(), - installed_app_id: z.string(), + c_id: z.uuid(), + installed_app_id: z.uuid(), }) /** @@ -663,7 +681,7 @@ export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinPath = z. export const zPatchInstalledAppsByInstalledAppIdConversationsByCIdUnpinResponse = zResultResponse export const zGetInstalledAppsByInstalledAppIdMessagesPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) export const zGetInstalledAppsByInstalledAppIdMessagesQuery = z.object({ @@ -681,8 +699,8 @@ export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksPath = z.object({ - installed_app_id: z.string(), - message_id: z.string(), + installed_app_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -692,8 +710,8 @@ export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksRespo = zResultResponse export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisPath = z.object({ - installed_app_id: z.string(), - message_id: z.string(), + installed_app_id: z.uuid(), + message_id: z.uuid(), }) export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisQuery = z.object({ @@ -707,8 +725,8 @@ export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdMoreLikeThisRes = zGeneratedAppResponse export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuestionsPath = z.object({ - installed_app_id: z.string(), - message_id: z.string(), + installed_app_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -718,7 +736,7 @@ export const zGetInstalledAppsByInstalledAppIdMessagesByMessageIdSuggestedQuesti = zSuggestedQuestionsResponse export const zGetInstalledAppsByInstalledAppIdMetaPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -727,7 +745,7 @@ export const zGetInstalledAppsByInstalledAppIdMetaPath = z.object({ export const zGetInstalledAppsByInstalledAppIdMetaResponse = zExploreAppMetaResponse export const zGetInstalledAppsByInstalledAppIdParametersPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -736,7 +754,7 @@ export const zGetInstalledAppsByInstalledAppIdParametersPath = z.object({ export const zGetInstalledAppsByInstalledAppIdParametersResponse = zParameters export const zGetInstalledAppsByInstalledAppIdSavedMessagesPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) export const zGetInstalledAppsByInstalledAppIdSavedMessagesQuery = z.object({ @@ -753,7 +771,7 @@ export const zGetInstalledAppsByInstalledAppIdSavedMessagesResponse export const zPostInstalledAppsByInstalledAppIdSavedMessagesBody = zSavedMessageCreatePayload export const zPostInstalledAppsByInstalledAppIdSavedMessagesPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -762,8 +780,8 @@ export const zPostInstalledAppsByInstalledAppIdSavedMessagesPath = z.object({ export const zPostInstalledAppsByInstalledAppIdSavedMessagesResponse = zResultResponse export const zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdPath = z.object({ - installed_app_id: z.string(), - message_id: z.string(), + installed_app_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -774,7 +792,7 @@ export const zDeleteInstalledAppsByInstalledAppIdSavedMessagesByMessageIdRespons export const zPostInstalledAppsByInstalledAppIdTextToAudioBody = zTextToAudioPayload export const zPostInstalledAppsByInstalledAppIdTextToAudioPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -785,7 +803,7 @@ export const zPostInstalledAppsByInstalledAppIdTextToAudioResponse = zAudioBinar export const zPostInstalledAppsByInstalledAppIdWorkflowsRunBody = zWorkflowRunPayload export const zPostInstalledAppsByInstalledAppIdWorkflowsRunPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), }) /** @@ -794,7 +812,7 @@ export const zPostInstalledAppsByInstalledAppIdWorkflowsRunPath = z.object({ export const zPostInstalledAppsByInstalledAppIdWorkflowsRunResponse = zGeneratedAppResponse export const zPostInstalledAppsByInstalledAppIdWorkflowsTasksByTaskIdStopPath = z.object({ - installed_app_id: z.string(), + installed_app_id: z.uuid(), task_id: z.string(), }) diff --git a/packages/contracts/generated/api/console/notion/zod.gen.ts b/packages/contracts/generated/api/console/notion/zod.gen.ts index 633ae90be35..7f228414f19 100644 --- a/packages/contracts/generated/api/console/notion/zod.gen.ts +++ b/packages/contracts/generated/api/console/notion/zod.gen.ts @@ -48,7 +48,7 @@ export const zNotionIntegrateInfoListResponse = z.object({ }) export const zGetNotionPagesByPageIdByPageTypePreviewPath = z.object({ - page_id: z.string(), + page_id: z.uuid(), page_type: z.string(), }) diff --git a/packages/contracts/generated/api/console/oauth/zod.gen.ts b/packages/contracts/generated/api/console/oauth/zod.gen.ts index 569b35ec4d0..f38227c2b26 100644 --- a/packages/contracts/generated/api/console/oauth/zod.gen.ts +++ b/packages/contracts/generated/api/console/oauth/zod.gen.ts @@ -117,7 +117,7 @@ export const zGetOauthDataSourceByProviderPath = z.object({ export const zGetOauthDataSourceByProviderResponse = zOAuthDataSourceResponse export const zGetOauthDataSourceByProviderByBindingIdSyncPath = z.object({ - binding_id: z.string(), + binding_id: z.uuid(), provider: z.string(), }) diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 49572f971b7..b9862a8d1e8 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -53,8 +53,10 @@ export type DatasetDetailResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index 31120fab148..717db30baa7 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -656,8 +656,10 @@ export const zDatasetDetailResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -777,7 +779,7 @@ export const zGetRagPipelinesRecommendedPluginsQuery = z.object({ export const zGetRagPipelinesRecommendedPluginsResponse = zRagPipelineOpaqueResponse export const zPostRagPipelinesTransformDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -810,7 +812,7 @@ export const zGetRagPipelinesByPipelineIdExportsQuery = z.object({ export const zGetRagPipelinesByPipelineIdExportsResponse = zSimpleDataResponse export const zGetRagPipelinesByPipelineIdWorkflowRunsPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowRunsQuery = z.object({ @@ -824,7 +826,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsQuery = z.object({ export const zGetRagPipelinesByPipelineIdWorkflowRunsResponse = zWorkflowRunPaginationResponse export const zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), task_id: z.string(), }) @@ -835,8 +837,8 @@ export const zPostRagPipelinesByPipelineIdWorkflowRunsTasksByTaskIdStopResponse = zSimpleResultResponse export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdPath = z.object({ - pipeline_id: z.string(), - run_id: z.string(), + pipeline_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -845,8 +847,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdPath = z.object({ export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ - pipeline_id: z.string(), - run_id: z.string(), + pipeline_id: z.uuid(), + run_id: z.uuid(), }) /** @@ -856,7 +858,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowRunsByRunIdNodeExecutionsRespon = zWorkflowRunNodeExecutionListResponse export const zGetRagPipelinesByPipelineIdWorkflowsPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsQuery = z.object({ @@ -872,7 +874,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsQuery = z.object({ export const zGetRagPipelinesByPipelineIdWorkflowsResponse = zWorkflowPaginationResponse export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -884,7 +886,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsRes export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypePath = z.object({ block_type: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeQuery @@ -899,7 +901,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDefaultWorkflowBlockConfigsByB = zDefaultBlockConfigResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -910,7 +912,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftResponse = zWorkflowRespo export const zPostRagPipelinesByPipelineIdWorkflowsDraftBody = zDraftWorkflowSyncPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -923,7 +925,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdR export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceNodesByNodeIdRunPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -936,7 +938,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspe = zDatasourceVariablesPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspectPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -946,7 +948,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftDatasourceVariablesInspe = zWorkflowRunNodeExecutionResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftEnvironmentVariablesPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -960,7 +962,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRu export const zPostRagPipelinesByPipelineIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -973,7 +975,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunBody export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -984,7 +986,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftLoopNodesByNodeIdRunResp export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -998,7 +1000,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunBody export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1009,7 +1011,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdRunResponse export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1019,7 +1021,7 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariables export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1029,7 +1031,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftNodesByNodeIdVariablesRes = zWorkflowDraftVariableList export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersQuery = z.object({ @@ -1043,7 +1045,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftPreProcessingParametersRe = zRagPipelineStepParametersResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersQuery = z.object({ @@ -1059,7 +1061,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftProcessingParametersRespo export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunBody = zDraftWorkflowRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1068,7 +1070,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunPath = z.object({ export const zPostRagPipelinesByPipelineIdWorkflowsDraftRunResponse = zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1078,7 +1080,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftSystemVariablesResponse = zWorkflowDraftVariableList export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1087,7 +1089,7 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesPath = z.obje export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = z.void() export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesQuery = z.object({ @@ -1102,8 +1104,8 @@ export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesResponse = zWorkflowDraftVariableListWithoutValue export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - pipeline_id: z.string(), - variable_id: z.string(), + pipeline_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -1112,8 +1114,8 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdP export const zDeleteRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResponse = z.void() export const zGetRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - pipeline_id: z.string(), - variable_id: z.string(), + pipeline_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -1126,8 +1128,8 @@ export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdBo = zWorkflowDraftVariablePatchPayload export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - pipeline_id: z.string(), - variable_id: z.string(), + pipeline_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -1137,8 +1139,8 @@ export const zPatchRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdRe = zWorkflowDraftVariable export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ - pipeline_id: z.string(), - variable_id: z.string(), + pipeline_id: z.uuid(), + variable_id: z.uuid(), }) export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union( @@ -1146,7 +1148,7 @@ export const zPutRagPipelinesByPipelineIdWorkflowsDraftVariablesByVariableIdRese ) export const zGetRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1155,7 +1157,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ export const zGetRagPipelinesByPipelineIdWorkflowsPublishResponse = zWorkflowResponse export const zPostRagPipelinesByPipelineIdWorkflowsPublishPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1170,7 +1172,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdPreviewPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1185,7 +1187,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNodeIdRunPath = z.object({ node_id: z.string(), - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1195,7 +1197,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedDatasourceNodesByNod = zRagPipelineOpaqueResponse export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParametersQuery = z.object({ @@ -1209,7 +1211,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPublishedPreProcessingParamete = zRagPipelineStepParametersResponse export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersQuery = z.object({ @@ -1225,7 +1227,7 @@ export const zGetRagPipelinesByPipelineIdWorkflowsPublishedProcessingParametersR export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunBody = zPublishedWorkflowRunPayload export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), }) /** @@ -1234,7 +1236,7 @@ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunPath = z.object({ export const zPostRagPipelinesByPipelineIdWorkflowsPublishedRunResponse = zRagPipelineOpaqueResponse export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), workflow_id: z.string(), }) @@ -1246,7 +1248,7 @@ export const zDeleteRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = z.vo export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), workflow_id: z.string(), }) @@ -1256,7 +1258,7 @@ export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdPath = z.object( export const zPatchRagPipelinesByPipelineIdWorkflowsByWorkflowIdResponse = zWorkflowResponse export const zPostRagPipelinesByPipelineIdWorkflowsByWorkflowIdRestorePath = z.object({ - pipeline_id: z.string(), + pipeline_id: z.uuid(), workflow_id: z.string(), }) diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 8f1756ba499..1c861084434 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -387,7 +387,7 @@ export const zWorkflowDraftVariableListWithoutValue = z.object({ }) export const zGetSnippetsBySnippetIdWorkflowRunsPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) export const zGetSnippetsBySnippetIdWorkflowRunsQuery = z.object({ @@ -401,7 +401,7 @@ export const zGetSnippetsBySnippetIdWorkflowRunsQuery = z.object({ export const zGetSnippetsBySnippetIdWorkflowRunsResponse = zWorkflowRunPaginationResponse export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), task_id: z.string(), }) @@ -411,8 +411,8 @@ export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath = z.objec export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse = zSimpleResultResponse export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath = z.object({ - run_id: z.string(), - snippet_id: z.string(), + run_id: z.uuid(), + snippet_id: z.uuid(), }) /** @@ -421,8 +421,8 @@ export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath = z.object({ export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ - run_id: z.string(), - snippet_id: z.string(), + run_id: z.uuid(), + snippet_id: z.uuid(), }) /** @@ -432,7 +432,7 @@ export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse = zWorkflowRunNodeExecutionListResponse export const zGetSnippetsBySnippetIdWorkflowsPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) export const zGetSnippetsBySnippetIdWorkflowsQuery = z.object({ @@ -446,7 +446,7 @@ export const zGetSnippetsBySnippetIdWorkflowsQuery = z.object({ export const zGetSnippetsBySnippetIdWorkflowsResponse = zWorkflowPaginationResponse export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -456,7 +456,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse = zDefaultBlockConfigsResponse export const zGetSnippetsBySnippetIdWorkflowsDraftPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -467,7 +467,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftResponse = zSnippetWorkflowRes export const zPostSnippetsBySnippetIdWorkflowsDraftBody = zSnippetDraftSyncPayload export const zPostSnippetsBySnippetIdWorkflowsDraftPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -476,7 +476,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftPath = z.object({ export const zPostSnippetsBySnippetIdWorkflowsDraftResponse = zWorkflowRestoreResponse export const zGetSnippetsBySnippetIdWorkflowsDraftConfigPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -485,7 +485,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftConfigPath = z.object({ export const zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse = zSnippetDraftConfigResponse export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -495,7 +495,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse = zWorkflowDraftVariableList export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -509,7 +509,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ node_id: z.string(), - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -523,7 +523,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ node_id: z.string(), - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -534,7 +534,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ node_id: z.string(), - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -548,7 +548,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ node_id: z.string(), - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -559,7 +559,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -569,7 +569,7 @@ export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesRespo export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ node_id: z.string(), - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -581,7 +581,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse export const zPostSnippetsBySnippetIdWorkflowsDraftRunBody = zSnippetDraftRunPayload export const zPostSnippetsBySnippetIdWorkflowsDraftRunPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -590,7 +590,7 @@ export const zPostSnippetsBySnippetIdWorkflowsDraftRunPath = z.object({ export const zPostSnippetsBySnippetIdWorkflowsDraftRunResponse = zGeneratedAppResponse export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -600,7 +600,7 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse = zWorkflowDraftVariableList export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -609,7 +609,7 @@ export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse = z.void() export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery = z.object({ @@ -624,8 +624,8 @@ export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse = zWorkflowDraftVariableListWithoutValue export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - snippet_id: z.string(), - variable_id: z.string(), + snippet_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -634,8 +634,8 @@ export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = z.void() export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - snippet_id: z.string(), - variable_id: z.string(), + snippet_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -648,8 +648,8 @@ export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody = zWorkflowDraftVariableUpdatePayload export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ - snippet_id: z.string(), - variable_id: z.string(), + snippet_id: z.uuid(), + variable_id: z.uuid(), }) /** @@ -659,8 +659,8 @@ export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdRespons = zWorkflowDraftVariable export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ - snippet_id: z.string(), - variable_id: z.string(), + snippet_id: z.uuid(), + variable_id: z.uuid(), }) export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([ @@ -669,7 +669,7 @@ export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResp ]) export const zGetSnippetsBySnippetIdWorkflowsPublishPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -680,7 +680,7 @@ export const zGetSnippetsBySnippetIdWorkflowsPublishResponse = zSnippetWorkflowR export const zPostSnippetsBySnippetIdWorkflowsPublishBody = zPublishWorkflowPayload export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -689,7 +689,7 @@ export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({ export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = zWorkflowPublishResponse export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), workflow_id: z.string(), }) 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 4f2ef2fa94e..f1dcc7fc4b4 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -6,6 +6,7 @@ export type ClientOptions = { export type SystemFeatureModel = { branding: BrandingModel + enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean enable_creators_platform: boolean @@ -22,6 +23,7 @@ export type SystemFeatureModel = { max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel plugin_manager: PluginManagerModel + rbac_enabled: boolean sso_enforced_for_signin: boolean sso_enforced_for_signin_protocol: string webapp_auth: WebAppAuthModel 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 7464057a391..80b27e7843a 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -98,6 +98,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), @@ -125,6 +126,7 @@ export const zSystemFeatureModel = z.object({ restrict_to_marketplace_only: false, }), plugin_manager: zPluginManagerModel.default({ enabled: false }), + rbac_enabled: z.boolean().default(false), sso_enforced_for_signin: z.boolean().default(false), sso_enforced_for_signin_protocol: z.string().default(''), webapp_auth: zWebAppAuthModel.default({ diff --git a/packages/contracts/generated/api/console/tags/zod.gen.ts b/packages/contracts/generated/api/console/tags/zod.gen.ts index 0e7d7bd1c60..20f28eaa059 100644 --- a/packages/contracts/generated/api/console/tags/zod.gen.ts +++ b/packages/contracts/generated/api/console/tags/zod.gen.ts @@ -52,7 +52,7 @@ export const zPostTagsBody = zTagBasePayload export const zPostTagsResponse = zTagResponse export const zDeleteTagsByTagIdPath = z.object({ - tag_id: z.string(), + tag_id: z.uuid(), }) /** @@ -63,7 +63,7 @@ export const zDeleteTagsByTagIdResponse = z.void() export const zPatchTagsByTagIdBody = zTagUpdateRequestPayload export const zPatchTagsByTagIdPath = z.object({ - tag_id: z.string(), + tag_id: z.uuid(), }) /** diff --git a/packages/contracts/generated/api/console/trial-apps/types.gen.ts b/packages/contracts/generated/api/console/trial-apps/types.gen.ts index 5021da0afdf..894da1102ee 100644 --- a/packages/contracts/generated/api/console/trial-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/types.gen.ts @@ -22,6 +22,7 @@ export type TrialAppDetailWithSite = { mode?: string model_config?: TrialAppModelConfig name?: string + permission_keys?: Array site?: TrialSite tags?: Array updated_at?: number @@ -269,6 +270,7 @@ export type TrialDataset = { indexing_technique?: string name?: string permission?: string + permission_keys?: Array } export type JsonObject = { diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 8f284cda862..b8768790ef9 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -237,6 +237,7 @@ export const zTrialAppDetailWithSite = z.object({ mode: z.string().optional(), model_config: zTrialAppModelConfig.optional(), name: z.string().optional(), + permission_keys: z.array(z.string()).optional(), site: zTrialSite.optional(), tags: z.array(zTrialTag).optional(), updated_at: z.coerce @@ -286,6 +287,7 @@ export const zTrialDataset = z.object({ indexing_technique: z.string().optional(), name: z.string().optional(), permission: z.string().optional(), + permission_keys: z.array(z.string()).optional(), }) export const zTrialDatasetList = z.object({ @@ -435,7 +437,7 @@ export const zSiteWritable = z.object({ }) export const zGetTrialAppsByAppIdPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -444,7 +446,7 @@ export const zGetTrialAppsByAppIdPath = z.object({ export const zGetTrialAppsByAppIdResponse = zTrialAppDetailWithSite export const zPostTrialAppsByAppIdAudioToTextPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -455,7 +457,7 @@ export const zPostTrialAppsByAppIdAudioToTextResponse = zAudioTranscriptResponse export const zPostTrialAppsByAppIdChatMessagesBody = zChatRequest export const zPostTrialAppsByAppIdChatMessagesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -466,7 +468,7 @@ export const zPostTrialAppsByAppIdChatMessagesResponse = zGeneratedAppResponse export const zPostTrialAppsByAppIdCompletionMessagesBody = zCompletionRequest export const zPostTrialAppsByAppIdCompletionMessagesPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -475,7 +477,7 @@ export const zPostTrialAppsByAppIdCompletionMessagesPath = z.object({ export const zPostTrialAppsByAppIdCompletionMessagesResponse = zGeneratedAppResponse export const zGetTrialAppsByAppIdDatasetsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ @@ -490,8 +492,8 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetList export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath = z.object({ - app_id: z.string(), - message_id: z.string(), + app_id: z.uuid(), + message_id: z.uuid(), }) /** @@ -501,7 +503,7 @@ export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse = zSuggestedQuestionsResponse export const zGetTrialAppsByAppIdParametersPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -510,7 +512,7 @@ export const zGetTrialAppsByAppIdParametersPath = z.object({ export const zGetTrialAppsByAppIdParametersResponse = zParameters export const zGetTrialAppsByAppIdSitePath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -521,7 +523,7 @@ export const zGetTrialAppsByAppIdSiteResponse = zSite export const zPostTrialAppsByAppIdTextToAudioBody = zTextToSpeechRequest export const zPostTrialAppsByAppIdTextToAudioPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -530,7 +532,7 @@ export const zPostTrialAppsByAppIdTextToAudioPath = z.object({ export const zPostTrialAppsByAppIdTextToAudioResponse = zAudioBinaryResponse export const zGetTrialAppsByAppIdWorkflowsPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -541,7 +543,7 @@ export const zGetTrialAppsByAppIdWorkflowsResponse = zTrialWorkflow export const zPostTrialAppsByAppIdWorkflowsRunBody = zWorkflowRunRequest export const zPostTrialAppsByAppIdWorkflowsRunPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), }) /** @@ -550,7 +552,7 @@ export const zPostTrialAppsByAppIdWorkflowsRunPath = z.object({ export const zPostTrialAppsByAppIdWorkflowsRunResponse = zGeneratedAppResponse export const zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopPath = z.object({ - app_id: z.string(), + app_id: z.uuid(), task_id: z.string(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 630e8f6b354..7e676564999 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -19,6 +19,14 @@ import { zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, zDeleteWorkspacesCurrentModelProvidersByProviderModelsPath, zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse, + zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, + zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, + zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zDeleteWorkspacesCurrentRbacRolesByRoleIdPath, + zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse, zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath, zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse, zDeleteWorkspacesCurrentToolProviderMcpBody, @@ -90,6 +98,50 @@ import { zGetWorkspacesCurrentPluginTasksByTaskIdResponse, zGetWorkspacesCurrentPluginTasksQuery, zGetWorkspacesCurrentPluginTasksResponse, + zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, + zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, + zGetWorkspacesCurrentRbacAccessPoliciesResponse, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse, + zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesPath, + zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse, + zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath, + zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse, + zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, + zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse, + zGetWorkspacesCurrentRbacMyPermissionsResponse, + zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse, + zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse, + zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse, + zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath, + zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse, + zGetWorkspacesCurrentRbacRolesByRoleIdPath, + zGetWorkspacesCurrentRbacRolesByRoleIdResponse, + zGetWorkspacesCurrentRbacRolesResponse, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse, zGetWorkspacesCurrentToolLabelsResponse, zGetWorkspacesCurrentToolProviderApiGetQuery, zGetWorkspacesCurrentToolProviderApiGetResponse, @@ -251,6 +303,12 @@ import { zPostWorkspacesCurrentPluginUploadGithubBody, zPostWorkspacesCurrentPluginUploadGithubResponse, zPostWorkspacesCurrentPluginUploadPkgResponse, + zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath, + zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse, + zPostWorkspacesCurrentRbacAccessPoliciesResponse, + zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath, + zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse, + zPostWorkspacesCurrentRbacRolesResponse, zPostWorkspacesCurrentResponse, zPostWorkspacesCurrentToolProviderApiAddBody, zPostWorkspacesCurrentToolProviderApiAddResponse, @@ -326,6 +384,28 @@ import { zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, + zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, + zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse, + zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath, + zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse, + zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath, + zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse, + zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, + zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse, + zPutWorkspacesCurrentRbacRolesByRoleIdPath, + zPutWorkspacesCurrentRbacRolesByRoleIdResponse, + zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath, + zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse, + zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath, + zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse, zPutWorkspacesCurrentToolProviderMcpBody, zPutWorkspacesCurrentToolProviderMcpResponse, } from './zod.gen' @@ -2017,7 +2097,840 @@ export const plugin2 = { byCategory, } +export const post44 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopy', + path: '/workspaces/current/rbac/access-policies/{policy_id}/copy', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath })) + .output(zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse) + +export const copy = { + post: post44, +} + +export const delete9 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentRbacAccessPoliciesByPolicyId', + path: '/workspaces/current/rbac/access-policies/{policy_id}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) + .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) + export const get33 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAccessPoliciesByPolicyId', + path: '/workspaces/current/rbac/access-policies/{policy_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) + .output(zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) + +export const put4 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAccessPoliciesByPolicyId', + path: '/workspaces/current/rbac/access-policies/{policy_id}', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) + .output(zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) + +export const byPolicyId = { + delete: delete9, + get: get33, + put: put4, + copy, +} + +export const get34 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAccessPolicies', + path: '/workspaces/current/rbac/access-policies', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacAccessPoliciesResponse) + +export const post45 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacAccessPolicies', + path: '/workspaces/current/rbac/access-policies', + successStatus: 201, + tags: ['console'], + }) + .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) + +export const accessPolicies = { + get: get34, + post: post45, + byPolicyId, +} + +export const put5 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLock', + path: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/lock', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath })) + .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse) + +export const lock = { + put: put5, +} + +export const put6 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlock', + path: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/unlock', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath })) + .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse) + +export const unlock = { + put: put6, +} + +export const byBindingId = { + lock, + unlock, +} + +export const accessPolicyBindings = { + byBindingId, +} + +export const delete10 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const get35 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const memberBindings = { + delete: delete10, + get: get35, +} + +export const get36 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings = { + get: get36, +} + +export const byPolicyId2 = { + memberBindings, + roleBindings, +} + +export const accessPolicies2 = { + byPolicyId: byPolicyId2, +} + +export const get37 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdAccessPolicy', + path: '/workspaces/current/rbac/apps/{app_id}/access-policy', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath })) + .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) + +export const accessPolicy = { + get: get37, +} + +export const get38 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdUserAccessPolicies', + path: '/workspaces/current/rbac/apps/{app_id}/user-access-policies', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesPath })) + .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) + +export const userAccessPolicies = { + get: get38, +} + +export const put7 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPolicies', + path: '/workspaces/current/rbac/apps/{app_id}/users/{target_account_id}/access-policies', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath, + }), + ) + .output(zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse) + +export const accessPolicies3 = { + put: put7, +} + +export const byTargetAccountId = { + accessPolicies: accessPolicies3, +} + +export const users = { + byTargetAccountId, +} + +export const get39 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdWhitelist', + path: '/workspaces/current/rbac/apps/{app_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) + .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) + +export const put8 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAppsByAppIdWhitelist', + path: '/workspaces/current/rbac/apps/{app_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) + .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) + +export const whitelist = { + get: get39, + put: put8, +} + +export const byAppId = { + accessPolicies: accessPolicies2, + accessPolicy, + userAccessPolicies, + users, + whitelist, +} + +export const apps = { + byAppId, +} + +export const delete11 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: + 'deleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output( + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + ) + +export const get40 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: + 'getWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output( + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + ) + +export const memberBindings2 = { + delete: delete11, + get: get40, +} + +export const get41 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings2 = { + get: get41, +} + +export const byPolicyId3 = { + memberBindings: memberBindings2, + roleBindings: roleBindings2, +} + +export const accessPolicies4 = { + byPolicyId: byPolicyId3, +} + +export const get42 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicy', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policy', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath })) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) + +export const accessPolicy2 = { + get: get42, +} + +export const get43 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPolicies', + path: '/workspaces/current/rbac/datasets/{dataset_id}/user-access-policies', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesPath })) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) + +export const userAccessPolicies2 = { + get: get43, +} + +export const put9 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPolicies', + path: '/workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath, + }), + ) + .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse) + +export const accessPolicies5 = { + put: put9, +} + +export const byTargetAccountId2 = { + accessPolicies: accessPolicies5, +} + +export const users2 = { + byTargetAccountId: byTargetAccountId2, +} + +export const get44 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdWhitelist', + path: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) + +export const put10 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacDatasetsByDatasetIdWhitelist', + path: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) + .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) + +export const whitelist2 = { + get: get44, + put: put10, +} + +export const byDatasetId = { + accessPolicies: accessPolicies4, + accessPolicy: accessPolicy2, + userAccessPolicies: userAccessPolicies2, + users: users2, + whitelist: whitelist2, +} + +export const datasets = { + byDatasetId, +} + +export const get45 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacMembersByMemberIdRbacRoles', + path: '/workspaces/current/rbac/members/{member_id}/rbac-roles', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath })) + .output(zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) + +export const put11 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacMembersByMemberIdRbacRoles', + path: '/workspaces/current/rbac/members/{member_id}/rbac-roles', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath })) + .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) + +export const rbacRoles = { + get: get45, + put: put11, +} + +export const byMemberId2 = { + rbacRoles, +} + +export const members2 = { + byMemberId: byMemberId2, +} + +export const get46 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacMyPermissions', + path: '/workspaces/current/rbac/my-permissions', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) + +export const myPermissions = { + get: get46, +} + +export const get47 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolePermissionsCatalogApp', + path: '/workspaces/current/rbac/role-permissions/catalog/app', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) + +export const app = { + get: get47, +} + +export const get48 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolePermissionsCatalogDataset', + path: '/workspaces/current/rbac/role-permissions/catalog/dataset', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) + +export const dataset = { + get: get48, +} + +export const get49 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolePermissionsCatalog', + path: '/workspaces/current/rbac/role-permissions/catalog', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) + +export const catalog = { + get: get49, + app, + dataset, +} + +export const rolePermissions = { + catalog, +} + +export const post46 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacRolesByRoleIdCopy', + path: '/workspaces/current/rbac/roles/{role_id}/copy', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath })) + .output(zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse) + +export const copy2 = { + post: post46, +} + +export const get50 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolesByRoleIdMembers', + path: '/workspaces/current/rbac/roles/{role_id}/members', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath })) + .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) + +export const members3 = { + get: get50, +} + +export const delete12 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentRbacRolesByRoleId', + path: '/workspaces/current/rbac/roles/{role_id}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) + .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) + +export const get51 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolesByRoleId', + path: '/workspaces/current/rbac/roles/{role_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdPath })) + .output(zGetWorkspacesCurrentRbacRolesByRoleIdResponse) + +export const put12 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacRolesByRoleId', + path: '/workspaces/current/rbac/roles/{role_id}', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacRolesByRoleIdPath })) + .output(zPutWorkspacesCurrentRbacRolesByRoleIdResponse) + +export const byRoleId = { + delete: delete12, + get: get51, + put: put12, + copy: copy2, + members: members3, +} + +export const get52 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRoles', + path: '/workspaces/current/rbac/roles', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolesResponse) + +export const post47 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacRoles', + path: '/workspaces/current/rbac/roles', + successStatus: 201, + tags: ['console'], + }) + .output(zPostWorkspacesCurrentRbacRolesResponse) + +export const roles = { + get: get52, + post: post47, + byRoleId, +} + +export const put13 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindings', + path: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath, + }), + ) + .output(zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse) + +export const bindings = { + put: put13, +} + +export const get53 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const memberBindings3 = { + get: get53, +} + +export const get54 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings3 = { + get: get54, +} + +export const byPolicyId4 = { + bindings, + memberBindings: memberBindings3, + roleBindings: roleBindings3, +} + +export const accessPolicies6 = { + byPolicyId: byPolicyId4, +} + +export const get55 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceAppsAccessPolicy', + path: '/workspaces/current/rbac/workspace/apps/access-policy', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) + +export const accessPolicy3 = { + get: get55, +} + +export const apps2 = { + accessPolicies: accessPolicies6, + accessPolicy: accessPolicy3, +} + +export const put14 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindings', + path: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath, + }), + ) + .output(zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse) + +export const bindings2 = { + put: put14, +} + +export const get56 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const memberBindings4 = { + get: get56, +} + +export const get57 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings4 = { + get: get57, +} + +export const byPolicyId5 = { + bindings: bindings2, + memberBindings: memberBindings4, + roleBindings: roleBindings4, +} + +export const accessPolicies7 = { + byPolicyId: byPolicyId5, +} + +export const get58 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicy', + path: '/workspaces/current/rbac/workspace/datasets/access-policy', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) + +export const accessPolicy4 = { + get: get58, +} + +export const datasets2 = { + accessPolicies: accessPolicies7, + accessPolicy: accessPolicy4, +} + +export const workspace = { + apps: apps2, + datasets: datasets2, +} + +export const rbac = { + accessPolicies, + accessPolicyBindings, + apps, + datasets, + members: members2, + myPermissions, + rolePermissions, + roles, + workspace, +} + +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2028,10 +2941,10 @@ export const get33 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get33, + get: get59, } -export const post44 = oc +export const post48 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2043,10 +2956,10 @@ export const post44 = oc .output(zPostWorkspacesCurrentToolProviderApiAddResponse) export const add = { - post: post44, + post: post48, } -export const post45 = oc +export const post49 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2057,11 +2970,11 @@ export const post45 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) -export const delete9 = { - post: post45, +export const delete13 = { + post: post49, } -export const get34 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2072,11 +2985,11 @@ export const get34 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get35 = { - get: get34, +export const get61 = { + get: get60, } -export const get36 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2088,10 +3001,10 @@ export const get36 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get36, + get: get62, } -export const post46 = oc +export const post50 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2103,10 +3016,10 @@ export const post46 = oc .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) export const schema = { - post: post46, + post: post50, } -export const post47 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2118,14 +3031,14 @@ export const post47 = oc .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) export const pre = { - post: post47, + post: post51, } export const test = { pre, } -export const get37 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2137,10 +3050,10 @@ export const get37 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get37, + get: get63, } -export const post48 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2152,13 +3065,13 @@ export const post48 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post48, + post: post52, } export const api = { add, - delete: delete9, - get: get35, + delete: delete13, + get: get61, remote, schema, test, @@ -2166,7 +3079,7 @@ export const api = { update: update2, } -export const post49 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2183,10 +3096,10 @@ export const post49 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post49, + post: post53, } -export const get38 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2203,10 +3116,10 @@ export const get38 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get38, + get: get64, } -export const get39 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2226,7 +3139,7 @@ export const get39 = oc ) export const byCredentialType = { - get: get39, + get: get65, } export const schema2 = { @@ -2238,7 +3151,7 @@ export const credential = { schema: schema2, } -export const get40 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2255,10 +3168,10 @@ export const get40 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get40, + get: get66, } -export const post50 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2275,10 +3188,10 @@ export const post50 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post50, + post: post54, } -export const post51 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2294,11 +3207,11 @@ export const post51 = oc ) .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) -export const delete10 = { - post: post51, +export const delete14 = { + post: post55, } -export const get41 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2310,10 +3223,10 @@ export const get41 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get41, + get: get67, } -export const get42 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2325,10 +3238,10 @@ export const get42 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get42, + get: get68, } -export const get43 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2342,10 +3255,10 @@ export const get43 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get43, + get: get69, } -export const delete11 = oc +export const delete15 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2360,7 +3273,7 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get44 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2373,7 +3286,7 @@ export const get44 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const post52 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2390,9 +3303,9 @@ export const post52 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) export const customClient = { - delete: delete11, - get: get44, - post: post52, + delete: delete15, + get: get70, + post: post56, } export const oauth = { @@ -2400,7 +3313,7 @@ export const oauth = { customClient, } -export const get45 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2412,10 +3325,10 @@ export const get45 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get45, + get: get71, } -export const post53 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2432,7 +3345,7 @@ export const post53 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post53, + post: post57, } export const byProvider2 = { @@ -2440,7 +3353,7 @@ export const byProvider2 = { credential, credentials: credentials3, defaultCredential, - delete: delete10, + delete: delete14, icon: icon2, info: info2, oauth, @@ -2452,7 +3365,7 @@ export const builtin = { byProvider: byProvider2, } -export const post54 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2464,10 +3377,10 @@ export const post54 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post54, + post: post58, } -export const get46 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2479,14 +3392,14 @@ export const get46 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get46, + get: get72, } export const tools3 = { byProviderId, } -export const get47 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2498,14 +3411,14 @@ export const get47 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get47, + get: get73, } export const update4 = { byProviderId: byProviderId2, } -export const delete12 = oc +export const delete16 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2516,7 +3429,7 @@ export const delete12 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -export const post55 = oc +export const post59 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2527,7 +3440,7 @@ export const post55 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) -export const put4 = oc +export const put15 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2539,15 +3452,15 @@ export const put4 = oc .output(zPutWorkspacesCurrentToolProviderMcpResponse) export const mcp = { - delete: delete12, - post: post55, - put: put4, + delete: delete16, + post: post59, + put: put15, auth, tools: tools3, update: update4, } -export const post56 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2559,10 +3472,10 @@ export const post56 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse) export const create2 = { - post: post56, + post: post60, } -export const post57 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2573,11 +3486,11 @@ export const post57 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowDeleteBody })) .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) -export const delete13 = { - post: post57, +export const delete17 = { + post: post61, } -export const get48 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2588,11 +3501,11 @@ export const get48 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get49 = { - get: get48, +export const get75 = { + get: get74, } -export const get50 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2604,10 +3517,10 @@ export const get50 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get50, + get: get76, } -export const post58 = oc +export const post62 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2619,13 +3532,13 @@ export const post58 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse) export const update5 = { - post: post58, + post: post62, } export const workflow = { create: create2, - delete: delete13, - get: get49, + delete: delete17, + get: get75, tools: tools4, update: update5, } @@ -2637,7 +3550,7 @@ export const toolProvider = { workflow, } -export const get51 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2649,10 +3562,10 @@ export const get51 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get51, + get: get77, } -export const get52 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2663,10 +3576,10 @@ export const get52 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get52, + get: get78, } -export const get53 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2677,10 +3590,10 @@ export const get53 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get53, + get: get79, } -export const get54 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2691,10 +3604,10 @@ export const get54 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get54, + get: get80, } -export const get55 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2705,7 +3618,7 @@ export const get55 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get55, + get: get81, } export const tools5 = { @@ -2715,7 +3628,7 @@ export const tools5 = { workflow: workflow2, } -export const get56 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2727,13 +3640,13 @@ export const get56 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get56, + get: get82, } /** * Get info for a trigger provider */ -export const get57 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2746,13 +3659,13 @@ export const get57 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get57, + get: get83, } /** * Remove custom OAuth client configuration */ -export const delete14 = oc +export const delete18 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2767,7 +3680,7 @@ export const delete14 = oc /** * Get OAuth client configuration for a provider */ -export const get58 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2782,7 +3695,7 @@ export const get58 = oc /** * Configure custom OAuth client for a provider */ -export const post59 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2800,9 +3713,9 @@ export const post59 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse) export const client = { - delete: delete14, - get: get58, - post: post59, + delete: delete18, + get: get84, + post: post63, } export const oauth2 = { @@ -2812,7 +3725,7 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider */ -export const post60 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2834,7 +3747,7 @@ export const post60 = oc ) export const bySubscriptionBuilderId = { - post: post60, + post: post64, } export const build = { @@ -2844,7 +3757,7 @@ export const build = { /** * Add a new subscription instance for a trigger provider */ -export const post61 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2862,13 +3775,13 @@ export const post61 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post61, + post: post65, } /** * Get the request logs for a subscription instance for a trigger provider */ -export const get59 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2889,7 +3802,7 @@ export const get59 = oc ) export const bySubscriptionBuilderId2 = { - get: get59, + get: get85, } export const logs = { @@ -2899,7 +3812,7 @@ export const logs = { /** * Update a subscription instance for a trigger provider */ -export const post62 = oc +export const post66 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2921,7 +3834,7 @@ export const post62 = oc ) export const bySubscriptionBuilderId3 = { - post: post62, + post: post66, } export const update6 = { @@ -2931,7 +3844,7 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider */ -export const post63 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2953,7 +3866,7 @@ export const post63 = oc ) export const bySubscriptionBuilderId4 = { - post: post63, + post: post67, } export const verifyAndUpdate = { @@ -2963,7 +3876,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get60 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2984,7 +3897,7 @@ export const get60 = oc ) export const bySubscriptionBuilderId5 = { - get: get60, + get: get86, } export const builder = { @@ -2999,7 +3912,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get61 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3012,13 +3925,13 @@ export const get61 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get61, + get: get87, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get62 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3035,7 +3948,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get62, + get: get88, } export const oauth3 = { @@ -3045,7 +3958,7 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) */ -export const post64 = oc +export const post68 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3067,7 +3980,7 @@ export const post64 = oc ) export const bySubscriptionId = { - post: post64, + post: post68, } export const verify = { @@ -3091,7 +4004,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post65 = oc +export const post69 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3107,14 +4020,14 @@ export const post65 = oc ) .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) -export const delete15 = { - post: post65, +export const delete19 = { + post: post69, } /** * Update a subscription instance */ -export const post66 = oc +export const post70 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3132,11 +4045,11 @@ export const post66 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post66, + post: post70, } export const subscriptions2 = { - delete: delete15, + delete: delete19, update: update7, } @@ -3152,7 +4065,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get63 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3164,10 +4077,10 @@ export const get63 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get63, + get: get89, } -export const post67 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3178,7 +4091,7 @@ export const post67 = oc .output(zPostWorkspacesCurrentResponse) export const current = { - post: post67, + post: post71, agentProvider, agentProviders, customizedSnippets, @@ -3190,6 +4103,7 @@ export const current = { models: models2, permission, plugin: plugin2, + rbac, toolLabels, toolProvider, toolProviders, @@ -3198,7 +4112,7 @@ export const current = { triggers, } -export const post68 = oc +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3210,14 +4124,14 @@ export const post68 = oc .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) export const upload2 = { - post: post68, + post: post72, } export const webappLogo = { upload: upload2, } -export const post69 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3229,11 +4143,11 @@ export const post69 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - post: post69, + post: post73, webappLogo, } -export const post70 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3245,10 +4159,10 @@ export const post70 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post70, + post: post74, } -export const post71 = oc +export const post75 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3260,10 +4174,10 @@ export const post71 = oc .output(zPostWorkspacesSwitchResponse) export const switch3 = { - post: post71, + post: post75, } -export const get64 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3275,7 +4189,7 @@ export const get64 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get64, + get: get90, } export const byIconType = { @@ -3294,7 +4208,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get65 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3305,7 +4219,7 @@ export const get65 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get65, + get: get91, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 8b6f34b18ff..29f23567e95 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -32,7 +32,7 @@ export type AgentProviderListResponse = Array<{ }> export type SnippetPagination = { - data?: Array + data?: Array has_more?: boolean limit?: number page?: number @@ -182,7 +182,7 @@ export type EndpointUpdatePayload = { export type MemberInvitePayload = { emails?: Array language?: string | null - role: TenantAccountRole + role: string } export type MemberInviteResponse = { @@ -506,6 +506,109 @@ export type PluginCategoryListResponse = { plugins: Array } +export type AccessPolicyList = { + data?: Array + pagination?: Pagination | null +} + +export type AccessPolicy = { + category?: string + created_at?: number + description?: string + id: string + is_builtin?: boolean + name: string + permission_keys?: Array + policy_key?: string + resource_type: string + tenant_id?: string + updated_at?: number +} + +export type AccessPolicyBindingState = { + binding_id: string + is_locked?: boolean +} + +export type MemberBindingsResponse = { + data?: Array +} + +export type RoleBindingsResponse = { + data?: Array +} + +export type AppAccessMatrix = { + app_id?: string + items?: Array +} + +export type ResourceUserAccessPoliciesResponse = { + data?: Array + scope: string +} + +export type ReplaceUserAccessPoliciesResponse = { + access_policies?: Array +} + +export type ResourceWhitelist = { + account_ids?: Array +} + +export type DatasetAccessMatrix = { + dataset_id?: string + items?: Array +} + +export type MemberRolesResponse = { + account_id: string + roles?: Array +} + +export type MyPermissionsResponse = { + app?: ResourcePermissionSnapshot + dataset?: ResourcePermissionSnapshot + workspace?: WorkspacePermissionSnapshot +} + +export type PermissionCatalogResponse = { + groups?: Array +} + +export type RbacRoleList = { + data?: Array + pagination?: Pagination | null +} + +export type RbacRole = { + category?: string + description?: string + id: string + is_builtin?: boolean + name: string + permission_keys?: Array + role_tag?: string + tenant_id?: string | null + type: string +} + +export type MembersInRoleList = { + data?: Array + pagination?: Pagination | null +} + +export type AccessMatrixItem = { + accounts?: Array + policy?: AccessPolicy | null + roles?: Array +} + +export type WorkspaceAccessMatrix = { + items?: Array + pagination?: Pagination | null +} + export type ToolProviderOpaqueResponse = unknown export type ApiToolProviderAddPayload = { @@ -769,7 +872,7 @@ export type WorkspaceCustomConfigResponse = { replace_webapp_logo?: string | null } -export type AnonymousInlineModelEfd591151Ea9 = { +export type AnonymousInlineModel744Ff9Cc03E6 = { author_name?: string created_at?: number created_by?: string @@ -827,6 +930,9 @@ export type AccountWithRole = { last_login_at?: number | null name: string role: string + roles?: Array<{ + [key: string]: string + }> status: string } @@ -842,8 +948,6 @@ export type Inner = { provider?: string | null } -export type TenantAccountRole = 'admin' | 'dataset_operator' | 'editor' | 'normal' | 'owner' - export type MemberInviteResultResponse = { email: string message?: string | null @@ -1009,6 +1113,79 @@ export type PluginCategoryInstalledPluginResponse = { version: string } +export type Pagination = { + current_page?: number + per_page?: number + total_count?: number + total_pages?: number +} + +export type AccessPolicyMemberBinding = { + access_policy_id: string + account_id: string + account_name?: string + created_at?: number + id: string + resource_id?: string + resource_type: string + tenant_id?: string +} + +export type AccessPolicyRoleBinding = { + access_policy_id: string + created_at?: number + id: string + resource_id?: string + resource_type: string + role_id: string + role_name?: string + tenant_id?: string +} + +export type ResourceUserAccessPolicies = { + access_policies?: Array + account: RbacRoleAccount + roles?: Array +} + +export type ResourcePermissionSnapshot = { + default_permission_keys?: Array + overrides?: Array +} + +export type WorkspacePermissionSnapshot = { + permission_keys?: Array +} + +export type PermissionCatalogGroup = { + description?: string + group_key: string + group_name: string + permissions?: Array +} + +export type MembersInRole = { + account_id?: string + account_name?: string +} + +export type AccessPolicyAccount = { + account_id: string + account_name: string + avatar?: string + binding_id: string + email?: string + is_locked?: boolean +} + +export type AccessPolicyRole = { + binding_id: string + is_locked?: boolean + role_id: string + role_name: string + role_tag?: string +} + export type ApiProviderSchemaType = 'openai_actions' | 'openai_plugin' | 'openapi' | 'swagger' export type CredentialType = 'api-key' | 'oauth2' | 'unauthorized' @@ -1208,6 +1385,24 @@ export type PluginDeclarationResponse = { export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote' +export type RbacRoleAccount = { + account_id: string + account_name?: string + avatar?: string + email?: string +} + +export type ResourcePermissionKeys = { + permission_keys?: Array + resource_id: string +} + +export type PermissionCatalogItem = { + description?: string + key: string + name: string +} + export type ToolParameterForm = 'form' | 'llm' | 'schema' export type AiModelEntityResponse = { @@ -1238,7 +1433,7 @@ export type CustomModelConfiguration = { current_credential_name?: string | null model: string model_type: ModelType - unadded_to_model_list?: boolean | null + unadded_to_model_list?: boolean } export type CredentialFormSchema = { @@ -2791,6 +2986,723 @@ export type GetWorkspacesCurrentPluginByCategoryListResponses = { export type GetWorkspacesCurrentPluginByCategoryListResponse = GetWorkspacesCurrentPluginByCategoryListResponses[keyof GetWorkspacesCurrentPluginByCategoryListResponses] +export type GetWorkspacesCurrentRbacAccessPoliciesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/access-policies' +} + +export type GetWorkspacesCurrentRbacAccessPoliciesResponses = { + 200: AccessPolicyList +} + +export type GetWorkspacesCurrentRbacAccessPoliciesResponse + = GetWorkspacesCurrentRbacAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesResponses] + +export type PostWorkspacesCurrentRbacAccessPoliciesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/access-policies' +} + +export type PostWorkspacesCurrentRbacAccessPoliciesResponses = { + 201: AccessPolicy +} + +export type PostWorkspacesCurrentRbacAccessPoliciesResponse + = PostWorkspacesCurrentRbacAccessPoliciesResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesResponses] + +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}' +} + +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { + 200: AccessPolicy +} + +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse + = DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] + +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}' +} + +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { + 200: AccessPolicy +} + +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse + = GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] + +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}' +} + +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { + 200: AccessPolicy +} + +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse + = PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] + +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}/copy' +} + +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses = { + 201: AccessPolicy +} + +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse + = PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses] + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockData = { + body?: never + path: { + binding_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/lock' +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses = { + 200: AccessPolicyBindingState +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse + = PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses] + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockData = { + body?: never + path: { + binding_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/unlock' +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses = { + 200: AccessPolicyBindingState +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse + = PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses] + +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + app_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings' +} + +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + app_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses = { + 200: MemberBindingsResponse +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + app_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses = { + 200: RoleBindingsResponse +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policy' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses = { + 200: AppAccessMatrix +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse + = GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/user-access-policies' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses = { + 200: ResourceUserAccessPoliciesResponse +} + +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse + = GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses] + +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesData = { + body?: never + path: { + app_id: string + target_account_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/users/{target_account_id}/access-policies' +} + +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses = { + 200: ReplaceUserAccessPoliciesResponse +} + +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse + = PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/whitelist' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse + = GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] + +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/whitelist' +} + +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse + = PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] + +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData + = { + body?: never + path: { + dataset_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings' + } + +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData + = { + body?: never + path: { + dataset_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings' + } + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + dataset_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses + = { + 200: RoleBindingsResponse + } + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policy' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses = { + 200: DatasetAccessMatrix +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/user-access-policies' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses = { + 200: ResourceUserAccessPoliciesResponse +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses] + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesData = { + body?: never + path: { + dataset_id: string + target_account_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies' +} + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses + = { + 200: ReplaceUserAccessPoliciesResponse + } + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse + = PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist' +} + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse + = PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] + +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { + body?: never + path: { + member_id: string + } + query?: never + url: '/workspaces/current/rbac/members/{member_id}/rbac-roles' +} + +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses = { + 200: MemberRolesResponse +} + +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse + = GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] + +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { + body?: never + path: { + member_id: string + } + query?: never + url: '/workspaces/current/rbac/members/{member_id}/rbac-roles' +} + +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses = { + 200: MemberRolesResponse +} + +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse + = PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] + +export type GetWorkspacesCurrentRbacMyPermissionsData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/my-permissions' +} + +export type GetWorkspacesCurrentRbacMyPermissionsResponses = { + 200: MyPermissionsResponse +} + +export type GetWorkspacesCurrentRbacMyPermissionsResponse + = GetWorkspacesCurrentRbacMyPermissionsResponses[keyof GetWorkspacesCurrentRbacMyPermissionsResponses] + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/role-permissions/catalog' +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponses = { + 200: PermissionCatalogResponse +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponse + = GetWorkspacesCurrentRbacRolePermissionsCatalogResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogResponses] + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/role-permissions/catalog/app' +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses = { + 200: PermissionCatalogResponse +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse + = GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses] + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/role-permissions/catalog/dataset' +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses = { + 200: PermissionCatalogResponse +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse + = GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses] + +export type GetWorkspacesCurrentRbacRolesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/roles' +} + +export type GetWorkspacesCurrentRbacRolesResponses = { + 200: RbacRoleList +} + +export type GetWorkspacesCurrentRbacRolesResponse + = GetWorkspacesCurrentRbacRolesResponses[keyof GetWorkspacesCurrentRbacRolesResponses] + +export type PostWorkspacesCurrentRbacRolesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/roles' +} + +export type PostWorkspacesCurrentRbacRolesResponses = { + 201: RbacRole +} + +export type PostWorkspacesCurrentRbacRolesResponse + = PostWorkspacesCurrentRbacRolesResponses[keyof PostWorkspacesCurrentRbacRolesResponses] + +export type DeleteWorkspacesCurrentRbacRolesByRoleIdData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}' +} + +export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponses = { + 200: RbacRole +} + +export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponse + = DeleteWorkspacesCurrentRbacRolesByRoleIdResponses[keyof DeleteWorkspacesCurrentRbacRolesByRoleIdResponses] + +export type GetWorkspacesCurrentRbacRolesByRoleIdData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}' +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdResponses = { + 200: RbacRole +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdResponse + = GetWorkspacesCurrentRbacRolesByRoleIdResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdResponses] + +export type PutWorkspacesCurrentRbacRolesByRoleIdData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}' +} + +export type PutWorkspacesCurrentRbacRolesByRoleIdResponses = { + 200: RbacRole +} + +export type PutWorkspacesCurrentRbacRolesByRoleIdResponse + = PutWorkspacesCurrentRbacRolesByRoleIdResponses[keyof PutWorkspacesCurrentRbacRolesByRoleIdResponses] + +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}/copy' +} + +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses = { + 201: RbacRole +} + +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponse + = PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses[keyof PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses] + +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}/members' +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses = { + 200: MembersInRoleList +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponse + = GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses] + +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/bindings' +} + +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses = { + 200: AccessMatrixItem +} + +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse + = PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/member-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses = { + 200: MemberBindingsResponse +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses = { + 200: RoleBindingsResponse +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policy' +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses = { + 200: WorkspaceAccessMatrix +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse + = GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses] + +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/bindings' +} + +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses = { + 200: AccessMatrixItem +} + +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse + = PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses + = { + 200: RoleBindingsResponse + } + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policy' +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = { + 200: WorkspaceAccessMatrix +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse + = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses] + export type GetWorkspacesCurrentToolLabelsData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index b342c586594..fb6f643d7a9 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -138,6 +138,15 @@ export const zEndpointUpdatePayload = z.object({ settings: z.record(z.string(), z.unknown()), }) +/** + * MemberInvitePayload + */ +export const zMemberInvitePayload = z.object({ + emails: z.array(z.string()).optional(), + language: z.string().nullish(), + role: z.string(), +}) + /** * OwnerTransferCheckPayload */ @@ -446,6 +455,68 @@ export const zParserGithubUpload = z.object({ version: z.string(), }) +/** + * AccessPolicy + */ +export const zAccessPolicy = z.object({ + category: z.string().optional().default(''), + created_at: z.int().optional().default(0), + description: z.string().optional().default(''), + id: z.string(), + is_builtin: z.boolean().optional().default(false), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + policy_key: z.string().optional().default(''), + resource_type: z.string(), + tenant_id: z.string().optional().default(''), + updated_at: z.int().optional().default(0), +}) + +/** + * AccessPolicyBindingState + */ +export const zAccessPolicyBindingState = z.object({ + binding_id: z.string(), + is_locked: z.boolean().optional().default(false), +}) + +/** + * ReplaceUserAccessPoliciesResponse + */ +export const zReplaceUserAccessPoliciesResponse = z.object({ + access_policies: z.array(zAccessPolicy).optional(), +}) + +/** + * ResourceWhitelist + */ +export const zResourceWhitelist = z.object({ + account_ids: z.array(z.string()).optional(), +}) + +/** + * RBACRole + */ +export const zRbacRole = z.object({ + category: z.string().optional().default(''), + description: z.string().optional().default(''), + id: z.string(), + is_builtin: z.boolean().optional().default(false), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + role_tag: z.string().optional().default(''), + tenant_id: z.string().nullish(), + type: z.string(), +}) + +/** + * MemberRolesResponse + */ +export const zMemberRolesResponse = z.object({ + account_id: z.string(), + roles: z.array(zRbacRole).optional(), +}) + /** * ToolProviderOpaqueResponse */ @@ -776,7 +847,7 @@ export const zSnippet = z.object({ version: z.int().optional(), }) -export const zAnonymousInlineModelEfd591151Ea9 = z.object({ +export const zAnonymousInlineModel744Ff9Cc03E6 = z.object({ author_name: z.string().optional(), created_at: z.coerce .bigint() @@ -810,7 +881,7 @@ export const zAnonymousInlineModelEfd591151Ea9 = z.object({ }) export const zSnippetPagination = z.object({ - data: z.array(zAnonymousInlineModelEfd591151Ea9).optional(), + data: z.array(zAnonymousInlineModel744Ff9Cc03E6).optional(), has_more: z.boolean().optional(), limit: z.int().optional(), page: z.int().optional(), @@ -829,6 +900,7 @@ export const zAccountWithRole = z.object({ last_login_at: z.int().nullish(), name: z.string(), role: z.string(), + roles: z.array(z.record(z.string(), z.string())).optional(), status: z.string(), }) @@ -839,20 +911,6 @@ export const zAccountWithRoleList = z.object({ accounts: z.array(zAccountWithRole), }) -/** - * TenantAccountRole - */ -export const zTenantAccountRole = z.enum(['admin', 'dataset_operator', 'editor', 'normal', 'owner']) - -/** - * MemberInvitePayload - */ -export const zMemberInvitePayload = z.object({ - emails: z.array(z.string()).optional(), - language: z.string().nullish(), - role: zTenantAccountRole, -}) - /** * MemberInviteResultResponse */ @@ -1061,6 +1119,153 @@ export const zPluginPermissionResponse = z.object({ install_permission: zInstallPermission, }) +/** + * Pagination + */ +export const zPagination = z.object({ + current_page: z.int().optional().default(0), + per_page: z.int().optional().default(0), + total_count: z.int().optional().default(0), + total_pages: z.int().optional().default(0), +}) + +/** + * _AccessPolicyList + */ +export const zAccessPolicyList = z.object({ + data: z.array(zAccessPolicy).optional(), + pagination: zPagination.nullish(), +}) + +/** + * _RBACRoleList + */ +export const zRbacRoleList = z.object({ + data: z.array(zRbacRole).optional(), + pagination: zPagination.nullish(), +}) + +/** + * AccessPolicyMemberBinding + */ +export const zAccessPolicyMemberBinding = z.object({ + access_policy_id: z.string(), + account_id: z.string(), + account_name: z.string().optional().default(''), + created_at: z.int().optional().default(0), + id: z.string(), + resource_id: z.string().optional().default(''), + resource_type: z.string(), + tenant_id: z.string().optional().default(''), +}) + +/** + * MemberBindingsResponse + */ +export const zMemberBindingsResponse = z.object({ + data: z.array(zAccessPolicyMemberBinding).optional(), +}) + +/** + * AccessPolicyRoleBinding + */ +export const zAccessPolicyRoleBinding = z.object({ + access_policy_id: z.string(), + created_at: z.int().optional().default(0), + id: z.string(), + resource_id: z.string().optional().default(''), + resource_type: z.string(), + role_id: z.string(), + role_name: z.string().optional().default(''), + tenant_id: z.string().optional().default(''), +}) + +/** + * RoleBindingsResponse + */ +export const zRoleBindingsResponse = z.object({ + data: z.array(zAccessPolicyRoleBinding).optional(), +}) + +/** + * WorkspacePermissionSnapshot + */ +export const zWorkspacePermissionSnapshot = z.object({ + permission_keys: z.array(z.string()).optional(), +}) + +/** + * MembersInRole + */ +export const zMembersInRole = z.object({ + account_id: z.string().optional().default(''), + account_name: z.string().optional().default(''), +}) + +/** + * _MembersInRoleList + */ +export const zMembersInRoleList = z.object({ + data: z.array(zMembersInRole).optional(), + pagination: zPagination.nullish(), +}) + +/** + * AccessPolicyAccount + */ +export const zAccessPolicyAccount = z.object({ + account_id: z.string(), + account_name: z.string(), + avatar: z.string().optional().default(''), + binding_id: z.string(), + email: z.string().optional().default(''), + is_locked: z.boolean().optional().default(false), +}) + +/** + * AccessPolicyRole + */ +export const zAccessPolicyRole = z.object({ + binding_id: z.string(), + is_locked: z.boolean().optional().default(false), + role_id: z.string(), + role_name: z.string(), + role_tag: z.string().optional().default(''), +}) + +/** + * AccessMatrixItem + */ +export const zAccessMatrixItem = z.object({ + accounts: z.array(zAccessPolicyAccount).optional(), + policy: zAccessPolicy.nullish(), + roles: z.array(zAccessPolicyRole).optional(), +}) + +/** + * AppAccessMatrix + */ +export const zAppAccessMatrix = z.object({ + app_id: z.string().optional().default(''), + items: z.array(zAccessMatrixItem).optional(), +}) + +/** + * DatasetAccessMatrix + */ +export const zDatasetAccessMatrix = z.object({ + dataset_id: z.string().optional().default(''), + items: z.array(zAccessMatrixItem).optional(), +}) + +/** + * WorkspaceAccessMatrix + */ +export const zWorkspaceAccessMatrix = z.object({ + items: z.array(zAccessMatrixItem).optional(), + pagination: zPagination.nullish(), +}) + /** * ApiProviderSchemaType * @@ -1465,6 +1670,84 @@ export const zPluginCategoryBuiltinToolProviderResponse = z.object({ */ export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote']) +/** + * RBACRoleAccount + */ +export const zRbacRoleAccount = z.object({ + account_id: z.string(), + account_name: z.string().optional().default(''), + avatar: z.string().optional().default(''), + email: z.string().optional().default(''), +}) + +/** + * ResourceUserAccessPolicies + */ +export const zResourceUserAccessPolicies = z.object({ + access_policies: z.array(zAccessPolicy).optional(), + account: zRbacRoleAccount, + roles: z.array(zRbacRole).optional(), +}) + +/** + * ResourceUserAccessPoliciesResponse + */ +export const zResourceUserAccessPoliciesResponse = z.object({ + data: z.array(zResourceUserAccessPolicies).optional(), + scope: z.string(), +}) + +/** + * ResourcePermissionKeys + */ +export const zResourcePermissionKeys = z.object({ + permission_keys: z.array(z.string()).optional(), + resource_id: z.string(), +}) + +/** + * ResourcePermissionSnapshot + */ +export const zResourcePermissionSnapshot = z.object({ + default_permission_keys: z.array(z.string()).optional(), + overrides: z.array(zResourcePermissionKeys).optional(), +}) + +/** + * MyPermissionsResponse + */ +export const zMyPermissionsResponse = z.object({ + app: zResourcePermissionSnapshot.optional(), + dataset: zResourcePermissionSnapshot.optional(), + workspace: zWorkspacePermissionSnapshot.optional(), +}) + +/** + * PermissionCatalogItem + */ +export const zPermissionCatalogItem = z.object({ + description: z.string().optional().default(''), + key: z.string(), + name: z.string(), +}) + +/** + * PermissionCatalogGroup + */ +export const zPermissionCatalogGroup = z.object({ + description: z.string().optional().default(''), + group_key: z.string(), + group_name: z.string(), + permissions: z.array(zPermissionCatalogItem).optional(), +}) + +/** + * PermissionCatalogResponse + */ +export const zPermissionCatalogResponse = z.object({ + groups: z.array(zPermissionCatalogGroup).optional(), +}) + /** * ToolParameterForm */ @@ -1531,7 +1814,7 @@ export const zCustomModelConfiguration = z.object({ current_credential_name: z.string().nullish(), model: z.string(), model_type: zModelType, - unadded_to_model_list: z.boolean().nullish().default(false), + unadded_to_model_list: z.boolean().optional().default(false), }) /** @@ -1927,7 +2210,7 @@ export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmRes = zSnippetImportResponse export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -1936,7 +2219,7 @@ export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.objec export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = z.void() export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -1947,7 +2230,7 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = zSnipp export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody = zUpdateSnippetPayload export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -1956,7 +2239,7 @@ export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = zSnippet export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -1966,7 +2249,7 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependencies = zSnippetDependencyCheckResponse export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery = z.object({ @@ -1979,7 +2262,7 @@ export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportQuery = z.o export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse = zTextFileResponse export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementPath = z.object({ - snippet_id: z.string(), + snippet_id: z.uuid(), }) /** @@ -2121,7 +2404,7 @@ export const zPostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponse = zSimpleResultDataResponse export const zDeleteWorkspacesCurrentMembersByMemberIdPath = z.object({ - member_id: z.string(), + member_id: z.uuid(), }) /** @@ -2132,7 +2415,7 @@ export const zDeleteWorkspacesCurrentMembersByMemberIdResponse = zMemberActionTe export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferBody = zOwnerTransferPayload export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferPath = z.object({ - member_id: z.string(), + member_id: z.uuid(), }) /** @@ -2143,7 +2426,7 @@ export const zPostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse = zSim export const zPutWorkspacesCurrentMembersByMemberIdUpdateRoleBody = zMemberRoleUpdatePayload export const zPutWorkspacesCurrentMembersByMemberIdUpdateRolePath = z.object({ - member_id: z.string(), + member_id: z.uuid(), }) /** @@ -2711,6 +2994,411 @@ export const zGetWorkspacesCurrentPluginByCategoryListQuery = z.object({ */ export const zGetWorkspacesCurrentPluginByCategoryListResponse = zPluginCategoryListResponse +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAccessPoliciesResponse = zAccessPolicyList + +/** + * Policy created + */ +export const zPostWorkspacesCurrentRbacAccessPoliciesResponse = zAccessPolicy + +export const zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = zAccessPolicy + +export const zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = zAccessPolicy + +export const zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = zAccessPolicy + +export const zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Policy copied + */ +export const zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse = zAccessPolicy + +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath = z.object({ + binding_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse + = zAccessPolicyBindingState + +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath = z.object({ + binding_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse + = zAccessPolicyBindingState + +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + app_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + app_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + app_id: z.uuid(), + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse = zAppAccessMatrix + +export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse + = zResourceUserAccessPoliciesResponse + +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath + = z.object({ + app_id: z.uuid(), + target_account_id: z.uuid(), + }) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse + = zReplaceUserAccessPoliciesResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = zResourceWhitelist + +export const zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = zResourceWhitelist + +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + dataset_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + dataset_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + dataset_id: z.uuid(), + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse = zDatasetAccessMatrix + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse + = zResourceUserAccessPoliciesResponse + +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath + = z.object({ + dataset_id: z.uuid(), + target_account_id: z.uuid(), + }) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse + = zReplaceUserAccessPoliciesResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse = zResourceWhitelist + +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse = zResourceWhitelist + +export const zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath = z.object({ + member_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse = zMemberRolesResponse + +export const zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath = z.object({ + member_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse = zMemberRolesResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacMyPermissionsResponse = zMyPermissionsResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse = zPermissionCatalogResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse = zPermissionCatalogResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse + = zPermissionCatalogResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolesResponse = zRbacRoleList + +/** + * Role created + */ +export const zPostWorkspacesCurrentRbacRolesResponse = zRbacRole + +export const zDeleteWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole + +export const zGetWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole + +export const zPutWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole + +export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Role copied + */ +export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse = zRbacRole + +export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse = zMembersInRoleList + +export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse + = zAccessMatrixItem + +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse = zWorkspaceAccessMatrix + +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse + = zAccessMatrixItem + +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix + /** * Success */ diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 244ce92417c..e1217f3f6d7 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -257,6 +257,7 @@ export type Import = { error?: string id: string imported_dsl_version?: string + permission_keys?: Array status: ImportStatus } diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index df0d82117a0..51a3cb8f480 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -263,6 +263,7 @@ export const zImport = z.object({ error: z.string().optional().default(''), id: z.string(), imported_dsl_version: z.string().optional().default(''), + permission_keys: z.array(z.string()).optional(), status: zImportStatus, }) diff --git a/packages/contracts/generated/api/service/orpc.gen.ts b/packages/contracts/generated/api/service/orpc.gen.ts index 518b44e06ea..b15cc78d07d 100644 --- a/packages/contracts/generated/api/service/orpc.gen.ts +++ b/packages/contracts/generated/api/service/orpc.gen.ts @@ -6,6 +6,7 @@ import * as z from 'zod' import { zDeleteAppsAnnotationsByAnnotationIdPath, zDeleteAppsAnnotationsByAnnotationIdResponse, + zDeleteConversationsByCIdBody, zDeleteConversationsByCIdPath, zDeleteConversationsByCIdResponse, zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath, @@ -72,6 +73,7 @@ import { zGetFormHumanInputByFormTokenResponse, zGetInfoResponse, zGetMessagesByMessageIdSuggestedPath, + zGetMessagesByMessageIdSuggestedQuery, zGetMessagesByMessageIdSuggestedResponse, zGetMessagesQuery, zGetMessagesResponse, @@ -110,12 +112,15 @@ import { zPostAppsAnnotationReplyByActionResponse, zPostAppsAnnotationsBody, zPostAppsAnnotationsResponse, + zPostAudioToTextBody, zPostAudioToTextResponse, zPostChatMessagesBody, + zPostChatMessagesByTaskIdStopBody, zPostChatMessagesByTaskIdStopPath, zPostChatMessagesByTaskIdStopResponse, zPostChatMessagesResponse, zPostCompletionMessagesBody, + zPostCompletionMessagesByTaskIdStopBody, zPostCompletionMessagesByTaskIdStopPath, zPostCompletionMessagesByTaskIdStopResponse, zPostCompletionMessagesResponse, @@ -179,6 +184,7 @@ import { zPostDatasetsByDatasetIdRetrieveBody, zPostDatasetsByDatasetIdRetrievePath, zPostDatasetsByDatasetIdRetrieveResponse, + zPostDatasetsPipelineFileUploadBody, zPostDatasetsPipelineFileUploadResponse, zPostDatasetsResponse, zPostDatasetsTagsBindingBody, @@ -187,6 +193,7 @@ import { zPostDatasetsTagsResponse, zPostDatasetsTagsUnbindingBody, zPostDatasetsTagsUnbindingResponse, + zPostFilesUploadBody, zPostFilesUploadResponse, zPostFormHumanInputByFormTokenBody, zPostFormHumanInputByFormTokenPath, @@ -201,6 +208,7 @@ import { zPostWorkflowsByWorkflowIdRunResponse, zPostWorkflowsRunBody, zPostWorkflowsRunResponse, + zPostWorkflowsTasksByTaskIdStopBody, zPostWorkflowsTasksByTaskIdStopPath, zPostWorkflowsTasksByTaskIdStopResponse, zPutAppsAnnotationsByAnnotationIdBody, @@ -226,21 +234,20 @@ export const root = { } /** - * Get all feedbacks for the application + * List App Feedbacks * - * Get all feedbacks for the application - * Returns paginated list of all feedback submitted for messages in this app. + * Retrieve a paginated list of all feedback submitted for messages in this application, including both end-user and admin feedback. */ export const get2 = oc .route({ description: - 'Get all feedbacks for the application\nReturns paginated list of all feedback submitted for messages in this app.', + 'Retrieve a paginated list of all feedback submitted for messages in this application, including both end-user and admin feedback.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppFeedbacks', path: '/app/feedbacks', - summary: 'Get all feedbacks for the application', - tags: ['service_api'], + summary: 'List App Feedbacks', + tags: ['Feedback'], }) .input(z.object({ query: zGetAppFeedbacksQuery.optional() })) .output(zGetAppFeedbacksResponse) @@ -254,19 +261,20 @@ export const app = { } /** - * Get the status of an annotation reply action job + * Get Annotation Reply Job Status * - * Get the status of an annotation reply action job + * Retrieves the status of an asynchronous annotation reply configuration job started by [Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply). */ export const get3 = oc .route({ - description: 'Get the status of an annotation reply action job', + description: + 'Retrieves the status of an asynchronous annotation reply configuration job started by [Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply).', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsAnnotationReplyByActionStatusByJobId', path: '/apps/annotation-reply/{action}/status/{job_id}', - summary: 'Get the status of an annotation reply action job', - tags: ['service_api'], + summary: 'Get Annotation Reply Job Status', + tags: ['Annotations'], }) .input(z.object({ params: zGetAppsAnnotationReplyByActionStatusByJobIdPath })) .output(zGetAppsAnnotationReplyByActionStatusByJobIdResponse) @@ -280,19 +288,20 @@ export const status = { } /** - * Enable or disable annotation reply feature + * Configure Annotation Reply * - * Enable or disable annotation reply feature + * Enables or disables the annotation reply feature. Requires embedding model configuration when enabling. Executes asynchronously — use [Get Annotation Reply Job Status](/api-reference/annotations/get-annotation-reply-job-status) to track progress. */ export const post = oc .route({ - description: 'Enable or disable annotation reply feature', + description: + 'Enables or disables the annotation reply feature. Requires embedding model configuration when enabling. Executes asynchronously — use [Get Annotation Reply Job Status](/api-reference/annotations/get-annotation-reply-job-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsAnnotationReplyByAction', path: '/apps/annotation-reply/{action}', - summary: 'Enable or disable annotation reply feature', - tags: ['service_api'], + summary: 'Configure Annotation Reply', + tags: ['Annotations'], }) .input( z.object({ @@ -312,38 +321,38 @@ export const annotationReply = { } /** - * Delete an annotation + * Delete Annotation * - * Delete an annotation + * Deletes an annotation and its associated hit history. */ export const delete_ = oc .route({ - description: 'Delete an annotation', + description: 'Deletes an annotation and its associated hit history.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteAppsAnnotationsByAnnotationId', path: '/apps/annotations/{annotation_id}', successStatus: 204, - summary: 'Delete an annotation', - tags: ['service_api'], + summary: 'Delete Annotation', + tags: ['Annotations'], }) .input(z.object({ params: zDeleteAppsAnnotationsByAnnotationIdPath })) .output(zDeleteAppsAnnotationsByAnnotationIdResponse) /** - * Update an existing annotation + * Update Annotation * - * Update an existing annotation + * Updates the question and answer of an existing annotation. */ export const put = oc .route({ - description: 'Update an existing annotation', + description: 'Updates the question and answer of an existing annotation.', inputStructure: 'detailed', method: 'PUT', operationId: 'putAppsAnnotationsByAnnotationId', path: '/apps/annotations/{annotation_id}', - summary: 'Update an existing annotation', - tags: ['service_api'], + summary: 'Update Annotation', + tags: ['Annotations'], }) .input( z.object({ @@ -359,38 +368,40 @@ export const byAnnotationId = { } /** - * List annotations for the application + * List Annotations * - * List annotations for the application + * Retrieves a paginated list of annotations for the application. Supports keyword search filtering. */ export const get4 = oc .route({ - description: 'List annotations for the application', + description: + 'Retrieves a paginated list of annotations for the application. Supports keyword search filtering.', inputStructure: 'detailed', method: 'GET', operationId: 'getAppsAnnotations', path: '/apps/annotations', - summary: 'List annotations for the application', - tags: ['service_api'], + summary: 'List Annotations', + tags: ['Annotations'], }) .input(z.object({ query: zGetAppsAnnotationsQuery.optional() })) .output(zGetAppsAnnotationsResponse) /** - * Create a new annotation + * Create Annotation * - * Create a new annotation + * Creates a new annotation. Annotations provide predefined question-answer pairs that the app can match and return directly instead of generating a response. */ export const post2 = oc .route({ - description: 'Create a new annotation', + description: + 'Creates a new annotation. Annotations provide predefined question-answer pairs that the app can match and return directly instead of generating a response.', inputStructure: 'detailed', method: 'POST', operationId: 'postAppsAnnotations', path: '/apps/annotations', successStatus: 201, - summary: 'Create a new annotation', - tags: ['service_api'], + summary: 'Create Annotation', + tags: ['Annotations'], }) .input(z.object({ body: zPostAppsAnnotationsBody })) .output(zPostAppsAnnotationsResponse) @@ -407,22 +418,22 @@ export const apps = { } /** - * Convert audio to text using speech-to-text + * Convert Audio to Text * - * Convert audio to text using speech-to-text - * Accepts an audio file upload and returns the transcribed text. + * Convert audio file to text. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, `audio/wav`, and `audio/amr`. File size limit is `30 MB`. */ export const post3 = oc .route({ description: - 'Convert audio to text using speech-to-text\nAccepts an audio file upload and returns the transcribed text.', + 'Convert audio file to text. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, `audio/wav`, and `audio/amr`. File size limit is `30 MB`.', inputStructure: 'detailed', method: 'POST', operationId: 'postAudioToText', path: '/audio-to-text', - summary: 'Convert audio to text using speech-to-text', - tags: ['service_api'], + summary: 'Convert Audio to Text', + tags: ['TTS'], }) + .input(z.object({ body: zPostAudioToTextBody })) .output(zPostAudioToTextResponse) export const audioToText = { @@ -430,21 +441,26 @@ export const audioToText = { } /** - * Stop a running chat message generation + * Stop Chat Message Generation * - * Stop a running chat message generation + * Stops a chat message generation task. Only supported in `streaming` mode. */ export const post4 = oc .route({ - description: 'Stop a running chat message generation', + description: 'Stops a chat message generation task. Only supported in `streaming` mode.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessagesByTaskIdStop', path: '/chat-messages/{task_id}/stop', - summary: 'Stop a running chat message generation', - tags: ['service_api'], + summary: 'Stop Chat Message Generation', + tags: ['Chatflows', 'Chats'], }) - .input(z.object({ params: zPostChatMessagesByTaskIdStopPath })) + .input( + z.object({ + body: zPostChatMessagesByTaskIdStopBody, + params: zPostChatMessagesByTaskIdStopPath, + }), + ) .output(zPostChatMessagesByTaskIdStopResponse) export const stop = { @@ -456,22 +472,19 @@ export const byTaskId = { } /** - * Send a message in a chat conversation + * Send Chat Message * - * 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. + * Send a request to the chat application. */ export const post5 = oc .route({ - 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.', + description: 'Send a request to the chat application.', inputStructure: 'detailed', method: 'POST', operationId: 'postChatMessages', path: '/chat-messages', - summary: 'Send a message in a chat conversation', - tags: ['service_api'], + summary: 'Send Chat Message', + tags: ['Chatflows', 'Chats'], }) .input(z.object({ body: zPostChatMessagesBody })) .output(zPostChatMessagesResponse) @@ -482,21 +495,26 @@ export const chatMessages = { } /** - * Stop a running completion task + * Stop Completion Message Generation * - * Stop a running completion task + * Stops a completion message generation task. Only supported in `streaming` mode. */ export const post6 = oc .route({ - description: 'Stop a running completion task', + description: 'Stops a completion message generation task. Only supported in `streaming` mode.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessagesByTaskIdStop', path: '/completion-messages/{task_id}/stop', - summary: 'Stop a running completion task', - tags: ['service_api'], + summary: 'Stop Completion Message Generation', + tags: ['Completions'], }) - .input(z.object({ params: zPostCompletionMessagesByTaskIdStopPath })) + .input( + z.object({ + body: zPostCompletionMessagesByTaskIdStopBody, + params: zPostCompletionMessagesByTaskIdStopPath, + }), + ) .output(zPostCompletionMessagesByTaskIdStopResponse) export const stop2 = { @@ -508,22 +526,19 @@ export const byTaskId2 = { } /** - * Create a completion for the given prompt + * Send Completion Message * - * 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. + * Send a request to the text generation application. */ export const post7 = oc .route({ - 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.', + description: 'Send a request to the text generation application.', inputStructure: 'detailed', method: 'POST', operationId: 'postCompletionMessages', path: '/completion-messages', - summary: 'Create a completion for the given prompt', - tags: ['service_api'], + summary: 'Send Completion Message', + tags: ['Completions'], }) .input(z.object({ body: zPostCompletionMessagesBody })) .output(zPostCompletionMessagesResponse) @@ -534,19 +549,20 @@ export const completionMessages = { } /** - * Rename a conversation or auto-generate a name + * Rename Conversation * - * Rename a conversation or auto-generate a name + * Rename a conversation or auto-generate a name. The conversation name is used for display on clients that support multiple conversations. */ export const post8 = oc .route({ - description: 'Rename a conversation or auto-generate a name', + description: + 'Rename a conversation or auto-generate a name. The conversation name is used for display on clients that support multiple conversations.', inputStructure: 'detailed', method: 'POST', operationId: 'postConversationsByCIdName', path: '/conversations/{c_id}/name', - summary: 'Rename a conversation or auto-generate a name', - tags: ['service_api'], + summary: 'Rename Conversation', + tags: ['Conversations'], }) .input( z.object({ body: zPostConversationsByCIdNameBody, params: zPostConversationsByCIdNamePath }), @@ -558,22 +574,20 @@ export const name = { } /** - * Update a conversation variable's value + * Update Conversation Variable * - * Update a conversation variable's value - * Allows updating the value of a specific conversation variable. - * The value must match the variable's expected type. + * Update the value of a specific conversation variable. The value must match the expected type. */ export const put2 = oc .route({ description: - 'Update a conversation variable\'s value\nAllows updating the value of a specific conversation variable.\nThe value must match the variable\'s expected type.', + 'Update the value of a specific conversation variable. The value must match the expected type.', inputStructure: 'detailed', method: 'PUT', operationId: 'putConversationsByCIdVariablesByVariableId', path: '/conversations/{c_id}/variables/{variable_id}', - summary: 'Update a conversation variable\'s value', - tags: ['service_api'], + summary: 'Update Conversation Variable', + tags: ['Conversations'], }) .input( z.object({ @@ -588,21 +602,19 @@ export const byVariableId = { } /** - * List all variables for a conversation + * List Conversation Variables * - * List all variables for a conversation - * Conversational variables are only available for chat applications. + * Retrieve variables from a specific conversation. */ export const get5 = oc .route({ - description: - 'List all variables for a conversation\nConversational variables are only available for chat applications.', + description: 'Retrieve variables from a specific conversation.', inputStructure: 'detailed', method: 'GET', operationId: 'getConversationsByCIdVariables', path: '/conversations/{c_id}/variables', - summary: 'List all variables for a conversation', - tags: ['service_api'], + summary: 'List Conversation Variables', + tags: ['Conversations'], }) .input( z.object({ @@ -618,22 +630,22 @@ export const variables = { } /** - * Delete a specific conversation + * Delete Conversation * - * Delete a specific conversation + * Delete a conversation. */ export const delete2 = oc .route({ - description: 'Delete a specific conversation', + description: 'Delete a conversation.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteConversationsByCId', path: '/conversations/{c_id}', successStatus: 204, - summary: 'Delete a specific conversation', - tags: ['service_api'], + summary: 'Delete Conversation', + tags: ['Conversations'], }) - .input(z.object({ params: zDeleteConversationsByCIdPath })) + .input(z.object({ body: zDeleteConversationsByCIdBody, params: zDeleteConversationsByCIdPath })) .output(zDeleteConversationsByCIdResponse) export const byCId = { @@ -643,21 +655,20 @@ export const byCId = { } /** - * List all conversations for the current user + * List Conversations * - * List all conversations for the current user - * Supports pagination using last_id and limit parameters. + * Retrieve the conversation list for the current user, ordered by most recently active. */ export const get6 = oc .route({ description: - 'List all conversations for the current user\nSupports pagination using last_id and limit parameters.', + 'Retrieve the conversation list for the current user, ordered by most recently active.', inputStructure: 'detailed', method: 'GET', operationId: 'getConversations', path: '/conversations', - summary: 'List all conversations for the current user', - tags: ['service_api'], + summary: 'List Conversations', + tags: ['Conversations'], }) .input(z.object({ query: zGetConversationsQuery.optional() })) .output(zGetConversationsResponse) @@ -668,23 +679,23 @@ export const conversations = { } /** - * Upload a file for use in conversations + * Upload Pipeline File * - * Upload a file to a knowledgebase pipeline - * Accepts a single file upload via multipart/form-data. + * Upload a file for use in a knowledge pipeline. Accepts a single file via `multipart/form-data`. */ export const post9 = oc .route({ description: - 'Upload a file to a knowledgebase pipeline\nAccepts a single file upload via multipart/form-data.', + 'Upload a file for use in a knowledge pipeline. Accepts a single file via `multipart/form-data`.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsPipelineFileUpload', path: '/datasets/pipeline/file-upload', successStatus: 201, - summary: 'Upload a file for use in conversations', - tags: ['service_api'], + summary: 'Upload Pipeline File', + tags: ['Knowledge Pipeline'], }) + .input(z.object({ body: zPostDatasetsPipelineFileUploadBody })) .output(zPostDatasetsPipelineFileUploadResponse) export const fileUpload = { @@ -696,17 +707,21 @@ export const pipeline = { } /** - * Bind tags to a dataset + * Create Tag Binding + * + * Bind one or more tags to a knowledge base. A knowledge base can have multiple tags. */ export const post10 = oc .route({ - description: 'Bind tags to a dataset', + description: + 'Bind one or more tags to a knowledge base. A knowledge base can have multiple tags.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsTagsBinding', path: '/datasets/tags/binding', successStatus: 204, - tags: ['service_api'], + summary: 'Create Tag Binding', + tags: ['Tags'], }) .input(z.object({ body: zPostDatasetsTagsBindingBody })) .output(zPostDatasetsTagsBindingResponse) @@ -716,17 +731,20 @@ export const binding = { } /** - * Unbind tags from a dataset + * Delete Tag Binding + * + * Remove one or more tags from a knowledge base. */ export const post11 = oc .route({ - description: 'Unbind tags from a dataset', + description: 'Remove one or more tags from a knowledge base.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsTagsUnbinding', path: '/datasets/tags/unbinding', successStatus: 204, - tags: ['service_api'], + summary: 'Delete Tag Binding', + tags: ['Tags'], }) .input(z.object({ body: zPostDatasetsTagsUnbindingBody })) .output(zPostDatasetsTagsUnbindingResponse) @@ -736,70 +754,74 @@ export const unbinding = { } /** - * Delete a knowledge type tag + * Delete Knowledge Tag * - * Delete a knowledge type tag + * Permanently delete a knowledge base tag. Does not delete the knowledge bases that were tagged. */ export const delete3 = oc .route({ - description: 'Delete a knowledge type tag', + description: + 'Permanently delete a knowledge base tag. Does not delete the knowledge bases that were tagged.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsTags', path: '/datasets/tags', successStatus: 204, - summary: 'Delete a knowledge type tag', - tags: ['service_api'], + summary: 'Delete Knowledge Tag', + tags: ['Tags'], }) .input(z.object({ body: zDeleteDatasetsTagsBody })) .output(zDeleteDatasetsTagsResponse) /** - * Get all knowledge type tags + * List Knowledge Tags * - * Get all knowledge type tags + * Returns the list of all knowledge base tags in the workspace. */ export const get7 = oc .route({ - description: 'Get all knowledge type tags', + description: 'Returns the list of all knowledge base tags in the workspace.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsTags', path: '/datasets/tags', - summary: 'Get all knowledge type tags', - tags: ['service_api'], + summary: 'List Knowledge Tags', + tags: ['Tags'], }) .output(zGetDatasetsTagsResponse) /** - * Update a knowledge type tag + * Update Knowledge Tag + * + * Rename an existing knowledge base tag. */ export const patch = oc .route({ - description: 'Update a knowledge type tag', + description: 'Rename an existing knowledge base tag.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsTags', path: '/datasets/tags', - tags: ['service_api'], + summary: 'Update Knowledge Tag', + tags: ['Tags'], }) .input(z.object({ body: zPatchDatasetsTagsBody })) .output(zPatchDatasetsTagsResponse) /** - * Add a knowledge type tag + * Create Knowledge Tag * - * Add a knowledge type tag + * Create a new tag for organizing knowledge bases. */ export const post12 = oc .route({ - description: 'Add a knowledge type tag', + description: 'Create a new tag for organizing knowledge bases.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsTags', path: '/datasets/tags', - summary: 'Add a knowledge type tag', - tags: ['service_api'], + summary: 'Create Knowledge Tag', + tags: ['Tags'], }) .input(z.object({ body: zPostDatasetsTagsBody })) .output(zPostDatasetsTagsResponse) @@ -814,16 +836,20 @@ export const tags = { } /** - * Create a new document by uploading a file + * Create Document by File + * + * Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. */ export const post13 = oc .route({ - description: 'Create a new document by uploading a file', + description: + 'Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByFile', path: '/datasets/{dataset_id}/document/create-by-file', - tags: ['service_api'], + summary: 'Create Document by File', + tags: ['Documents'], }) .input( z.object({ @@ -834,16 +860,23 @@ export const post13 = oc .output(zPostDatasetsByDatasetIdDocumentCreateByFileResponse) /** - * Create a new document by uploading a file + * Create Document by File + * + * Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. + * + * @deprecated */ export const post14 = oc .route({ - description: 'Create a new document by uploading a file', + deprecated: true, + description: + 'Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByFile', path: '/datasets/{dataset_id}/document/create_by_file', - tags: ['service_api'], + summary: 'Create Document by File', + tags: ['Documents'], }) .input( z.object({ @@ -858,16 +891,20 @@ export const createByFile = { } /** - * Create a new document by providing text content + * Create Document by Text + * + * Create a document from raw text content. The document is processed asynchronously — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. */ export const post15 = oc .route({ - description: 'Create a new document by providing text content', + description: + 'Create a document from raw text content. The document is processed asynchronously — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentCreateByText', path: '/datasets/{dataset_id}/document/create-by-text', - tags: ['service_api'], + summary: 'Create Document by Text', + tags: ['Documents'], }) .input( z.object({ @@ -911,16 +948,20 @@ export const document_ = { } /** - * Download selected uploaded documents as a single ZIP archive + * Download Documents as ZIP + * + * Download multiple uploaded-file documents as a single ZIP archive. Accepts up to `100` document IDs. */ export const post17 = oc .route({ - description: 'Download selected uploaded documents as a single ZIP archive', + description: + 'Download multiple uploaded-file documents as a single ZIP archive. Accepts up to `100` document IDs.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsDownloadZip', path: '/datasets/{dataset_id}/documents/download-zip', - tags: ['service_api'], + summary: 'Download Documents as ZIP', + tags: ['Documents'], }) .input( z.object({ @@ -935,19 +976,20 @@ export const downloadZip = { } /** - * Update metadata for multiple documents + * Update Document Metadata in Batch * - * Update metadata for multiple documents + * Update metadata values for multiple documents at once. Each document in the request receives the specified metadata key-value pairs. */ export const post18 = oc .route({ - description: 'Update metadata for multiple documents', + description: + 'Update metadata values for multiple documents at once. Each document in the request receives the specified metadata key-value pairs.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsMetadata', path: '/datasets/{dataset_id}/documents/metadata', - summary: 'Update metadata for multiple documents', - tags: ['service_api'], + summary: 'Update Document Metadata in Batch', + tags: ['Metadata'], }) .input( z.object({ @@ -962,33 +1004,19 @@ export const metadata = { } /** - * Batch update document status + * Update Document Status in Batch * - * 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. + * Enable, disable, archive, or unarchive multiple documents at once. */ export const patch2 = oc .route({ - 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.', + description: 'Enable, disable, archive, or unarchive multiple documents at once.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsStatusByAction', path: '/datasets/{dataset_id}/documents/status/{action}', - summary: 'Batch update document status', - tags: ['service_api'], + summary: 'Update Document Status in Batch', + tags: ['Documents'], }) .input( z.object({ @@ -1007,16 +1035,20 @@ export const status2 = { } /** - * Get indexing status for documents in a batch + * Get Document Indexing Status + * + * Check the indexing progress of documents in a batch. Returns the current processing stage and chunk completion counts for each document. Poll this endpoint until `indexing_status` reaches `completed` or `error`. The status progresses through: `waiting` → `parsing` → `cleaning` → `splitting` → `indexing` → `completed`. */ export const get8 = oc .route({ - description: 'Get indexing status for documents in a batch', + description: + 'Check the indexing progress of documents in a batch. Returns the current processing stage and chunk completion counts for each document. Poll this endpoint until `indexing_status` reaches `completed` or `error`. The status progresses through: `waiting` → `parsing` → `cleaning` → `splitting` → `indexing` → `completed`.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByBatchIndexingStatus', path: '/datasets/{dataset_id}/documents/{batch}/indexing-status', - tags: ['service_api'], + summary: 'Get Document Indexing Status', + tags: ['Documents'], }) .input(z.object({ params: zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusPath })) .output(zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponse) @@ -1030,16 +1062,19 @@ export const byBatch = { } /** - * Get a signed download URL for a document's original uploaded file + * Download Document + * + * Get a signed download URL for a document's original uploaded file. */ export const get9 = oc .route({ - description: 'Get a signed download URL for a document\'s original uploaded file', + description: 'Get a signed download URL for a document\'s original uploaded file.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdDownload', path: '/datasets/{dataset_id}/documents/{document_id}/download', - tags: ['service_api'], + summary: 'Download Document', + tags: ['Documents'], }) .input(z.object({ params: zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadPath })) .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse) @@ -1049,18 +1084,21 @@ export const download = { } /** - * Delete a specific child chunk + * Delete Child Chunk + * + * Permanently delete a child chunk from its parent chunk. */ export const delete4 = oc .route({ - description: 'Delete a specific child chunk', + description: 'Permanently delete a child chunk from its parent chunk.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkId', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}', successStatus: 204, - tags: ['service_api'], + summary: 'Delete Child Chunk', + tags: ['Chunks'], }) .input( z.object({ @@ -1073,17 +1111,20 @@ export const delete4 = oc ) /** - * Update a specific child chunk + * Update Child Chunk + * + * Update the content of an existing child chunk. */ export const patch3 = oc .route({ - description: 'Update a specific child chunk', + description: 'Update the content of an existing child chunk.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkId', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}', - tags: ['service_api'], + summary: 'Update Child Chunk', + tags: ['Chunks'], }) .input( z.object({ @@ -1102,16 +1143,19 @@ export const byChildChunkId = { } /** - * List child chunks for a segment + * List Child Chunks + * + * Returns a paginated list of child chunks under a specific parent chunk. */ export const get10 = oc .route({ - description: 'List child chunks for a segment', + description: 'Returns a paginated list of child chunks under a specific parent chunk.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks', - tags: ['service_api'], + summary: 'List Child Chunks', + tags: ['Chunks'], }) .input( z.object({ @@ -1123,16 +1167,19 @@ export const get10 = oc .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse) /** - * Create a new child chunk for a segment + * Create Child Chunk + * + * Create a child chunk under the specified segment. */ export const post19 = oc .route({ - description: 'Create a new child chunk for a segment', + description: 'Create a child chunk under the specified segment.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunks', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks', - tags: ['service_api'], + summary: 'Create Child Chunk', + tags: ['Chunks'], }) .input( z.object({ @@ -1149,17 +1196,20 @@ export const childChunks = { } /** - * Delete a specific segment + * Delete Chunk + * + * Permanently delete a chunk from the document. */ export const delete5 = oc .route({ - description: 'Delete a specific segment', + description: 'Permanently delete a chunk from the document.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}', successStatus: 204, - tags: ['service_api'], + summary: 'Delete Chunk', + tags: ['Chunks'], }) .input( z.object({ params: zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath }), @@ -1167,31 +1217,39 @@ export const delete5 = oc .output(zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse) /** - * Get a specific segment by ID + * Get Chunk + * + * Retrieve detailed information about a specific chunk, including its content, keywords, and indexing status. */ export const get11 = oc .route({ - description: 'Get a specific segment by ID', + description: + 'Retrieve detailed information about a specific chunk, including its content, keywords, and indexing status.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}', - tags: ['service_api'], + summary: 'Get Chunk', + tags: ['Chunks'], }) .input(z.object({ params: zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath })) .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse) /** - * Update a specific segment + * Update Chunk + * + * Update a chunk's content, keywords, or answer. Re-triggers indexing for the modified chunk. */ export const post20 = oc .route({ - description: 'Update a specific segment', + description: + 'Update a chunk\'s content, keywords, or answer. Re-triggers indexing for the modified chunk.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentId', path: '/datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}', - tags: ['service_api'], + summary: 'Update Chunk', + tags: ['Chunks'], }) .input( z.object({ @@ -1209,16 +1267,20 @@ export const bySegmentId = { } /** - * List segments in a document + * List Chunks + * + * Returns a paginated list of chunks within a document. Supports filtering by keyword and status. */ export const get12 = oc .route({ - description: 'List segments in a document', + description: + 'Returns a paginated list of chunks within a document. Supports filtering by keyword and status.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentIdSegments', path: '/datasets/{dataset_id}/documents/{document_id}/segments', - tags: ['service_api'], + summary: 'List Chunks', + tags: ['Chunks'], }) .input( z.object({ @@ -1229,16 +1291,20 @@ export const get12 = oc .output(zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse) /** - * Create segments in a document + * Create Chunks + * + * Create one or more chunks within a document. Each chunk can include optional keywords and an answer field (for QA-mode documents). */ export const post21 = oc .route({ - description: 'Create segments in a document', + description: + 'Create one or more chunks within a document. Each chunk can include optional keywords and an answer field (for QA-mode documents).', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdSegments', path: '/datasets/{dataset_id}/documents/{document_id}/segments', - tags: ['service_api'], + summary: 'Create Chunks', + tags: ['Chunks'], }) .input( z.object({ @@ -1255,7 +1321,9 @@ export const segments = { } /** - * Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. + * Update Document by File + * + * Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. * * @deprecated */ @@ -1263,12 +1331,13 @@ 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.', + 'Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile', path: '/datasets/{dataset_id}/documents/{document_id}/update-by-file', - tags: ['service_api'], + summary: 'Update Document by File', + tags: ['Documents'], }) .input( z.object({ @@ -1279,7 +1348,9 @@ export const post22 = oc .output(zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse) /** - * Deprecated legacy alias for updating an existing document by uploading a file. Use PATCH /datasets/{dataset_id}/documents/{document_id} instead. + * Update Document by File + * + * Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. * * @deprecated */ @@ -1287,12 +1358,13 @@ 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.', + 'Update an existing document by uploading a new file. Re-triggers indexing — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile', path: '/datasets/{dataset_id}/documents/{document_id}/update_by_file', - tags: ['service_api'], + summary: 'Update Document by File', + tags: ['Documents'], }) .input( z.object({ @@ -1307,16 +1379,20 @@ export const updateByFile = { } /** - * Update an existing document by providing text content + * Update Document by Text + * + * Update an existing document's text content, name, or processing configuration. Re-triggers indexing if content changes — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress. */ export const post24 = oc .route({ - description: 'Update an existing document by providing text content', + description: + 'Update an existing document\'s text content, name, or processing configuration. Re-triggers indexing if content changes — use the returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText', path: '/datasets/{dataset_id}/documents/{document_id}/update-by-text', - tags: ['service_api'], + summary: 'Update Document by Text', + tags: ['Documents'], }) .input( z.object({ @@ -1355,35 +1431,39 @@ export const updateByText = { } /** - * Delete document + * Delete Document * - * Delete a document + * Permanently delete a document and all its chunks from the knowledge base. */ export const delete6 = oc .route({ - description: 'Delete a document', + description: 'Permanently delete a document and all its chunks from the knowledge base.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdDocumentsByDocumentId', path: '/datasets/{dataset_id}/documents/{document_id}', successStatus: 204, - summary: 'Delete document', - tags: ['service_api'], + summary: 'Delete Document', + tags: ['Documents'], }) .input(z.object({ params: zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath })) .output(zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse) /** - * Get a specific document by ID + * Get Document + * + * Retrieve detailed information about a specific document, including its indexing status, metadata, and processing statistics. */ export const get13 = oc .route({ - description: 'Get a specific document by ID', + description: + 'Retrieve detailed information about a specific document, including its indexing status, metadata, and processing statistics.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocumentsByDocumentId', path: '/datasets/{dataset_id}/documents/{document_id}', - tags: ['service_api'], + summary: 'Get Document', + tags: ['Documents'], }) .input( z.object({ @@ -1424,16 +1504,20 @@ export const byDocumentId = { } /** - * List all documents in a dataset + * List Documents + * + * Returns a paginated list of documents in the knowledge base. Supports filtering by keyword and indexing status. */ export const get14 = oc .route({ - description: 'List all documents in a dataset', + description: + 'Returns a paginated list of documents in the knowledge base. Supports filtering by keyword and indexing status.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdDocuments', path: '/datasets/{dataset_id}/documents', - tags: ['service_api'], + summary: 'List Documents', + tags: ['Documents'], }) .input( z.object({ @@ -1453,21 +1537,20 @@ export const documents = { } /** - * Perform hit testing on a dataset + * Retrieve Chunks from a Knowledge Base / Test Retrieval * - * Perform hit testing on a dataset - * Tests retrieval performance for the specified dataset. + * Performs a search query against a knowledge base to retrieve the most relevant chunks. This endpoint can be used for both production retrieval and test retrieval. */ export const post26 = oc .route({ description: - 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.', + 'Performs a search query against a knowledge base to retrieve the most relevant chunks. This endpoint can be used for both production retrieval and test retrieval.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdHitTesting', path: '/datasets/{dataset_id}/hit-testing', - summary: 'Perform hit testing on a dataset', - tags: ['service_api'], + summary: 'Retrieve Chunks from a Knowledge Base / Test Retrieval', + tags: ['Knowledge Bases'], }) .input( z.object({ @@ -1482,19 +1565,19 @@ export const hitTesting = { } /** - * Enable or disable built-in metadata field + * Update Built-in Metadata Field * - * Enable or disable built-in metadata field + * Enable or disable built-in metadata fields for the knowledge base. */ export const post27 = oc .route({ - description: 'Enable or disable built-in metadata field', + description: 'Enable or disable built-in metadata fields for the knowledge base.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdMetadataBuiltInByAction', path: '/datasets/{dataset_id}/metadata/built-in/{action}', - summary: 'Enable or disable built-in metadata field', - tags: ['service_api'], + summary: 'Update Built-in Metadata Field', + tags: ['Metadata'], }) .input(z.object({ params: zPostDatasetsByDatasetIdMetadataBuiltInByActionPath })) .output(zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse) @@ -1504,19 +1587,20 @@ export const byAction3 = { } /** - * Get all built-in metadata fields + * Get Built-in Metadata Fields * - * Get all built-in metadata fields + * Returns the list of built-in metadata fields provided by the system (e.g., document type, source URL). */ export const get15 = oc .route({ - description: 'Get all built-in metadata fields', + description: + 'Returns the list of built-in metadata fields provided by the system (e.g., document type, source URL).', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdMetadataBuiltIn', path: '/datasets/{dataset_id}/metadata/built-in', - summary: 'Get all built-in metadata fields', - tags: ['service_api'], + summary: 'Get Built-in Metadata Fields', + tags: ['Metadata'], }) .input(z.object({ params: zGetDatasetsByDatasetIdMetadataBuiltInPath })) .output(zGetDatasetsByDatasetIdMetadataBuiltInResponse) @@ -1527,38 +1611,39 @@ export const builtIn = { } /** - * Delete metadata + * Delete Metadata Field * - * Delete metadata + * Permanently delete a custom metadata field. Documents using this field will lose their metadata values for it. */ export const delete7 = oc .route({ - description: 'Delete metadata', + description: + 'Permanently delete a custom metadata field. Documents using this field will lose their metadata values for it.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetIdMetadataByMetadataId', path: '/datasets/{dataset_id}/metadata/{metadata_id}', successStatus: 204, - summary: 'Delete metadata', - tags: ['service_api'], + summary: 'Delete Metadata Field', + tags: ['Metadata'], }) .input(z.object({ params: zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath })) .output(zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse) /** - * Update metadata name + * Update Metadata Field * - * Update metadata name + * Rename a custom metadata field. */ export const patch5 = oc .route({ - description: 'Update metadata name', + description: 'Rename a custom metadata field.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetIdMetadataByMetadataId', path: '/datasets/{dataset_id}/metadata/{metadata_id}', - summary: 'Update metadata name', - tags: ['service_api'], + summary: 'Update Metadata Field', + tags: ['Metadata'], }) .input( z.object({ @@ -1574,38 +1659,40 @@ export const byMetadataId = { } /** - * Get all metadata for a dataset + * List Metadata Fields * - * Get all metadata for a dataset + * Returns the list of all metadata fields (both custom and built-in) for the knowledge base, along with the count of documents using each field. */ export const get16 = oc .route({ - description: 'Get all metadata for a dataset', + description: + 'Returns the list of all metadata fields (both custom and built-in) for the knowledge base, along with the count of documents using each field.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdMetadata', path: '/datasets/{dataset_id}/metadata', - summary: 'Get all metadata for a dataset', - tags: ['service_api'], + summary: 'List Metadata Fields', + tags: ['Metadata'], }) .input(z.object({ params: zGetDatasetsByDatasetIdMetadataPath })) .output(zGetDatasetsByDatasetIdMetadataResponse) /** - * Create metadata for a dataset + * Create Metadata Field * - * Create metadata for a dataset + * Create a custom metadata field for the knowledge base. Metadata fields can be used to annotate documents with structured information. */ export const post28 = oc .route({ - description: 'Create metadata for a dataset', + description: + 'Create a custom metadata field for the knowledge base. Metadata fields can be used to annotate documents with structured information.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdMetadata', path: '/datasets/{dataset_id}/metadata', successStatus: 201, - summary: 'Create metadata for a dataset', - tags: ['service_api'], + summary: 'Create Metadata Field', + tags: ['Metadata'], }) .input( z.object({ @@ -1623,19 +1710,20 @@ export const metadata2 = { } /** - * Resource for getting datasource plugins + * List Datasource Plugins * - * List all datasource plugins for a rag pipeline + * List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses plus the metadata needed to run it. */ export const get17 = oc .route({ - description: 'List all datasource plugins for a rag pipeline', + description: + 'List the datasource nodes configured in the knowledge pipeline. Each node includes the plugin it uses plus the metadata needed to run it.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdPipelineDatasourcePlugins', path: '/datasets/{dataset_id}/pipeline/datasource-plugins', - summary: 'Resource for getting datasource plugins', - tags: ['service_api'], + summary: 'List Datasource Plugins', + tags: ['Knowledge Pipeline'], }) .input( z.object({ @@ -1650,19 +1738,20 @@ export const datasourcePlugins = { } /** - * Resource for getting datasource plugins + * Run Datasource Node * - * Run a datasource node for a rag pipeline + * Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the node execution results. */ export const post29 = oc .route({ - description: 'Run a datasource node for a rag pipeline', + description: + 'Execute a single datasource node within the knowledge pipeline. Returns a streaming response with the node execution results.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRun', path: '/datasets/{dataset_id}/pipeline/datasource/nodes/{node_id}/run', - summary: 'Resource for getting datasource plugins', - tags: ['service_api'], + summary: 'Run Datasource Node', + tags: ['Knowledge Pipeline'], }) .input( z.object({ @@ -1689,19 +1778,20 @@ export const datasource = { } /** - * Resource for running a rag pipeline + * Run Pipeline * - * Run a datasource node for a rag pipeline + * Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response modes. */ export const post30 = oc .route({ - description: 'Run a datasource node for a rag pipeline', + description: + 'Execute the full knowledge pipeline for a knowledge base. Supports both streaming and blocking response modes.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdPipelineRun', path: '/datasets/{dataset_id}/pipeline/run', - summary: 'Resource for running a rag pipeline', - tags: ['service_api'], + summary: 'Run Pipeline', + tags: ['Knowledge Pipeline'], }) .input( z.object({ @@ -1722,21 +1812,20 @@ export const pipeline2 = { } /** - * Perform hit testing on a dataset + * Retrieve Chunks from a Knowledge Base / Test Retrieval * - * Perform hit testing on a dataset - * Tests retrieval performance for the specified dataset. + * Performs a search query against a knowledge base to retrieve the most relevant chunks. This endpoint can be used for both production retrieval and test retrieval. */ export const post31 = oc .route({ description: - 'Perform hit testing on a dataset\nTests retrieval performance for the specified dataset.', + 'Performs a search query against a knowledge base to retrieve the most relevant chunks. This endpoint can be used for both production retrieval and test retrieval.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasetsByDatasetIdRetrieve', path: '/datasets/{dataset_id}/retrieve', - summary: 'Perform hit testing on a dataset', - tags: ['service_api'], + summary: 'Retrieve Chunks from a Knowledge Base / Test Retrieval', + tags: ['Knowledge Bases'], }) .input( z.object({ @@ -1751,19 +1840,19 @@ export const retrieve = { } /** - * Get all knowledge type tags + * Get Knowledge Base Tags * - * Get tags bound to a specific dataset + * Returns the list of tags bound to a specific knowledge base. */ export const get18 = oc .route({ - description: 'Get tags bound to a specific dataset', + description: 'Returns the list of tags bound to a specific knowledge base.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetIdTags', path: '/datasets/{dataset_id}/tags', - summary: 'Get all knowledge type tags', - tags: ['service_api'], + summary: 'Get Knowledge Base Tags', + tags: ['Tags'], }) .input(z.object({ params: zGetDatasetsByDatasetIdTagsPath })) .output(zGetDatasetsByDatasetIdTagsResponse) @@ -1773,62 +1862,59 @@ export const tags2 = { } /** - * Deletes a dataset given its ID + * Delete Knowledge Base * - * 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. + * Permanently delete a knowledge base and all its documents. The knowledge base must not be in use by any application. */ export const delete8 = oc .route({ description: - 'Delete a dataset\nArgs:\n _: ignore\n dataset_id (UUID): The ID of the dataset to be deleted.\n\nReturns:\n dict: A dictionary with a key \'result\' and a value \'success\'\n if the dataset was successfully deleted. Omitted in HTTP response.\n int: HTTP status code 204 indicating that the operation was successful.\n\nRaises:\n NotFound: If the dataset with the given ID does not exist.', + 'Permanently delete a knowledge base and all its documents. The knowledge base must not be in use by any application.', inputStructure: 'detailed', method: 'DELETE', operationId: 'deleteDatasetsByDatasetId', path: '/datasets/{dataset_id}', successStatus: 204, - summary: 'Deletes a dataset given its ID', - tags: ['service_api'], + summary: 'Delete Knowledge Base', + tags: ['Knowledge Bases'], }) .input(z.object({ params: zDeleteDatasetsByDatasetIdPath })) .output(zDeleteDatasetsByDatasetIdResponse) /** - * Get a specific dataset by ID + * Get Knowledge Base + * + * Retrieve detailed information about a specific knowledge base, including its embedding model, retrieval configuration, and document statistics. */ export const get19 = oc .route({ - description: 'Get a specific dataset by ID', + description: + 'Retrieve detailed information about a specific knowledge base, including its embedding model, retrieval configuration, and document statistics.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasetsByDatasetId', path: '/datasets/{dataset_id}', - tags: ['service_api'], + summary: 'Get Knowledge Base', + tags: ['Knowledge Bases'], }) .input(z.object({ params: zGetDatasetsByDatasetIdPath })) .output(zGetDatasetsByDatasetIdResponse) /** - * Update an existing dataset + * Update Knowledge Base + * + * Update the name, description, permissions, or retrieval settings of an existing knowledge base. Only the fields provided in the request body are updated. */ export const patch6 = oc .route({ - description: 'Update an existing dataset', + description: + 'Update the name, description, permissions, or retrieval settings of an existing knowledge base. Only the fields provided in the request body are updated.', inputStructure: 'detailed', method: 'PATCH', operationId: 'patchDatasetsByDatasetId', path: '/datasets/{dataset_id}', - tags: ['service_api'], + summary: 'Update Knowledge Base', + tags: ['Knowledge Bases'], }) .input(z.object({ body: zPatchDatasetsByDatasetIdBody, params: zPatchDatasetsByDatasetIdPath })) .output(zPatchDatasetsByDatasetIdResponse) @@ -1847,37 +1933,39 @@ export const byDatasetId = { } /** - * Resource for getting datasets + * List Knowledge Bases * - * List all datasets + * Returns a paginated list of knowledge bases. Supports filtering by keyword and tags. */ export const get20 = oc .route({ - description: 'List all datasets', + description: + 'Returns a paginated list of knowledge bases. Supports filtering by keyword and tags.', inputStructure: 'detailed', method: 'GET', operationId: 'getDatasets', path: '/datasets', - summary: 'Resource for getting datasets', - tags: ['service_api'], + summary: 'List Knowledge Bases', + tags: ['Knowledge Bases'], }) .input(z.object({ query: zGetDatasetsQuery.optional() })) .output(zGetDatasetsResponse) /** - * Resource for creating datasets + * Create an Empty Knowledge Base * - * Create a new dataset + * Create a new empty knowledge base. After creation, use [Create Document by Text](/api-reference/documents/create-document-by-text) or [Create Document by File](/api-reference/documents/create-document-by-file) to add documents. */ export const post32 = oc .route({ - description: 'Create a new dataset', + description: + 'Create a new empty knowledge base. After creation, use [Create Document by Text](/api-reference/documents/create-document-by-text) or [Create Document by File](/api-reference/documents/create-document-by-file) to add documents.', inputStructure: 'detailed', method: 'POST', operationId: 'postDatasets', path: '/datasets', - summary: 'Resource for creating datasets', - tags: ['service_api'], + summary: 'Create an Empty Knowledge Base', + tags: ['Knowledge Bases'], }) .input(z.object({ body: zPostDatasetsBody })) .output(zPostDatasetsResponse) @@ -1891,22 +1979,20 @@ export const datasets = { } /** - * Get end user detail + * Get End User Info * - * 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. + * Retrieve an end user by ID. Useful when other APIs return an end-user ID (e.g., `created_by` from [Upload File](/api-reference/files/upload-file)). */ export const get21 = oc .route({ 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.', + 'Retrieve an end user by ID. Useful when other APIs return an end-user ID (e.g., `created_by` from [Upload File](/api-reference/files/upload-file)).', inputStructure: 'detailed', method: 'GET', operationId: 'getEndUsersByEndUserId', path: '/end-users/{end_user_id}', - summary: 'Get end user detail', - tags: ['service_api'], + summary: 'Get End User Info', + tags: ['End Users'], }) .input(z.object({ params: zGetEndUsersByEndUserIdPath })) .output(zGetEndUsersByEndUserIdResponse) @@ -1920,23 +2006,23 @@ export const endUsers = { } /** - * Upload a file for use in conversations + * Upload File * - * Upload a file for use in conversations - * Accepts a single file upload via multipart/form-data. + * Upload a file for use when sending messages, enabling multimodal understanding of images, documents, audio, and video. Uploaded files are for use by the current end-user only. */ export const post33 = oc .route({ description: - 'Upload a file for use in conversations\nAccepts a single file upload via multipart/form-data.', + 'Upload a file for use when sending messages, enabling multimodal understanding of images, documents, audio, and video. Uploaded files are for use by the current end-user only.', inputStructure: 'detailed', method: 'POST', operationId: 'postFilesUpload', path: '/files/upload', successStatus: 201, - summary: 'Upload a file for use in conversations', - tags: ['service_api'], + summary: 'Upload File', + tags: ['Files'], }) + .input(z.object({ body: zPostFilesUploadBody })) .output(zPostFilesUploadResponse) export const upload = { @@ -1944,22 +2030,20 @@ export const upload = { } /** - * Preview/Download a file that was uploaded via Service API + * Download File * - * 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. + * Preview or download uploaded files previously uploaded via the [Upload File](/api-reference/files/upload-file) API. Files can only be accessed if they belong to messages within the requesting application. */ export const get22 = oc .route({ 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 uploaded files previously uploaded via the [Upload File](/api-reference/files/upload-file) API. Files can only be accessed if they belong to messages within the requesting application.', inputStructure: 'detailed', method: 'GET', operationId: 'getFilesByFileIdPreview', path: '/files/{file_id}/preview', - summary: 'Preview/Download a file that was uploaded via Service API', - tags: ['service_api'], + summary: 'Download File', + tags: ['Files'], }) .input( z.object({ @@ -1983,31 +2067,39 @@ export const files = { } /** - * Get a paused human input form by token + * Get Human Input Form + * + * Retrieve a paused Human Input form's contents using the `form_token` from a `human_input_required` event. Requires **WebApp** delivery. */ export const get23 = oc .route({ - description: 'Get a paused human input form by token', + description: + 'Retrieve a paused Human Input form\'s contents using the `form_token` from a `human_input_required` event. Requires **WebApp** delivery.', inputStructure: 'detailed', method: 'GET', operationId: 'getFormHumanInputByFormToken', path: '/form/human_input/{form_token}', - tags: ['service_api'], + summary: 'Get Human Input Form', + tags: ['Human Input'], }) .input(z.object({ params: zGetFormHumanInputByFormTokenPath })) .output(zGetFormHumanInputByFormTokenResponse) /** - * Submit a paused human input form by token + * Submit Human Input Form + * + * Submit the recipient's response to a paused Human Input form. The workflow resumes on acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) to follow subsequent events. Requires **WebApp** delivery. */ export const post34 = oc .route({ - description: 'Submit a paused human input form by token', + description: + 'Submit the recipient\'s response to a paused Human Input form. The workflow resumes on acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) to follow subsequent events. Requires **WebApp** delivery.', inputStructure: 'detailed', method: 'POST', operationId: 'postFormHumanInputByFormToken', path: '/form/human_input/{form_token}', - tags: ['service_api'], + summary: 'Submit Human Input Form', + tags: ['Human Input'], }) .input( z.object({ @@ -2031,21 +2123,20 @@ export const form = { } /** - * Get app information + * Get App Info * - * Get basic application information - * Returns basic information about the application including name, description, tags, and mode. + * Retrieve basic information about this application, including name, description, tags, and mode. */ export const get24 = oc .route({ description: - 'Get basic application information\nReturns basic information about the application including name, description, tags, and mode.', + 'Retrieve basic information about this application, including name, description, tags, and mode.', inputStructure: 'detailed', method: 'GET', operationId: 'getInfo', path: '/info', - summary: 'Get app information', - tags: ['service_api'], + summary: 'Get App Info', + tags: ['Applications'], }) .output(zGetInfoResponse) @@ -2054,21 +2145,20 @@ export const info = { } /** - * Submit feedback for a message + * Submit Message Feedback * - * Submit feedback for a message - * Allows users to rate messages as like/dislike and provide optional feedback content. + * Submit feedback for a message. End users can rate messages as `like` or `dislike`, and optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted feedback. */ export const post35 = oc .route({ description: - 'Submit feedback for a message\nAllows users to rate messages as like/dislike and provide optional feedback content.', + 'Submit feedback for a message. End users can rate messages as `like` or `dislike`, and optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted feedback.', inputStructure: 'detailed', method: 'POST', operationId: 'postMessagesByMessageIdFeedbacks', path: '/messages/{message_id}/feedbacks', - summary: 'Submit feedback for a message', - tags: ['service_api'], + summary: 'Submit Message Feedback', + tags: ['Feedback'], }) .input( z.object({ @@ -2083,23 +2173,26 @@ export const feedbacks2 = { } /** - * Get suggested follow-up questions for a message + * Get Next Suggested Questions * - * Get suggested follow-up questions for a message - * Returns AI-generated follow-up questions based on the message content. + * Get next questions suggestions for the current message. */ export const get25 = oc .route({ - description: - 'Get suggested follow-up questions for a message\nReturns AI-generated follow-up questions based on the message content.', + description: 'Get next questions suggestions for the current message.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessagesByMessageIdSuggested', path: '/messages/{message_id}/suggested', - summary: 'Get suggested follow-up questions for a message', - tags: ['service_api'], + summary: 'Get Next Suggested Questions', + tags: ['Chatflows', 'Chats'], }) - .input(z.object({ params: zGetMessagesByMessageIdSuggestedPath })) + .input( + z.object({ + params: zGetMessagesByMessageIdSuggestedPath, + query: zGetMessagesByMessageIdSuggestedQuery, + }), + ) .output(zGetMessagesByMessageIdSuggestedResponse) export const suggested = { @@ -2112,21 +2205,20 @@ export const byMessageId = { } /** - * List messages in a conversation + * List Conversation Messages * - * List messages in a conversation - * Retrieves messages with pagination support using first_id. + * Returns historical chat records in a scrolling load format, with the first page returning the latest `limit` messages, i.e., in reverse order. */ export const get26 = oc .route({ description: - 'List messages in a conversation\nRetrieves messages with pagination support using first_id.', + 'Returns historical chat records in a scrolling load format, with the first page returning the latest `limit` messages, i.e., in reverse order.', inputStructure: 'detailed', method: 'GET', operationId: 'getMessages', path: '/messages', - summary: 'List messages in a conversation', - tags: ['service_api'], + summary: 'List Conversation Messages', + tags: ['Conversations'], }) .input(z.object({ query: zGetMessagesQuery })) .output(zGetMessagesResponse) @@ -2137,21 +2229,20 @@ export const messages = { } /** - * Get app metadata + * Get App Meta * - * Get application metadata - * Returns metadata about the application including configuration and settings. + * Retrieve metadata about this application, including tool icons and other configuration details. */ export const get27 = oc .route({ description: - 'Get application metadata\nReturns metadata about the application including configuration and settings.', + 'Retrieve metadata about this application, including tool icons and other configuration details.', inputStructure: 'detailed', method: 'GET', operationId: 'getMeta', path: '/meta', - summary: 'Get app metadata', - tags: ['service_api'], + summary: 'Get App Meta', + tags: ['Applications'], }) .output(zGetMetaResponse) @@ -2160,21 +2251,20 @@ export const meta = { } /** - * Retrieve app parameters + * Get App Parameters * - * Retrieve application input parameters and configuration - * Returns the input form parameters and configuration for the application. + * Retrieve the application's input form configuration, including feature switches, input parameter names, types, and default values. */ export const get28 = oc .route({ description: - 'Retrieve application input parameters and configuration\nReturns the input form parameters and configuration for the application.', + 'Retrieve the application\'s input form configuration, including feature switches, input parameter names, types, and default values.', inputStructure: 'detailed', method: 'GET', operationId: 'getParameters', path: '/parameters', - summary: 'Retrieve app parameters', - tags: ['service_api'], + summary: 'Get App Parameters', + tags: ['Applications'], }) .output(zGetParametersResponse) @@ -2183,21 +2273,20 @@ export const parameters = { } /** - * Retrieve app site info + * Get App WebApp Settings * - * Get application site configuration - * Returns the site configuration for the application including theme, icons, and text. + * Retrieve the WebApp settings of this application, including site configuration, theme, and customization options. */ export const get29 = oc .route({ description: - 'Get application site configuration\nReturns the site configuration for the application including theme, icons, and text.', + 'Retrieve the WebApp settings of this application, including site configuration, theme, and customization options.', inputStructure: 'detailed', method: 'GET', operationId: 'getSite', path: '/site', - summary: 'Retrieve app site info', - tags: ['service_api'], + summary: 'Get App WebApp Settings', + tags: ['Applications'], }) .output(zGetSiteResponse) @@ -2206,21 +2295,19 @@ export const site = { } /** - * Convert text to audio using text-to-speech + * Convert Text to Audio * - * Convert text to audio using text-to-speech - * Converts the provided text to audio using the specified voice. + * Convert text to speech. */ export const post36 = oc .route({ - description: - 'Convert text to audio using text-to-speech\nConverts the provided text to audio using the specified voice.', + description: 'Convert text to speech.', inputStructure: 'detailed', method: 'POST', operationId: 'postTextToAudio', path: '/text-to-audio', - summary: 'Convert text to audio using text-to-speech', - tags: ['service_api'], + summary: 'Convert Text to Audio', + tags: ['TTS'], }) .input(z.object({ body: zPostTextToAudioBody })) .output(zPostTextToAudioResponse) @@ -2230,16 +2317,20 @@ export const textToAudio = { } /** - * Get workflow execution events stream after resume + * Stream Workflow Events + * + * Resume the Server-Sent Events stream for a workflow run after a pause or a dropped SSE connection. For runs that have already finished, the stream emits a single `workflow_finished` event and closes. */ export const get30 = oc .route({ - description: 'Get workflow execution events stream after resume', + description: + 'Resume the Server-Sent Events stream for a workflow run after a pause or a dropped SSE connection. For runs that have already finished, the stream emits a single `workflow_finished` event and closes.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowByTaskIdEvents', path: '/workflow/{task_id}/events', - tags: ['service_api'], + summary: 'Stream Workflow Events', + tags: ['Chatflows', 'Workflows'], }) .input( z.object({ params: zGetWorkflowByTaskIdEventsPath, query: zGetWorkflowByTaskIdEventsQuery }), @@ -2259,21 +2350,19 @@ export const workflow = { } /** - * Get workflow app logs + * List Workflow Logs * - * Get workflow execution logs - * Returns paginated workflow execution logs with filtering options. + * Retrieve paginated workflow execution logs with filtering options. */ export const get31 = oc .route({ - description: - 'Get workflow execution logs\nReturns paginated workflow execution logs with filtering options.', + description: 'Retrieve paginated workflow execution logs with filtering options.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowsLogs', path: '/workflows/logs', - summary: 'Get workflow app logs', - tags: ['service_api'], + summary: 'List Workflow Logs', + tags: ['Chatflows', 'Workflows'], }) .input(z.object({ query: zGetWorkflowsLogsQuery.optional() })) .output(zGetWorkflowsLogsResponse) @@ -2283,21 +2372,20 @@ export const logs = { } /** - * Get a workflow task running detail + * Get Workflow Run Detail * - * Get workflow run details - * Returns detailed information about a specific workflow run. + * Retrieve the current execution results of a workflow task based on the workflow execution ID. */ export const get32 = oc .route({ description: - 'Get workflow run details\nReturns detailed information about a specific workflow run.', + 'Retrieve the current execution results of a workflow task based on the workflow execution ID.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkflowsRunByWorkflowRunId', path: '/workflows/run/{workflow_run_id}', - summary: 'Get a workflow task running detail', - tags: ['service_api'], + summary: 'Get Workflow Run Detail', + tags: ['Chatflows', 'Workflows'], }) .input(z.object({ params: zGetWorkflowsRunByWorkflowRunIdPath })) .output(zGetWorkflowsRunByWorkflowRunIdResponse) @@ -2307,22 +2395,19 @@ export const byWorkflowRunId = { } /** - * Execute a workflow + * Run Workflow * - * Execute a workflow - * Runs a workflow with the provided inputs and returns the results. - * Supports both blocking and streaming response modes. + * Execute a workflow. Cannot be executed without a published workflow. */ export const post37 = oc .route({ - description: - 'Execute a workflow\nRuns a workflow with the provided inputs and returns the results.\nSupports both blocking and streaming response modes.', + description: 'Execute a workflow. Cannot be executed without a published workflow.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsRun', path: '/workflows/run', - summary: 'Execute a workflow', - tags: ['service_api'], + summary: 'Run Workflow', + tags: ['Workflows'], }) .input(z.object({ body: zPostWorkflowsRunBody })) .output(zPostWorkflowsRunResponse) @@ -2333,21 +2418,26 @@ export const run3 = { } /** - * Stop a running workflow task + * Stop Workflow Task * - * Stop a running workflow task + * Stop a running workflow task. Only supported in `streaming` mode. */ export const post38 = oc .route({ - description: 'Stop a running workflow task', + description: 'Stop a running workflow task. Only supported in `streaming` mode.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsTasksByTaskIdStop', path: '/workflows/tasks/{task_id}/stop', - summary: 'Stop a running workflow task', - tags: ['service_api'], + summary: 'Stop Workflow Task', + tags: ['Workflows'], }) - .input(z.object({ params: zPostWorkflowsTasksByTaskIdStopPath })) + .input( + z.object({ + body: zPostWorkflowsTasksByTaskIdStopBody, + params: zPostWorkflowsTasksByTaskIdStopPath, + }), + ) .output(zPostWorkflowsTasksByTaskIdStopResponse) export const stop3 = { @@ -2363,21 +2453,20 @@ export const tasks = { } /** - * Run specific workflow by ID + * Run Workflow by ID * - * Execute a specific workflow by ID - * Executes a specific workflow version identified by its ID. + * Execute a specific workflow version identified by its ID. Useful for running a particular published version of the workflow. */ export const post39 = oc .route({ description: - 'Execute a specific workflow by ID\nExecutes a specific workflow version identified by its ID.', + 'Execute a specific workflow version identified by its ID. Useful for running a particular published version of the workflow.', inputStructure: 'detailed', method: 'POST', operationId: 'postWorkflowsByWorkflowIdRun', path: '/workflows/{workflow_id}/run', - summary: 'Run specific workflow by ID', - tags: ['service_api'], + summary: 'Run Workflow by ID', + tags: ['Workflows'], }) .input( z.object({ @@ -2403,21 +2492,20 @@ export const workflows = { } /** - * Get available models by model type + * Get Available Models * - * Get available models by model type - * Returns a list of available models for the specified model type. + * Retrieve the list of available models by type. Primarily used to query `text-embedding` and `rerank` models for knowledge base configuration. */ export const get33 = oc .route({ description: - 'Get available models by model type\nReturns a list of available models for the specified model type.', + 'Retrieve the list of available models by type. Primarily used to query `text-embedding` and `rerank` models for knowledge base configuration.', inputStructure: 'detailed', method: 'GET', operationId: 'getWorkspacesCurrentModelsModelTypesByModelType', path: '/workspaces/current/models/model-types/{model_type}', - summary: 'Get available models by model type', - tags: ['service_api'], + summary: 'Get Available Models', + tags: ['Models'], }) .input(z.object({ params: zGetWorkspacesCurrentModelsModelTypesByModelTypePath })) .output(zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse) diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index a3cea1127da..97921643514 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -103,15 +103,34 @@ export type ChatRequestPayload = { auto_generate_name?: boolean conversation_id?: string | null files?: Array<{ - [key: string]: unknown + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string }> | null inputs: { [key: string]: unknown } query: string response_mode?: 'blocking' | 'streaming' | null - retriever_from?: string - trace_session_id?: string | null + workflow_id?: string | null +} + +export type ChatRequestPayloadWithUser = { + auto_generate_name?: boolean + conversation_id?: string | null + files?: Array<{ + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string + }> | null + inputs: { + [key: string]: unknown + } + query: string + response_mode?: 'blocking' | 'streaming' | null + user: string workflow_id?: string | null } @@ -154,15 +173,31 @@ export type ChildChunkUpdatePayload = { export type CompletionRequestPayload = { files?: Array<{ - [key: string]: unknown + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string }> | null inputs: { [key: string]: unknown } query?: string response_mode?: 'blocking' | 'streaming' | null - retriever_from?: string - trace_session_id?: string | null +} + +export type CompletionRequestPayloadWithUser = { + files?: Array<{ + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string + }> | null + inputs: { + [key: string]: unknown + } + query?: string + response_mode?: 'blocking' | 'streaming' | null + user: string } export type Condition = { @@ -186,7 +221,7 @@ export type Condition = { | '≤' | '≥' name: string - value?: string | Array | number | number | null + value?: string | Array | number | null } export type ConversationInfiniteScrollPagination = { @@ -201,11 +236,37 @@ export type ConversationListQuery = { sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } -export type ConversationRenamePayload = { +export type ConversationRenamePayload = ( + | { + auto_generate: true + name?: string | null + } + | { + auto_generate?: false + name: string + } +) & { auto_generate?: boolean name?: string | null } +export type ConversationRenamePayloadWithUser = ( + | { + auto_generate: true + name?: string | null + user?: string + } + | { + auto_generate?: false + name: string + user?: string + } +) & { + auto_generate?: boolean + name?: string | null + user?: string +} + export type ConversationVariableInfiniteScrollPaginationResponse = { data: Array has_more: boolean @@ -226,6 +287,11 @@ export type ConversationVariableUpdatePayload = { value: unknown } +export type ConversationVariableUpdatePayloadWithUser = { + user?: string + value: unknown +} + export type ConversationVariablesQuery = { last_id?: string | null limit?: number @@ -253,10 +319,13 @@ export type DatasetCreatePayload = { indexing_technique?: 'economy' | 'high_quality' | null name: string permission?: PermissionEnum | null - provider?: string + provider?: 'external' | 'vendor' retrieval_model?: RetrievalModel | null summary_index_setting?: { - [key: string]: unknown + enable?: boolean + model_name?: string + model_provider_name?: string + summary_prompt?: string } | null } @@ -283,8 +352,10 @@ export type DatasetDetailResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -321,9 +392,11 @@ export type DatasetDetailWithPartialMembersResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string partial_member_list?: Array | null permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -450,12 +523,14 @@ export type DatasetUpdatePayload = { external_knowledge_api_id?: string | null external_knowledge_id?: string | null external_retrieval_model?: { - [key: string]: unknown + score_threshold?: number + score_threshold_enabled?: boolean + top_k?: number } | null indexing_technique?: 'economy' | 'high_quality' | null name?: string | null partial_member_list?: Array<{ - [key: string]: string + user_id?: string }> | null permission?: PermissionEnum | null retrieval_model?: RetrievalModel | null @@ -482,7 +557,7 @@ export type DatasourceCredentialInfoResponse = { export type DatasourceNodeRunPayload = { credential_id?: string | null - datasource_type: string + datasource_type: 'local_file' | 'online_document' | 'online_drive' | 'website_crawl' inputs: { [key: string]: unknown } @@ -564,7 +639,15 @@ export type DocumentListQuery = { keyword?: string | null limit?: number page?: number - status?: string | null + status?: + | 'archived' + | 'available' + | 'disabled' + | 'error' + | 'indexing' + | 'paused' + | 'queuing' + | null } export type DocumentListResponse = { @@ -639,11 +722,11 @@ export type DocumentStatusResponse = { } export type DocumentTextCreatePayload = { - doc_form?: string + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' doc_language?: string embedding_model?: string | null embedding_model_provider?: string | null - indexing_technique?: string | null + indexing_technique?: 'economy' | 'high_quality' | null name: string original_document_id?: string | null process_rule?: ProcessRule | null @@ -651,8 +734,25 @@ export type DocumentTextCreatePayload = { text: string } -export type DocumentTextUpdate = { - doc_form?: string +export type DocumentTextUpdate = ( + | { + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' + doc_language?: string + name: string + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null + text: string + } + | { + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' + doc_language?: string + name?: string | null + process_rule?: ProcessRule | null + retrieval_model?: RetrievalModel | null + text?: null + } +) & { + doc_form?: 'hierarchical_model' | 'qa_model' | 'text_model' doc_language?: string name?: string | null process_rule?: ProcessRule | null @@ -770,7 +870,9 @@ export type HitTestingFile = { export type HitTestingPayload = { attachment_ids?: Array | null external_retrieval_model?: { - [key: string]: unknown + score_threshold?: number + score_threshold_enabled?: boolean + top_k?: number } | null query: string retrieval_model?: RetrievalModel | null @@ -875,6 +977,14 @@ export type HumanInputFormSubmitPayload = { } } +export type HumanInputFormSubmitPayloadWithUser = { + action: string + inputs: { + [key: string]: JsonValue2 + } + user: string +} + export type HumanInputFormSubmitResponse = { [key: string]: never } @@ -923,6 +1033,12 @@ export type MessageFeedbackPayload = { rating?: 'dislike' | 'like' | null } +export type MessageFeedbackPayloadWithUser = { + content?: string | null + rating?: 'dislike' | 'like' | null + user: string +} + export type MessageFile = { belongs_to?: string | null filename: string @@ -1025,6 +1141,10 @@ export type ModelStatus export type ModelType = 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' +export type OptionalServiceApiUserPayload = { + user?: string +} + export type ParagraphInputConfig = { default?: StringSource | null output_variable_name: string @@ -1049,15 +1169,37 @@ export type Parameters = { export type PermissionEnum = 'all_team_members' | 'only_me' | 'partial_members' export type PipelineRunApiEntity = { - datasource_info_list: Array<{ - [key: string]: unknown - }> - datasource_type: string + datasource_info_list: Array< + | { + name?: string + reference: string + } + | { + credential_id?: string + page: { + page_id: string + page_name?: string + type: string + } + workspace_id: string + } + | { + title?: string + url: string + } + | { + bucket?: string + id: string + name?: string + type: 'file' | 'folder' + } + > + datasource_type: 'local_file' | 'online_document' | 'online_drive' | 'website_crawl' inputs: { [key: string]: unknown } is_published: boolean - response_mode: string + response_mode: 'blocking' | 'streaming' start_node_id: string } @@ -1073,7 +1215,7 @@ export type PipelineUploadFileResponse = { export type PreProcessingRule = { enabled: boolean - id: string + id: 'remove_extra_spaces' | 'remove_stopwords' | 'remove_urls_emails' } export type ProcessRule = { @@ -1112,6 +1254,10 @@ export type ProviderWithModelsResponse = { tenant_id: string } +export type RequiredServiceApiUserPayload = { + user: string +} + export type RerankingModel = { reranking_model_name?: string | null reranking_provider_name?: string | null @@ -1130,7 +1276,7 @@ export type RetrievalMethod export type RetrievalModel = { metadata_filtering_conditions?: MetadataFilteringCondition | null reranking_enable: boolean - reranking_mode?: string | null + reranking_mode?: 'reranking_model' | 'weighted_score' | null reranking_model?: RerankingModel | null score_threshold?: number | null score_threshold_enabled: boolean @@ -1356,11 +1502,17 @@ export type TagDeletePayload = { tag_id: string } -export type TagUnbindingPayload = { - tag_id?: string | null - tag_ids?: Array - target_id: string -} +export type TagUnbindingPayload + = | { + tag_id: string + tag_ids?: Array + target_id: string + } + | { + tag_id?: string + tag_ids: Array + target_id: string + } export type TagUpdatePayload = { name: string @@ -1374,6 +1526,14 @@ export type TextToAudioPayload = { voice?: string | null } +export type TextToAudioPayloadWithUser = { + message_id?: string | null + streaming?: boolean | null + text?: string | null + user?: string + voice?: string | null +} + export type UrlResponse = { url: string } @@ -1463,13 +1623,29 @@ export type WorkflowRunForLogResponse = { export type WorkflowRunPayload = { files?: Array<{ - [key: string]: unknown + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string }> | null inputs: { [key: string]: unknown } response_mode?: 'blocking' | 'streaming' | null - trace_session_id?: string | null +} + +export type WorkflowRunPayloadWithUser = { + files?: Array<{ + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string + }> | null + inputs: { + [key: string]: unknown + } + response_mode?: 'blocking' | 'streaming' | null + user: string } export type WorkflowRunResponse = { @@ -1544,6 +1720,7 @@ export type GetAppFeedbacksData = { export type GetAppFeedbacksErrors = { 401: unknown + 403: unknown } export type GetAppFeedbacksResponses = { @@ -1555,7 +1732,7 @@ export type GetAppFeedbacksResponse = GetAppFeedbacksResponses[keyof GetAppFeedb export type PostAppsAnnotationReplyByActionData = { body: AnnotationReplyActionPayload path: { - action: string + action: 'disable' | 'enable' } query?: never url: '/apps/annotation-reply/{action}' @@ -1563,6 +1740,7 @@ export type PostAppsAnnotationReplyByActionData = { export type PostAppsAnnotationReplyByActionErrors = { 401: unknown + 403: unknown } export type PostAppsAnnotationReplyByActionResponses = { @@ -1575,7 +1753,7 @@ export type PostAppsAnnotationReplyByActionResponse export type GetAppsAnnotationReplyByActionStatusByJobIdData = { body?: never path: { - action: string + action: 'disable' | 'enable' job_id: string } query?: never @@ -1583,7 +1761,9 @@ export type GetAppsAnnotationReplyByActionStatusByJobIdData = { } export type GetAppsAnnotationReplyByActionStatusByJobIdErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1607,6 +1787,7 @@ export type GetAppsAnnotationsData = { export type GetAppsAnnotationsErrors = { 401: unknown + 403: unknown } export type GetAppsAnnotationsResponses = { @@ -1625,6 +1806,7 @@ export type PostAppsAnnotationsData = { export type PostAppsAnnotationsErrors = { 401: unknown + 403: unknown } export type PostAppsAnnotationsResponses = { @@ -1679,7 +1861,10 @@ export type PutAppsAnnotationsByAnnotationIdResponse = PutAppsAnnotationsByAnnotationIdResponses[keyof PutAppsAnnotationsByAnnotationIdResponses] export type PostAudioToTextData = { - body?: never + body: { + file: Blob | File + user?: string + } path?: never query?: never url: '/audio-to-text' @@ -1688,6 +1873,7 @@ export type PostAudioToTextData = { export type PostAudioToTextErrors = { 400: unknown 401: unknown + 403: unknown 413: unknown 415: unknown 500: unknown @@ -1700,7 +1886,7 @@ export type PostAudioToTextResponses = { export type PostAudioToTextResponse = PostAudioToTextResponses[keyof PostAudioToTextResponses] export type PostChatMessagesData = { - body: ChatRequestPayload + body: ChatRequestPayloadWithUser path?: never query?: never url: '/chat-messages' @@ -1709,6 +1895,7 @@ export type PostChatMessagesData = { export type PostChatMessagesErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown 429: unknown 500: unknown @@ -1721,7 +1908,7 @@ export type PostChatMessagesResponses = { export type PostChatMessagesResponse = PostChatMessagesResponses[keyof PostChatMessagesResponses] export type PostChatMessagesByTaskIdStopData = { - body?: never + body: RequiredServiceApiUserPayload path: { task_id: string } @@ -1730,7 +1917,9 @@ export type PostChatMessagesByTaskIdStopData = { } export type PostChatMessagesByTaskIdStopErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1742,7 +1931,7 @@ export type PostChatMessagesByTaskIdStopResponse = PostChatMessagesByTaskIdStopResponses[keyof PostChatMessagesByTaskIdStopResponses] export type PostCompletionMessagesData = { - body: CompletionRequestPayload + body: CompletionRequestPayloadWithUser path?: never query?: never url: '/completion-messages' @@ -1751,7 +1940,9 @@ export type PostCompletionMessagesData = { export type PostCompletionMessagesErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown + 429: unknown 500: unknown } @@ -1763,7 +1954,7 @@ export type PostCompletionMessagesResponse = PostCompletionMessagesResponses[keyof PostCompletionMessagesResponses] export type PostCompletionMessagesByTaskIdStopData = { - body?: never + body: RequiredServiceApiUserPayload path: { task_id: string } @@ -1772,7 +1963,9 @@ export type PostCompletionMessagesByTaskIdStopData = { } export type PostCompletionMessagesByTaskIdStopErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1790,12 +1983,15 @@ export type GetConversationsData = { last_id?: string limit?: number sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' + user?: string } url: '/conversations' } export type GetConversationsErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1806,7 +2002,7 @@ export type GetConversationsResponses = { export type GetConversationsResponse = GetConversationsResponses[keyof GetConversationsResponses] export type DeleteConversationsByCIdData = { - body?: never + body: OptionalServiceApiUserPayload path: { c_id: string } @@ -1815,7 +2011,9 @@ export type DeleteConversationsByCIdData = { } export type DeleteConversationsByCIdErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1827,7 +2025,7 @@ export type DeleteConversationsByCIdResponse = DeleteConversationsByCIdResponses[keyof DeleteConversationsByCIdResponses] export type PostConversationsByCIdNameData = { - body: ConversationRenamePayload + body: ConversationRenamePayloadWithUser path: { c_id: string } @@ -1836,7 +2034,9 @@ export type PostConversationsByCIdNameData = { } export type PostConversationsByCIdNameErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1855,13 +2055,16 @@ export type GetConversationsByCIdVariablesData = { query?: { last_id?: string limit?: number + user?: string variable_name?: string } url: '/conversations/{c_id}/variables' } export type GetConversationsByCIdVariablesErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1873,7 +2076,7 @@ export type GetConversationsByCIdVariablesResponse = GetConversationsByCIdVariablesResponses[keyof GetConversationsByCIdVariablesResponses] export type PutConversationsByCIdVariablesByVariableIdData = { - body: ConversationVariableUpdatePayload + body: ConversationVariableUpdatePayloadWithUser path: { c_id: string variable_id: string @@ -1885,6 +2088,7 @@ export type PutConversationsByCIdVariablesByVariableIdData = { export type PutConversationsByCIdVariablesByVariableIdErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -1910,6 +2114,7 @@ export type GetDatasetsData = { export type GetDatasetsErrors = { 401: unknown + 403: unknown } export type GetDatasetsResponses = { @@ -1928,6 +2133,8 @@ export type PostDatasetsData = { export type PostDatasetsErrors = { 400: unknown 401: unknown + 403: unknown + 409: unknown } export type PostDatasetsResponses = { @@ -1937,7 +2144,9 @@ export type PostDatasetsResponses = { export type PostDatasetsResponse = PostDatasetsResponses[keyof PostDatasetsResponses] export type PostDatasetsPipelineFileUploadData = { - body?: never + body: { + file: Blob | File + } path?: never query?: never url: '/datasets/pipeline/file-upload' @@ -1946,6 +2155,7 @@ export type PostDatasetsPipelineFileUploadData = { export type PostDatasetsPipelineFileUploadErrors = { 400: unknown 401: unknown + 403: unknown 413: unknown 415: unknown } @@ -1985,6 +2195,7 @@ export type GetDatasetsTagsData = { export type GetDatasetsTagsErrors = { 401: unknown + 403: unknown } export type GetDatasetsTagsResponses = { @@ -2078,6 +2289,7 @@ export type DeleteDatasetsByDatasetIdData = { export type DeleteDatasetsByDatasetIdErrors = { 401: unknown + 403: unknown 404: unknown 409: unknown } @@ -2148,6 +2360,7 @@ export type PostDatasetsByDatasetIdDocumentCreateByFileData = { export type PostDatasetsByDatasetIdDocumentCreateByFileErrors = { 400: unknown 401: unknown + 403: unknown } export type PostDatasetsByDatasetIdDocumentCreateByFileResponses = { @@ -2169,6 +2382,7 @@ export type PostDatasetsByDatasetIdDocumentCreateByTextData = { export type PostDatasetsByDatasetIdDocumentCreateByTextErrors = { 400: unknown 401: unknown + 403: unknown } export type PostDatasetsByDatasetIdDocumentCreateByTextResponses = { @@ -2193,6 +2407,7 @@ export type PostDatasetsByDatasetIdDocumentCreateByFile2Data = { export type PostDatasetsByDatasetIdDocumentCreateByFile2Errors = { 400: unknown 401: unknown + 403: unknown } export type PostDatasetsByDatasetIdDocumentCreateByFile2Responses = { @@ -2214,6 +2429,7 @@ export type PostDatasetsByDatasetIdDocumentCreateByText2Data = { export type PostDatasetsByDatasetIdDocumentCreateByText2Errors = { 400: unknown 401: unknown + 403: unknown } export type PostDatasetsByDatasetIdDocumentCreateByText2Responses = { @@ -2232,13 +2448,14 @@ export type GetDatasetsByDatasetIdDocumentsData = { keyword?: string limit?: number page?: number - status?: string + status?: 'archived' | 'available' | 'disabled' | 'error' | 'indexing' | 'paused' | 'queuing' } url: '/datasets/{dataset_id}/documents' } export type GetDatasetsByDatasetIdDocumentsErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2259,13 +2476,16 @@ export type PostDatasetsByDatasetIdDocumentsDownloadZipData = { } export type PostDatasetsByDatasetIdDocumentsDownloadZipErrors = { - 401: unknown - 403: unknown - 404: unknown + 401: Blob | File + 403: Blob | File + 404: Blob | File } +export type PostDatasetsByDatasetIdDocumentsDownloadZipError + = PostDatasetsByDatasetIdDocumentsDownloadZipErrors[keyof PostDatasetsByDatasetIdDocumentsDownloadZipErrors] + export type PostDatasetsByDatasetIdDocumentsDownloadZipResponses = { - 200: BinaryFileResponse + 200: Blob | File } export type PostDatasetsByDatasetIdDocumentsDownloadZipResponse @@ -2282,6 +2502,7 @@ export type PostDatasetsByDatasetIdDocumentsMetadataData = { export type PostDatasetsByDatasetIdDocumentsMetadataErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2295,7 +2516,7 @@ export type PostDatasetsByDatasetIdDocumentsMetadataResponse export type PatchDatasetsByDatasetIdDocumentsStatusByActionData = { body: DocumentStatusPayload path: { - action: string + action: 'archive' | 'disable' | 'enable' | 'un_archive' dataset_id: string } query?: never @@ -2328,6 +2549,7 @@ export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusData = { export type GetDatasetsByDatasetIdDocumentsByBatchIndexingStatusErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2349,6 +2571,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdData = { } export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdErrors = { + 400: unknown 401: unknown 403: unknown 404: unknown @@ -2374,6 +2597,7 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdData = { } export type GetDatasetsByDatasetIdDocumentsByDocumentIdErrors = { + 400: unknown 401: unknown 403: unknown 404: unknown @@ -2401,6 +2625,7 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdData = { export type PatchDatasetsByDatasetIdDocumentsByDocumentIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2451,6 +2676,7 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2474,6 +2700,7 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsData = { export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2497,6 +2724,7 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdDat export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2520,6 +2748,7 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData = export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2543,6 +2772,7 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdData export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2570,6 +2800,7 @@ export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildC export type GetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2592,7 +2823,9 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChild } export type PostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2618,7 +2851,9 @@ export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export type DeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2645,7 +2880,9 @@ export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil export type PatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2671,7 +2908,9 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileData = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2693,7 +2932,9 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextData = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2718,7 +2959,9 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Data = { } export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Errors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -2741,6 +2984,7 @@ export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Data = { export type PostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Errors = { 401: unknown + 403: unknown 404: unknown } @@ -2761,8 +3005,11 @@ export type PostDatasetsByDatasetIdHitTestingData = { } export type PostDatasetsByDatasetIdHitTestingErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown + 500: unknown } export type PostDatasetsByDatasetIdHitTestingResponses = { @@ -2783,6 +3030,7 @@ export type GetDatasetsByDatasetIdMetadataData = { export type GetDatasetsByDatasetIdMetadataErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2804,6 +3052,7 @@ export type PostDatasetsByDatasetIdMetadataData = { export type PostDatasetsByDatasetIdMetadataErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2825,6 +3074,7 @@ export type GetDatasetsByDatasetIdMetadataBuiltInData = { export type GetDatasetsByDatasetIdMetadataBuiltInErrors = { 401: unknown + 403: unknown } export type GetDatasetsByDatasetIdMetadataBuiltInResponses = { @@ -2837,7 +3087,7 @@ export type GetDatasetsByDatasetIdMetadataBuiltInResponse export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { body?: never path: { - action: string + action: 'disable' | 'enable' dataset_id: string } query?: never @@ -2846,6 +3096,7 @@ export type PostDatasetsByDatasetIdMetadataBuiltInByActionData = { export type PostDatasetsByDatasetIdMetadataBuiltInByActionErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2868,6 +3119,7 @@ export type DeleteDatasetsByDatasetIdMetadataByMetadataIdData = { export type DeleteDatasetsByDatasetIdMetadataByMetadataIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2890,6 +3142,7 @@ export type PatchDatasetsByDatasetIdMetadataByMetadataIdData = { export type PatchDatasetsByDatasetIdMetadataByMetadataIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -2913,6 +3166,8 @@ export type GetDatasetsByDatasetIdPipelineDatasourcePluginsData = { export type GetDatasetsByDatasetIdPipelineDatasourcePluginsErrors = { 401: unknown + 403: unknown + 404: unknown } export type GetDatasetsByDatasetIdPipelineDatasourcePluginsResponses = { @@ -2934,6 +3189,8 @@ export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunData = { export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunErrors = { 401: unknown + 403: unknown + 404: unknown } export type PostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponses = { @@ -2954,6 +3211,9 @@ export type PostDatasetsByDatasetIdPipelineRunData = { export type PostDatasetsByDatasetIdPipelineRunErrors = { 401: unknown + 403: unknown + 404: unknown + 500: unknown } export type PostDatasetsByDatasetIdPipelineRunResponses = { @@ -2973,8 +3233,11 @@ export type PostDatasetsByDatasetIdRetrieveData = { } export type PostDatasetsByDatasetIdRetrieveErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown + 500: unknown } export type PostDatasetsByDatasetIdRetrieveResponses = { @@ -2995,6 +3258,7 @@ export type GetDatasetsByDatasetIdTagsData = { export type GetDatasetsByDatasetIdTagsErrors = { 401: unknown + 403: unknown } export type GetDatasetsByDatasetIdTagsResponses = { @@ -3015,6 +3279,7 @@ export type GetEndUsersByEndUserIdData = { export type GetEndUsersByEndUserIdErrors = { 401: unknown + 403: unknown 404: unknown } @@ -3026,7 +3291,10 @@ export type GetEndUsersByEndUserIdResponse = GetEndUsersByEndUserIdResponses[keyof GetEndUsersByEndUserIdResponses] export type PostFilesUploadData = { - body?: never + body: { + file: Blob | File + user?: string + } path?: never query?: never url: '/files/upload' @@ -3035,6 +3303,7 @@ export type PostFilesUploadData = { export type PostFilesUploadErrors = { 400: unknown 401: unknown + 403: unknown 413: unknown 415: unknown } @@ -3052,18 +3321,22 @@ export type GetFilesByFileIdPreviewData = { } query?: { as_attachment?: boolean + user?: string } url: '/files/{file_id}/preview' } export type GetFilesByFileIdPreviewErrors = { - 401: unknown - 403: unknown - 404: unknown + 401: Blob | File + 403: Blob | File + 404: Blob | File } +export type GetFilesByFileIdPreviewError + = GetFilesByFileIdPreviewErrors[keyof GetFilesByFileIdPreviewErrors] + export type GetFilesByFileIdPreviewResponses = { - 200: BinaryFileResponse + 200: Blob | File } export type GetFilesByFileIdPreviewResponse @@ -3080,6 +3353,7 @@ export type GetFormHumanInputByFormTokenData = { export type GetFormHumanInputByFormTokenErrors = { 401: unknown + 403: unknown 404: unknown 412: unknown } @@ -3092,7 +3366,7 @@ export type GetFormHumanInputByFormTokenResponse = GetFormHumanInputByFormTokenResponses[keyof GetFormHumanInputByFormTokenResponses] export type PostFormHumanInputByFormTokenData = { - body: HumanInputFormSubmitPayload + body: HumanInputFormSubmitPayloadWithUser path: { form_token: string } @@ -3103,6 +3377,7 @@ export type PostFormHumanInputByFormTokenData = { export type PostFormHumanInputByFormTokenErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown 412: unknown } @@ -3123,6 +3398,7 @@ export type GetInfoData = { export type GetInfoErrors = { 401: unknown + 403: unknown 404: unknown } @@ -3139,12 +3415,15 @@ export type GetMessagesData = { conversation_id: string first_id?: string limit?: number + user?: string } url: '/messages' } export type GetMessagesErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -3155,7 +3434,7 @@ export type GetMessagesResponses = { export type GetMessagesResponse = GetMessagesResponses[keyof GetMessagesResponses] export type PostMessagesByMessageIdFeedbacksData = { - body: MessageFeedbackPayload + body: MessageFeedbackPayloadWithUser path: { message_id: string } @@ -3165,6 +3444,7 @@ export type PostMessagesByMessageIdFeedbacksData = { export type PostMessagesByMessageIdFeedbacksErrors = { 401: unknown + 403: unknown 404: unknown } @@ -3180,13 +3460,16 @@ export type GetMessagesByMessageIdSuggestedData = { path: { message_id: string } - query?: never + query: { + user: string + } url: '/messages/{message_id}/suggested' } export type GetMessagesByMessageIdSuggestedErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown 500: unknown } @@ -3207,6 +3490,7 @@ export type GetMetaData = { export type GetMetaErrors = { 401: unknown + 403: unknown 404: unknown } @@ -3224,7 +3508,9 @@ export type GetParametersData = { } export type GetParametersErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -3253,20 +3539,23 @@ export type GetSiteResponses = { export type GetSiteResponse = GetSiteResponses[keyof GetSiteResponses] export type PostTextToAudioData = { - body: TextToAudioPayload + body: TextToAudioPayloadWithUser path?: never query?: never url: '/text-to-audio' } export type PostTextToAudioErrors = { - 400: unknown - 401: unknown - 500: unknown + 400: Blob | File + 401: Blob | File + 403: Blob | File + 500: Blob | File } +export type PostTextToAudioError = PostTextToAudioErrors[keyof PostTextToAudioErrors] + export type PostTextToAudioResponses = { - 200: AudioBinaryResponse + 200: Blob | File } export type PostTextToAudioResponse = PostTextToAudioResponses[keyof PostTextToAudioResponses] @@ -3285,7 +3574,9 @@ export type GetWorkflowByTaskIdEventsData = { } export type GetWorkflowByTaskIdEventsErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -3314,6 +3605,7 @@ export type GetWorkflowsLogsData = { export type GetWorkflowsLogsErrors = { 401: unknown + 403: unknown } export type GetWorkflowsLogsResponses = { @@ -3323,7 +3615,7 @@ export type GetWorkflowsLogsResponses = { export type GetWorkflowsLogsResponse = GetWorkflowsLogsResponses[keyof GetWorkflowsLogsResponses] export type PostWorkflowsRunData = { - body: WorkflowRunPayload + body: WorkflowRunPayloadWithUser path?: never query?: never url: '/workflows/run' @@ -3332,6 +3624,7 @@ export type PostWorkflowsRunData = { export type PostWorkflowsRunErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown 429: unknown 500: unknown @@ -3353,7 +3646,9 @@ export type GetWorkflowsRunByWorkflowRunIdData = { } export type GetWorkflowsRunByWorkflowRunIdErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -3365,7 +3660,7 @@ export type GetWorkflowsRunByWorkflowRunIdResponse = GetWorkflowsRunByWorkflowRunIdResponses[keyof GetWorkflowsRunByWorkflowRunIdResponses] export type PostWorkflowsTasksByTaskIdStopData = { - body?: never + body: RequiredServiceApiUserPayload path: { task_id: string } @@ -3374,7 +3669,9 @@ export type PostWorkflowsTasksByTaskIdStopData = { } export type PostWorkflowsTasksByTaskIdStopErrors = { + 400: unknown 401: unknown + 403: unknown 404: unknown } @@ -3386,7 +3683,7 @@ export type PostWorkflowsTasksByTaskIdStopResponse = PostWorkflowsTasksByTaskIdStopResponses[keyof PostWorkflowsTasksByTaskIdStopResponses] export type PostWorkflowsByWorkflowIdRunData = { - body: WorkflowRunPayload + body: WorkflowRunPayloadWithUser path: { workflow_id: string } @@ -3397,6 +3694,7 @@ export type PostWorkflowsByWorkflowIdRunData = { export type PostWorkflowsByWorkflowIdRunErrors = { 400: unknown 401: unknown + 403: unknown 404: unknown 429: unknown 500: unknown @@ -3412,7 +3710,7 @@ export type PostWorkflowsByWorkflowIdRunResponse export type GetWorkspacesCurrentModelsModelTypesByModelTypeData = { body?: never path: { - model_type: string + model_type: 'llm' | 'moderation' | 'rerank' | 'speech2text' | 'text-embedding' | 'tts' } query?: never url: '/workspaces/current/models/model-types/{model_type}' diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index ce31666d074..6ccc5671cb2 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -131,12 +131,42 @@ export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) export const zChatRequestPayload = z.object({ auto_generate_name: z.boolean().optional().default(true), conversation_id: z.string().nullish(), - files: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), inputs: z.record(z.string(), z.unknown()), query: z.string(), response_mode: z.enum(['blocking', 'streaming']).nullish(), - retriever_from: z.string().optional().default('dev'), - trace_session_id: z.string().nullish(), + workflow_id: z.string().nullish(), +}) + +/** + * ChatRequestPayload + */ +export const zChatRequestPayloadWithUser = z.object({ + auto_generate_name: z.boolean().optional().default(true), + conversation_id: z.string().nullish(), + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()), + query: z.string(), + response_mode: z.enum(['blocking', 'streaming']).nullish(), + user: z.string(), workflow_id: z.string().nullish(), }) @@ -199,12 +229,39 @@ export const zChildChunkUpdatePayload = z.object({ * CompletionRequestPayload */ export const zCompletionRequestPayload = z.object({ - files: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), inputs: z.record(z.string(), z.unknown()), query: z.string().optional().default(''), response_mode: z.enum(['blocking', 'streaming']).nullish(), - retriever_from: z.string().optional().default('dev'), - trace_session_id: z.string().nullish(), +}) + +/** + * CompletionRequestPayload + */ +export const zCompletionRequestPayloadWithUser = z.object({ + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()), + query: z.string().optional().default(''), + response_mode: z.enum(['blocking', 'streaming']).nullish(), + user: z.string(), }) /** @@ -234,7 +291,7 @@ export const zCondition = z.object({ '≥', ]), name: z.string(), - value: z.union([z.string(), z.array(z.string()), z.int(), z.number()]).nullish(), + value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(), }) /** @@ -249,13 +306,42 @@ export const zConversationListQuery = z.object({ .default('-updated_at'), }) -/** - * ConversationRenamePayload - */ -export const zConversationRenamePayload = z.object({ - auto_generate: z.boolean().optional().default(false), - name: z.string().nullish(), -}) +export const zConversationRenamePayload = z.intersection( + z.union([ + z.object({ + auto_generate: z.literal(true), + name: z.string().nullish(), + }), + z.object({ + auto_generate: z.literal(false).optional().default(false), + name: z.string().regex(/.*\S.*/), + }), + ]), + z.object({ + auto_generate: z.boolean().optional().default(false), + name: z.string().nullish(), + }), +) + +export const zConversationRenamePayloadWithUser = z.intersection( + z.union([ + z.object({ + auto_generate: z.literal(true), + name: z.string().nullish(), + user: z.string().optional(), + }), + z.object({ + auto_generate: z.literal(false).optional().default(false), + name: z.string().regex(/.*\S.*/), + user: z.string().optional(), + }), + ]), + z.object({ + auto_generate: z.boolean().optional().default(false), + name: z.string().nullish(), + user: z.string().optional(), + }), +) /** * ConversationVariableResponse @@ -286,6 +372,14 @@ export const zConversationVariableUpdatePayload = z.object({ value: z.unknown(), }) +/** + * ConversationVariableUpdatePayload + */ +export const zConversationVariableUpdatePayloadWithUser = z.object({ + user: z.string().optional(), + value: z.unknown(), +}) + /** * ConversationVariablesQuery */ @@ -508,8 +602,10 @@ export const zDatasetDetailResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -549,9 +645,11 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()).nullish(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -591,7 +689,7 @@ export const zDatasourceCredentialInfoResponse = z.object({ */ export const zDatasourceNodeRunPayload = z.object({ credential_id: z.string().nullish(), - datasource_type: z.string(), + datasource_type: z.enum(['local_file', 'online_document', 'online_drive', 'website_crawl']), inputs: z.record(z.string(), z.unknown()), is_published: z.boolean(), }) @@ -644,7 +742,9 @@ 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(), + status: z + .enum(['archived', 'available', 'disabled', 'error', 'indexing', 'paused', 'queuing']) + .nullish(), }) /** @@ -1071,6 +1171,15 @@ export const zHumanInputFormSubmitPayload = z.object({ inputs: z.record(z.string(), zJsonValue2), }) +/** + * HumanInputFormSubmitPayload + */ +export const zHumanInputFormSubmitPayloadWithUser = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue2), + user: z.string(), +}) + /** * KnowledgeTagResponse */ @@ -1094,6 +1203,15 @@ export const zMessageFeedbackPayload = z.object({ rating: z.enum(['dislike', 'like']).nullish(), }) +/** + * MessageFeedbackPayload + */ +export const zMessageFeedbackPayloadWithUser = z.object({ + content: z.string().nullish(), + rating: z.enum(['dislike', 'like']).nullish(), + user: z.string(), +}) + /** * MessageFile */ @@ -1235,6 +1353,13 @@ export const zModelType = z.enum([ 'tts', ]) +/** + * ServiceApiUserPayload + */ +export const zOptionalServiceApiUserPayload = z.object({ + user: z.string().optional(), +}) + /** * PermissionEnum * @@ -1246,11 +1371,37 @@ export const zPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_m * PipelineRunApiEntity */ export const zPipelineRunApiEntity = z.object({ - datasource_info_list: z.array(z.record(z.string(), z.unknown())), - datasource_type: z.string(), + datasource_info_list: z.array( + z.union([ + z.object({ + name: z.string().optional(), + reference: z.string(), + }), + z.object({ + credential_id: z.string().optional(), + page: z.object({ + page_id: z.string(), + page_name: z.string().optional(), + type: z.string(), + }), + workspace_id: z.string(), + }), + z.object({ + title: z.string().optional(), + url: z.string(), + }), + z.object({ + bucket: z.string().optional(), + id: z.string(), + name: z.string().optional(), + type: z.enum(['file', 'folder']), + }), + ]), + ), + datasource_type: z.enum(['local_file', 'online_document', 'online_drive', 'website_crawl']), inputs: z.record(z.string(), z.unknown()), is_published: z.boolean(), - response_mode: z.string(), + response_mode: z.enum(['blocking', 'streaming']), start_node_id: z.string(), }) @@ -1272,7 +1423,7 @@ export const zPipelineUploadFileResponse = z.object({ */ export const zPreProcessingRule = z.object({ enabled: z.boolean(), - id: z.string(), + id: z.enum(['remove_extra_spaces', 'remove_stopwords', 'remove_urls_emails']), }) /** @@ -1322,6 +1473,13 @@ export const zProviderWithModelsListResponse = z.object({ data: z.array(zProviderWithModelsResponse), }) +/** + * ServiceApiUserPayload + */ +export const zRequiredServiceApiUserPayload = z.object({ + user: z.string(), +}) + /** * RerankingModel */ @@ -1654,13 +1812,20 @@ export const zTagDeletePayload = z.object({ /** * TagUnbindingPayload * - * Accept the legacy single-tag Service API payload while exposing a normalized tag_ids list internally. + * Accepts either the legacy tag_id payload or the normalized tag_ids payload. */ -export const zTagUnbindingPayload = z.object({ - tag_id: z.string().nullish(), - tag_ids: z.array(z.string()).optional(), - target_id: z.string(), -}) +export const zTagUnbindingPayload = z.union([ + z.object({ + tag_id: z.string(), + tag_ids: z.array(z.string()).min(1).optional(), + target_id: z.string(), + }), + z.object({ + tag_id: z.string().optional(), + tag_ids: z.array(z.string()).min(1), + target_id: z.string(), + }), +]) /** * TagUpdatePayload @@ -1674,12 +1839,23 @@ export const zTagUpdatePayload = z.object({ * TextToAudioPayload */ export const zTextToAudioPayload = z.object({ - message_id: z.string().nullish(), + message_id: z.uuid().nullish(), streaming: z.boolean().nullish(), text: z.string().nullish(), voice: z.string().nullish(), }) +/** + * TextToAudioPayload + */ +export const zTextToAudioPayloadWithUser = z.object({ + message_id: z.uuid().nullish(), + streaming: z.boolean().nullish(), + text: z.string().nullish(), + user: z.string().optional(), + voice: z.string().nullish(), +}) + /** * UrlResponse */ @@ -1840,7 +2016,7 @@ export const zWeightModel = z.object({ export const zRetrievalModel = z.object({ metadata_filtering_conditions: zMetadataFilteringCondition.nullish(), reranking_enable: z.boolean(), - reranking_mode: z.string().nullish(), + reranking_mode: z.enum(['reranking_model', 'weighted_score']).nullish(), reranking_model: zRerankingModel.nullish(), score_threshold: z.number().nullish(), score_threshold_enabled: z.boolean(), @@ -1861,9 +2037,16 @@ export const zDatasetCreatePayload = z.object({ indexing_technique: z.enum(['economy', 'high_quality']).nullish(), name: z.string().min(1).max(40), permission: zPermissionEnum.nullish().default('only_me'), - provider: z.string().optional().default('vendor'), + provider: z.enum(['external', 'vendor']).optional().default('vendor'), retrieval_model: zRetrievalModel.nullish(), - summary_index_setting: z.record(z.string(), z.unknown()).nullish(), + summary_index_setting: z + .object({ + enable: z.boolean().optional(), + model_name: z.string().optional(), + model_provider_name: z.string().optional(), + summary_prompt: z.string().optional(), + }) + .nullish(), }) /** @@ -1875,10 +2058,22 @@ export const zDatasetUpdatePayload = z.object({ 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(), + external_retrieval_model: z + .object({ + score_threshold: z.number().optional(), + score_threshold_enabled: z.boolean().optional(), + top_k: z.int().optional(), + }) + .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(), + partial_member_list: z + .array( + z.object({ + user_id: z.string().optional(), + }), + ) + .nullish(), permission: zPermissionEnum.nullish(), retrieval_model: zRetrievalModel.nullish(), }) @@ -1887,11 +2082,14 @@ export const zDatasetUpdatePayload = z.object({ * DocumentTextCreatePayload */ export const zDocumentTextCreatePayload = z.object({ - doc_form: z.string().optional().default('text_model'), + doc_form: z + .enum(['hierarchical_model', 'qa_model', 'text_model']) + .optional() + .default('text_model'), doc_language: z.string().optional().default('English'), embedding_model: z.string().nullish(), embedding_model_provider: z.string().nullish(), - indexing_technique: z.string().nullish(), + indexing_technique: z.enum(['economy', 'high_quality']).nullish(), name: z.string(), original_document_id: z.string().nullish(), process_rule: zProcessRule.nullish(), @@ -1899,24 +2097,56 @@ export const zDocumentTextCreatePayload = z.object({ text: z.string(), }) -/** - * DocumentTextUpdate - */ -export const zDocumentTextUpdate = z.object({ - doc_form: z.string().optional().default('text_model'), - doc_language: z.string().optional().default('English'), - name: z.string().nullish(), - process_rule: zProcessRule.nullish(), - retrieval_model: zRetrievalModel.nullish(), - text: z.string().nullish(), -}) +export const zDocumentTextUpdate = z.intersection( + z.union([ + z.object({ + doc_form: z + .enum(['hierarchical_model', 'qa_model', 'text_model']) + .optional() + .default('text_model'), + doc_language: z.string().optional().default('English'), + name: z.string(), + process_rule: zProcessRule.nullish(), + retrieval_model: zRetrievalModel.nullish(), + text: z.string(), + }), + z.object({ + doc_form: z + .enum(['hierarchical_model', 'qa_model', 'text_model']) + .optional() + .default('text_model'), + doc_language: z.string().optional().default('English'), + name: z.string().nullish(), + process_rule: zProcessRule.nullish(), + retrieval_model: zRetrievalModel.nullish(), + text: z.null().optional(), + }), + ]), + z.object({ + doc_form: z + .enum(['hierarchical_model', 'qa_model', 'text_model']) + .optional() + .default('text_model'), + doc_language: z.string().optional().default('English'), + name: z.string().nullish(), + process_rule: zProcessRule.nullish(), + retrieval_model: zRetrievalModel.nullish(), + text: z.string().nullish(), + }), +) /** * HitTestingPayload */ export const zHitTestingPayload = z.object({ attachment_ids: z.array(z.string()).nullish(), - external_retrieval_model: z.record(z.string(), z.unknown()).nullish(), + external_retrieval_model: z + .object({ + score_threshold: z.number().optional(), + score_threshold_enabled: z.boolean().optional(), + top_k: z.int().optional(), + }) + .nullish(), query: z.string().max(250), retrieval_model: zRetrievalModel.nullish(), }) @@ -1999,10 +2229,37 @@ export const zWorkflowAppLogPaginationResponse = z.object({ * WorkflowRunPayload */ export const zWorkflowRunPayload = z.object({ - files: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), inputs: z.record(z.string(), z.unknown()), response_mode: z.enum(['blocking', 'streaming']).nullish(), - trace_session_id: z.string().nullish(), +}) + +/** + * WorkflowRunPayload + */ +export const zWorkflowRunPayloadWithUser = z.object({ + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), + inputs: z.record(z.string(), z.unknown()), + response_mode: z.enum(['blocking', 'streaming']).nullish(), + user: z.string(), }) /** @@ -2071,28 +2328,28 @@ export const zGetAppFeedbacksQuery = z.object({ }) /** - * Feedbacks retrieved successfully + * A list of application feedbacks. */ export const zGetAppFeedbacksResponse = zAppFeedbackListResponse export const zPostAppsAnnotationReplyByActionBody = zAnnotationReplyActionPayload export const zPostAppsAnnotationReplyByActionPath = z.object({ - action: z.string(), + action: z.enum(['disable', 'enable']), }) /** - * Action completed successfully + * Annotation reply settings task initiated. */ export const zPostAppsAnnotationReplyByActionResponse = zAnnotationJobStatusResponse export const zGetAppsAnnotationReplyByActionStatusByJobIdPath = z.object({ - action: z.string(), - job_id: z.string(), + action: z.enum(['disable', 'enable']), + job_id: z.uuid(), }) /** - * Job status retrieved successfully + * Successfully retrieved task status. */ export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse = zAnnotationJobStatusResponse @@ -2103,49 +2360,59 @@ export const zGetAppsAnnotationsQuery = z.object({ }) /** - * Annotations retrieved successfully + * Successfully retrieved annotation list. */ export const zGetAppsAnnotationsResponse = zAnnotationList export const zPostAppsAnnotationsBody = zAnnotationCreatePayload /** - * Annotation created successfully + * Annotation created successfully. */ export const zPostAppsAnnotationsResponse = zAnnotation export const zDeleteAppsAnnotationsByAnnotationIdPath = z.object({ - annotation_id: z.string(), + annotation_id: z.uuid(), }) /** - * Annotation deleted successfully + * Annotation deleted successfully. */ export const zDeleteAppsAnnotationsByAnnotationIdResponse = z.void() export const zPutAppsAnnotationsByAnnotationIdBody = zAnnotationCreatePayload export const zPutAppsAnnotationsByAnnotationIdPath = z.object({ - annotation_id: z.string(), + annotation_id: z.uuid(), }) /** - * Annotation updated successfully + * Annotation updated successfully. */ export const zPutAppsAnnotationsByAnnotationIdResponse = zAnnotation +export const zPostAudioToTextBody = z.object({ + file: z.custom(), + user: z.string().optional(), +}) + /** - * Audio successfully transcribed + * Successfully converted audio to text. */ export const zPostAudioToTextResponse = zAudioTranscriptResponse -export const zPostChatMessagesBody = zChatRequestPayload +export const zPostChatMessagesBody = zChatRequestPayloadWithUser /** - * Message sent successfully + * Successful response. The content type and structure depend on the `response_mode` parameter in the request. + * + * - If `response_mode` is `blocking`, returns `application/json` with a `ChatCompletionResponse` object. + * - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of Server-Sent Events. */ export const zPostChatMessagesResponse = zGeneratedAppResponse +export const zPostChatMessagesByTaskIdStopBody = zRequiredServiceApiUserPayload + export const zPostChatMessagesByTaskIdStopPath = z.object({ task_id: z.string(), }) @@ -2155,13 +2422,18 @@ export const zPostChatMessagesByTaskIdStopPath = z.object({ */ export const zPostChatMessagesByTaskIdStopResponse = zSimpleResultResponse -export const zPostCompletionMessagesBody = zCompletionRequestPayload +export const zPostCompletionMessagesBody = zCompletionRequestPayloadWithUser /** - * Completion created successfully + * Successful response. The content type and structure depend on the `response_mode` parameter in the request. + * + * - If `response_mode` is `blocking`, returns `application/json` with a `CompletionResponse` object. + * - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkCompletionEvent` objects. */ export const zPostCompletionMessagesResponse = zGeneratedAppResponse +export const zPostCompletionMessagesByTaskIdStopBody = zRequiredServiceApiUserPayload + export const zPostCompletionMessagesByTaskIdStopPath = z.object({ task_id: z.string(), }) @@ -2178,58 +2450,63 @@ export const zGetConversationsQuery = z.object({ .enum(['-created_at', '-updated_at', 'created_at', 'updated_at']) .optional() .default('-updated_at'), + user: z.string().optional(), }) /** - * Conversations retrieved successfully + * Successfully retrieved conversations list. */ export const zGetConversationsResponse = zConversationInfiniteScrollPagination +export const zDeleteConversationsByCIdBody = zOptionalServiceApiUserPayload + export const zDeleteConversationsByCIdPath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) /** - * Conversation deleted successfully + * Conversation deleted successfully. */ export const zDeleteConversationsByCIdResponse = z.void() -export const zPostConversationsByCIdNameBody = zConversationRenamePayload +export const zPostConversationsByCIdNameBody = zConversationRenamePayloadWithUser export const zPostConversationsByCIdNamePath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) /** - * Conversation renamed successfully + * Conversation renamed successfully. */ export const zPostConversationsByCIdNameResponse = zSimpleConversation export const zGetConversationsByCIdVariablesPath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) export const zGetConversationsByCIdVariablesQuery = z.object({ last_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), + user: z.string().optional(), variable_name: z.string().min(1).max(255).optional(), }) /** - * Variables retrieved successfully + * Successfully retrieved conversation variables. */ export const zGetConversationsByCIdVariablesResponse = zConversationVariableInfiniteScrollPaginationResponse -export const zPutConversationsByCIdVariablesByVariableIdBody = zConversationVariableUpdatePayload +export const zPutConversationsByCIdVariablesByVariableIdBody + = zConversationVariableUpdatePayloadWithUser export const zPutConversationsByCIdVariablesByVariableIdPath = z.object({ - c_id: z.string(), - variable_id: z.string(), + c_id: z.uuid(), + variable_id: z.uuid(), }) /** - * Variable updated successfully + * Variable updated successfully. */ export const zPutConversationsByCIdVariablesByVariableIdResponse = zConversationVariableResponse @@ -2242,88 +2519,92 @@ export const zGetDatasetsQuery = z.object({ }) /** - * Datasets retrieved successfully + * List of knowledge bases. */ export const zGetDatasetsResponse = zDatasetListResponse export const zPostDatasetsBody = zDatasetCreatePayload /** - * Dataset created successfully + * Knowledge base created successfully. */ export const zPostDatasetsResponse = zDatasetDetailResponse +export const zPostDatasetsPipelineFileUploadBody = z.object({ + file: z.custom(), +}) + /** - * File uploaded successfully + * File uploaded successfully. */ export const zPostDatasetsPipelineFileUploadResponse = zPipelineUploadFileResponse export const zDeleteDatasetsTagsBody = zTagDeletePayload /** - * Tag deleted successfully + * Success. */ export const zDeleteDatasetsTagsResponse = z.void() /** - * Tags retrieved successfully + * List of tags. */ export const zGetDatasetsTagsResponse = zKnowledgeTagListResponse export const zPatchDatasetsTagsBody = zTagUpdatePayload /** - * Tag updated successfully + * Tag updated successfully. */ export const zPatchDatasetsTagsResponse = zKnowledgeTagResponse export const zPostDatasetsTagsBody = zTagCreatePayload /** - * Tag created successfully + * Tag created successfully. */ export const zPostDatasetsTagsResponse = zKnowledgeTagResponse export const zPostDatasetsTagsBindingBody = zTagBindingPayload /** - * Tags bound successfully + * Success. */ export const zPostDatasetsTagsBindingResponse = z.void() export const zPostDatasetsTagsUnbindingBody = zTagUnbindingPayload /** - * Tags unbound successfully + * Success. */ export const zPostDatasetsTagsUnbindingResponse = z.void() export const zDeleteDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Dataset deleted successfully + * Success. */ export const zDeleteDatasetsByDatasetIdResponse = z.void() export const zGetDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Dataset retrieved successfully + * Knowledge base details. */ export const zGetDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMembersResponse export const zPatchDatasetsByDatasetIdBody = zDatasetUpdatePayload export const zPatchDatasetsByDatasetIdPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Dataset updated successfully + * Knowledge base updated successfully. */ export const zPatchDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMembersResponse @@ -2333,22 +2614,22 @@ export const zPostDatasetsByDatasetIdDocumentCreateByFileBody = z.object({ }) export const zPostDatasetsByDatasetIdDocumentCreateByFilePath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Document created successfully + * Document created successfully. */ export const zPostDatasetsByDatasetIdDocumentCreateByFileResponse = zDocumentAndBatchResponse export const zPostDatasetsByDatasetIdDocumentCreateByTextBody = zDocumentTextCreatePayload export const zPostDatasetsByDatasetIdDocumentCreateByTextPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Document created successfully + * Document created successfully. */ export const zPostDatasetsByDatasetIdDocumentCreateByTextResponse = zDocumentAndBatchResponse @@ -2358,18 +2639,18 @@ export const zPostDatasetsByDatasetIdDocumentCreateByFile2Body = z.object({ }) export const zPostDatasetsByDatasetIdDocumentCreateByFile2Path = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Document created successfully + * Document created successfully. */ export const zPostDatasetsByDatasetIdDocumentCreateByFile2Response = zDocumentAndBatchResponse export const zPostDatasetsByDatasetIdDocumentCreateByText2Body = zDocumentTextCreatePayload export const zPostDatasetsByDatasetIdDocumentCreateByText2Path = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** @@ -2378,48 +2659,50 @@ export const zPostDatasetsByDatasetIdDocumentCreateByText2Path = z.object({ export const zPostDatasetsByDatasetIdDocumentCreateByText2Response = zDocumentAndBatchResponse export const zGetDatasetsByDatasetIdDocumentsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsQuery = z.object({ keyword: z.string().optional(), limit: z.int().optional().default(20), page: z.int().optional().default(1), - status: z.string().optional(), + status: z + .enum(['archived', 'available', 'disabled', 'error', 'indexing', 'paused', 'queuing']) + .optional(), }) /** - * Documents retrieved successfully + * List of documents. */ export const zGetDatasetsByDatasetIdDocumentsResponse = zDocumentListResponse export const zPostDatasetsByDatasetIdDocumentsDownloadZipBody = zDocumentBatchDownloadZipPayload export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * ZIP archive generated successfully + * ZIP archive containing the requested documents. */ -export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = zBinaryFileResponse +export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom() export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData export const zPostDatasetsByDatasetIdDocumentsMetadataPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Documents metadata updated successfully + * Document metadata updated successfully. */ export const zPostDatasetsByDatasetIdDocumentsMetadataResponse = zDatasetMetadataActionResponse export const zPatchDatasetsByDatasetIdDocumentsStatusByActionBody = zDocumentStatusPayload export const zPatchDatasetsByDatasetIdDocumentsStatusByActionPath = z.object({ - action: z.string(), - dataset_id: z.string(), + action: z.enum(['archive', 'disable', 'enable', 'un_archive']), + dataset_id: z.uuid(), }) /** @@ -2429,28 +2712,28 @@ export const zPatchDatasetsByDatasetIdDocumentsStatusByActionResponse = zSimpleR export const zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusPath = z.object({ batch: z.string(), - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Indexing status retrieved successfully + * Indexing status for documents in the batch. */ export const zGetDatasetsByDatasetIdDocumentsByBatchIndexingStatusResponse = zDocumentStatusListResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** - * Document deleted successfully + * Success. */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery = z.object({ @@ -2458,7 +2741,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdQuery = z.object({ }) /** - * Document retrieved successfully + * Document details. The response shape varies based on the `metadata` query parameter. When `metadata` is `only`, only `id`, `doc_type`, and `doc_metadata` are returned. When `metadata` is `without`, `doc_type` and `doc_metadata` are omitted. */ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentDetailResponse @@ -2468,8 +2751,8 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdBody = z.object({ }) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -2478,18 +2761,18 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentAndBatchResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** - * Download URL generated successfully + * Download URL generated successfully. */ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdDownloadResponse = zUrlResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.object({ @@ -2500,42 +2783,42 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsQuery = z.objec }) /** - * Segments retrieved successfully + * List of chunks. */ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = zSegmentListResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBody = zSegmentCreatePayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** - * Segments created successfully + * Chunks created successfully. */ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsResponse = zSegmentCreateListResponse export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** - * Segment deleted successfully + * Success. */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = z.void() export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** - * Segment retrieved successfully + * Chunk details. */ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = zSegmentDetailResponse @@ -2544,22 +2827,22 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdBod = zSegmentUpdatePayload export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** - * Segment updated successfully + * Chunk updated successfully. */ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdResponse = zSegmentDetailResponse export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksQuery @@ -2570,7 +2853,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChil }) /** - * Child chunks retrieved successfully + * List of child chunks. */ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = zChildChunkListResponse @@ -2580,27 +2863,27 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChi export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), - segment_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** - * Child chunk created successfully + * Child chunk created successfully. */ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksResponse = zChildChunkDetailResponse 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.uuid(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** - * Child chunk deleted successfully + * Success. */ export const zDeleteDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = z.void() @@ -2610,14 +2893,14 @@ 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.uuid(), + dataset_id: z.uuid(), + document_id: z.uuid(), + segment_id: z.uuid(), }) /** - * Child chunk updated successfully + * Child chunk updated successfully. */ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdChildChunksByChildChunkIdResponse = zChildChunkDetailResponse @@ -2628,12 +2911,12 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileBody = z.o }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFilePath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** - * Document updated successfully + * Document updated successfully. */ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse = zDocumentAndBatchResponse @@ -2641,12 +2924,12 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileResponse export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextBody = zDocumentTextUpdate export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextPath = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** - * Document updated successfully + * Document updated successfully. */ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse = zDocumentAndBatchResponse @@ -2657,12 +2940,12 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Body = z. }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Path = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** - * Document updated successfully + * Document updated successfully. */ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Response = zDocumentAndBatchResponse @@ -2670,8 +2953,8 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Response export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Body = zDocumentTextUpdate export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Path = z.object({ - dataset_id: z.string(), - document_id: z.string(), + dataset_id: z.uuid(), + document_id: z.uuid(), }) /** @@ -2683,78 +2966,78 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByText2Response export const zPostDatasetsByDatasetIdHitTestingBody = zHitTestingPayload export const zPostDatasetsByDatasetIdHitTestingPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Hit testing results + * Retrieval results. */ export const zPostDatasetsByDatasetIdHitTestingResponse = zHitTestingResponse export const zGetDatasetsByDatasetIdMetadataPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Metadata retrieved successfully + * Metadata fields for the knowledge base. */ export const zGetDatasetsByDatasetIdMetadataResponse = zDatasetMetadataListResponse export const zPostDatasetsByDatasetIdMetadataBody = zMetadataArgs export const zPostDatasetsByDatasetIdMetadataPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Metadata created successfully + * Metadata field created successfully. */ export const zPostDatasetsByDatasetIdMetadataResponse = zDatasetMetadataResponse export const zGetDatasetsByDatasetIdMetadataBuiltInPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Built-in fields retrieved successfully + * Built-in metadata fields. */ export const zGetDatasetsByDatasetIdMetadataBuiltInResponse = zDatasetMetadataBuiltInFieldsResponse export const zPostDatasetsByDatasetIdMetadataBuiltInByActionPath = z.object({ - action: z.string(), - dataset_id: z.string(), + action: z.enum(['disable', 'enable']), + dataset_id: z.uuid(), }) /** - * Action completed successfully + * Built-in metadata field toggled successfully. */ export const zPostDatasetsByDatasetIdMetadataBuiltInByActionResponse = zDatasetMetadataActionResponse export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ - dataset_id: z.string(), - metadata_id: z.string(), + dataset_id: z.uuid(), + metadata_id: z.uuid(), }) /** - * Metadata deleted successfully + * Success. */ export const zDeleteDatasetsByDatasetIdMetadataByMetadataIdResponse = z.void() export const zPatchDatasetsByDatasetIdMetadataByMetadataIdBody = zMetadataUpdatePayload export const zPatchDatasetsByDatasetIdMetadataByMetadataIdPath = z.object({ - dataset_id: z.string(), - metadata_id: z.string(), + dataset_id: z.uuid(), + metadata_id: z.uuid(), }) /** - * Metadata updated successfully + * Metadata field updated successfully. */ export const zPatchDatasetsByDatasetIdMetadataByMetadataIdResponse = zDatasetMetadataResponse export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsQuery = z.object({ @@ -2762,7 +3045,7 @@ export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsQuery = z.object({ }) /** - * Datasource plugins retrieved successfully + * List of datasource nodes configured in the pipeline. */ export const zGetDatasetsByDatasetIdPipelineDatasourcePluginsResponse = zDatasourcePluginListResponse @@ -2771,12 +3054,12 @@ export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunBody = zDatasourceNodeRunPayload export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), node_id: z.string(), }) /** - * Datasource node run successfully + * Streaming response with node execution events. */ export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse = zGeneratedAppResponse @@ -2784,83 +3067,89 @@ export const zPostDatasetsByDatasetIdPipelineDatasourceNodesByNodeIdRunResponse export const zPostDatasetsByDatasetIdPipelineRunBody = zPipelineRunApiEntity export const zPostDatasetsByDatasetIdPipelineRunPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Pipeline run successfully + * Pipeline execution result. Format depends on `response_mode`: streaming returns a `text/event-stream`, blocking returns a JSON object. */ export const zPostDatasetsByDatasetIdPipelineRunResponse = zGeneratedAppResponse export const zPostDatasetsByDatasetIdRetrieveBody = zHitTestingPayload export const zPostDatasetsByDatasetIdRetrievePath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Hit testing results + * Retrieval results. */ export const zPostDatasetsByDatasetIdRetrieveResponse = zHitTestingResponse export const zGetDatasetsByDatasetIdTagsPath = z.object({ - dataset_id: z.string(), + dataset_id: z.uuid(), }) /** - * Tags retrieved successfully + * Tags bound to the knowledge base. */ export const zGetDatasetsByDatasetIdTagsResponse = zDatasetBoundTagListResponse export const zGetEndUsersByEndUserIdPath = z.object({ - end_user_id: z.string(), + end_user_id: z.uuid(), }) /** - * End user retrieved successfully + * End user retrieved successfully. */ export const zGetEndUsersByEndUserIdResponse = zEndUserDetail +export const zPostFilesUploadBody = z.object({ + file: z.custom(), + user: z.string().optional(), +}) + /** - * File uploaded successfully + * File uploaded successfully. */ export const zPostFilesUploadResponse = zFileResponse export const zGetFilesByFileIdPreviewPath = z.object({ - file_id: z.string(), + file_id: z.uuid(), }) export const zGetFilesByFileIdPreviewQuery = z.object({ as_attachment: z.boolean().optional().default(false), + user: z.string().optional(), }) /** - * File retrieved successfully + * Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If `as_attachment` is `true`, the file is returned as a download with `Content-Disposition: attachment`. */ -export const zGetFilesByFileIdPreviewResponse = zBinaryFileResponse +export const zGetFilesByFileIdPreviewResponse = z.custom() export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) /** - * Form retrieved successfully + * Form contents retrieved successfully. */ export const zGetFormHumanInputByFormTokenResponse = zHumanInputFormDefinitionResponse -export const zPostFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload +export const zPostFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayloadWithUser export const zPostFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), }) /** - * Form submitted successfully + * Form submitted successfully. The response body is an empty object. */ export const zPostFormHumanInputByFormTokenResponse = zHumanInputFormSubmitResponse /** - * Application info retrieved successfully + * Basic information of the application. */ export const zGetInfoResponse = zAppInfoResponse @@ -2868,17 +3157,18 @@ export const zGetMessagesQuery = z.object({ conversation_id: z.string(), first_id: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), + user: z.string().optional(), }) /** - * Messages retrieved successfully + * Successfully retrieved conversation history. */ export const zGetMessagesResponse = zMessageInfiniteScrollPagination -export const zPostMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload +export const zPostMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayloadWithUser export const zPostMessagesByMessageIdFeedbacksPath = z.object({ - message_id: z.string(), + message_id: z.uuid(), }) /** @@ -2887,7 +3177,11 @@ export const zPostMessagesByMessageIdFeedbacksPath = z.object({ export const zPostMessagesByMessageIdFeedbacksResponse = zResultResponse export const zGetMessagesByMessageIdSuggestedPath = z.object({ - message_id: z.string(), + message_id: z.uuid(), +}) + +export const zGetMessagesByMessageIdSuggestedQuery = z.object({ + user: z.string(), }) /** @@ -2896,26 +3190,26 @@ export const zGetMessagesByMessageIdSuggestedPath = z.object({ export const zGetMessagesByMessageIdSuggestedResponse = zSimpleResultStringListResponse /** - * Metadata retrieved successfully + * Successfully retrieved application meta information. */ export const zGetMetaResponse = zAppMetaResponse /** - * Parameters retrieved successfully + * Application parameters information. */ export const zGetParametersResponse = zParameters /** - * Site configuration retrieved successfully + * WebApp settings of the application. */ export const zGetSiteResponse = zSite -export const zPostTextToAudioBody = zTextToAudioPayload +export const zPostTextToAudioBody = zTextToAudioPayloadWithUser /** - * Text successfully converted to audio + * Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly. */ -export const zPostTextToAudioResponse = zAudioBinaryResponse +export const zPostTextToAudioResponse = z.custom() export const zGetWorkflowByTaskIdEventsPath = z.object({ task_id: z.string(), @@ -2928,13 +3222,13 @@ export const zGetWorkflowByTaskIdEventsQuery = z.object({ }) /** - * SSE event stream + * Server-Sent Events stream. Each event is delivered as `data: {JSON}\n\n`. Event payloads follow the same schemas as the original streaming response. */ export const zGetWorkflowByTaskIdEventsResponse = zEventStreamResponse export const zGetWorkflowsLogsQuery = z.object({ - created_at__after: z.string().optional(), - created_at__before: z.string().optional(), + created_at__after: z.iso.datetime().optional(), + created_at__before: z.iso.datetime().optional(), created_by_account: z.string().optional(), created_by_end_user_session_id: z.string().optional(), keyword: z.string().optional(), @@ -2944,14 +3238,17 @@ export const zGetWorkflowsLogsQuery = z.object({ }) /** - * Logs retrieved successfully + * Successfully retrieved workflow logs. */ export const zGetWorkflowsLogsResponse = zWorkflowAppLogPaginationResponse -export const zPostWorkflowsRunBody = zWorkflowRunPayload +export const zPostWorkflowsRunBody = zWorkflowRunPayloadWithUser /** - * Workflow executed successfully + * Successful response. The content type and structure depend on the `response_mode` parameter in the request. + * + * - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. + * - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. */ export const zPostWorkflowsRunResponse = zGeneratedAppResponse @@ -2960,10 +3257,12 @@ export const zGetWorkflowsRunByWorkflowRunIdPath = z.object({ }) /** - * Workflow run details retrieved successfully + * Successfully retrieved workflow run details. */ export const zGetWorkflowsRunByWorkflowRunIdResponse = zWorkflowRunResponse +export const zPostWorkflowsTasksByTaskIdStopBody = zRequiredServiceApiUserPayload + export const zPostWorkflowsTasksByTaskIdStopPath = z.object({ task_id: z.string(), }) @@ -2973,23 +3272,26 @@ export const zPostWorkflowsTasksByTaskIdStopPath = z.object({ */ export const zPostWorkflowsTasksByTaskIdStopResponse = zSimpleResultResponse -export const zPostWorkflowsByWorkflowIdRunBody = zWorkflowRunPayload +export const zPostWorkflowsByWorkflowIdRunBody = zWorkflowRunPayloadWithUser export const zPostWorkflowsByWorkflowIdRunPath = z.object({ workflow_id: z.string(), }) /** - * Workflow executed successfully + * Successful response. The content type and structure depend on the `response_mode` parameter in the request. + * + * - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. + * - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. */ export const zPostWorkflowsByWorkflowIdRunResponse = zGeneratedAppResponse export const zGetWorkspacesCurrentModelsModelTypesByModelTypePath = z.object({ - model_type: z.string(), + model_type: z.enum(['llm', 'moderation', 'rerank', 'speech2text', 'text-embedding', 'tts']), }) /** - * Models retrieved successfully + * Available models for the specified type. */ export const zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse = zProviderWithModelsListResponse diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 9d16cb99524..61c9cf103be 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -146,7 +146,16 @@ export type ConversationListQuery = { sort_by?: '-created_at' | '-updated_at' | 'created_at' | 'updated_at' } -export type ConversationRenamePayload = { +export type ConversationRenamePayload = ( + | { + auto_generate: true + name?: string | null + } + | { + auto_generate?: false + name: string + } +) & { auto_generate?: boolean name?: string | null } @@ -540,6 +549,7 @@ export type SuggestedQuestionsResponse = { export type SystemFeatureModel = { branding: BrandingModel + enable_app_deploy: boolean enable_change_email: boolean enable_collaboration_mode: boolean enable_creators_platform: boolean @@ -556,6 +566,7 @@ export type SystemFeatureModel = { max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel plugin_manager: PluginManagerModel + rbac_enabled: boolean sso_enforced_for_signin: boolean sso_enforced_for_signin_protocol: string webapp_auth: WebAppAuthModel @@ -630,7 +641,10 @@ export type WebMessageListItem = { export type WorkflowRunPayload = { files?: Array<{ - [key: string]: unknown + transfer_method: 'local_file' | 'remote_url' + type: 'audio' | 'custom' | 'document' | 'image' | 'video' + upload_file_id?: string + url?: string }> | null inputs: { [key: string]: unknown diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index cb731344ab6..8045ad341e3 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -168,13 +168,22 @@ export const zConversationListQuery = z.object({ .default('-updated_at'), }) -/** - * ConversationRenamePayload - */ -export const zConversationRenamePayload = z.object({ - auto_generate: z.boolean().optional().default(false), - name: z.string().nullish(), -}) +export const zConversationRenamePayload = z.intersection( + z.union([ + z.object({ + auto_generate: z.literal(true), + name: z.string().nullish(), + }), + z.object({ + auto_generate: z.literal(false).optional().default(false), + name: z.string().regex(/.*\S.*/), + }), + ]), + z.object({ + auto_generate: z.boolean().optional().default(false), + name: z.string().nullish(), + }), +) /** * EmailCodeLoginSendPayload @@ -687,7 +696,7 @@ export const zParameters = z.object({ * TextToAudioPayload */ export const zTextToAudioPayload = z.object({ - message_id: z.string().nullish(), + message_id: z.uuid().nullish(), streaming: z.boolean().nullish(), text: z.string().nullish(), voice: z.string().nullish(), @@ -824,6 +833,7 @@ export const zSystemFeatureModel = z.object({ login_page_logo: '', workspace_logo: '', }), + enable_app_deploy: z.boolean().default(false), enable_change_email: z.boolean().default(true), enable_collaboration_mode: z.boolean().default(true), enable_creators_platform: z.boolean().default(false), @@ -851,6 +861,7 @@ export const zSystemFeatureModel = z.object({ restrict_to_marketplace_only: false, }), plugin_manager: zPluginManagerModel.default({ enabled: false }), + rbac_enabled: z.boolean().default(false), sso_enforced_for_signin: z.boolean().default(false), sso_enforced_for_signin_protocol: z.string().default(''), webapp_auth: zWebAppAuthModel.default({ @@ -896,7 +907,16 @@ export const zWebMessageInfiniteScrollPagination = z.object({ * WorkflowRunPayload */ export const zWorkflowRunPayload = z.object({ - files: z.array(z.record(z.string(), z.unknown())).nullish(), + files: z + .array( + z.object({ + transfer_method: z.enum(['local_file', 'remote_url']), + type: z.enum(['audio', 'custom', 'document', 'image', 'video']), + upload_file_id: z.string().optional(), + url: z.string().optional(), + }), + ) + .nullish(), inputs: z.record(z.string(), z.unknown()), }) @@ -953,7 +973,7 @@ export const zGetConversationsQuery = z.object({ export const zGetConversationsResponse = zConversationInfiniteScrollPagination export const zDeleteConversationsByCIdPath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) /** @@ -964,7 +984,7 @@ export const zDeleteConversationsByCIdResponse = z.void() export const zPostConversationsByCIdNameBody = zConversationRenamePayload export const zPostConversationsByCIdNamePath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) export const zPostConversationsByCIdNameQuery = z.object({ @@ -978,7 +998,7 @@ export const zPostConversationsByCIdNameQuery = z.object({ export const zPostConversationsByCIdNameResponse = zSimpleConversation export const zPatchConversationsByCIdPinPath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) /** @@ -987,7 +1007,7 @@ export const zPatchConversationsByCIdPinPath = z.object({ export const zPatchConversationsByCIdPinResponse = zResultResponse export const zPatchConversationsByCIdUnpinPath = z.object({ - c_id: z.string(), + c_id: z.uuid(), }) /** @@ -1105,7 +1125,7 @@ export const zGetMessagesResponse = zWebMessageInfiniteScrollPagination export const zPostMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload export const zPostMessagesByMessageIdFeedbacksPath = z.object({ - message_id: z.string(), + message_id: z.uuid(), }) export const zPostMessagesByMessageIdFeedbacksQuery = z.object({ @@ -1119,7 +1139,7 @@ export const zPostMessagesByMessageIdFeedbacksQuery = z.object({ export const zPostMessagesByMessageIdFeedbacksResponse = zResultResponse export const zGetMessagesByMessageIdMoreLikeThisPath = z.object({ - message_id: z.string(), + message_id: z.uuid(), }) export const zGetMessagesByMessageIdMoreLikeThisQuery = z.object({ @@ -1132,7 +1152,7 @@ export const zGetMessagesByMessageIdMoreLikeThisQuery = z.object({ export const zGetMessagesByMessageIdMoreLikeThisResponse = zGeneratedAppResponse export const zGetMessagesByMessageIdSuggestedQuestionsPath = z.object({ - message_id: z.string(), + message_id: z.uuid(), }) /** @@ -1197,7 +1217,7 @@ export const zPostSavedMessagesQuery = z.object({ export const zPostSavedMessagesResponse = zResultResponse export const zDeleteSavedMessagesByMessageIdPath = z.object({ - message_id: z.string(), + message_id: z.uuid(), }) /** diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 6b9b76470aa..61503a7f742 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -4,9 +4,97 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zAccessServiceCreateApiKeyBody, + zAccessServiceCreateApiKeyPath, + zAccessServiceCreateApiKeyResponse, + zAccessServiceDeleteApiKeyPath, + zAccessServiceDeleteApiKeyResponse, + zAccessServiceGetAccessChannelsPath, + zAccessServiceGetAccessChannelsResponse, + zAccessServiceGetAccessPolicyPath, + zAccessServiceGetAccessPolicyResponse, + zAccessServiceGetAccessSettingsPath, + zAccessServiceGetAccessSettingsResponse, + zAccessServiceGetDeveloperApiSettingsPath, + zAccessServiceGetDeveloperApiSettingsResponse, + zAccessServiceListApiKeysPath, + zAccessServiceListApiKeysResponse, + zAccessServiceUpdateAccessChannelsBody, + zAccessServiceUpdateAccessChannelsPath, + zAccessServiceUpdateAccessChannelsResponse, + zAccessServiceUpdateAccessPolicyBody, + zAccessServiceUpdateAccessPolicyPath, + zAccessServiceUpdateAccessPolicyResponse, + zAccessSubjectServiceListAccessSubjectsQuery, + zAccessSubjectServiceListAccessSubjectsResponse, + zAppInstanceServiceCreateAppInstanceBody, + zAppInstanceServiceCreateAppInstanceResponse, + zAppInstanceServiceDeleteAppInstancePath, + zAppInstanceServiceDeleteAppInstanceResponse, + zAppInstanceServiceGetAppInstanceOverviewPath, + zAppInstanceServiceGetAppInstanceOverviewResponse, + zAppInstanceServiceGetAppInstancePath, + zAppInstanceServiceGetAppInstanceResponse, + zAppInstanceServiceListAppInstancesQuery, + zAppInstanceServiceListAppInstancesResponse, + zAppInstanceServiceListAppInstanceSummariesQuery, + zAppInstanceServiceListAppInstanceSummariesResponse, + zAppInstanceServiceUpdateAppInstanceBody, + zAppInstanceServiceUpdateAppInstancePath, + zAppInstanceServiceUpdateAppInstanceResponse, zConsoleSsoOAuth2LoginResponse, zConsoleSsoOidcLoginResponse, zConsoleSsoSamlLoginResponse, + zDeploymentServiceCancelDeploymentBody, + zDeploymentServiceCancelDeploymentPath, + zDeploymentServiceCancelDeploymentResponse, + zDeploymentServiceDeployBody, + zDeploymentServiceDeployResponse, + zDeploymentServiceListDeploymentsPath, + zDeploymentServiceListDeploymentsQuery, + zDeploymentServiceListDeploymentsResponse, + zDeploymentServiceListEnvironmentDeploymentsPath, + zDeploymentServiceListEnvironmentDeploymentsResponse, + zDeploymentServiceListRollbackTargetsPath, + zDeploymentServiceListRollbackTargetsQuery, + zDeploymentServiceListRollbackTargetsResponse, + zDeploymentServicePromoteBody, + zDeploymentServicePromotePath, + zDeploymentServicePromoteResponse, + zDeploymentServiceRollbackBody, + zDeploymentServiceRollbackPath, + zDeploymentServiceRollbackResponse, + zDeploymentServiceUndeployBody, + zDeploymentServiceUndeployPath, + zDeploymentServiceUndeployResponse, + zEnvironmentServiceListEnvironmentsQuery, + zEnvironmentServiceListEnvironmentsResponse, + zReleaseServiceComputeDeploymentOptionsBody, + zReleaseServiceComputeDeploymentOptionsResponse, + zReleaseServiceComputeReleaseDeploymentViewPath, + zReleaseServiceComputeReleaseDeploymentViewQuery, + zReleaseServiceComputeReleaseDeploymentViewResponse, + zReleaseServiceCreateReleaseBody, + zReleaseServiceCreateReleaseResponse, + zReleaseServiceDeleteReleasePath, + zReleaseServiceDeleteReleaseResponse, + zReleaseServiceExportReleaseDslPath, + zReleaseServiceExportReleaseDslResponse, + zReleaseServiceGetReleasePath, + zReleaseServiceGetReleaseResponse, + zReleaseServiceListReleaseCredentialCandidatesPath, + zReleaseServiceListReleaseCredentialCandidatesResponse, + zReleaseServiceListReleasesPath, + zReleaseServiceListReleasesQuery, + zReleaseServiceListReleasesResponse, + zReleaseServiceListReleaseSummariesPath, + zReleaseServiceListReleaseSummariesQuery, + zReleaseServiceListReleaseSummariesResponse, + zReleaseServicePrecheckReleaseBody, + zReleaseServicePrecheckReleaseResponse, + zReleaseServiceUpdateReleaseBody, + zReleaseServiceUpdateReleasePath, + zReleaseServiceUpdateReleaseResponse, zWebAppAuthGetGroupSubjectsQuery, zWebAppAuthGetGroupSubjectsResponse, zWebAppAuthGetWebAppAccessModeQuery, @@ -21,6 +109,524 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' +export const listAccessSubjects = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessSubjectService_ListAccessSubjects', + path: '/enterprise/access-subjects', + tags: ['AccessSubjectService'], + }) + .input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() })) + .output(zAccessSubjectServiceListAccessSubjectsResponse) + +export const accessSubjectService = { + listAccessSubjects, +} + +export const listAppInstanceSummaries = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_ListAppInstanceSummaries', + path: '/enterprise/app-deploy/appInstanceSummaries', + tags: ['AppInstanceService'], + }) + .input(z.object({ query: zAppInstanceServiceListAppInstanceSummariesQuery.optional() })) + .output(zAppInstanceServiceListAppInstanceSummariesResponse) + +export const listAppInstances = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_ListAppInstances', + path: '/enterprise/app-deploy/appInstances', + tags: ['AppInstanceService'], + }) + .input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() })) + .output(zAppInstanceServiceListAppInstancesResponse) + +export const createAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'AppInstanceService_CreateAppInstance', + path: '/enterprise/app-deploy/appInstances', + tags: ['AppInstanceService'], + }) + .input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody })) + .output(zAppInstanceServiceCreateAppInstanceResponse) + +export const deleteAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'AppInstanceService_DeleteAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath })) + .output(zAppInstanceServiceDeleteAppInstanceResponse) + +export const getAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_GetAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceGetAppInstancePath })) + .output(zAppInstanceServiceGetAppInstanceResponse) + +export const updateAppInstance = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'AppInstanceService_UpdateAppInstance', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}', + tags: ['AppInstanceService'], + }) + .input( + z.object({ + body: zAppInstanceServiceUpdateAppInstanceBody, + params: zAppInstanceServiceUpdateAppInstancePath, + }), + ) + .output(zAppInstanceServiceUpdateAppInstanceResponse) + +export const getAppInstanceOverview = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AppInstanceService_GetAppInstanceOverview', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview', + tags: ['AppInstanceService'], + }) + .input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath })) + .output(zAppInstanceServiceGetAppInstanceOverviewResponse) + +export const appInstanceService = { + listAppInstanceSummaries, + listAppInstances, + createAppInstance, + deleteAppInstance, + getAppInstance, + updateAppInstance, + getAppInstanceOverview, +} + +export const getAccessChannels = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessChannels', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessChannelsPath })) + .output(zAccessServiceGetAccessChannelsResponse) + +export const updateAccessChannels = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'AccessService_UpdateAccessChannels', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels', + tags: ['AccessService'], + }) + .input( + z.object({ + body: zAccessServiceUpdateAccessChannelsBody, + params: zAccessServiceUpdateAccessChannelsPath, + }), + ) + .output(zAccessServiceUpdateAccessChannelsResponse) + +export const getAccessSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessSettings', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessSettingsPath })) + .output(zAccessServiceGetAccessSettingsResponse) + +export const getDeveloperApiSettings = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetDeveloperApiSettings', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetDeveloperApiSettingsPath })) + .output(zAccessServiceGetDeveloperApiSettingsResponse) + +export const getAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_GetAccessPolicy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceGetAccessPolicyPath })) + .output(zAccessServiceGetAccessPolicyResponse) + +export const updateAccessPolicy = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'AccessService_UpdateAccessPolicy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy', + tags: ['AccessService'], + }) + .input( + z.object({ + body: zAccessServiceUpdateAccessPolicyBody, + params: zAccessServiceUpdateAccessPolicyPath, + }), + ) + .output(zAccessServiceUpdateAccessPolicyResponse) + +export const listApiKeys = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'AccessService_ListApiKeys', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceListApiKeysPath })) + .output(zAccessServiceListApiKeysResponse) + +export const createApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'AccessService_CreateApiKey', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys', + tags: ['AccessService'], + }) + .input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath })) + .output(zAccessServiceCreateApiKeyResponse) + +export const deleteApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'AccessService_DeleteApiKey', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}', + tags: ['AccessService'], + }) + .input(z.object({ params: zAccessServiceDeleteApiKeyPath })) + .output(zAccessServiceDeleteApiKeyResponse) + +export const accessService = { + getAccessChannels, + updateAccessChannels, + getAccessSettings, + getDeveloperApiSettings, + getAccessPolicy, + updateAccessPolicy, + listApiKeys, + createApiKey, + deleteApiKey, +} + +export const listDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListDeployments', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments', + tags: ['DeploymentService'], + }) + .input( + z.object({ + params: zDeploymentServiceListDeploymentsPath, + query: zDeploymentServiceListDeploymentsQuery.optional(), + }), + ) + .output(zDeploymentServiceListDeploymentsResponse) + +export const listEnvironmentDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListEnvironmentDeployments', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments', + tags: ['DeploymentService'], + }) + .input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath })) + .output(zDeploymentServiceListEnvironmentDeploymentsResponse) + +export const listRollbackTargets = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'DeploymentService_ListRollbackTargets', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets', + tags: ['DeploymentService'], + }) + .input( + z.object({ + params: zDeploymentServiceListRollbackTargetsPath, + query: zDeploymentServiceListRollbackTargetsQuery.optional(), + }), + ) + .output(zDeploymentServiceListRollbackTargetsResponse) + +/** + * CancelDeployment cancels the in-flight deployment on the environment. + */ +export const cancelDeployment = oc + .route({ + description: 'CancelDeployment cancels the in-flight deployment on the environment.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_CancelDeployment', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment', + tags: ['DeploymentService'], + }) + .input( + z.object({ + body: zDeploymentServiceCancelDeploymentBody, + params: zDeploymentServiceCancelDeploymentPath, + }), + ) + .output(zDeploymentServiceCancelDeploymentResponse) + +export const promote = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Promote', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServicePromoteBody, params: zDeploymentServicePromotePath })) + .output(zDeploymentServicePromoteResponse) + +export const rollback = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Rollback', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceRollbackBody, params: zDeploymentServiceRollbackPath })) + .output(zDeploymentServiceRollbackResponse) + +export const undeploy = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Undeploy', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceUndeployBody, params: zDeploymentServiceUndeployPath })) + .output(zDeploymentServiceUndeployResponse) + +export const deploy = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'DeploymentService_Deploy', + path: '/enterprise/app-deploy/appInstances:deploy', + tags: ['DeploymentService'], + }) + .input(z.object({ body: zDeploymentServiceDeployBody })) + .output(zDeploymentServiceDeployResponse) + +export const deploymentService = { + listDeployments, + listEnvironmentDeployments, + listRollbackTargets, + cancelDeployment, + promote, + rollback, + undeploy, + deploy, +} + +export const listReleaseSummaries = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleaseSummaries', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceListReleaseSummariesPath, + query: zReleaseServiceListReleaseSummariesQuery.optional(), + }), + ) + .output(zReleaseServiceListReleaseSummariesResponse) + +export const listReleases = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleases', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceListReleasesPath, + query: zReleaseServiceListReleasesQuery.optional(), + }), + ) + .output(zReleaseServiceListReleasesResponse) + +export const computeReleaseDeploymentView = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ComputeReleaseDeploymentView', + path: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView', + tags: ['ReleaseService'], + }) + .input( + z.object({ + params: zReleaseServiceComputeReleaseDeploymentViewPath, + query: zReleaseServiceComputeReleaseDeploymentViewQuery.optional(), + }), + ) + .output(zReleaseServiceComputeReleaseDeploymentViewResponse) + +export const createRelease = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_CreateRelease', + path: '/enterprise/app-deploy/releases', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServiceCreateReleaseBody })) + .output(zReleaseServiceCreateReleaseResponse) + +export const deleteRelease = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'ReleaseService_DeleteRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceDeleteReleasePath })) + .output(zReleaseServiceDeleteReleaseResponse) + +export const getRelease = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_GetRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceGetReleasePath })) + .output(zReleaseServiceGetReleaseResponse) + +export const updateRelease = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'ReleaseService_UpdateRelease', + path: '/enterprise/app-deploy/releases/{releaseId}', + tags: ['ReleaseService'], + }) + .input( + z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }), + ) + .output(zReleaseServiceUpdateReleaseResponse) + +export const exportReleaseDsl = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ExportReleaseDSL', + path: '/enterprise/app-deploy/releases/{releaseId}:exportDsl', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceExportReleaseDslPath })) + .output(zReleaseServiceExportReleaseDslResponse) + +export const listReleaseCredentialCandidates = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ReleaseService_ListReleaseCredentialCandidates', + path: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates', + tags: ['ReleaseService'], + }) + .input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath })) + .output(zReleaseServiceListReleaseCredentialCandidatesResponse) + +export const computeDeploymentOptions = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_ComputeDeploymentOptions', + path: '/enterprise/app-deploy/releases:computeDeploymentOptions', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServiceComputeDeploymentOptionsBody })) + .output(zReleaseServiceComputeDeploymentOptionsResponse) + +export const precheckRelease = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ReleaseService_PrecheckRelease', + path: '/enterprise/app-deploy/releases:precheck', + tags: ['ReleaseService'], + }) + .input(z.object({ body: zReleaseServicePrecheckReleaseBody })) + .output(zReleaseServicePrecheckReleaseResponse) + +export const releaseService = { + listReleaseSummaries, + listReleases, + computeReleaseDeploymentView, + createRelease, + deleteRelease, + getRelease, + updateRelease, + exportReleaseDsl, + listReleaseCredentialCandidates, + computeDeploymentOptions, + precheckRelease, +} + +/** + * ListEnvironments returns only the environments the current user can + * deploy to. + */ +export const listEnvironments = oc + .route({ + description: 'ListEnvironments returns only the environments the current user can\n deploy to.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'EnvironmentService_ListEnvironments', + path: '/enterprise/app-deploy/environments', + tags: ['EnvironmentService'], + }) + .input(z.object({ query: zEnvironmentServiceListEnvironmentsQuery.optional() })) + .output(zEnvironmentServiceListEnvironmentsResponse) + +export const environmentService = { + listEnvironments, +} + export const oAuth2Login = oc .route({ inputStructure: 'detailed', @@ -133,6 +739,12 @@ export const webAppAuth = { } export const contract = { + accessSubjectService, + appInstanceService, + accessService, + deploymentService, + releaseService, + environmentService, consoleSso, webAppAuth, } diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index b747c4baa89..600f8975678 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -4,6 +4,1051 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } +export const AccessMode = { + ACCESS_MODE_UNSPECIFIED: 'ACCESS_MODE_UNSPECIFIED', + ACCESS_MODE_PUBLIC: 'ACCESS_MODE_PUBLIC', + ACCESS_MODE_PRIVATE: 'ACCESS_MODE_PRIVATE', + ACCESS_MODE_PRIVATE_ALL: 'ACCESS_MODE_PRIVATE_ALL', +} as const + +export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode] + +export const SubjectType = { + SUBJECT_TYPE_UNSPECIFIED: 'SUBJECT_TYPE_UNSPECIFIED', + SUBJECT_TYPE_ACCOUNT: 'SUBJECT_TYPE_ACCOUNT', + SUBJECT_TYPE_GROUP: 'SUBJECT_TYPE_GROUP', +} as const + +export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType] + +export const AppRunnerLogStatus = { + APP_RUNNER_LOG_STATUS_UNSPECIFIED: 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', + APP_RUNNER_LOG_STATUS_RUNNING: 'APP_RUNNER_LOG_STATUS_RUNNING', + APP_RUNNER_LOG_STATUS_SUCCEEDED: 'APP_RUNNER_LOG_STATUS_SUCCEEDED', + APP_RUNNER_LOG_STATUS_FAILED: 'APP_RUNNER_LOG_STATUS_FAILED', + APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED: 'APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED', +} as const + +export type AppRunnerLogStatus = (typeof AppRunnerLogStatus)[keyof typeof AppRunnerLogStatus] + +export const AssignmentOperation = { + ASSIGNMENT_OPERATION_UNSPECIFIED: 'ASSIGNMENT_OPERATION_UNSPECIFIED', + ASSIGNMENT_OPERATION_LOAD: 'ASSIGNMENT_OPERATION_LOAD', + ASSIGNMENT_OPERATION_UNLOAD: 'ASSIGNMENT_OPERATION_UNLOAD', +} as const + +export type AssignmentOperation = (typeof AssignmentOperation)[keyof typeof AssignmentOperation] + +export const EnvironmentMode = { + ENVIRONMENT_MODE_UNSPECIFIED: 'ENVIRONMENT_MODE_UNSPECIFIED', + ENVIRONMENT_MODE_SHARED: 'ENVIRONMENT_MODE_SHARED', + ENVIRONMENT_MODE_ISOLATED: 'ENVIRONMENT_MODE_ISOLATED', +} as const + +export type EnvironmentMode = (typeof EnvironmentMode)[keyof typeof EnvironmentMode] + +export const RuntimeBackend = { + RUNTIME_BACKEND_UNSPECIFIED: 'RUNTIME_BACKEND_UNSPECIFIED', + RUNTIME_BACKEND_K8S: 'RUNTIME_BACKEND_K8S', + RUNTIME_BACKEND_EXTERNAL: 'RUNTIME_BACKEND_EXTERNAL', +} as const + +export type RuntimeBackend = (typeof RuntimeBackend)[keyof typeof RuntimeBackend] + +export const PluginCategory = { + PLUGIN_CATEGORY_UNSPECIFIED: 'PLUGIN_CATEGORY_UNSPECIFIED', + PLUGIN_CATEGORY_MODEL: 'PLUGIN_CATEGORY_MODEL', + PLUGIN_CATEGORY_TOOL: 'PLUGIN_CATEGORY_TOOL', +} as const + +export type PluginCategory = (typeof PluginCategory)[keyof typeof PluginCategory] + +export const DeploymentStatus = { + DEPLOYMENT_STATUS_UNSPECIFIED: 'DEPLOYMENT_STATUS_UNSPECIFIED', + DEPLOYMENT_STATUS_DEPLOYING: 'DEPLOYMENT_STATUS_DEPLOYING', + DEPLOYMENT_STATUS_READY: 'DEPLOYMENT_STATUS_READY', + DEPLOYMENT_STATUS_FAILED: 'DEPLOYMENT_STATUS_FAILED', + DEPLOYMENT_STATUS_CANCELLED: 'DEPLOYMENT_STATUS_CANCELLED', +} as const + +export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus] + +export const DeploymentAction = { + DEPLOYMENT_ACTION_UNSPECIFIED: 'DEPLOYMENT_ACTION_UNSPECIFIED', + DEPLOYMENT_ACTION_DEPLOY: 'DEPLOYMENT_ACTION_DEPLOY', + DEPLOYMENT_ACTION_PROMOTE: 'DEPLOYMENT_ACTION_PROMOTE', + DEPLOYMENT_ACTION_ROLLBACK: 'DEPLOYMENT_ACTION_ROLLBACK', + DEPLOYMENT_ACTION_UNDEPLOY: 'DEPLOYMENT_ACTION_UNDEPLOY', +} as const + +export type DeploymentAction = (typeof DeploymentAction)[keyof typeof DeploymentAction] + +export const DeveloperApiUrlStatus = { + DEVELOPER_API_URL_STATUS_UNSPECIFIED: 'DEVELOPER_API_URL_STATUS_UNSPECIFIED', + DEVELOPER_API_URL_STATUS_CONFIGURED: 'DEVELOPER_API_URL_STATUS_CONFIGURED', + DEVELOPER_API_URL_STATUS_NOT_CONFIGURED: 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', +} as const + +export type DeveloperApiUrlStatus + = (typeof DeveloperApiUrlStatus)[keyof typeof DeveloperApiUrlStatus] + +export const EnvVarValueSource = { + ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + ENV_VAR_VALUE_SOURCE_LITERAL: 'ENV_VAR_VALUE_SOURCE_LITERAL', + ENV_VAR_VALUE_SOURCE_DSL_DEFAULT: 'ENV_VAR_VALUE_SOURCE_DSL_DEFAULT', + ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT: 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT', +} as const + +export type EnvVarValueSource = (typeof EnvVarValueSource)[keyof typeof EnvVarValueSource] + +export const EnvVarValueType = { + ENV_VAR_VALUE_TYPE_UNSPECIFIED: 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + ENV_VAR_VALUE_TYPE_STRING: 'ENV_VAR_VALUE_TYPE_STRING', + ENV_VAR_VALUE_TYPE_NUMBER: 'ENV_VAR_VALUE_TYPE_NUMBER', + ENV_VAR_VALUE_TYPE_SECRET: 'ENV_VAR_VALUE_TYPE_SECRET', +} as const + +export type EnvVarValueType = (typeof EnvVarValueType)[keyof typeof EnvVarValueType] + +export const EnvironmentStatus = { + ENVIRONMENT_STATUS_UNSPECIFIED: 'ENVIRONMENT_STATUS_UNSPECIFIED', + ENVIRONMENT_STATUS_ADMISSION: 'ENVIRONMENT_STATUS_ADMISSION', + ENVIRONMENT_STATUS_BOOTSTRAPPING: 'ENVIRONMENT_STATUS_BOOTSTRAPPING', + ENVIRONMENT_STATUS_READY: 'ENVIRONMENT_STATUS_READY', + ENVIRONMENT_STATUS_FAILED: 'ENVIRONMENT_STATUS_FAILED', + ENVIRONMENT_STATUS_DELETING: 'ENVIRONMENT_STATUS_DELETING', +} as const + +export type EnvironmentStatus = (typeof EnvironmentStatus)[keyof typeof EnvironmentStatus] + +export const RuntimeInstanceStatus = { + RUNTIME_INSTANCE_STATUS_UNSPECIFIED: 'RUNTIME_INSTANCE_STATUS_UNSPECIFIED', + RUNTIME_INSTANCE_STATUS_UNDEPLOYED: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED', + RUNTIME_INSTANCE_STATUS_DEPLOYING: 'RUNTIME_INSTANCE_STATUS_DEPLOYING', + RUNTIME_INSTANCE_STATUS_READY: 'RUNTIME_INSTANCE_STATUS_READY', + RUNTIME_INSTANCE_STATUS_FAILED: 'RUNTIME_INSTANCE_STATUS_FAILED', + RUNTIME_INSTANCE_STATUS_DRIFTED: 'RUNTIME_INSTANCE_STATUS_DRIFTED', + RUNTIME_INSTANCE_STATUS_INVALID: 'RUNTIME_INSTANCE_STATUS_INVALID', + RUNTIME_INSTANCE_STATUS_UNDEPLOYING: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', +} as const + +export type RuntimeInstanceStatus + = (typeof RuntimeInstanceStatus)[keyof typeof RuntimeInstanceStatus] + +export const AppRunnerLaunchProfileMode = { + APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED: 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', + APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG: 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', +} as const + +export type AppRunnerLaunchProfileMode + = (typeof AppRunnerLaunchProfileMode)[keyof typeof AppRunnerLaunchProfileMode] + +export const OperatorType = { + OPERATOR_TYPE_UNSPECIFIED: 'OPERATOR_TYPE_UNSPECIFIED', + OPERATOR_TYPE_END_USER: 'OPERATOR_TYPE_END_USER', + OPERATOR_TYPE_ACCOUNT: 'OPERATOR_TYPE_ACCOUNT', + OPERATOR_TYPE_SERVICE_ACCOUNT: 'OPERATOR_TYPE_SERVICE_ACCOUNT', + OPERATOR_TYPE_SYSTEM: 'OPERATOR_TYPE_SYSTEM', +} as const + +export type OperatorType = (typeof OperatorType)[keyof typeof OperatorType] + +export const ReleaseSource = { + RELEASE_SOURCE_UNSPECIFIED: 'RELEASE_SOURCE_UNSPECIFIED', + RELEASE_SOURCE_SOURCE_APP: 'RELEASE_SOURCE_SOURCE_APP', + RELEASE_SOURCE_UPLOAD: 'RELEASE_SOURCE_UPLOAD', +} as const + +export type ReleaseSource = (typeof ReleaseSource)[keyof typeof ReleaseSource] + +export const ReleaseEnvironmentActionKind = { + RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED: 'RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED', + RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE: 'RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE', + RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK: 'RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK', + RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT: 'RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT', + RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING: 'RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING', + RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED: 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', +} as const + +export type ReleaseEnvironmentActionKind + = (typeof ReleaseEnvironmentActionKind)[keyof typeof ReleaseEnvironmentActionKind] + +export const AckStatus = { + ACK_STATUS_UNSPECIFIED: 'ACK_STATUS_UNSPECIFIED', + ACK_STATUS_READY: 'ACK_STATUS_READY', + ACK_STATUS_FAILED: 'ACK_STATUS_FAILED', +} as const + +export type AckStatus = (typeof AckStatus)[keyof typeof AckStatus] + +export const SlotType = { + SLOT_TYPE_UNSPECIFIED: 'SLOT_TYPE_UNSPECIFIED', + SLOT_TYPE_PLUGIN_CREDENTIAL: 'SLOT_TYPE_PLUGIN_CREDENTIAL', + SLOT_TYPE_ENV_VAR: 'SLOT_TYPE_ENV_VAR', +} as const + +export type SlotType = (typeof SlotType)[keyof typeof SlotType] + +export const RouteTargetKind = { + ROUTE_TARGET_KIND_UNSPECIFIED: 'ROUTE_TARGET_KIND_UNSPECIFIED', + ROUTE_TARGET_KIND_K8S_SERVICE: 'ROUTE_TARGET_KIND_K8S_SERVICE', + ROUTE_TARGET_KIND_DIRECT_UPSTREAM: 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +} as const + +export type RouteTargetKind = (typeof RouteTargetKind)[keyof typeof RouteTargetKind] + +export const PasswordChangeReason = { + PASSWORD_CHANGE_REASON_UNSPECIFIED: 'PASSWORD_CHANGE_REASON_UNSPECIFIED', + PASSWORD_CHANGE_REASON_TEMP: 'PASSWORD_CHANGE_REASON_TEMP', + PASSWORD_CHANGE_REASON_EXPIRED: 'PASSWORD_CHANGE_REASON_EXPIRED', + PASSWORD_CHANGE_REASON_POLICY: 'PASSWORD_CHANGE_REASON_POLICY', +} as const + +export type PasswordChangeReason = (typeof PasswordChangeReason)[keyof typeof PasswordChangeReason] + +export const OtelEndpointMode = { + OTEL_ENDPOINT_MODE_UNIFIED: 'OTEL_ENDPOINT_MODE_UNIFIED', + OTEL_ENDPOINT_MODE_DEDICATED: 'OTEL_ENDPOINT_MODE_DEDICATED', +} as const + +export type OtelEndpointMode = (typeof OtelEndpointMode)[keyof typeof OtelEndpointMode] + +export const AppStatus = { + APP_STATUS_UNSPECIFIED: 'APP_STATUS_UNSPECIFIED', + APP_STATUS_PUBLISHED: 'APP_STATUS_PUBLISHED', + APP_STATUS_UNPUBLISHED: 'APP_STATUS_UNPUBLISHED', + APP_STATUS_DELETED: 'APP_STATUS_DELETED', +} as const + +export type AppStatus = (typeof AppStatus)[keyof typeof AppStatus] + +export const LimitType = { + LIMIT_TYPE_UNSPECIFIED: 'LIMIT_TYPE_UNSPECIFIED', + LIMIT_TYPE_RPM: 'LIMIT_TYPE_RPM', + LIMIT_TYPE_CONCURRENCY: 'LIMIT_TYPE_CONCURRENCY', + LIMIT_TYPE_TOKEN: 'LIMIT_TYPE_TOKEN', +} as const + +export type LimitType = (typeof LimitType)[keyof typeof LimitType] + +export const LimitAction = { + LIMIT_ACTION_UNSPECIFIED: 'LIMIT_ACTION_UNSPECIFIED', + LIMIT_ACTION_BLOCK: 'LIMIT_ACTION_BLOCK', + LIMIT_ACTION_TRACK: 'LIMIT_ACTION_TRACK', +} as const + +export type LimitAction = (typeof LimitAction)[keyof typeof LimitAction] + +export const PasswordStrengthLevel = { + PASSWORD_STRENGTH_LEVEL_UNSPECIFIED: 'PASSWORD_STRENGTH_LEVEL_UNSPECIFIED', + PASSWORD_STRENGTH_LEVEL_WEAK: 'PASSWORD_STRENGTH_LEVEL_WEAK', + PASSWORD_STRENGTH_LEVEL_MEDIUM: 'PASSWORD_STRENGTH_LEVEL_MEDIUM', + PASSWORD_STRENGTH_LEVEL_STRONG: 'PASSWORD_STRENGTH_LEVEL_STRONG', +} as const + +export type PasswordStrengthLevel + = (typeof PasswordStrengthLevel)[keyof typeof PasswordStrengthLevel] + +export const PluginInstallationScope = { + PLUGIN_INSTALLATION_SCOPE_ALL: 'PLUGIN_INSTALLATION_SCOPE_ALL', + PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY: 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY', + PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS: + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS', + PLUGIN_INSTALLATION_SCOPE_NONE: 'PLUGIN_INSTALLATION_SCOPE_NONE', +} as const + +export type PluginInstallationScope + = (typeof PluginInstallationScope)[keyof typeof PluginInstallationScope] + +export const LimitStatus = { + LIMIT_STATUS_UNSPECIFIED: 'LIMIT_STATUS_UNSPECIFIED', + LIMIT_STATUS_NA: 'LIMIT_STATUS_NA', + LIMIT_STATUS_NORMAL: 'LIMIT_STATUS_NORMAL', + LIMIT_STATUS_THROTTLED: 'LIMIT_STATUS_THROTTLED', +} as const + +export type LimitStatus = (typeof LimitStatus)[keyof typeof LimitStatus] + +export type AccessChannels = { + id: string + appInstanceId: string + webAppEnabled: boolean + developerApiEnabled: boolean + updatedBy: Actor + createdAt: string + updatedAt: string +} + +export type AccessEndpoint = { + environment?: Environment + endpointUrl: string +} + +export type AccessPolicy = { + id: string + appInstanceId: string + environmentId: string + mode: AccessMode + subjects: Array + createdAt: string + updatedAt: string +} + +export type AccessSubject = { + subjectType: SubjectType + subjectId: string +} + +export type Actor = { + id: string + displayName: string +} + +export type ApiKey = { + id: string + appInstanceId: string + environmentId: string + displayName: string + maskedToken: string + createdBy: Actor + createdAt: string + lastUsedAt?: string +} + +export type ApiKeySummary = { + apiKeyCount: number + environmentCount: number + developerApiEnabled: boolean + developerApiUrl: DeveloperApiUrl +} + +export type AppInstance = { + id: string + tenantId: string + displayName: string + description: string + createdBy: Actor + updatedBy: Actor + createdAt: string + updatedAt: string +} + +export type AppInstanceSummary = { + appInstance: AppInstance + environmentDeployments: Array + latestRelease?: Release + accessChannels: AccessChannels + apiKeySummary: ApiKeySummary +} + +export type AppRunnerLog = { + id: string + timestamp: string + workflowRunId: string + status: AppRunnerLogStatus + durationSeconds: number + totalTokens: string + workspace: NamedRef + environment: NamedRef + appInstance: NamedRef + operator: Operator + invokeFrom: string + traceId: string + difyTraceId: string + gateCommitId: string + body?: string + attributesJson?: string + resourceAttributesJson?: string +} + +export type BatchResolveRuntimeArtifactsRequest = { + requests?: Array +} + +export type BatchResolveRuntimeArtifactsResponse = { + results?: Array +} + +export type BootstrapAssignment = { + appId?: string + environmentId?: string + workflowId?: string + runtimeInstanceId?: string + workspaceId?: string + runtimeInstanceVersion?: string + bindingSnapshotVersion?: string + executionTokenVersion?: string + executionToken?: string + releaseId?: string + operation?: AssignmentOperation + deploymentId?: string + requiresStatusReport?: boolean +} + +export type BootstrapRunnerRequest = { + runner?: RunnerInfo +} + +export type BootstrapRunnerResponse = { + runnerId?: string + assignmentRevision?: string + assignments?: Array +} + +export type CancelDeploymentRequest = { + appInstanceId?: string + environmentId?: string +} + +export type CancelDeploymentResponse = { + deployment: Deployment +} + +export type ComputeDeploymentOptionsRequest = { + environmentId?: string + appInstanceId?: string + dsl?: string + sourceAppId?: string + releaseId?: string +} + +export type ComputeDeploymentOptionsResponse = { + options: DeploymentOptions +} + +export type ComputeReleaseDeploymentViewResponse = { + releases: Array + environmentDeployments: Array + environmentActions: Array + options?: DeploymentOptions +} + +export type CreateApiKeyRequest = { + appInstanceId?: string + environmentId?: string + displayName: string +} + +export type CreateApiKeyResponse = { + apiKey: ApiKey + token: string +} + +export type CreateAppInstanceRequest = { + displayName: string + description?: string +} + +export type CreateAppInstanceResponse = { + appInstance: AppInstance +} + +export type CreateEnvironmentRequest = { + displayName: string + description?: string + mode?: EnvironmentMode + backend?: RuntimeBackend + k8s?: K8sEnvironmentConfig + external?: ExternalAppRunnerConfig + cpuCount?: number + idempotencyKey: string +} + +export type CreateEnvironmentResponse = { + environment?: Environment +} + +export type CreateReleaseRequest = { + createAppInstance?: boolean + appInstanceId?: string + displayName?: string + description?: string + dsl?: string + sourceAppId?: string +} + +export type CreateReleaseResponse = { + release: Release + appInstance: AppInstance +} + +export type CredentialCandidate = { + credentialId: string + providerId: string + category: PluginCategory + displayName: string + fromEnterprise: boolean +} + +export type CredentialSelectionInput = { + providerId: string + category?: PluginCategory + credentialId: string +} + +export type CredentialSlot = { + providerId: string + category: PluginCategory + candidates: Array + lastCredentialId: string +} + +export type DashboardListAppInstancesResponse = { + appInstances: Array + pagination: Pagination +} + +export type DashboardListEnvironmentDeploymentsResponse = { + deployments?: Array + pagination?: Pagination +} + +export type DeleteApiKeyResponse = { + [key: string]: unknown +} + +export type DeleteAppInstanceResponse = { + [key: string]: unknown +} + +export type DeleteEnvironmentResponse = { + [key: string]: unknown +} + +export type DeleteReleaseResponse = { + [key: string]: unknown +} + +export type DeployRequest = { + dsl?: string + sourceAppId?: string + newAppInstance?: NewAppInstance + environmentId: string + releaseName?: string + releaseDescription?: string + credentials?: Array + envVars?: Array + idempotencyKey: string + expectedDslDigest?: string +} + +export type DeployResponse = { + appInstance: AppInstance + release: Release + deployment: Deployment +} + +export type Deployment = { + id: string + appInstanceId: string + status: DeploymentStatus + action: DeploymentAction + environment: Environment + release: Release + error?: Error + createdBy: Actor + createdAt: string + finalizedAt?: string +} + +export type DeploymentOptions = { + dslDigest: string + appInstanceDefaults: DeploymentOptionsAppInstanceDefaults + releaseDefaults: DeploymentOptionsReleaseDefaults + credentialSlots: Array + envVarSlots: Array +} + +export type DeploymentOptionsAppInstanceDefaults = { + displayName: string + description: string +} + +export type DeploymentOptionsReleaseDefaults = { + displayName: string + description: string +} + +export type DeveloperApiUrl = { + apiUrl: string + status: DeveloperApiUrlStatus + error?: Error +} + +export type EnvVarInput = { + key: string + value?: string + valueSource?: EnvVarValueSource +} + +export type EnvVarSlot = { + key: string + valueType: EnvVarValueType + description: string + defaultValue?: string + lastValue?: string +} + +export type Environment = { + id: string + displayName: string + description: string + mode: EnvironmentMode + backend: RuntimeBackend + status: EnvironmentStatus + statusMessage: string + lastError?: Error + apiServer?: string + namespace?: string + managedBy?: string + runtimeEndpoint?: string + cpuCount: number + createdAt: string + updatedAt: string +} + +export type EnvironmentAccessPolicy = { + environment: Environment + policy?: AccessPolicy + resolvedSubjects: Array +} + +export type EnvironmentAppInstance = { + appInstance?: AppInstance + currentRelease?: Release + status?: RuntimeInstanceStatus + lastError?: Error + workspaceId?: string + workspaceName?: string +} + +export type EnvironmentDeployment = { + appInstanceId: string + environment: Environment + status: RuntimeInstanceStatus + currentRelease?: Release + desiredRelease?: Release + currentDeployment?: EnvironmentDeploymentRecord + error?: Error + updatedAt: string + releasesBehind?: number +} + +export type EnvironmentDeploymentHistoryItem = { + deployment?: Deployment + appInstanceId?: string + appInstanceName?: string + workspaceId?: string + workspaceName?: string +} + +export type EnvironmentDeploymentRecord = { + id: string + status: DeploymentStatus + createdAt: string + finalizedAt?: string +} + +export type Error = { + code?: string + message?: string + phase?: string + occurredAt?: string +} + +export type ExchangeControlTokenRequest = { + joinToken?: string +} + +export type ExchangeControlTokenResponse = { + accessToken?: string + expiresAt?: string +} + +export type ExportReleaseDslResponse = { + dsl: string +} + +export type ExternalAppRunnerConfig = { + runtimeEndpoint?: string +} + +export type GenerateAppRunnerLaunchProfileRequest = { + environmentId?: string + mode?: AppRunnerLaunchProfileMode + controlEndpoint: string + pluginDaemonBaseUrl: string + runtimeListenAddr: string + debugListenAddr?: string +} + +export type GenerateAppRunnerLaunchProfileResponse = { + environmentId?: string + joinToken?: string + configYaml?: string + runtimeEndpoint?: string + sourceCommands?: Array + dockerCommands?: Array +} + +export type GetAccessChannelsResponse = { + accessChannels: AccessChannels +} + +export type GetAccessPolicyResponse = { + policy: AccessPolicy +} + +export type GetAccessSettingsResponse = { + accessChannels: AccessChannels + environmentPolicies: Array + webAppEndpoints?: Array + cliEndpoint?: AccessEndpoint +} + +export type GetAppInstanceOverviewResponse = { + appInstance: AppInstance + environmentDeployments: Array + recentReleases: Array + accessChannels: AccessChannels + apiKeySummary: ApiKeySummary + totalReleaseCount: number +} + +export type GetAppInstanceResponse = { + appInstance: AppInstance +} + +export type GetAppRunnerLogResponse = { + appRunnerLog: AppRunnerLog + lastArchived?: string +} + +export type GetDeveloperApiSettingsResponse = { + accessChannels: AccessChannels + environments: Array + apiKeys: Array + developerApiUrl: DeveloperApiUrl +} + +export type GetEnvironmentResponse = { + environment?: Environment +} + +export type GetReleaseResponse = { + release: Release +} + +export type K8sEnvironmentConfig = { + namespace?: string + apiServer?: string + caBundle?: string + bearerToken?: string +} + +export type ListApiKeysResponse = { + apiKeys: Array + apiUrl: string +} + +export type ListAppInstanceSummariesResponse = { + appInstanceSummaries: Array + pagination: Pagination +} + +export type ListAppInstancesResponse = { + appInstances: Array + pagination: Pagination +} + +export type ListAppRunnerLogsResponse = { + appRunnerLogs: Array + pagination: CursorPagination + lastArchived?: string +} + +export type ListDeploymentsResponse = { + deployments: Array + pagination: Pagination +} + +export type ListEnvironmentAppInstancesResponse = { + appInstances?: Array + pagination?: Pagination +} + +export type ListEnvironmentDeploymentsResponse = { + environmentDeployments: Array +} + +export type ListEnvironmentsResponse = { + environments: Array + pagination: Pagination +} + +export type ListReleaseCredentialCandidatesResponse = { + slots: Array +} + +export type ListReleaseSummariesResponse = { + releaseSummaries: Array + pagination: Pagination +} + +export type ListReleasesResponse = { + releases: Array + pagination: Pagination +} + +export type ListRollbackTargetsResponse = { + rollbackTargets: Array + pagination: Pagination +} + +export type NamedRef = { + id: string + displayName: string +} + +export type NewAppInstance = { + displayName?: string + description?: string +} + +export type Operator = { + type: OperatorType + id: string + displayName: string +} + +export type PrecheckReleaseRequest = { + appInstanceId?: string + dsl?: string + sourceAppId?: string +} + +export type PrecheckReleaseResponse = { + gateCommitId: string + canCreate: boolean + matchedRelease?: ReleaseContentMatch + unsupportedNodes: Array +} + +export type PromoteRequest = { + appInstanceId?: string + releaseId: string + environmentId?: string + credentials?: Array + envVars?: Array + idempotencyKey: string +} + +export type PromoteResponse = { + deployment: Deployment +} + +export type Release = { + id: string + appInstanceId: string + displayName: string + description: string + source: ReleaseSource + sourceAppId?: string + gateCommitId: string + requiredSlots: Array + createdBy: Actor + createdAt: string +} + +export type ReleaseContentMatch = { + releaseId: string + displayName: string + createdAt: string +} + +export type ReleaseEnvironmentAction = { + environment: Environment + kind: ReleaseEnvironmentActionKind + disabledReason?: string + requiresRuntimeInputs: boolean + currentReleaseId: string +} + +export type ReleaseEnvironmentDeployment = { + environment: Environment + status: RuntimeInstanceStatus +} + +export type ReleaseSummary = { + release: Release + deployedEnvironments: Array + environmentActions: Array + activeEnvironmentCount: number +} + +export type ReportRuntimeAssignmentStatusRequest = { + deploymentId?: string + runtimeInstanceId?: string + releaseId?: string + status?: AckStatus + lastError?: Error + runnerId?: string + assignmentRevision?: string +} + +export type ReportRuntimeAssignmentStatusResponse = { + accepted?: boolean + stale?: boolean +} + +export type RequiredSlot = { + type: SlotType + providerId: string + category: PluginCategory + key: string +} + +export type ResolveApiTokenRouteRequest = { + token?: string +} + +export type ResolveApiTokenRouteResponse = { + environmentId?: string + namespace?: string + serviceName?: string + servicePort?: number + environmentStatus?: EnvironmentStatus + appId?: string + tenantId?: string + runtimeInstanceId?: string + observedReleaseId?: string + runtimeInstanceStatus?: RuntimeInstanceStatus + revoked?: boolean + unavailableReason?: string + targetKind?: RouteTargetKind + directUpstream?: string +} + +export type RollbackRequest = { + appInstanceId?: string + environmentId?: string + targetReleaseId: string + idempotencyKey: string +} + +export type RollbackResponse = { + deployment: Deployment +} + +export type RollbackTarget = { + release: Release + resolvedDeploymentId: string + deployedAt: string + isCurrent: boolean +} + +export type RunnerInfo = { + hostname?: string +} + +export type RuntimeArtifact = { + dslYaml?: string + bindingSnapshotVersion?: string + bindingSnapshot?: { + [key: string]: unknown + } +} + +export type RuntimeArtifactRequest = { + runtimeInstanceId?: string + releaseId?: string + deploymentId?: string + bindingSnapshotVersion?: string +} + +export type RuntimeArtifactResult = { + runtimeInstanceId?: string + releaseId?: string + artifact?: RuntimeArtifact + error?: Error + deploymentId?: string +} + +export type TestConnectionRequest = { + environmentId?: string +} + +export type TestConnectionResponse = { + reachable?: boolean + message?: string +} + +export type UndeployRequest = { + appInstanceId?: string + environmentId?: string + idempotencyKey: string +} + +export type UndeployResponse = { + deployment: Deployment +} + +export type UnsupportedDslNode = { + id: string + type: string +} + +export type UpdateAccessChannelsRequest = { + appInstanceId?: string + webAppEnabled?: boolean + developerApiEnabled?: boolean +} + +export type UpdateAccessChannelsResponse = { + accessChannels: AccessChannels +} + +export type UpdateAccessPolicyRequest = { + appInstanceId?: string + environmentId?: string + mode: AccessMode + subjects?: Array +} + +export type UpdateAccessPolicyResponse = { + policy: AccessPolicy +} + +export type UpdateAppInstanceRequest = { + appInstanceId?: string + displayName: string + description?: string +} + +export type UpdateAppInstanceResponse = { + appInstance: AppInstance +} + +export type UpdateEnvironmentRequest = { + environmentId?: string + displayName: string + description?: string +} + +export type UpdateEnvironmentResponse = { + environment?: Environment +} + +export type UpdateReleaseRequest = { + releaseId?: string + displayName: string + description?: string +} + +export type UpdateReleaseResponse = { + release: Release +} + export type Account = { id?: string email?: string @@ -68,7 +1113,7 @@ export type BrandingInfo = { export type CheckPasswordStatusReply = { requirePasswordChange?: boolean - changeReason?: number + changeReason?: PasswordChangeReason daysToExpire?: number message?: string } @@ -185,7 +1230,7 @@ export type DeleteWorkspaceReply = { } export type EndpointReply = { - mode?: number + mode?: OtelEndpointMode metricsEndpoint?: OtelExporterEndpoint tracesEndpoint?: OtelExporterEndpoint } @@ -196,6 +1241,14 @@ export type EnterpriseSystemUserSettingReply = { enableEmailPasswordLogin?: boolean } +export type ExternallyAccessibleApp = { + appId?: string + tenantId?: string + mode?: string + name?: string + updatedAt?: string +} + export type GetBearerTokenResponse = { maskedToken?: string } @@ -237,6 +1290,11 @@ export type GetMfaInfoReply = { globalEnabled?: boolean } +export type GetMemberRbacRolesReply = { + accountId?: string + roles?: Array +} + export type GetMemberReply = { account?: AccountDetail } @@ -280,7 +1338,7 @@ export type GroupAppItem = { app_name?: string workspace_id?: string workspace_name?: string - app_status?: number + app_status?: AppStatus token_usage?: string rpm?: string concurrency?: string @@ -304,6 +1362,7 @@ export type InfoConfigReply = { Branding?: BrandingInfo WebAppAuth?: WebAppAuthInfo PluginInstallationPermission?: PluginInstallationPermissionInfo + EnableAppDeploy?: boolean } export type InnerAdmission = { @@ -355,6 +1414,19 @@ export type InnerIsUserAllowedToAccessWebAppRes = { result?: boolean } +export type InnerListExternallyAccessibleAppsReq = { + page?: number + limit?: number + mode?: string + name?: string +} + +export type InnerListExternallyAccessibleAppsRes = { + data?: Array + total?: number + hasMore?: boolean +} + export type InnerReleaseAdmissionRequest = { admission?: InnerAdmission } @@ -411,15 +1483,21 @@ export type LicenseStatus = { } export type LimitConfig = { - type?: number + type?: LimitType threshold?: string - action?: number + action?: LimitAction reached?: boolean } export type LimitFields = { workspaceMembers?: number workspaces?: ResourceQuota + appRunnerEnvCpus?: ResourceQuota +} + +export type ListAccessSubjectsReply = { + subjects?: Array + pagination?: Pagination } export type ListGroupAppsResponse = { @@ -442,6 +1520,11 @@ export type ListSecretKeysReply = { pagination?: Pagination } +export type ListTraceProvidersReply = { + providers?: Array + catalog?: Array +} + export type ListUsersReply = { data?: Array pagination?: Pagination @@ -531,7 +1614,7 @@ export type OidcReply = { export type OtelExporterEndpoint = { endpoint?: string compression?: string - protocol?: number + protocol?: 'HTTP_PROTOBUF' | 'HTTP_JSON' | 'GRPC' timeout?: string headers?: { [key: string]: string @@ -549,7 +1632,7 @@ export type OtelExporterStatusReply = { bytesPushed?: string itemsInQueue?: string logs?: string - status?: number + status?: 'RUNNING' | 'ERROR' | 'STOPPED' } export type PasswordPolicyConfig = { @@ -565,7 +1648,7 @@ export type PasswordPolicyConfig = { } export type PasswordStrengthReply = { - level?: number + level?: PasswordStrengthLevel } export type PasswordStrengthReq = { @@ -578,10 +1661,20 @@ export type PluginInstallationPermissionInfo = { } export type PluginInstallationSettingsReply = { - pluginInstallationScope?: number + pluginInstallationScope?: PluginInstallationScope restrictToMarketplaceOnly?: boolean } +export type RbacRole = { + id?: string + type?: string + name?: string + description?: string + isBuiltin?: boolean + category?: string + permissionKeys?: Array +} + export type ResetMemberPasswordReply = { id?: string password?: string @@ -616,11 +1709,11 @@ export type ResourceGroupDetail = { description?: string enabled?: boolean rpm_limit?: number - rpm_action?: number + rpm_action?: LimitAction concurrency_limit?: number - concurrency_action?: number + concurrency_action?: LimitAction token_quota?: string - token_action?: number + token_action?: LimitAction created_at?: string updated_at?: string } @@ -635,8 +1728,8 @@ export type ResourceGroupItem = { token_quota?: string token_usage?: string app_count?: string - rpm_status?: number - conc_status?: number + rpm_status?: LimitStatus + conc_status?: LimitStatus created_at?: string updated_at?: string } @@ -684,7 +1777,7 @@ export type SearchAppItem = { app_name?: string workspace_id?: string workspace_name?: string - app_status?: number + app_status?: AppStatus icon?: string icon_type?: string icon_background?: string @@ -720,7 +1813,7 @@ export type SetDefaultWorkspaceReq = { export type Subject = { subjectId?: string - subjectType?: string + subjectType?: SubjectType accountData?: SubjectAccountData groupData?: SubjectGroupData } @@ -753,10 +1846,50 @@ export type TestConnectionReply = { error?: string } +export type TestTraceProviderRequest = { + provider?: TraceProvider +} + export type ToggleEndpointRequest = { enabled?: boolean } +export type ToggleTraceProviderRequest = { + id?: string + enabled?: boolean +} + +export type TraceProvider = { + id?: string + name?: string + provider?: string + endpoint?: string + protocol?: string + credentials?: { + [key: string]: string + } + settings?: { + [key: string]: string + } + enabled?: boolean +} + +export type TraceProviderDescriptor = { + provider?: string + displayName?: string + credentialFields?: Array + settingFields?: Array + defaultProtocol?: string + supportedProtocols?: Array +} + +export type TraceProviderField = { + key?: string + displayName?: string + required?: boolean + secret?: boolean +} + export type UpdateAccessModeReq = { appId?: string accessMode?: string @@ -821,6 +1954,16 @@ export type UpdateMfaStatusRes = { message?: string } +export type UpdateMemberRbacRolesReply = { + accountId?: string + roles?: Array +} + +export type UpdateMemberRbacRolesReq = { + id?: string + roleIds?: Array +} + export type UpdateMemberReply = { account?: Account } @@ -850,7 +1993,7 @@ export type UpdateOfflineLicenseReq = { } export type UpdatePluginInstallationSettingsRequest = { - pluginInstallationScope?: number + pluginInstallationScope?: PluginInstallationScope restrictToMarketplaceOnly?: boolean } @@ -860,11 +2003,11 @@ export type UpdateResourceGroupRequest = { description?: string enabled?: boolean rpm_limit?: number - rpm_action?: number + rpm_action?: LimitAction concurrency_limit?: number - concurrency_action?: number + concurrency_action?: LimitAction token_quota?: string - token_action?: number + token_action?: LimitAction } export type UpdateUserReply = { @@ -919,6 +2062,14 @@ export type UpdateWorkspaceReq = { status?: string } +export type UpsertTraceProviderReply = { + provider?: TraceProvider +} + +export type UpsertTraceProviderRequest = { + provider?: TraceProvider +} + export type WebAppAuthInfo = { allowSso?: boolean allowEmailCodeLogin?: boolean @@ -956,6 +2107,15 @@ export type WorkspacePermission = { allowOwnerTransfer?: boolean } +export type CursorPagination = { + pageSize?: number + nextCursor?: string + prevCursor?: string + hasNextPage?: boolean + hasPrevPage?: boolean + totalCount?: string +} + export type Pagination = { totalCount?: number perPage?: number @@ -963,6 +2123,633 @@ export type Pagination = { totalPages?: number } +export type AccessSubjectServiceListAccessSubjectsData = { + body?: never + path?: never + query?: { + keyword?: string + groupId?: string + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/access-subjects' +} + +export type AccessSubjectServiceListAccessSubjectsResponses = { + 200: ListAccessSubjectsReply +} + +export type AccessSubjectServiceListAccessSubjectsResponse + = AccessSubjectServiceListAccessSubjectsResponses[keyof AccessSubjectServiceListAccessSubjectsResponses] + +export type AppInstanceServiceListAppInstanceSummariesData = { + body?: never + path?: never + query?: { + pageNumber?: number + resultsPerPage?: number + displayName?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstanceSummaries' +} + +export type AppInstanceServiceListAppInstanceSummariesResponses = { + 200: ListAppInstanceSummariesResponse +} + +export type AppInstanceServiceListAppInstanceSummariesResponse + = AppInstanceServiceListAppInstanceSummariesResponses[keyof AppInstanceServiceListAppInstanceSummariesResponses] + +export type AppInstanceServiceListAppInstancesData = { + body?: never + path?: never + query?: { + pageNumber?: number + resultsPerPage?: number + displayName?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances' +} + +export type AppInstanceServiceListAppInstancesResponses = { + 200: ListAppInstancesResponse +} + +export type AppInstanceServiceListAppInstancesResponse + = AppInstanceServiceListAppInstancesResponses[keyof AppInstanceServiceListAppInstancesResponses] + +export type AppInstanceServiceCreateAppInstanceData = { + body: CreateAppInstanceRequest + path?: never + query?: never + url: '/enterprise/app-deploy/appInstances' +} + +export type AppInstanceServiceCreateAppInstanceResponses = { + 200: CreateAppInstanceResponse +} + +export type AppInstanceServiceCreateAppInstanceResponse + = AppInstanceServiceCreateAppInstanceResponses[keyof AppInstanceServiceCreateAppInstanceResponses] + +export type AppInstanceServiceDeleteAppInstanceData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceDeleteAppInstanceResponses = { + 200: DeleteAppInstanceResponse +} + +export type AppInstanceServiceDeleteAppInstanceResponse + = AppInstanceServiceDeleteAppInstanceResponses[keyof AppInstanceServiceDeleteAppInstanceResponses] + +export type AppInstanceServiceGetAppInstanceData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceGetAppInstanceResponses = { + 200: GetAppInstanceResponse +} + +export type AppInstanceServiceGetAppInstanceResponse + = AppInstanceServiceGetAppInstanceResponses[keyof AppInstanceServiceGetAppInstanceResponses] + +export type AppInstanceServiceUpdateAppInstanceData = { + body: UpdateAppInstanceRequest + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}' +} + +export type AppInstanceServiceUpdateAppInstanceResponses = { + 200: UpdateAppInstanceResponse +} + +export type AppInstanceServiceUpdateAppInstanceResponse + = AppInstanceServiceUpdateAppInstanceResponses[keyof AppInstanceServiceUpdateAppInstanceResponses] + +export type AccessServiceGetAccessChannelsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels' +} + +export type AccessServiceGetAccessChannelsResponses = { + 200: GetAccessChannelsResponse +} + +export type AccessServiceGetAccessChannelsResponse + = AccessServiceGetAccessChannelsResponses[keyof AccessServiceGetAccessChannelsResponses] + +export type AccessServiceUpdateAccessChannelsData = { + body: UpdateAccessChannelsRequest + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessChannels' +} + +export type AccessServiceUpdateAccessChannelsResponses = { + 200: UpdateAccessChannelsResponse +} + +export type AccessServiceUpdateAccessChannelsResponse + = AccessServiceUpdateAccessChannelsResponses[keyof AccessServiceUpdateAccessChannelsResponses] + +export type AccessServiceGetAccessSettingsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/accessSettings' +} + +export type AccessServiceGetAccessSettingsResponses = { + 200: GetAccessSettingsResponse +} + +export type AccessServiceGetAccessSettingsResponse + = AccessServiceGetAccessSettingsResponses[keyof AccessServiceGetAccessSettingsResponses] + +export type DeploymentServiceListDeploymentsData = { + body?: never + path: { + appInstanceId: string + } + query?: { + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/deployments' +} + +export type DeploymentServiceListDeploymentsResponses = { + 200: ListDeploymentsResponse +} + +export type DeploymentServiceListDeploymentsResponse + = DeploymentServiceListDeploymentsResponses[keyof DeploymentServiceListDeploymentsResponses] + +export type AccessServiceGetDeveloperApiSettingsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/developerApiSettings' +} + +export type AccessServiceGetDeveloperApiSettingsResponses = { + 200: GetDeveloperApiSettingsResponse +} + +export type AccessServiceGetDeveloperApiSettingsResponse + = AccessServiceGetDeveloperApiSettingsResponses[keyof AccessServiceGetDeveloperApiSettingsResponses] + +export type DeploymentServiceListEnvironmentDeploymentsData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environmentDeployments' +} + +export type DeploymentServiceListEnvironmentDeploymentsResponses = { + 200: ListEnvironmentDeploymentsResponse +} + +export type DeploymentServiceListEnvironmentDeploymentsResponse + = DeploymentServiceListEnvironmentDeploymentsResponses[keyof DeploymentServiceListEnvironmentDeploymentsResponses] + +export type AccessServiceGetAccessPolicyData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy' +} + +export type AccessServiceGetAccessPolicyResponses = { + 200: GetAccessPolicyResponse +} + +export type AccessServiceGetAccessPolicyResponse + = AccessServiceGetAccessPolicyResponses[keyof AccessServiceGetAccessPolicyResponses] + +export type AccessServiceUpdateAccessPolicyData = { + body: UpdateAccessPolicyRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/accessPolicy' +} + +export type AccessServiceUpdateAccessPolicyResponses = { + 200: UpdateAccessPolicyResponse +} + +export type AccessServiceUpdateAccessPolicyResponse + = AccessServiceUpdateAccessPolicyResponses[keyof AccessServiceUpdateAccessPolicyResponses] + +export type AccessServiceListApiKeysData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys' +} + +export type AccessServiceListApiKeysResponses = { + 200: ListApiKeysResponse +} + +export type AccessServiceListApiKeysResponse + = AccessServiceListApiKeysResponses[keyof AccessServiceListApiKeysResponses] + +export type AccessServiceCreateApiKeyData = { + body: CreateApiKeyRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys' +} + +export type AccessServiceCreateApiKeyResponses = { + 200: CreateApiKeyResponse +} + +export type AccessServiceCreateApiKeyResponse + = AccessServiceCreateApiKeyResponses[keyof AccessServiceCreateApiKeyResponses] + +export type AccessServiceDeleteApiKeyData = { + body?: never + path: { + appInstanceId: string + environmentId: string + apiKeyId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/apiKeys/{apiKeyId}' +} + +export type AccessServiceDeleteApiKeyResponses = { + 200: DeleteApiKeyResponse +} + +export type AccessServiceDeleteApiKeyResponse + = AccessServiceDeleteApiKeyResponses[keyof AccessServiceDeleteApiKeyResponses] + +export type DeploymentServiceListRollbackTargetsData = { + body?: never + path: { + appInstanceId: string + environmentId: string + } + query?: { + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}/rollbackTargets' +} + +export type DeploymentServiceListRollbackTargetsResponses = { + 200: ListRollbackTargetsResponse +} + +export type DeploymentServiceListRollbackTargetsResponse + = DeploymentServiceListRollbackTargetsResponses[keyof DeploymentServiceListRollbackTargetsResponses] + +export type DeploymentServiceCancelDeploymentData = { + body: CancelDeploymentRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:cancelDeployment' +} + +export type DeploymentServiceCancelDeploymentResponses = { + 200: CancelDeploymentResponse +} + +export type DeploymentServiceCancelDeploymentResponse + = DeploymentServiceCancelDeploymentResponses[keyof DeploymentServiceCancelDeploymentResponses] + +export type DeploymentServicePromoteData = { + body: PromoteRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:promote' +} + +export type DeploymentServicePromoteResponses = { + 200: PromoteResponse +} + +export type DeploymentServicePromoteResponse + = DeploymentServicePromoteResponses[keyof DeploymentServicePromoteResponses] + +export type DeploymentServiceRollbackData = { + body: RollbackRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:rollback' +} + +export type DeploymentServiceRollbackResponses = { + 200: RollbackResponse +} + +export type DeploymentServiceRollbackResponse + = DeploymentServiceRollbackResponses[keyof DeploymentServiceRollbackResponses] + +export type DeploymentServiceUndeployData = { + body: UndeployRequest + path: { + appInstanceId: string + environmentId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/environments/{environmentId}:undeploy' +} + +export type DeploymentServiceUndeployResponses = { + 200: UndeployResponse +} + +export type DeploymentServiceUndeployResponse + = DeploymentServiceUndeployResponses[keyof DeploymentServiceUndeployResponses] + +export type ReleaseServiceListReleaseSummariesData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/releaseSummaries' +} + +export type ReleaseServiceListReleaseSummariesResponses = { + 200: ListReleaseSummariesResponse +} + +export type ReleaseServiceListReleaseSummariesResponse + = ReleaseServiceListReleaseSummariesResponses[keyof ReleaseServiceListReleaseSummariesResponses] + +export type ReleaseServiceListReleasesData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}/releases' +} + +export type ReleaseServiceListReleasesResponses = { + 200: ListReleasesResponse +} + +export type ReleaseServiceListReleasesResponse + = ReleaseServiceListReleasesResponses[keyof ReleaseServiceListReleasesResponses] + +export type ReleaseServiceComputeReleaseDeploymentViewData = { + body?: never + path: { + appInstanceId: string + } + query?: { + releaseId?: string + environmentId?: string + } + url: '/enterprise/app-deploy/appInstances/{appInstanceId}:computeReleaseDeploymentView' +} + +export type ReleaseServiceComputeReleaseDeploymentViewResponses = { + 200: ComputeReleaseDeploymentViewResponse +} + +export type ReleaseServiceComputeReleaseDeploymentViewResponse + = ReleaseServiceComputeReleaseDeploymentViewResponses[keyof ReleaseServiceComputeReleaseDeploymentViewResponses] + +export type AppInstanceServiceGetAppInstanceOverviewData = { + body?: never + path: { + appInstanceId: string + } + query?: never + url: '/enterprise/app-deploy/appInstances/{appInstanceId}:getOverview' +} + +export type AppInstanceServiceGetAppInstanceOverviewResponses = { + 200: GetAppInstanceOverviewResponse +} + +export type AppInstanceServiceGetAppInstanceOverviewResponse + = AppInstanceServiceGetAppInstanceOverviewResponses[keyof AppInstanceServiceGetAppInstanceOverviewResponses] + +export type DeploymentServiceDeployData = { + body: DeployRequest + path?: never + query?: never + url: '/enterprise/app-deploy/appInstances:deploy' +} + +export type DeploymentServiceDeployResponses = { + 200: DeployResponse +} + +export type DeploymentServiceDeployResponse + = DeploymentServiceDeployResponses[keyof DeploymentServiceDeployResponses] + +export type EnvironmentServiceListEnvironmentsData = { + body?: never + path?: never + query?: { + environmentId?: string + displayName?: string + pageNumber?: number + resultsPerPage?: number + } + url: '/enterprise/app-deploy/environments' +} + +export type EnvironmentServiceListEnvironmentsResponses = { + 200: ListEnvironmentsResponse +} + +export type EnvironmentServiceListEnvironmentsResponse + = EnvironmentServiceListEnvironmentsResponses[keyof EnvironmentServiceListEnvironmentsResponses] + +export type ReleaseServiceCreateReleaseData = { + body: CreateReleaseRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases' +} + +export type ReleaseServiceCreateReleaseResponses = { + 200: CreateReleaseResponse +} + +export type ReleaseServiceCreateReleaseResponse + = ReleaseServiceCreateReleaseResponses[keyof ReleaseServiceCreateReleaseResponses] + +export type ReleaseServiceDeleteReleaseData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceDeleteReleaseResponses = { + 200: DeleteReleaseResponse +} + +export type ReleaseServiceDeleteReleaseResponse + = ReleaseServiceDeleteReleaseResponses[keyof ReleaseServiceDeleteReleaseResponses] + +export type ReleaseServiceGetReleaseData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceGetReleaseResponses = { + 200: GetReleaseResponse +} + +export type ReleaseServiceGetReleaseResponse + = ReleaseServiceGetReleaseResponses[keyof ReleaseServiceGetReleaseResponses] + +export type ReleaseServiceUpdateReleaseData = { + body: UpdateReleaseRequest + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}' +} + +export type ReleaseServiceUpdateReleaseResponses = { + 200: UpdateReleaseResponse +} + +export type ReleaseServiceUpdateReleaseResponse + = ReleaseServiceUpdateReleaseResponses[keyof ReleaseServiceUpdateReleaseResponses] + +export type ReleaseServiceExportReleaseDslData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}:exportDsl' +} + +export type ReleaseServiceExportReleaseDslResponses = { + 200: ExportReleaseDslResponse +} + +export type ReleaseServiceExportReleaseDslResponse + = ReleaseServiceExportReleaseDslResponses[keyof ReleaseServiceExportReleaseDslResponses] + +export type ReleaseServiceListReleaseCredentialCandidatesData = { + body?: never + path: { + releaseId: string + } + query?: never + url: '/enterprise/app-deploy/releases/{releaseId}:listCredentialCandidates' +} + +export type ReleaseServiceListReleaseCredentialCandidatesResponses = { + 200: ListReleaseCredentialCandidatesResponse +} + +export type ReleaseServiceListReleaseCredentialCandidatesResponse + = ReleaseServiceListReleaseCredentialCandidatesResponses[keyof ReleaseServiceListReleaseCredentialCandidatesResponses] + +export type ReleaseServiceComputeDeploymentOptionsData = { + body: ComputeDeploymentOptionsRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases:computeDeploymentOptions' +} + +export type ReleaseServiceComputeDeploymentOptionsResponses = { + 200: ComputeDeploymentOptionsResponse +} + +export type ReleaseServiceComputeDeploymentOptionsResponse + = ReleaseServiceComputeDeploymentOptionsResponses[keyof ReleaseServiceComputeDeploymentOptionsResponses] + +export type ReleaseServicePrecheckReleaseData = { + body: PrecheckReleaseRequest + path?: never + query?: never + url: '/enterprise/app-deploy/releases:precheck' +} + +export type ReleaseServicePrecheckReleaseResponses = { + 200: PrecheckReleaseResponse +} + +export type ReleaseServicePrecheckReleaseResponse + = ReleaseServicePrecheckReleaseResponses[keyof ReleaseServicePrecheckReleaseResponses] + 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 cef500a9064..d7a42b35d4c 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2,6 +2,975 @@ import * as z from 'zod' +export const zAccessMode = z.enum([ + 'ACCESS_MODE_UNSPECIFIED', + 'ACCESS_MODE_PUBLIC', + 'ACCESS_MODE_PRIVATE', + 'ACCESS_MODE_PRIVATE_ALL', +]) + +export const zSubjectType = z.enum([ + 'SUBJECT_TYPE_UNSPECIFIED', + 'SUBJECT_TYPE_ACCOUNT', + 'SUBJECT_TYPE_GROUP', +]) + +export const zAppRunnerLogStatus = z.enum([ + 'APP_RUNNER_LOG_STATUS_UNSPECIFIED', + 'APP_RUNNER_LOG_STATUS_RUNNING', + 'APP_RUNNER_LOG_STATUS_SUCCEEDED', + 'APP_RUNNER_LOG_STATUS_FAILED', + 'APP_RUNNER_LOG_STATUS_PARTIAL_SUCCEEDED', +]) + +export const zAssignmentOperation = z.enum([ + 'ASSIGNMENT_OPERATION_UNSPECIFIED', + 'ASSIGNMENT_OPERATION_LOAD', + 'ASSIGNMENT_OPERATION_UNLOAD', +]) + +export const zEnvironmentMode = z.enum([ + 'ENVIRONMENT_MODE_UNSPECIFIED', + 'ENVIRONMENT_MODE_SHARED', + 'ENVIRONMENT_MODE_ISOLATED', +]) + +export const zRuntimeBackend = z.enum([ + 'RUNTIME_BACKEND_UNSPECIFIED', + 'RUNTIME_BACKEND_K8S', + 'RUNTIME_BACKEND_EXTERNAL', +]) + +export const zPluginCategory = z.enum([ + 'PLUGIN_CATEGORY_UNSPECIFIED', + 'PLUGIN_CATEGORY_MODEL', + 'PLUGIN_CATEGORY_TOOL', +]) + +export const zDeploymentStatus = z.enum([ + 'DEPLOYMENT_STATUS_UNSPECIFIED', + 'DEPLOYMENT_STATUS_DEPLOYING', + 'DEPLOYMENT_STATUS_READY', + 'DEPLOYMENT_STATUS_FAILED', + 'DEPLOYMENT_STATUS_CANCELLED', +]) + +export const zDeploymentAction = z.enum([ + 'DEPLOYMENT_ACTION_UNSPECIFIED', + 'DEPLOYMENT_ACTION_DEPLOY', + 'DEPLOYMENT_ACTION_PROMOTE', + 'DEPLOYMENT_ACTION_ROLLBACK', + 'DEPLOYMENT_ACTION_UNDEPLOY', +]) + +export const zDeveloperApiUrlStatus = z.enum([ + 'DEVELOPER_API_URL_STATUS_UNSPECIFIED', + 'DEVELOPER_API_URL_STATUS_CONFIGURED', + 'DEVELOPER_API_URL_STATUS_NOT_CONFIGURED', +]) + +export const zEnvVarValueSource = z.enum([ + 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + 'ENV_VAR_VALUE_SOURCE_LITERAL', + 'ENV_VAR_VALUE_SOURCE_DSL_DEFAULT', + 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT', +]) + +export const zEnvVarValueType = z.enum([ + 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + 'ENV_VAR_VALUE_TYPE_STRING', + 'ENV_VAR_VALUE_TYPE_NUMBER', + 'ENV_VAR_VALUE_TYPE_SECRET', +]) + +export const zEnvironmentStatus = z.enum([ + 'ENVIRONMENT_STATUS_UNSPECIFIED', + 'ENVIRONMENT_STATUS_ADMISSION', + 'ENVIRONMENT_STATUS_BOOTSTRAPPING', + 'ENVIRONMENT_STATUS_READY', + 'ENVIRONMENT_STATUS_FAILED', + 'ENVIRONMENT_STATUS_DELETING', +]) + +export const zRuntimeInstanceStatus = z.enum([ + 'RUNTIME_INSTANCE_STATUS_UNSPECIFIED', + 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED', + 'RUNTIME_INSTANCE_STATUS_DEPLOYING', + 'RUNTIME_INSTANCE_STATUS_READY', + 'RUNTIME_INSTANCE_STATUS_FAILED', + 'RUNTIME_INSTANCE_STATUS_DRIFTED', + 'RUNTIME_INSTANCE_STATUS_INVALID', + 'RUNTIME_INSTANCE_STATUS_UNDEPLOYING', +]) + +export const zAppRunnerLaunchProfileMode = z.enum([ + 'APP_RUNNER_LAUNCH_PROFILE_MODE_UNSPECIFIED', + 'APP_RUNNER_LAUNCH_PROFILE_MODE_DEBUG', +]) + +export const zOperatorType = z.enum([ + 'OPERATOR_TYPE_UNSPECIFIED', + 'OPERATOR_TYPE_END_USER', + 'OPERATOR_TYPE_ACCOUNT', + 'OPERATOR_TYPE_SERVICE_ACCOUNT', + 'OPERATOR_TYPE_SYSTEM', +]) + +export const zReleaseSource = z.enum([ + 'RELEASE_SOURCE_UNSPECIFIED', + 'RELEASE_SOURCE_SOURCE_APP', + 'RELEASE_SOURCE_UPLOAD', +]) + +export const zReleaseEnvironmentActionKind = z.enum([ + 'RELEASE_ENVIRONMENT_ACTION_KIND_UNSPECIFIED', + 'RELEASE_ENVIRONMENT_ACTION_KIND_PROMOTE', + 'RELEASE_ENVIRONMENT_ACTION_KIND_ROLLBACK', + 'RELEASE_ENVIRONMENT_ACTION_KIND_CURRENT', + 'RELEASE_ENVIRONMENT_ACTION_KIND_DEPLOYING', + 'RELEASE_ENVIRONMENT_ACTION_KIND_BLOCKED', +]) + +export const zAckStatus = z.enum([ + 'ACK_STATUS_UNSPECIFIED', + 'ACK_STATUS_READY', + 'ACK_STATUS_FAILED', +]) + +export const zSlotType = z.enum([ + 'SLOT_TYPE_UNSPECIFIED', + 'SLOT_TYPE_PLUGIN_CREDENTIAL', + 'SLOT_TYPE_ENV_VAR', +]) + +export const zRouteTargetKind = z.enum([ + 'ROUTE_TARGET_KIND_UNSPECIFIED', + 'ROUTE_TARGET_KIND_K8S_SERVICE', + 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +]) + +export const zPasswordChangeReason = z.enum([ + 'PASSWORD_CHANGE_REASON_UNSPECIFIED', + 'PASSWORD_CHANGE_REASON_TEMP', + 'PASSWORD_CHANGE_REASON_EXPIRED', + 'PASSWORD_CHANGE_REASON_POLICY', +]) + +export const zOtelEndpointMode = z.enum([ + 'OTEL_ENDPOINT_MODE_UNIFIED', + 'OTEL_ENDPOINT_MODE_DEDICATED', +]) + +export const zAppStatus = z.enum([ + 'APP_STATUS_UNSPECIFIED', + 'APP_STATUS_PUBLISHED', + 'APP_STATUS_UNPUBLISHED', + 'APP_STATUS_DELETED', +]) + +export const zLimitType = z.enum([ + 'LIMIT_TYPE_UNSPECIFIED', + 'LIMIT_TYPE_RPM', + 'LIMIT_TYPE_CONCURRENCY', + 'LIMIT_TYPE_TOKEN', +]) + +export const zLimitAction = z.enum([ + 'LIMIT_ACTION_UNSPECIFIED', + 'LIMIT_ACTION_BLOCK', + 'LIMIT_ACTION_TRACK', +]) + +export const zPasswordStrengthLevel = z.enum([ + 'PASSWORD_STRENGTH_LEVEL_UNSPECIFIED', + 'PASSWORD_STRENGTH_LEVEL_WEAK', + 'PASSWORD_STRENGTH_LEVEL_MEDIUM', + 'PASSWORD_STRENGTH_LEVEL_STRONG', +]) + +export const zPluginInstallationScope = z.enum([ + 'PLUGIN_INSTALLATION_SCOPE_ALL', + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_ONLY', + 'PLUGIN_INSTALLATION_SCOPE_OFFICIAL_AND_SPECIFIC_PARTNERS', + 'PLUGIN_INSTALLATION_SCOPE_NONE', +]) + +export const zLimitStatus = z.enum([ + 'LIMIT_STATUS_UNSPECIFIED', + 'LIMIT_STATUS_NA', + 'LIMIT_STATUS_NORMAL', + 'LIMIT_STATUS_THROTTLED', +]) + +export const zAccessSubject = z.object({ + subjectType: zSubjectType, + subjectId: z.string(), +}) + +export const zAccessPolicy = z.object({ + id: z.string(), + appInstanceId: z.string(), + environmentId: z.string(), + mode: zAccessMode, + subjects: z.array(zAccessSubject), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zActor = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zAccessChannels = z.object({ + id: z.string(), + appInstanceId: z.string(), + webAppEnabled: z.boolean(), + developerApiEnabled: z.boolean(), + updatedBy: zActor, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zApiKey = z.object({ + id: z.string(), + appInstanceId: z.string(), + environmentId: z.string(), + displayName: z.string(), + maskedToken: z.string(), + createdBy: zActor, + createdAt: z.iso.datetime(), + lastUsedAt: z.iso.datetime().optional(), +}) + +export const zAppInstance = z.object({ + id: z.string(), + tenantId: z.string(), + displayName: z.string(), + description: z.string(), + createdBy: zActor, + updatedBy: zActor, + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +/** + * BootstrapAssignment is one runtime_instance assignment in a runner's startup + * baseline. + */ +export const zBootstrapAssignment = z.object({ + appId: z.string().optional(), + environmentId: z.string().optional(), + workflowId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + workspaceId: z.string().optional(), + runtimeInstanceVersion: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), + executionTokenVersion: z.string().optional(), + executionToken: z.string().optional(), + releaseId: z.string().optional(), + operation: zAssignmentOperation.optional(), + deploymentId: z.string().optional(), + requiresStatusReport: z.boolean().optional(), +}) + +export const zBootstrapRunnerResponse = z.object({ + runnerId: z.string().optional(), + assignmentRevision: z.string().optional(), + assignments: z.array(zBootstrapAssignment).optional(), +}) + +export const zCancelDeploymentRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), +}) + +export const zComputeDeploymentOptionsRequest = z.object({ + environmentId: z.string().optional(), + appInstanceId: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), + releaseId: z.string().optional(), +}) + +export const zCreateApiKeyRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + displayName: z.string(), +}) + +export const zCreateApiKeyResponse = z.object({ + apiKey: zApiKey, + token: z.string(), +}) + +export const zCreateAppInstanceRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), +}) + +export const zCreateAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zCreateReleaseRequest = z.object({ + createAppInstance: z.boolean().optional(), + appInstanceId: z.string().optional(), + displayName: z.string().optional(), + description: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), +}) + +/** + * CredentialCandidate is one tenant-visible credential a frontend may + * pick for a credential slot. It carries no secret. + */ +export const zCredentialCandidate = z.object({ + credentialId: z.string(), + providerId: z.string(), + category: zPluginCategory, + displayName: z.string(), + fromEnterprise: z.boolean(), +}) + +/** + * CredentialSelectionInput is one deploy-time plugin-credential + * selection: a shared credential id chosen for a required DSL slot. + */ +export const zCredentialSelectionInput = z.object({ + providerId: z.string(), + category: zPluginCategory.optional(), + credentialId: z.string(), +}) + +/** + * CredentialSlot is one model/tool plugin-credential requirement a + * Release's DSL declares, paired with the candidates selectable for it. + */ +export const zCredentialSlot = z.object({ + providerId: z.string(), + category: zPluginCategory, + candidates: z.array(zCredentialCandidate), + lastCredentialId: z.string(), +}) + +export const zDeleteApiKeyResponse = z.record(z.string(), z.unknown()) + +export const zDeleteAppInstanceResponse = z.record(z.string(), z.unknown()) + +export const zDeleteEnvironmentResponse = z.record(z.string(), z.unknown()) + +export const zDeleteReleaseResponse = z.record(z.string(), z.unknown()) + +export const zDeploymentOptionsAppInstanceDefaults = z.object({ + displayName: z.string(), + description: z.string(), +}) + +export const zDeploymentOptionsReleaseDefaults = z.object({ + displayName: z.string(), + description: z.string(), +}) + +export const zEnvVarInput = z.object({ + key: z.string(), + value: z.string().optional(), + valueSource: zEnvVarValueSource.optional(), +}) + +export const zEnvVarSlot = z.object({ + key: z.string(), + valueType: zEnvVarValueType, + description: z.string(), + defaultValue: z.string().optional(), + lastValue: z.string().optional(), +}) + +export const zDeploymentOptions = z.object({ + dslDigest: z.string(), + appInstanceDefaults: zDeploymentOptionsAppInstanceDefaults, + releaseDefaults: zDeploymentOptionsReleaseDefaults, + credentialSlots: z.array(zCredentialSlot), + envVarSlots: z.array(zEnvVarSlot), +}) + +export const zComputeDeploymentOptionsResponse = z.object({ + options: zDeploymentOptions, +}) + +export const zEnvironmentDeploymentRecord = z.object({ + id: z.string(), + status: zDeploymentStatus, + createdAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +/** + * Error is the package-wide failure shape, carried wherever an operation or + * resource reports an error. + */ +export const zError = z.object({ + code: z.string().optional(), + message: z.string().optional(), + phase: z.string().optional(), + occurredAt: z.iso.datetime().optional(), +}) + +export const zDeveloperApiUrl = z.object({ + apiUrl: z.string(), + status: zDeveloperApiUrlStatus, + error: zError.optional(), +}) + +export const zApiKeySummary = z.object({ + apiKeyCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + environmentCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + developerApiEnabled: z.boolean(), + developerApiUrl: zDeveloperApiUrl, +}) + +export const zEnvironment = z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + mode: zEnvironmentMode, + backend: zRuntimeBackend, + status: zEnvironmentStatus, + statusMessage: z.string(), + lastError: zError.optional(), + apiServer: z.string().optional(), + namespace: z.string().optional(), + managedBy: z.string().optional(), + runtimeEndpoint: z.string().optional(), + cpuCount: z.number(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}) + +export const zAccessEndpoint = z.object({ + environment: zEnvironment.optional(), + endpointUrl: z.string(), +}) + +export const zCreateEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zExchangeControlTokenRequest = z.object({ + joinToken: z.string().optional(), +}) + +export const zExchangeControlTokenResponse = z.object({ + accessToken: z.string().optional(), + expiresAt: z.iso.datetime().optional(), +}) + +export const zExportReleaseDslResponse = z.object({ + dsl: z.string(), +}) + +export const zExternalAppRunnerConfig = z.object({ + runtimeEndpoint: z.string().optional(), +}) + +export const zGenerateAppRunnerLaunchProfileRequest = z.object({ + environmentId: z.string().optional(), + mode: zAppRunnerLaunchProfileMode.optional(), + controlEndpoint: z.string(), + pluginDaemonBaseUrl: z.string(), + runtimeListenAddr: z.string(), + debugListenAddr: z.string().optional(), +}) + +export const zGenerateAppRunnerLaunchProfileResponse = z.object({ + environmentId: z.string().optional(), + joinToken: z.string().optional(), + configYaml: z.string().optional(), + runtimeEndpoint: z.string().optional(), + sourceCommands: z.array(z.string()).optional(), + dockerCommands: z.array(z.string()).optional(), +}) + +export const zGetAccessChannelsResponse = z.object({ + accessChannels: zAccessChannels, +}) + +export const zGetAccessPolicyResponse = z.object({ + policy: zAccessPolicy, +}) + +export const zGetAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zGetDeveloperApiSettingsResponse = z.object({ + accessChannels: zAccessChannels, + environments: z.array(zEnvironment), + apiKeys: z.array(zApiKey), + developerApiUrl: zDeveloperApiUrl, +}) + +export const zGetEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zK8sEnvironmentConfig = z.object({ + namespace: z.string().optional(), + apiServer: z.string().optional(), + caBundle: z.string().optional(), + bearerToken: z.string().optional(), +}) + +export const zCreateEnvironmentRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), + mode: zEnvironmentMode.optional(), + backend: zRuntimeBackend.optional(), + k8s: zK8sEnvironmentConfig.optional(), + external: zExternalAppRunnerConfig.optional(), + cpuCount: z.number().optional(), + idempotencyKey: z.string(), +}) + +export const zListApiKeysResponse = z.object({ + apiKeys: z.array(zApiKey), + apiUrl: z.string(), +}) + +export const zListReleaseCredentialCandidatesResponse = z.object({ + slots: z.array(zCredentialSlot), +}) + +export const zNamedRef = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zNewAppInstance = z.object({ + displayName: z.string().optional(), + description: z.string().optional(), +}) + +export const zDeployRequest = z.object({ + dsl: z.string().optional(), + sourceAppId: z.string().optional(), + newAppInstance: zNewAppInstance.optional(), + environmentId: z.string(), + releaseName: z.string().optional(), + releaseDescription: z.string().optional(), + credentials: z.array(zCredentialSelectionInput).optional(), + envVars: z.array(zEnvVarInput).optional(), + idempotencyKey: z.string(), + expectedDslDigest: z.string().optional(), +}) + +/** + * Operator is who triggered the run (the "END USER OR ACCOUNT" column). + */ +export const zOperator = z.object({ + type: zOperatorType, + id: z.string(), + displayName: z.string(), +}) + +export const zAppRunnerLog = z.object({ + id: z.string(), + timestamp: z.iso.datetime(), + workflowRunId: z.string(), + status: zAppRunnerLogStatus, + durationSeconds: z.number(), + totalTokens: z.string(), + workspace: zNamedRef, + environment: zNamedRef, + appInstance: zNamedRef, + operator: zOperator, + invokeFrom: z.string(), + traceId: z.string(), + difyTraceId: z.string(), + gateCommitId: z.string(), + body: z.string().optional(), + attributesJson: z.string().optional(), + resourceAttributesJson: z.string().optional(), +}) + +export const zGetAppRunnerLogResponse = z.object({ + appRunnerLog: zAppRunnerLog, + lastArchived: z.iso.datetime().optional(), +}) + +export const zPrecheckReleaseRequest = z.object({ + appInstanceId: z.string().optional(), + dsl: z.string().optional(), + sourceAppId: z.string().optional(), +}) + +export const zPromoteRequest = z.object({ + appInstanceId: z.string().optional(), + releaseId: z.string(), + environmentId: z.string().optional(), + credentials: z.array(zCredentialSelectionInput).optional(), + envVars: z.array(zEnvVarInput).optional(), + idempotencyKey: z.string(), +}) + +/** + * ReleaseContentMatch identifies an existing release whose DSL content is + * identical to the checked content. + */ +export const zReleaseContentMatch = z.object({ + releaseId: z.string(), + displayName: z.string(), + createdAt: z.iso.datetime(), +}) + +export const zReleaseEnvironmentAction = z.object({ + environment: zEnvironment, + kind: zReleaseEnvironmentActionKind, + disabledReason: z.string().optional(), + requiresRuntimeInputs: z.boolean(), + currentReleaseId: z.string(), +}) + +/** + * ReleaseEnvironmentDeployment is an environment where the release is the + * active deployment, paired with that environment's runtime status so the + * version history can show running vs failed vs deploying. + */ +export const zReleaseEnvironmentDeployment = z.object({ + environment: zEnvironment, + status: zRuntimeInstanceStatus, +}) + +export const zReportRuntimeAssignmentStatusRequest = z.object({ + deploymentId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + status: zAckStatus.optional(), + lastError: zError.optional(), + runnerId: z.string().optional(), + assignmentRevision: z.string().optional(), +}) + +export const zReportRuntimeAssignmentStatusResponse = z.object({ + accepted: z.boolean().optional(), + stale: z.boolean().optional(), +}) + +/** + * RequiredSlot is an input requirement extracted from a Release's + * DSL. + */ +export const zRequiredSlot = z.object({ + type: zSlotType, + providerId: z.string(), + category: zPluginCategory, + key: z.string(), +}) + +export const zRelease = z.object({ + id: z.string(), + appInstanceId: z.string(), + displayName: z.string(), + description: z.string(), + source: zReleaseSource, + sourceAppId: z.string().optional(), + gateCommitId: z.string(), + requiredSlots: z.array(zRequiredSlot), + createdBy: zActor, + createdAt: z.iso.datetime(), +}) + +export const zCreateReleaseResponse = z.object({ + release: zRelease, + appInstance: zAppInstance, +}) + +export const zDeployment = z.object({ + id: z.string(), + appInstanceId: z.string(), + status: zDeploymentStatus, + action: zDeploymentAction, + environment: zEnvironment, + release: zRelease, + error: zError.optional(), + createdBy: zActor, + createdAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +export const zCancelDeploymentResponse = z.object({ + deployment: zDeployment, +}) + +export const zDeployResponse = z.object({ + appInstance: zAppInstance, + release: zRelease, + deployment: zDeployment, +}) + +/** + * EnvironmentAppInstance is one app instance as seen from a single environment: + * its current release, runtime status, and derived last error in THIS env. + */ +export const zEnvironmentAppInstance = z.object({ + appInstance: zAppInstance.optional(), + currentRelease: zRelease.optional(), + status: zRuntimeInstanceStatus.optional(), + lastError: zError.optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), +}) + +export const zEnvironmentDeployment = z.object({ + appInstanceId: z.string(), + environment: zEnvironment, + status: zRuntimeInstanceStatus, + currentRelease: zRelease.optional(), + desiredRelease: zRelease.optional(), + currentDeployment: zEnvironmentDeploymentRecord.optional(), + error: zError.optional(), + updatedAt: z.iso.datetime(), + releasesBehind: 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 zAppInstanceSummary = z.object({ + appInstance: zAppInstance, + environmentDeployments: z.array(zEnvironmentDeployment), + latestRelease: zRelease.optional(), + accessChannels: zAccessChannels, + apiKeySummary: zApiKeySummary, +}) + +export const zComputeReleaseDeploymentViewResponse = z.object({ + releases: z.array(zRelease), + environmentDeployments: z.array(zEnvironmentDeployment), + environmentActions: z.array(zReleaseEnvironmentAction), + options: zDeploymentOptions.optional(), +}) + +/** + * EnvironmentDeploymentHistoryItem is one deployment row in an environment's + * history, with a thin reference to the owning app instance. + */ +export const zEnvironmentDeploymentHistoryItem = z.object({ + deployment: zDeployment.optional(), + appInstanceId: z.string().optional(), + appInstanceName: z.string().optional(), + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), +}) + +export const zGetAppInstanceOverviewResponse = z.object({ + appInstance: zAppInstance, + environmentDeployments: z.array(zEnvironmentDeployment), + recentReleases: z.array(zRelease), + accessChannels: zAccessChannels, + apiKeySummary: zApiKeySummary, + totalReleaseCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zGetReleaseResponse = z.object({ + release: zRelease, +}) + +export const zListEnvironmentDeploymentsResponse = z.object({ + environmentDeployments: z.array(zEnvironmentDeployment), +}) + +export const zPromoteResponse = z.object({ + deployment: zDeployment, +}) + +export const zReleaseSummary = z.object({ + release: zRelease, + deployedEnvironments: z.array(zReleaseEnvironmentDeployment), + environmentActions: z.array(zReleaseEnvironmentAction), + activeEnvironmentCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zResolveApiTokenRouteRequest = z.object({ + token: z.string().optional(), +}) + +export const zResolveApiTokenRouteResponse = 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: zEnvironmentStatus.optional(), + appId: z.string().optional(), + tenantId: z.string().optional(), + runtimeInstanceId: z.string().optional(), + observedReleaseId: z.string().optional(), + runtimeInstanceStatus: zRuntimeInstanceStatus.optional(), + revoked: z.boolean().optional(), + unavailableReason: z.string().optional(), + targetKind: zRouteTargetKind.optional(), + directUpstream: z.string().optional(), +}) + +export const zRollbackRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + targetReleaseId: z.string(), + idempotencyKey: z.string(), +}) + +export const zRollbackResponse = z.object({ + deployment: zDeployment, +}) + +export const zRollbackTarget = z.object({ + release: zRelease, + resolvedDeploymentId: z.string(), + deployedAt: z.iso.datetime(), + isCurrent: z.boolean(), +}) + +export const zRunnerInfo = z.object({ + hostname: z.string().optional(), +}) + +export const zBootstrapRunnerRequest = z.object({ + runner: zRunnerInfo.optional(), +}) + +export const zRuntimeArtifact = z.object({ + dslYaml: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), + bindingSnapshot: z.record(z.string(), z.unknown()).optional(), +}) + +export const zRuntimeArtifactRequest = z.object({ + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + deploymentId: z.string().optional(), + bindingSnapshotVersion: z.string().optional(), +}) + +export const zBatchResolveRuntimeArtifactsRequest = z.object({ + requests: z.array(zRuntimeArtifactRequest).optional(), +}) + +export const zRuntimeArtifactResult = z.object({ + runtimeInstanceId: z.string().optional(), + releaseId: z.string().optional(), + artifact: zRuntimeArtifact.optional(), + error: zError.optional(), + deploymentId: z.string().optional(), +}) + +export const zBatchResolveRuntimeArtifactsResponse = z.object({ + results: z.array(zRuntimeArtifactResult).optional(), +}) + +export const zTestConnectionRequest = z.object({ + environmentId: z.string().optional(), +}) + +export const zTestConnectionResponse = z.object({ + reachable: z.boolean().optional(), + message: z.string().optional(), +}) + +export const zUndeployRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + idempotencyKey: z.string(), +}) + +export const zUndeployResponse = z.object({ + deployment: zDeployment, +}) + +/** + * UnsupportedDslNode identifies a workflow node whose type the app runner + * cannot execute. + */ +export const zUnsupportedDslNode = z.object({ + id: z.string(), + type: z.string(), +}) + +export const zPrecheckReleaseResponse = z.object({ + gateCommitId: z.string(), + canCreate: z.boolean(), + matchedRelease: zReleaseContentMatch.optional(), + unsupportedNodes: z.array(zUnsupportedDslNode), +}) + +export const zUpdateAccessChannelsRequest = z.object({ + appInstanceId: z.string().optional(), + webAppEnabled: z.boolean().optional(), + developerApiEnabled: z.boolean().optional(), +}) + +export const zUpdateAccessChannelsResponse = z.object({ + accessChannels: zAccessChannels, +}) + +export const zUpdateAccessPolicyRequest = z.object({ + appInstanceId: z.string().optional(), + environmentId: z.string().optional(), + mode: zAccessMode, + subjects: z.array(zAccessSubject).optional(), +}) + +export const zUpdateAccessPolicyResponse = z.object({ + policy: zAccessPolicy, +}) + +export const zUpdateAppInstanceRequest = z.object({ + appInstanceId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateAppInstanceResponse = z.object({ + appInstance: zAppInstance, +}) + +export const zUpdateEnvironmentRequest = z.object({ + environmentId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateEnvironmentResponse = z.object({ + environment: zEnvironment.optional(), +}) + +export const zUpdateReleaseRequest = z.object({ + releaseId: z.string().optional(), + displayName: z.string(), + description: z.string().optional(), +}) + +export const zUpdateReleaseResponse = z.object({ + release: zRelease, +}) + /** * Account represents a basic user account */ @@ -52,7 +1021,7 @@ export const zBrandingInfo = z.object({ export const zCheckPasswordStatusReply = z.object({ requirePasswordChange: z.boolean().optional(), - changeReason: z.int().optional(), + changeReason: zPasswordChangeReason.optional(), daysToExpire: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) @@ -178,6 +1147,14 @@ export const zEnterpriseSystemUserSettingReply = z.object({ enableEmailPasswordLogin: z.boolean().optional(), }) +export const zExternallyAccessibleApp = z.object({ + appId: z.string().optional(), + tenantId: z.string().optional(), + mode: z.string().optional(), + name: z.string().optional(), + updatedAt: z.string().optional(), +}) + export const zGetBearerTokenResponse = z.object({ maskedToken: z.string().optional(), }) @@ -228,7 +1205,7 @@ export const zGroupAppItem = z.object({ app_name: z.string().optional(), workspace_id: z.string().optional(), workspace_name: z.string().optional(), - app_status: z.int().optional(), + app_status: zAppStatus.optional(), token_usage: z.string().optional(), rpm: z.string().optional(), concurrency: z.string().optional(), @@ -277,6 +1254,31 @@ export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zInnerListExternallyAccessibleAppsReq = z.object({ + page: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + mode: z.string().optional(), + name: z.string().optional(), +}) + +export const zInnerListExternallyAccessibleAppsRes = z.object({ + data: z.array(zExternallyAccessibleApp).optional(), + total: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + hasMore: z.boolean().optional(), +}) + export const zInnerReleaseAdmissionRequest = z.object({ admission: zInnerAdmission.optional(), }) @@ -314,9 +1316,9 @@ export const zJoinWorkspaceReq = z.object({ }) export const zLimitConfig = z.object({ - type: z.int().optional(), + type: zLimitType.optional(), threshold: z.string().optional(), - action: z.int().optional(), + action: zLimitAction.optional(), reached: z.boolean().optional(), }) @@ -424,7 +1426,7 @@ export const zOidcReply = z.object({ export const zOtelExporterEndpoint = z.object({ endpoint: z.string().optional(), compression: z.string().optional(), - protocol: z.int().optional(), + protocol: z.enum(['HTTP_PROTOBUF', 'HTTP_JSON', 'GRPC']).optional(), timeout: z .string() .regex(/^-?(?:0|[1-9]\d{0,11})(?:\.\d{1,9})?s$/) @@ -439,7 +1441,7 @@ export const zOtelExporterEndpoint = z.object({ }) export const zEndpointReply = z.object({ - mode: z.int().optional(), + mode: zOtelEndpointMode.optional(), metricsEndpoint: zOtelExporterEndpoint.optional(), tracesEndpoint: zOtelExporterEndpoint.optional(), }) @@ -449,7 +1451,7 @@ export const zOtelExporterStatusReply = z.object({ bytesPushed: z.string().optional(), itemsInQueue: z.string().optional(), logs: z.string().optional(), - status: z.int().optional(), + status: z.enum(['RUNNING', 'ERROR', 'STOPPED']).optional(), }) export const zPasswordPolicyConfig = z.object({ @@ -473,7 +1475,7 @@ export const zPasswordPolicyConfig = z.object({ }) export const zPasswordStrengthReply = z.object({ - level: z.int().optional(), + level: zPasswordStrengthLevel.optional(), }) export const zPasswordStrengthReq = z.object({ @@ -486,10 +1488,25 @@ export const zPluginInstallationPermissionInfo = z.object({ }) export const zPluginInstallationSettingsReply = z.object({ - pluginInstallationScope: z.int().optional(), + pluginInstallationScope: zPluginInstallationScope.optional(), restrictToMarketplaceOnly: z.boolean().optional(), }) +export const zRbacRole = z.object({ + id: z.string().optional(), + type: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + isBuiltin: z.boolean().optional(), + category: z.string().optional(), + permissionKeys: z.array(z.string()).optional(), +}) + +export const zGetMemberRbacRolesReply = z.object({ + accountId: z.string().optional(), + roles: z.array(zRbacRole).optional(), +}) + export const zResetMemberPasswordReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -534,15 +1551,15 @@ export const zResourceGroupDetail = z.object({ .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(), + rpm_action: zLimitAction.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(), + concurrency_action: zLimitAction.optional(), token_quota: z.string().optional(), - token_action: z.int().optional(), + token_action: zLimitAction.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -565,8 +1582,8 @@ export const zResourceGroupItem = z.object({ 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(), + rpm_status: zLimitStatus.optional(), + conc_status: zLimitStatus.optional(), created_at: z.string().optional(), updated_at: z.string().optional(), }) @@ -606,6 +1623,7 @@ export const zLimitFields = z.object({ .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), workspaces: zResourceQuota.optional(), + appRunnerEnvCpus: zResourceQuota.optional(), }) /** @@ -693,7 +1711,7 @@ export const zSearchAppItem = z.object({ app_name: z.string().optional(), workspace_id: z.string().optional(), workspace_name: z.string().optional(), - app_status: z.int().optional(), + app_status: zAppStatus.optional(), icon: z.string().optional(), icon_type: z.string().optional(), icon_background: z.string().optional(), @@ -760,11 +1778,24 @@ export const zGetWebAppWhitelistSubjectsRes = z.object({ */ export const zSubject = z.object({ subjectId: z.string().optional(), - subjectType: z.string().optional(), + subjectType: zSubjectType.optional(), accountData: zSubjectAccountData.optional(), groupData: zSubjectGroupData.optional(), }) +export const zEnvironmentAccessPolicy = z.object({ + environment: zEnvironment, + policy: zAccessPolicy.optional(), + resolvedSubjects: z.array(zSubject), +}) + +export const zGetAccessSettingsResponse = z.object({ + accessChannels: zAccessChannels, + environmentPolicies: z.array(zEnvironmentAccessPolicy), + webAppEndpoints: z.array(zAccessEndpoint).optional(), + cliEndpoint: zAccessEndpoint.optional(), +}) + export const zGetGroupSubjectsRes = z.object({ subjects: z.array(zSubject).optional(), }) @@ -798,6 +1829,72 @@ export const zToggleEndpointRequest = z.object({ enabled: z.boolean().optional(), }) +export const zToggleTraceProviderRequest = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), +}) + +/** + * TraceProvider is one configured trace-export destination. Trace data + * collected by the enterprise collector is fanned out to every enabled + * destination. Credentials carries per-provider secret values (e.g. Langfuse + * public/secret keys); Settings carries non-secret options (e.g. host, + * project). Secret credential values are redacted on read and preserved on + * write when the client echoes the redaction sentinel back (same round-trip + * contract as endpoint headers / TLS keys). + */ +export const zTraceProvider = z.object({ + id: z.string().optional(), + name: z.string().optional(), + provider: z.string().optional(), + endpoint: z.string().optional(), + protocol: z.string().optional(), + credentials: z.record(z.string(), z.string()).optional(), + settings: z.record(z.string(), z.string()).optional(), + enabled: z.boolean().optional(), +}) + +/** + * TestTraceProviderRequest tests connectivity/auth for a destination config + * before (or without) persisting it. When credentials carry the redaction + * sentinel, the stored secret for the destination with the same id is used. + */ +export const zTestTraceProviderRequest = z.object({ + provider: zTraceProvider.optional(), +}) + +/** + * TraceProviderField describes one credential or setting field of a provider in + * the static catalog: the key the dashboard sends back, its display label, + * whether it is required, and (for credentials) whether it is a secret that + * gets redacted. + */ +export const zTraceProviderField = z.object({ + key: z.string().optional(), + displayName: z.string().optional(), + required: z.boolean().optional(), + secret: z.boolean().optional(), +}) + +/** + * TraceProviderDescriptor is one entry in the static provider catalog: the + * field definitions and transport defaults for a provider type. The dashboard + * uses it to render the add/edit form and to know which fields are secret. + */ +export const zTraceProviderDescriptor = z.object({ + provider: z.string().optional(), + displayName: z.string().optional(), + credentialFields: z.array(zTraceProviderField).optional(), + settingFields: z.array(zTraceProviderField).optional(), + defaultProtocol: z.string().optional(), + supportedProtocols: z.array(z.string()).optional(), +}) + +export const zListTraceProvidersReply = z.object({ + providers: z.array(zTraceProvider).optional(), + catalog: z.array(zTraceProviderDescriptor).optional(), +}) + export const zUpdateAccessModeReq = z.object({ appId: z.string().optional(), accessMode: z.string().optional(), @@ -862,6 +1959,16 @@ export const zUpdateMfaStatusRes = z.object({ message: z.string().optional(), }) +export const zUpdateMemberRbacRolesReply = z.object({ + accountId: z.string().optional(), + roles: z.array(zRbacRole).optional(), +}) + +export const zUpdateMemberRbacRolesReq = z.object({ + id: z.string().optional(), + roleIds: z.array(z.string()).optional(), +}) + export const zUpdateMemberReply = z.object({ account: zAccount.optional(), }) @@ -894,7 +2001,7 @@ export const zUpdateOfflineLicenseReq = z.object({ }) export const zUpdatePluginInstallationSettingsRequest = z.object({ - pluginInstallationScope: z.int().optional(), + pluginInstallationScope: zPluginInstallationScope.optional(), restrictToMarketplaceOnly: z.boolean().optional(), }) @@ -908,15 +2015,15 @@ export const zUpdateResourceGroupRequest = z.object({ .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(), + rpm_action: zLimitAction.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(), + concurrency_action: zLimitAction.optional(), token_quota: z.string().optional(), - token_action: z.int().optional(), + token_action: zLimitAction.optional(), }) export const zUpdateUserReply = z.object({ @@ -963,6 +2070,18 @@ export const zUpdateWorkspaceReq = z.object({ status: z.string().optional(), }) +export const zUpsertTraceProviderReply = z.object({ + provider: zTraceProvider.optional(), +}) + +/** + * UpsertTraceProviderRequest creates a destination when id is empty (a new id + * is allocated) or updates the destination with the given id otherwise. + */ +export const zUpsertTraceProviderRequest = z.object({ + provider: zTraceProvider.optional(), +}) + export const zWebAppAuthInfo = z.object({ allowSso: z.boolean().optional(), allowEmailCodeLogin: z.boolean().optional(), @@ -985,6 +2104,7 @@ export const zInfoConfigReply = z.object({ Branding: zBrandingInfo.optional(), WebAppAuth: zWebAppAuthInfo.optional(), PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(), + EnableAppDeploy: z.boolean().optional(), }) export const zWebOAuth2LoginReply = z.object({ @@ -1058,6 +2178,28 @@ export const zUpdateWorkspacePermissionReq = z.object({ permission: zWorkspacePermission.optional(), }) +/** + * CursorPagination: pagination by cursor token + */ +export const zCursorPagination = z.object({ + pageSize: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + nextCursor: z.string().optional(), + prevCursor: z.string().optional(), + hasNextPage: z.boolean().optional(), + hasPrevPage: z.boolean().optional(), + totalCount: z.string().optional(), +}) + +export const zListAppRunnerLogsResponse = z.object({ + appRunnerLogs: z.array(zAppRunnerLog), + pagination: zCursorPagination, + lastArchived: z.iso.datetime().optional(), +}) + /** * Pagination : Just for pagination by page */ @@ -1084,6 +2226,61 @@ export const zPagination = z.object({ .optional(), }) +export const zDashboardListAppInstancesResponse = z.object({ + appInstances: z.array(zAppInstance), + pagination: zPagination, +}) + +export const zDashboardListEnvironmentDeploymentsResponse = z.object({ + deployments: z.array(zEnvironmentDeploymentHistoryItem).optional(), + pagination: zPagination.optional(), +}) + +export const zListAppInstanceSummariesResponse = z.object({ + appInstanceSummaries: z.array(zAppInstanceSummary), + pagination: zPagination, +}) + +export const zListAppInstancesResponse = z.object({ + appInstances: z.array(zAppInstance), + pagination: zPagination, +}) + +export const zListDeploymentsResponse = z.object({ + deployments: z.array(zDeployment), + pagination: zPagination, +}) + +export const zListEnvironmentAppInstancesResponse = z.object({ + appInstances: z.array(zEnvironmentAppInstance).optional(), + pagination: zPagination.optional(), +}) + +export const zListEnvironmentsResponse = z.object({ + environments: z.array(zEnvironment), + pagination: zPagination, +}) + +export const zListReleaseSummariesResponse = z.object({ + releaseSummaries: z.array(zReleaseSummary), + pagination: zPagination, +}) + +export const zListReleasesResponse = z.object({ + releases: z.array(zRelease), + pagination: zPagination, +}) + +export const zListRollbackTargetsResponse = z.object({ + rollbackTargets: z.array(zRollbackTarget), + pagination: zPagination, +}) + +export const zListAccessSubjectsReply = z.object({ + subjects: z.array(zSubject).optional(), + pagination: zPagination.optional(), +}) + export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), @@ -1104,6 +2301,469 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) +export const zAccessSubjectServiceListAccessSubjectsQuery = z.object({ + keyword: z.string().optional(), + groupId: 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 zAccessSubjectServiceListAccessSubjectsResponse = zListAccessSubjectsReply + +export const zAppInstanceServiceListAppInstanceSummariesQuery = 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(), + displayName: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zAppInstanceServiceListAppInstanceSummariesResponse = zListAppInstanceSummariesResponse + +export const zAppInstanceServiceListAppInstancesQuery = 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(), + displayName: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zAppInstanceServiceListAppInstancesResponse = zListAppInstancesResponse + +export const zAppInstanceServiceCreateAppInstanceBody = zCreateAppInstanceRequest + +/** + * OK + */ +export const zAppInstanceServiceCreateAppInstanceResponse = zCreateAppInstanceResponse + +export const zAppInstanceServiceDeleteAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceDeleteAppInstanceResponse = zDeleteAppInstanceResponse + +export const zAppInstanceServiceGetAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceGetAppInstanceResponse = zGetAppInstanceResponse + +export const zAppInstanceServiceUpdateAppInstanceBody = zUpdateAppInstanceRequest + +export const zAppInstanceServiceUpdateAppInstancePath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceUpdateAppInstanceResponse = zUpdateAppInstanceResponse + +export const zAccessServiceGetAccessChannelsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessChannelsResponse = zGetAccessChannelsResponse + +export const zAccessServiceUpdateAccessChannelsBody = zUpdateAccessChannelsRequest + +export const zAccessServiceUpdateAccessChannelsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceUpdateAccessChannelsResponse = zUpdateAccessChannelsResponse + +export const zAccessServiceGetAccessSettingsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessSettingsResponse = zGetAccessSettingsResponse + +export const zDeploymentServiceListDeploymentsPath = z.object({ + appInstanceId: z.string(), +}) + +export const zDeploymentServiceListDeploymentsQuery = 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(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zDeploymentServiceListDeploymentsResponse = zListDeploymentsResponse + +export const zAccessServiceGetDeveloperApiSettingsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetDeveloperApiSettingsResponse = zGetDeveloperApiSettingsResponse + +export const zDeploymentServiceListEnvironmentDeploymentsPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceListEnvironmentDeploymentsResponse + = zListEnvironmentDeploymentsResponse + +export const zAccessServiceGetAccessPolicyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceGetAccessPolicyResponse = zGetAccessPolicyResponse + +export const zAccessServiceUpdateAccessPolicyBody = zUpdateAccessPolicyRequest + +export const zAccessServiceUpdateAccessPolicyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceUpdateAccessPolicyResponse = zUpdateAccessPolicyResponse + +export const zAccessServiceListApiKeysPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceListApiKeysResponse = zListApiKeysResponse + +export const zAccessServiceCreateApiKeyBody = zCreateApiKeyRequest + +export const zAccessServiceCreateApiKeyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceCreateApiKeyResponse = zCreateApiKeyResponse + +export const zAccessServiceDeleteApiKeyPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), + apiKeyId: z.string(), +}) + +/** + * OK + */ +export const zAccessServiceDeleteApiKeyResponse = zDeleteApiKeyResponse + +export const zDeploymentServiceListRollbackTargetsPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +export const zDeploymentServiceListRollbackTargetsQuery = 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 zDeploymentServiceListRollbackTargetsResponse = zListRollbackTargetsResponse + +export const zDeploymentServiceCancelDeploymentBody = zCancelDeploymentRequest + +export const zDeploymentServiceCancelDeploymentPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceCancelDeploymentResponse = zCancelDeploymentResponse + +export const zDeploymentServicePromoteBody = zPromoteRequest + +export const zDeploymentServicePromotePath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServicePromoteResponse = zPromoteResponse + +export const zDeploymentServiceRollbackBody = zRollbackRequest + +export const zDeploymentServiceRollbackPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceRollbackResponse = zRollbackResponse + +export const zDeploymentServiceUndeployBody = zUndeployRequest + +export const zDeploymentServiceUndeployPath = z.object({ + appInstanceId: z.string(), + environmentId: z.string(), +}) + +/** + * OK + */ +export const zDeploymentServiceUndeployResponse = zUndeployResponse + +export const zReleaseServiceListReleaseSummariesPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceListReleaseSummariesQuery = z.object({ + releaseId: z.string().optional(), + displayName: 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(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleaseSummariesResponse = zListReleaseSummariesResponse + +export const zReleaseServiceListReleasesPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceListReleasesQuery = z.object({ + releaseId: z.string().optional(), + displayName: 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(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleasesResponse = zListReleasesResponse + +export const zReleaseServiceComputeReleaseDeploymentViewPath = z.object({ + appInstanceId: z.string(), +}) + +export const zReleaseServiceComputeReleaseDeploymentViewQuery = z.object({ + releaseId: z.string().optional(), + environmentId: z.string().optional(), +}) + +/** + * OK + */ +export const zReleaseServiceComputeReleaseDeploymentViewResponse + = zComputeReleaseDeploymentViewResponse + +export const zAppInstanceServiceGetAppInstanceOverviewPath = z.object({ + appInstanceId: z.string(), +}) + +/** + * OK + */ +export const zAppInstanceServiceGetAppInstanceOverviewResponse = zGetAppInstanceOverviewResponse + +export const zDeploymentServiceDeployBody = zDeployRequest + +/** + * OK + */ +export const zDeploymentServiceDeployResponse = zDeployResponse + +export const zEnvironmentServiceListEnvironmentsQuery = z.object({ + environmentId: z.string().optional(), + displayName: 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 zEnvironmentServiceListEnvironmentsResponse = zListEnvironmentsResponse + +export const zReleaseServiceCreateReleaseBody = zCreateReleaseRequest + +/** + * OK + */ +export const zReleaseServiceCreateReleaseResponse = zCreateReleaseResponse + +export const zReleaseServiceDeleteReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceDeleteReleaseResponse = zDeleteReleaseResponse + +export const zReleaseServiceGetReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceGetReleaseResponse = zGetReleaseResponse + +export const zReleaseServiceUpdateReleaseBody = zUpdateReleaseRequest + +export const zReleaseServiceUpdateReleasePath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceUpdateReleaseResponse = zUpdateReleaseResponse + +export const zReleaseServiceExportReleaseDslPath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceExportReleaseDslResponse = zExportReleaseDslResponse + +export const zReleaseServiceListReleaseCredentialCandidatesPath = z.object({ + releaseId: z.string(), +}) + +/** + * OK + */ +export const zReleaseServiceListReleaseCredentialCandidatesResponse + = zListReleaseCredentialCandidatesResponse + +export const zReleaseServiceComputeDeploymentOptionsBody = zComputeDeploymentOptionsRequest + +/** + * OK + */ +export const zReleaseServiceComputeDeploymentOptionsResponse = zComputeDeploymentOptionsResponse + +export const zReleaseServicePrecheckReleaseBody = zPrecheckReleaseRequest + +/** + * OK + */ +export const zReleaseServicePrecheckReleaseResponse = zPrecheckReleaseResponse + /** * OK */ diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 26c9e4cef40..8fce8a25bd3 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -183,7 +183,17 @@ const addOperationIds = (document: SwaggerDocument) => { } const hasSuccessResponse = (operation: SwaggerOperation) => { - return Object.keys(operation.responses ?? {}).some(status => /^2\d\d$/.test(status)) + return Object.entries(operation.responses ?? {}).some(([status, response]) => { + if (!/^2\d\d$/.test(status)) + return false + if (!isObject(response)) + return false + const content = (response as JsonObject).content + // 204 No Content is a valid success response without a body + if (!isObject(content) || Object.keys(content).length === 0) + return status === '204' + return true + }) } const filterContractOperations = (document: SwaggerDocument) => { diff --git a/packages/contracts/openapi-ts.enterprise.config.ts b/packages/contracts/openapi-ts.enterprise.config.ts index 3c9bc903ab4..aae8dcb215b 100644 --- a/packages/contracts/openapi-ts.enterprise.config.ts +++ b/packages/contracts/openapi-ts.enterprise.config.ts @@ -7,9 +7,36 @@ import yaml from 'js-yaml' type JsonObject = Record type OpenApiDocument = JsonObject & { + components?: OpenApiComponents paths?: Record } +type OpenApiComponents = JsonObject & { + schemas?: Record +} + +type OpenApiMediaType = JsonObject & { + schema?: unknown +} + +type OpenApiOperation = JsonObject & { + operationId?: string + responses?: Record +} + +type OpenApiPathItem = Record + +type OpenApiResponse = JsonObject & { + content?: Record +} + +type OpenApiSchema = JsonObject & { + enum?: unknown[] + format?: string + properties?: Record + type?: string | string[] +} + type ContractOperation = { id: string operationId?: string @@ -21,9 +48,30 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER ? path.resolve(process.env.DIFY_ENTERPRISE_SERVER) : path.resolve(currentDir, '../../../dify-enterprise/server') const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml') +const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/') +const isObject = (value: unknown): value is JsonObject => { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +const isOpenApiSchema = (value: unknown): value is OpenApiSchema => { + return isObject(value) +} + +const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => { + return isObject(value) ? value as OpenApiOperation : undefined +} + +const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => { + return isObject(value) ? value as OpenApiResponse : undefined +} + +const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => { + return isObject(value) ? value as OpenApiMediaType : undefined +} + const stripConsoleApiPrefix = (routePath: string) => { if (isConsoleApiPath(routePath)) return routePath.replace('/console/api', '') @@ -34,9 +82,18 @@ const stripConsoleApiPrefix = (routePath: string) => { const stripSchemaNamePrefix = (schemaName: string) => { return schemaName .replace(/^dify\.enterprise\.api\.enterprise\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\./, '') .replace(/^pagination\./, '') } +const contractTagSegment = (tag?: string) => { + if (tag === 'EnterpriseAppDeployConsole') + return 'AppDeploy' + + return tag || 'default' +} + const contractNameSegments = (operation: ContractOperation) => { const operationId = operation.operationId || operation.id const tag = operation.tags?.[0] @@ -48,7 +105,165 @@ const contractNameSegments = (operation: ContractOperation) => { } const contractPathSegments = (operation: ContractOperation) => { - return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)] + return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)] +} + +const hasSchemaLessResponseContent = (operation: OpenApiOperation) => { + if (!isObject(operation.responses)) + return false + + return Object.values(operation.responses).some((response) => { + const openApiResponse = asOpenApiResponse(response) + if (!openApiResponse || !isObject(openApiResponse.content)) + return false + + return Object.values(openApiResponse.content).some((mediaType) => { + const openApiMediaType = asOpenApiMediaType(mediaType) + return !!openApiMediaType && !('schema' in openApiMediaType) + }) + }) +} + +// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these +// raw download operations until the source OpenAPI exposes an explicit schema. +const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => { + return Object.fromEntries( + Object.entries(pathItem).filter(([method, operation]) => { + if (!operationMethods.has(method.toLowerCase())) + return true + + const openApiOperation = asOpenApiOperation(operation) + return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation) + }), + ) +} + +const toWords = (value: string) => { + return value + .replace(/[{}]/g, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^a-z0-9]+/i) + .filter(Boolean) +} + +const toPascalCase = (words: string[]) => { + return words.map(word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('') +} + +const commonWordPrefix = (values: string[]) => { + const wordLists = values.map(value => value.split('_')) + const firstWords = wordLists[0] ?? [] + const prefix: string[] = [] + + for (const [index, word] of firstWords.entries()) { + if (!wordLists.every(words => words[index] === word)) + break + + prefix.push(word) + } + + return prefix +} + +const enumSchemaNameFromValues = (values: unknown[]) => { + if (values.length === 0 || !values.every(value => typeof value === 'string')) + return undefined + + const prefix = commonWordPrefix(values) + if (prefix.length < 2) + return undefined + + return toPascalCase(prefix.map(word => word.toLowerCase())) +} + +const findSchemaEntry = ( + schemas: Record, + schemaName: string, +): [string, OpenApiSchema] | undefined => { + return Object.entries(schemas) + .find(([name]) => stripSchemaNamePrefix(name) === schemaName) +} + +const enumValuesKey = (values: unknown[]) => JSON.stringify(values) + +const reusableEnumSchema = (propertySchema: OpenApiSchema): OpenApiSchema => ({ + ...(propertySchema.format ? { format: propertySchema.format } : {}), + enum: propertySchema.enum, + type: propertySchema.type ?? 'string', +}) + +const enumSchemaKey = ( + schemas: Record, + preferredName: string, + valuesKey: string, + valuesToSchemaKey: Map, + schemaName: string, + propertyName: string, +) => { + const existingKey = valuesToSchemaKey.get(valuesKey) + if (existingKey) + return existingKey + + const existingEnumEntry = findSchemaEntry(schemas, preferredName) + if (!existingEnumEntry) + return preferredName + + const existingEnumValues = existingEnumEntry[1].enum + if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey) + return existingEnumEntry[0] + + return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}` +} + +const promoteInlineEnumSchema = ( + schemas: Record, + schemaName: string, + properties: Record, + propertyName: string, + propertySchema: OpenApiSchema, + valuesToSchemaKey: Map, +) => { + if (!Array.isArray(propertySchema.enum)) + return + + const preferredName = enumSchemaNameFromValues(propertySchema.enum) + if (!preferredName) + return + + const valuesKey = enumValuesKey(propertySchema.enum) + const key = enumSchemaKey(schemas, preferredName, valuesKey, valuesToSchemaKey, schemaName, propertyName) + + if (!schemas[key]) + schemas[key] = reusableEnumSchema(propertySchema) + + valuesToSchemaKey.set(valuesKey, key) + properties[propertyName] = { + $ref: `#/components/schemas/${key}`, + } +} + +// gnostic's protoc-gen-openapi inlines proto enum schemas into every field. +// Promote prefixable inline enums to reusable schemas so Hey API can emit +// runtime enum objects from the generated contract. +const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => { + const schemas = document.components?.schemas + if (!schemas) + return + + const valuesToSchemaKey = new Map() + + Object.entries(schemas).forEach(([schemaName, schema]) => { + const properties = schema.properties + if (!properties) + return + + Object.entries(properties).forEach(([propertyName, propertySchema]) => { + if (!isOpenApiSchema(propertySchema)) + return + + promoteInlineEnumSchema(schemas, schemaName, properties, propertyName, propertySchema, valuesToSchemaKey) + }) + }) } const normalizeEnterpriseOpenApi = () => { @@ -63,9 +278,17 @@ const normalizeEnterpriseOpenApi = () => { document.paths = Object.fromEntries( Object.entries(paths) .filter(([routePath]) => isConsoleApiPath(routePath)) - .map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]), + .map(([routePath, pathItem]) => { + if (!isObject(pathItem)) + return [stripConsoleApiPrefix(routePath), pathItem] + + return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)] + }) + .filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0), ) + promoteReusableEnumSchemasForHeyApi(document) + return document } @@ -97,6 +320,9 @@ export default defineConfig({ { name: '@hey-api/typescript', comments: false, + enums: { + mode: 'javascript', + }, }, 'zod', { diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index b362a9450fd..4d115024ab1 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -145,9 +145,9 @@ const autocompleteControlVariants = cva( { variants: { size: { - small: 'mr-1 size-4', - medium: 'mr-1.5 size-5', - large: 'mr-2 size-5', + small: 'me-1 size-4', + medium: 'me-1.5 size-5', + large: 'me-2 size-5', }, }, defaultVariants: { diff --git a/packages/dify-ui/src/button/index.tsx b/packages/dify-ui/src/button/index.tsx index 2181b880a55..0d36d7d1510 100644 --- a/packages/dify-ui/src/button/index.tsx +++ b/packages/dify-ui/src/button/index.tsx @@ -131,7 +131,7 @@ export function Button({ {children} {loading && (