chore: chatflow api should check workflow_id and sandbox plan (#38892)

This commit is contained in:
非法操作 2026-07-15 10:01:53 +08:00 committed by GitHub
parent 302d7b1e1b
commit 38aec8b506
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 251 additions and 13 deletions

View File

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.app.wraps import with_session
@ -23,6 +24,7 @@ from controllers.service_api.app.error import (
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
WorkflowVersionExecutionNotAllowedError,
)
from controllers.service_api.schema import (
InputFileList,
@ -40,6 +42,7 @@ from core.errors.error import (
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
@ -47,6 +50,7 @@ from libs.helper import UUIDStrOrEmpty
from models.model import App, AppMode, EndUser
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.billing_service import BillingService
from services.conversation_service import ConversationService
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
@ -331,6 +335,10 @@ class ChatApi(Resource):
"- `model_currently_not_support` : Current model unavailable.\n"
"- `completion_request_error` : Text generation failed."
),
403: (
"`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
"current plan. Upgrade to a paid plan."
),
404: "`not_found` : Conversation does not exist.",
429: (
"- `too_many_requests` : Too many concurrent requests for this app.\n"
@ -348,6 +356,7 @@ class ChatApi(Resource):
200: "Message sent successfully",
400: "Bad request - invalid parameters or workflow issues",
401: "Unauthorized - invalid API token",
403: "Forbidden - upgrade to a paid plan to execute a specific workflow version",
404: "Conversation or workflow not found",
429: "Rate limit exceeded",
500: "Internal server error",
@ -368,6 +377,11 @@ class ChatApi(Resource):
payload = ChatRequestPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {})
if app_mode == AppMode.ADVANCED_CHAT and payload.workflow_id and dify_config.BILLING_ENABLED:
billing_info = BillingService.get_info(app_model.tenant_id, exclude_vector_space=True)
if billing_info["enabled"] and billing_info["subscription"]["plan"] == CloudPlan.SANDBOX:
raise WorkflowVersionExecutionNotAllowedError()
external_trace_id = get_external_trace_id(request)
args = payload.model_dump(exclude_none=True)
trace_session_id = get_trace_session_id(request)

View File

@ -327,7 +327,7 @@ Send a request to the chat application.
| 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. |
| 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 |
| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. |
| 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. |
@ -474,7 +474,7 @@ Send a request to the chat application.
| 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. |
| 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 |
| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. |
| 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. |

View File

@ -11,6 +11,7 @@ Focus on:
- Error types and their mappings
"""
import sys
import uuid
from decimal import Decimal
from inspect import unwrap
@ -37,15 +38,18 @@ from controllers.service_api.app.error import (
AppUnavailableError,
ConversationCompletedError,
NotChatAppError,
WorkflowVersionExecutionNotAllowedError,
)
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.errors.error import QuotaExceededError
from enums.cloud_plan import CloudPlan
from graphon.model_runtime.errors.invoke import InvokeError
from models.base import TypeBase
from models.enums import ConversationFromSource, EndUserType
from models.model import App, AppMode, Conversation, EndUser, IconType, Message
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.billing_service import BillingService
from services.conversation_service import ConversationService
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.conversation import ConversationNotExistsError
@ -548,6 +552,80 @@ class TestCompletionStopApiController:
class TestChatApiController:
def test_rejects_sandbox_plan_workflow_version(
self, app: Flask, monkeypatch: pytest.MonkeyPatch, orm_session: Session
) -> None:
completion_module = sys.modules["controllers.service_api.app.completion"]
monkeypatch.setattr(completion_module.dify_config, "BILLING_ENABLED", True)
billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}})
generate = Mock()
monkeypatch.setattr(BillingService, "get_info", billing_get_info)
monkeypatch.setattr(AppGenerateService, "generate", generate)
api = ChatApi()
handler = unwrap(api.post)
app_model, end_user, _, _ = _persist_completion_state(orm_session, AppMode.ADVANCED_CHAT)
workflow_id = str(uuid.uuid4())
with app.test_request_context(
"/chat-messages",
method="POST",
json={"inputs": {}, "query": "hi", "workflow_id": workflow_id},
):
with pytest.raises(WorkflowVersionExecutionNotAllowedError) as exc_info:
handler(api, session=orm_session, app_model=app_model, end_user=end_user)
billing_get_info.assert_called_once_with(app_model.tenant_id, exclude_vector_space=True)
generate.assert_not_called()
assert exc_info.value.code == 403
assert exc_info.value.error_code == "workflow_version_execution_not_allowed"
@pytest.mark.parametrize(
("billing_config_enabled", "billing_enabled", "plan", "workflow_id"),
[
(False, True, CloudPlan.SANDBOX, str(uuid.uuid4())),
(True, False, CloudPlan.SANDBOX, str(uuid.uuid4())),
(True, True, CloudPlan.PROFESSIONAL, str(uuid.uuid4())),
(True, True, CloudPlan.SANDBOX, None),
],
)
def test_allows_default_or_entitled_workflow_version_execution(
self,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
orm_session: Session,
billing_config_enabled: bool,
billing_enabled: bool,
plan: CloudPlan,
workflow_id: str | None,
) -> None:
completion_module = sys.modules["controllers.service_api.app.completion"]
monkeypatch.setattr(completion_module.dify_config, "BILLING_ENABLED", billing_config_enabled)
billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}})
generate = Mock(return_value={"result": "ok"})
monkeypatch.setattr(BillingService, "get_info", billing_get_info)
monkeypatch.setattr(AppGenerateService, "generate", generate)
monkeypatch.setattr(completion_module.helper, "compact_generate_response", lambda response: response)
api = ChatApi()
handler = unwrap(api.post)
app_model, end_user, _, _ = _persist_completion_state(orm_session, AppMode.ADVANCED_CHAT)
request_json = {"inputs": {}, "query": "hi"}
if workflow_id:
request_json["workflow_id"] = workflow_id
with app.test_request_context("/chat-messages", method="POST", json=request_json):
response = handler(api, session=orm_session, app_model=app_model, end_user=end_user)
assert response == {"result": "ok"}
generate.assert_called_once()
if billing_config_enabled and workflow_id:
billing_get_info.assert_called_once_with(app_model.tenant_id, exclude_vector_space=True)
else:
billing_get_info.assert_not_called()
def test_wrong_mode(self, app: Flask, orm_session: Session) -> None:
api = ChatApi()
handler = unwrap(api.post)

View File

@ -346,6 +346,20 @@ describe('md.tsx components', () => {
expect(screen.getByText('User identifier')).toBeInTheDocument()
})
it('should render a name action next to the property name', () => {
render(
<Property {...defaultProps} nameAction={<button type="button">Upgrade</button>}>
User identifier
</Property>,
)
const nameContainer = screen.getByText('user_id').parentElement!
const actionContainer = screen.getByRole('button', { name: 'Upgrade' }).parentElement!
expect(nameContainer).not.toHaveClass('flex')
expect(actionContainer).toHaveClass('ml-2', 'inline-flex', 'align-middle')
expect(nameContainer.children[1]).toBe(actionContainer)
})
it('should render as li element', () => {
const { container } = render(<Property {...defaultProps}>Description</Property>)
expect(container.querySelector('li')).toBeInTheDocument()

View File

@ -1,6 +1,9 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import WorkflowVersionApiUpgradeNotice from '../workflow-version-api-upgrade-notice'
import {
WorkflowVersionApiContent,
WorkflowVersionApiUpgradeNotice,
} from '../workflow-version-api-upgrade-notice'
let mockPlanType = Plan.professional
let mockEnableBilling = true
@ -127,3 +130,50 @@ describe('WorkflowVersionApiUpgradeNotice', () => {
).toBeInTheDocument()
})
})
describe('WorkflowVersionApiContent', () => {
beforeEach(() => {
mockPlanType = Plan.professional
mockEnableBilling = true
mockIsFetchedPlan = true
})
it('should hide content while the billing plan is loading', () => {
mockIsFetchedPlan = false
render(<WorkflowVersionApiContent>paid API details</WorkflowVersionApiContent>)
expect(screen.queryByText('paid API details')).not.toBeInTheDocument()
})
it('should hide content for sandbox plans', () => {
mockPlanType = Plan.sandbox
const { container } = render(
<>
<h2>paid API</h2>
<WorkflowVersionApiContent>paid API details</WorkflowVersionApiContent>
<hr />
</>,
)
expect(screen.queryByText('paid API details')).not.toBeInTheDocument()
expect(container.querySelector('h2 + [aria-hidden="true"] + hr')).toBeInTheDocument()
})
it('should show content for paid plans', () => {
render(<WorkflowVersionApiContent>paid API details</WorkflowVersionApiContent>)
expect(screen.getByText('paid API details')).toBeInTheDocument()
})
it('should show content when billing is disabled', () => {
mockPlanType = Plan.sandbox
mockEnableBilling = false
mockIsFetchedPlan = false
render(<WorkflowVersionApiContent>paid API details</WorkflowVersionApiContent>)
expect(screen.getByText('paid API details')).toBeInTheDocument()
})
})

View File

@ -95,14 +95,16 @@ export function Properties({ children }: IChildrenProps) {
type IProperty = IChildrenProps & {
name: string
type: string
nameAction?: React.ReactNode
}
export function Property({ name, type, children }: IProperty) {
export function Property({ name, type, nameAction, children }: IProperty) {
return (
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0">
<dl className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
<dt className="sr-only">Name</dt>
<dd>
<code>{name}</code>
{nameAction ? <span className="ml-2 inline-flex align-middle">{nameAction}</span> : null}
</dd>
<dt className="sr-only">Type</dt>
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">{type}</dd>

View File

@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const templates = [
['English', 'template_advanced_chat.en.mdx'],
['Japanese', 'template_advanced_chat.ja.mdx'],
['Chinese', 'template_advanced_chat.zh.mdx'],
] as const
describe.each(templates)('%s advanced chat API template', (_language, templateFile) => {
it('should show the workflow version upgrade action next to workflow_id', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/components/develop/template', templateFile),
'utf8',
)
expect(source).toContain(
"import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'",
)
expect(source).toContain(
"<Property name='workflow_id' type='string' key='workflow_id' nameAction={<WorkflowVersionApiUpgradeNotice />}>",
)
expect(source).not.toContain('titleAction={<WorkflowVersionApiUpgradeNotice />}')
})
it('should document the workflow version plan error', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/components/develop/template', templateFile),
'utf8',
)
expect(source).toContain('403')
expect(source).toContain('workflow_version_execution_not_allowed')
})
})

View File

@ -0,0 +1,26 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const templates = [
['English', 'template_workflow.en.mdx'],
['Japanese', 'template_workflow.ja.mdx'],
['Chinese', 'template_workflow.zh.mdx'],
] as const
describe.each(templates)('%s workflow API template', (_language, templateFile) => {
it('should hide only the paid API details behind the plan gate', () => {
const source = readFileSync(
resolve(process.cwd(), 'app/components/develop/template', templateFile),
'utf8',
)
const paidApiStart = source.indexOf("name='#Execute-Specific-Workflow'")
const nextApiStart = source.indexOf("url='/workflows/run/:workflow_run_id'", paidApiStart)
const paidApi = source.slice(paidApiStart, nextApiStart)
expect(paidApiStart).toBeGreaterThanOrEqual(0)
expect(nextApiStart).toBeGreaterThan(paidApiStart)
expect(paidApi).toContain('titleAction={<WorkflowVersionApiUpgradeNotice />}')
expect(paidApi).toContain('<WorkflowVersionApiContent>\n<Row>')
expect(paidApi).toContain('</Row>\n</WorkflowVersionApiContent>')
})
})

View File

@ -1,5 +1,6 @@
import { CodeGroup, Embed } from '../code.tsx'
import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'
# Advanced Chat App API
@ -74,7 +75,7 @@ Chat applications support session persistence, allowing previous chat history to
Auto-generate title, default is `true`.
If set to `false`, can achieve async title generation by calling the conversation rename API and setting `auto_generate` to `true`.
</Property>
<Property name='workflow_id' type='string' key='workflow_id'>
<Property name='workflow_id' type='string' key='workflow_id' nameAction={<WorkflowVersionApiUpgradeNotice />}>
(Optional) Workflow ID to specify a specific version, if not provided, uses the default published version.
</Property>
<Property name='trace_id' type='string' key='trace_id'>
@ -250,6 +251,7 @@ Chat applications support session persistence, allowing previous chat history to
- 400, `draft_workflow_error`, cannot use draft workflow version
- 400, `workflow_id_format_error`, invalid workflow_id format, expected UUID format
- 400, `completion_request_error`, text generation failed
- 403, `workflow_version_execution_not_allowed`, Workflow version execution is not available on your current plan. Please upgrade to a paid plan.
- 500, internal server error
</Col>

View File

@ -1,5 +1,6 @@
import { CodeGroup } from '../code.tsx'
import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'
# 高度なチャットアプリ API
@ -74,7 +75,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
タイトルを自動生成、デフォルトは`true`。
`false`に設定すると、会話のリネームAPIを呼び出し、`auto_generate`を`true`に設定することで非同期タイトル生成を実現できます。
</Property>
<Property name='workflow_id' type='string' key='workflow_id'>
<Property name='workflow_id' type='string' key='workflow_id' nameAction={<WorkflowVersionApiUpgradeNotice />}>
オプションワークフローID、特定のバージョンを指定するために使用、提供されない場合はデフォルトの公開バージョンを使用。
</Property>
<Property name='trace_id' type='string' key='trace_id'>
@ -250,6 +251,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- 400, `draft_workflow_error`, ドラフトワークフローバージョンは使用できません
- 400, `workflow_id_format_error`, ワークフローID形式エラー、UUID形式が必要です
- 400, `completion_request_error`, テキスト生成に失敗しました
- 403, `workflow_version_execution_not_allowed`, 現在のプランではワークフローバージョンを実行できません。有料プランにアップグレードしてください
- 500, 内部サーバーエラー
</Col>

View File

@ -1,5 +1,6 @@
import { CodeGroup } from '../code.tsx'
import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'
# 工作流编排对话型应用 API
@ -71,7 +72,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
<Property name='auto_generate_name' type='bool' key='auto_generate_name'>
(选填)自动生成标题,默认 `true`。 若设置为 `false`,则可通过调用会话重命名接口并设置 `auto_generate` 为 `true` 实现异步生成标题。
</Property>
<Property name='workflow_id' type='string' key='workflow_id'>
<Property name='workflow_id' type='string' key='workflow_id' nameAction={<WorkflowVersionApiUpgradeNotice />}>
选填工作流ID用于指定特定版本如果不提供则使用默认的已发布版本。
</Property>
<Property name='trace_id' type='string' key='trace_id'>
@ -248,6 +249,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- 400`draft_workflow_error`,无法使用草稿工作流版本
- 400`workflow_id_format_error`工作流ID格式错误需要UUID格式
- 400`completion_request_error`,文本生成失败
- 403`workflow_version_execution_not_allowed`,当前套餐不支持执行工作流版本,请升级至付费套餐
- 500服务内部异常
</Properties>

View File

@ -1,6 +1,6 @@
import { CodeGroup } from '../code.tsx'
import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
import WorkflowVersionApiUpgradeNotice from '../workflow-version-api-upgrade-notice.tsx'
import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'
# Workflow App API
@ -645,6 +645,7 @@ Workflow applications offers non-session support and is ideal for translation, a
name='#Execute-Specific-Workflow'
titleAction={<WorkflowVersionApiUpgradeNotice />}
/>
<WorkflowVersionApiContent>
<Row>
<Col>
Execute a specific version of workflow by specifying the workflow ID in the path parameter.
@ -895,6 +896,7 @@ Workflow applications offers non-session support and is ideal for translation, a
</CodeGroup>
</Col>
</Row>
</WorkflowVersionApiContent>
---

View File

@ -1,6 +1,6 @@
import { CodeGroup } from '../code.tsx'
import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
import WorkflowVersionApiUpgradeNotice from '../workflow-version-api-upgrade-notice.tsx'
import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'
# ワークフローアプリ API
@ -645,6 +645,7 @@ import WorkflowVersionApiUpgradeNotice from '../workflow-version-api-upgrade-not
name='#Execute-Specific-Workflow'
titleAction={<WorkflowVersionApiUpgradeNotice />}
/>
<WorkflowVersionApiContent>
<Row>
<Col>
パスパラメータでワークフローIDを指定して、特定バージョンのワークフローを実行します。
@ -890,6 +891,7 @@ import WorkflowVersionApiUpgradeNotice from '../workflow-version-api-upgrade-not
</CodeGroup>
</Col>
</Row>
</WorkflowVersionApiContent>
---

View File

@ -1,6 +1,6 @@
import { CodeGroup } from '../code.tsx'
import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
import WorkflowVersionApiUpgradeNotice from '../workflow-version-api-upgrade-notice.tsx'
import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade-notice.tsx'
# Workflow 应用 API
@ -635,6 +635,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
name='#Execute-Specific-Workflow'
titleAction={<WorkflowVersionApiUpgradeNotice />}
/>
<WorkflowVersionApiContent>
<Row>
<Col>
执行指定版本的工作流通过路径参数指定工作流ID。
@ -883,6 +884,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
</CodeGroup>
</Col>
</Row>
</WorkflowVersionApiContent>
---

View File

@ -1,5 +1,6 @@
'use client'
import type { PropsWithChildren } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
@ -7,7 +8,15 @@ import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useProviderContext } from '@/context/provider-context'
const WorkflowVersionApiUpgradeNotice = () => {
export const WorkflowVersionApiContent = ({ children }: PropsWithChildren) => {
const { plan, enableBilling, isFetchedPlan } = useProviderContext()
if (enableBilling && (!isFetchedPlan || plan.type === Plan.sandbox)) return <div aria-hidden />
return children
}
export const WorkflowVersionApiUpgradeNotice = () => {
const { t } = useTranslation('billing')
const { plan, enableBilling, isFetchedPlan } = useProviderContext()
const [isPlanUpgradeModalOpen, setIsPlanUpgradeModalOpen] = useState(false)
@ -39,5 +48,3 @@ const WorkflowVersionApiUpgradeNotice = () => {
</>
)
}
export default WorkflowVersionApiUpgradeNotice