Compare commits

...

271 Commits

Author SHA1 Message Date
Yansong Zhang
24f1efddf8 refactor(billing): use declared migration and message fields 2026-09-28 21:27:32 +08:00
Yansong Zhang
7224a668c1 refactor(billing): restore migration domain import boundaries 2026-09-28 21:05:38 +08:00
Yansong Zhang
11d06e9b9b fix(web): show hosted credits processing during Tokener migration 2026-09-28 20:37:21 +08:00
Yansong Zhang
19761f4ae1 fix(billing): keep workspace reads available during Tokener migration 2026-09-28 20:34:48 +08:00
林玮 (Jade Lin)
1c7f31a93d Merge origin/main into deploy/agent
# Conflicts:
#	api/services/agent/roster_package_exporter.py
#	api/services/agent/roster_package_importer.py
#	api/services/app_dsl_service.py
#	web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
2026-09-28 16:57:24 +08:00
yyh
f6ad937a0a Merge remote-tracking branch 'origin/linw/FPRD-144-new-agent-templates' into deploy/agent 2026-09-28 16:10:14 +08:00
yyh
f93db6391d fix: align agent template orchestration preview with design 2026-09-28 16:08:34 +08:00
yyh
c65e593715 fix: disable pointer dismissal 2026-09-28 15:03:37 +08:00
Yansong Zhang
625d80b467 test: verify atomic rollback of managed credential bindings 2026-09-28 14:47:10 +08:00
Yansong Zhang
755d07227f Merge remote-tracking branch 'origin/deploy/agent' into work/tokener-legacy-migration-deploy 2026-09-28 14:43:48 +08:00
Yansong Zhang
79048e83c0 feat: migrate legacy hosted credits to Tokener with frozen routing and recovery 2026-09-28 14:43:48 +08:00
yyh
38946927ae chore(deploy): merge agent template PR updates 2026-09-28 12:54:28 +08:00
yyh
24d5f651a9 Merge branch 'main' into linw/FPRD-144-new-agent-templates 2026-09-28 12:49:07 +08:00
yyh
9de1ba560d fix(explore): remove unused agent setup hint translations 2026-09-28 12:48:42 +08:00
yyh
155f170543 fix(explore): remove details tab fade transition 2026-09-28 12:44:53 +08:00
yyh
a6ca4dcb28 fix(explore): align agent template preview details 2026-09-28 12:33:33 +08:00
yyh
b62f8a6b2f fix(agent-v2): hide downloads in template previews 2026-09-28 12:25:15 +08:00
yyh
c68b2e5476 fix(agent-v2): align orchestration field spacing with design 2026-09-28 12:17:08 +08:00
yyh
96660fb013 fix: iconbutton 2026-09-28 12:01:51 +08:00
yyh
add43e9e47 Merge remote-tracking branch 'origin/main' into linw/FPRD-144-new-agent-templates 2026-09-28 11:56:34 +08:00
林玮 (Jade Lin)
d076cf8407 Merge origin/main into deploy/agent 2026-09-28 11:15:24 +08:00
林玮 (Jade Lin)
9fbadfabf3 Merge origin/deploy/agent into deploy/agent 2026-09-28 11:15:21 +08:00
yyh
67ffa9e767 chore(deploy): merge agent template updates 2026-09-28 11:09:16 +08:00
林玮 (Jade Lin)
c5fb28086a Merge PR #42837 updates into deploy/agent 2026-09-28 11:08:41 +08:00
林玮 (Jade Lin)
31d69e8b1d Merge origin/main into deploy/agent 2026-09-28 11:08:38 +08:00
yyh
d9a20b2f94 fix(agent-v2): show field tips in template preview 2026-09-28 11:04:40 +08:00
林玮 (Jade Lin)
f5271f2405 Merge PR #42863 into deploy/agent 2026-09-28 11:01:36 +08:00
林玮 (Jade Lin)
c2cd488656 Merge PR #42837 into deploy/agent
# Conflicts:
#	api/services/agent/roster_package_importer.py
2026-09-28 11:01:28 +08:00
林玮 (Jade Lin)
9246fc8cdd Merge origin/main into deploy/agent
# Conflicts:
#	api/controllers/console/explore/recommended_app.py
#	api/controllers/console/explore/trial_app_admission.py
#	api/services/agent/roster_package_importer.py
#	api/tests/unit_tests/services/agent/test_roster_package_importer.py
#	web/app/components/explore/create-app-modal/index.tsx
#	web/features/home/home-content/home-content.tsx
#	web/i18n/locales/ja-JP/model-provider.json
2026-09-28 11:00:48 +08:00
yyh
2b92b3b114 Merge remote-tracking branch 'origin/main' into linw/FPRD-144-new-agent-templates 2026-09-28 10:51:58 +08:00
林玮 (Jade Lin)
905768666b fix(api): use validation context for Site export 2026-09-28 10:47:00 +08:00
林玮 (Jade Lin)
ca98122d42 fix(api): export legacy Site settings in DSL 2026-09-28 10:41:28 +08:00
林玮 (Jade Lin)
b27f734099 Merge remote-tracking branch 'origin/linw/FPRD-144-new-agent-templates' into linw/FPRD-144-new-agent-templates 2026-09-28 10:40:34 +08:00
林玮 (Jade Lin)
20cc7ba6f0 chore: merge main into agent templates 2026-09-28 10:33:26 +08:00
yyh
2fadd5bcab fix(explore): show categories for Agent templates 2026-09-28 10:26:11 +08:00
yyh
8eb62a2526 test(explore): keep preview fetch mock through cleanup 2026-09-24 21:34:41 +08:00
yyh
81c5e3e531 test(explore): assert agent preview user semantics 2026-09-24 21:25:46 +08:00
yyh
16ebec5782 test(explore): seed workspace context for agent preview 2026-09-24 21:23:54 +08:00
yyh
a368af92ad chore: merge main into agent templates 2026-09-24 21:02:07 +08:00
yyh
020a3ea107 Merge remote-tracking branch 'origin/linw/FPRD-144-new-agent-templates' into HEAD 2026-09-24 20:23:49 +08:00
yyh
f5c90ecbaa fix(explore): keep tab panel next in focus order 2026-09-24 20:23:27 +08:00
yyh
f26a7eac9f Merge branch 'linw/FPRD-144-new-agent-templates' into codex/merge-agent-template-prompt-icons 2026-09-24 19:56:22 +08:00
yyh
f8ae28cc11 fix(agent-v2): show tool icons in template prompt references 2026-09-24 19:55:59 +08:00
yyh
0b0786637f chore(deploy): sync agent template responsive preview 2026-09-24 19:41:37 +08:00
yyh
4f40a83d75 fix(explore): adapt template preview to narrow viewports 2026-09-24 19:41:07 +08:00
yyh
ceead8f488 chore(deploy): sync agent template picker 2026-09-24 19:27:07 +08:00
yyh
c5d7109310 fix(explore): simplify agent template picker accessibility 2026-09-24 19:26:03 +08:00
yyh
f18d98a6b5 fix(agent-v2): show template tool icons without metadata lookup 2026-09-24 19:25:32 +08:00
yyh
bc5cf3fa18 Merge remote-tracking branch 'origin/linw/FPRD-144-new-agent-templates' into codex/deploy-agent-template-ui 2026-09-24 19:10:23 +08:00
yyh
f5ca1f565e feat(explore): align agent template preview with design 2026-09-24 19:09:52 +08:00
yyh
08e380dc44 fix: infotip style 2026-09-24 18:41:54 +08:00
yyh
f590082ead Merge commit '6bbeec968d' into HEAD 2026-09-24 18:22:57 +08:00
yyh
6bbeec968d fix(explore): scope template retries to dialog sessions 2026-09-24 18:22:41 +08:00
yyh
e0a61f32ea Merge remote-tracking branch 'origin/main' into linw/FPRD-144-new-agent-templates 2026-09-24 18:19:34 +08:00
yyh
8a2e03053e Merge commit 'bda58a77cf' into HEAD 2026-09-24 18:14:54 +08:00
yyh
bda58a77cf fix(api): serialize model-less agent template previews 2026-09-24 18:14:27 +08:00
yyh
7e0d6308d1 fix(explore): preserve template error state during retry 2026-09-24 18:14:19 +08:00
yyh
220ca914f8 Merge remote-tracking branch 'origin/main' into linw/FPRD-144-new-agent-templates 2026-09-24 17:42:10 +08:00
林玮 (Jade Lin)
12c00556d4 fix(contracts): align trial Agent voice type 2026-09-24 17:17:59 +08:00
autofix-ci[bot]
da0f3eac05 [autofix.ci] apply automated fixes 2026-09-24 09:07:36 +00:00
林玮 (Jade Lin)
084f52998f Merge PR #42913 updates into deploy/agent 2026-09-24 17:07:09 +08:00
林玮 (Jade Lin)
6114b2e86a Merge origin/deploy/agent into deploy/agent 2026-09-24 17:07:02 +08:00
林玮 (Jade Lin)
7ac5d3c30e test(agent): cover package imports without dependencies 2026-09-24 17:05:59 +08:00
yyh
6b631ee990 Merge remote-tracking branch 'origin/linw/FPRD-144-new-agent-templates' into codex/merge-fprd-144-into-deploy-agent 2026-09-24 17:05:51 +08:00
yyh
31b31c765c Merge remote-tracking branch 'origin/main' into linw/FPRD-144-new-agent-templates 2026-09-24 17:04:20 +08:00
yyh
40e88140de feat(explore): preview and import Agent templates 2026-09-24 17:03:44 +08:00
林玮 (Jade Lin)
6ac8d34453 Merge PR #42913 into deploy/agent 2026-09-24 16:59:20 +08:00
林玮 (Jade Lin)
49687d7183 fix(agent): allow imports with missing plugins 2026-09-24 16:53:44 +08:00
林玮 (Jade Lin)
9accabbe6c style(api): format Site import check 2026-09-24 16:43:10 +08:00
林玮 (Jade Lin)
c23cb8c703 Merge PR #42863 updates into deploy/agent 2026-09-24 16:36:36 +08:00
林玮 (Jade Lin)
01d2fc49de Merge PR #42837 updates into deploy/agent 2026-09-24 16:35:35 +08:00
林玮 (Jade Lin)
2679bf1880 test(api): align app transfer fixtures with template ports 2026-09-24 16:11:38 +08:00
autofix-ci[bot]
e0a76a8645 [autofix.ci] apply automated fixes 2026-09-24 08:10:14 +00:00
林玮 (Jade Lin)
195fc5299b fix(api): preserve template import compatibility 2026-09-24 15:52:26 +08:00
林玮 (Jade Lin)
7842252964 merge: sync main into agent templates 2026-09-24 15:15:29 +08:00
林玮 (Jade Lin)
992d89c518 merge: sync main into Site DSL branch 2026-09-24 15:05:00 +08:00
林玮 (Jade Lin)
5ac2748c7b fix(api): drop paid Site settings from free imports 2026-09-24 14:55:28 +08:00
林玮 (Jade Lin)
66d989a063 fix(web): align Tokener translations with model provider namespace 2026-09-24 14:07:30 +08:00
林玮 (Jade Lin)
c5c8753e12 Merge PR #42863 into deploy/agent 2026-09-24 13:54:57 +08:00
林玮 (Jade Lin)
c48bdd32ad Merge PR #42837 into deploy/agent 2026-09-24 13:53:51 +08:00
林玮 (Jade Lin)
74d67ca072 Merge origin/main into deploy/agent 2026-09-24 13:53:39 +08:00
林玮 (Jade Lin)
629d418ae8 test(api): consolidate trial composer admission coverage 2026-09-24 11:33:07 +08:00
林玮 (Jade Lin)
d147e52a33 test(api): narrow Agent app ID before Site setup 2026-09-24 11:28:30 +08:00
林玮 (Jade Lin)
5bfbd5e48b test(api): cover Site export in Agent packages 2026-09-24 11:25:44 +08:00
林玮 (Jade Lin)
bf5724a3b6 refactor(web): remove out-of-scope agent trial UI changes 2026-09-24 11:24:52 +08:00
林玮 (Jade Lin)
fced61a505 test(api): focus template export coverage on public behavior 2026-09-24 11:13:39 +08:00
林玮 (Jade Lin)
040e9b56d9 test(api): initialize Site in DSL integration fixtures 2026-09-24 11:09:46 +08:00
林玮 (Jade Lin)
049fdb88da refactor(api): align trial template exports with app formats 2026-09-24 11:05:26 +08:00
林玮 (Jade Lin)
55eaafdfc5 fix(api): avoid getattr in Site DSL validation 2026-09-24 11:02:46 +08:00
林玮 (Jade Lin)
5404df5339 feat(api): include site settings in app DSL 2026-09-24 10:55:58 +08:00
林玮 (Jade Lin)
ce43812da6 feat(api): allow read-only access to published trial agent files 2026-09-24 10:31:26 +08:00
林玮 (Jade Lin)
498c98fc05 refactor(api): remove redundant trial and template adapters 2026-09-23 22:54:55 +08:00
林玮 (Jade Lin)
9bcfd614fb test(api): reuse resource safety cases for local templates 2026-09-23 20:35:47 +08:00
林玮 (Jade Lin)
283538bff9 test(web): cover composer protocol resource variants 2026-09-23 20:28:51 +08:00
林玮 (Jade Lin)
a3157e2ede fix(api): preserve types in template and trial adapters 2026-09-23 20:27:05 +08:00
林玮 (Jade Lin)
1678c10481 test(api): include agent template routes in registration contract 2026-09-23 20:19:50 +08:00
林玮 (Jade Lin)
fb2d1413c2 merge: align trial routes with main 2026-09-23 20:19:16 +08:00
林玮 (Jade Lin)
66cf320dd0 test(web): align trial requirements with generated query 2026-09-23 20:15:16 +08:00
林玮 (Jade Lin)
7cb501332e test(api): type agent template regression fixtures 2026-09-23 20:14:23 +08:00
林玮 (Jade Lin)
5ebd4bf132 fix(web): align trial preview with composer protocol 2026-09-23 18:28:46 +08:00
autofix-ci[bot]
01ea676576 [autofix.ci] apply automated fixes 2026-09-23 10:25:41 +00:00
林玮 (Jade Lin)
4df04cb7fb merge: sync main into new agent templates 2026-09-23 18:23:02 +08:00
林玮 (Jade Lin)
00fdcbe910 refactor(api): reuse composer contract for agent templates 2026-09-23 18:21:00 +08:00
yyh
2456073556 fix(agent): initialize model in new agent snapshot 2026-09-23 11:12:25 +08:00
Yansong Zhang
16d7826514 fix(billing): initialize Tokener billing on email-code signup 2026-09-23 10:47:18 +08:00
yyh
cce98ca401 chore(deploy): sync transfer state simplification 2026-09-22 17:41:27 +08:00
yyh
d247415a05 fix(app): simplify transfer state and failure handling 2026-09-22 17:40:49 +08:00
林玮 (Jade Lin)
1d2dbe71f0 fix(api): allow stopping trial tasks after usage limit is reached
Add a check_usage flag to the trial app admission decorator and access
service so task cancellation can skip the generation quota check, letting
users stop the last trial run without unlocking another generation.
2026-09-22 17:21:43 +08:00
yyh
ab4e262bcb chore(deploy): sync app package CI fixes 2026-09-22 16:47:10 +08:00
yyh
eaa8750e47 test(app): align package export checks and retire DSL keys 2026-09-22 16:44:51 +08:00
yyh
6c2439d996 chore(deploy): integrate FPRD-155 app package feedback 2026-09-22 16:32:38 +08:00
yyh
7c92504b9f fix(app): align package import and export feedback 2026-09-22 16:31:08 +08:00
林玮 (Jade Lin)
d60f0a6fd6 feat: support new agent templates and trials 2026-09-22 16:12:44 +08:00
林玮 (Jade Lin)
682b298f8d Merge PR #42660 into deploy/agent 2026-09-21 17:54:33 +08:00
林玮 (Jade Lin)
acf51ce258 Merge origin/main into deploy/agent 2026-09-21 17:54:33 +08:00
林玮 (Jade Lin)
0a41494c3c fix(web): skip parameter rules for unavailable models 2026-09-21 17:51:01 +08:00
yyh
8acb2b47d3 fix(agent): align publish guidance translations with PRD 2026-09-21 17:06:14 +08:00
yyh
6178ceb2fd feat(agent): sync publish success guidance from PR #42645 2026-09-21 15:43:21 +08:00
yyh
887662f714 feat(agent): return publication kind after publishing 2026-09-21 14:45:28 +08:00
林玮 (Jade Lin)
51069c8f05 Merge PR #42626 into deploy/agent 2026-09-21 11:49:37 +08:00
林玮 (Jade Lin)
54a91d9c71 Merge PR #42623 into deploy/agent 2026-09-21 11:49:20 +08:00
林玮 (Jade Lin)
14b79c48fc Merge PR #42622 into deploy/agent 2026-09-21 11:49:20 +08:00
林玮 (Jade Lin)
9d60f80758 Merge origin/main into deploy/agent 2026-09-21 11:49:19 +08:00
林玮 (Jade Lin)
0717dbe949 test(app): annotate package icon tests 2026-09-21 11:38:38 +08:00
林玮 (Jade Lin)
796b09962a fix(app): include image icons in app packages 2026-09-21 11:36:36 +08:00
Yansong Zhang
509d5508bb refactor(agent): retain original runtime shutdown order 2026-09-21 11:34:04 +08:00
林玮 (Jade Lin)
2e9c9ca055 fix(agent): auto-rename duplicate package imports 2026-09-21 11:27:46 +08:00
Yansong Zhang
39da6c4ff3 docs(agent): clarify collection deadlines for gevent workers 2026-09-21 11:26:22 +08:00
林玮 (Jade Lin)
f0618be386 test(app): use explicit agent asset references 2026-09-21 11:18:07 +08:00
林玮 (Jade Lin)
4d8eefa9af refactor(app): extract missing agent asset validation 2026-09-21 11:14:20 +08:00
林玮 (Jade Lin)
d76f4759a4 fix(app): mark unavailable legacy DSL agent assets as missing 2026-09-21 11:14:20 +08:00
Yansong Zhang
46eb284016 refactor(agent): isolate E2B accounting behind Celery collection 2026-09-21 10:59:32 +08:00
Yansong Zhang
326ccb0551 refactor(agent): report sandbox operations inline without a queue 2026-09-20 20:14:39 +08:00
Yansong Zhang
7799af2053 docs(agent): align run server guide with best-effort metering 2026-09-20 19:13:11 +08:00
Yansong Zhang
1a9b97d8f2 refactor(agent): deliver sandbox diagnostics best effort 2026-09-20 18:41:02 +08:00
林玮 (Jade Lin)
a7b139e64c Merge PR #42575 into deploy/agent 2026-09-20 17:46:00 +08:00
林玮 (Jade Lin)
b9c000d9f0 Merge PR #42579 into deploy/agent 2026-09-20 17:44:09 +08:00
林玮 (Jade Lin)
b28f71a1ca Merge remote-tracking branch 'origin/main' into deploy/agent 2026-09-20 17:43:58 +08:00
林玮 (Jade Lin)
eb92a4480b refactor(app): simplify agent package resource handling 2026-09-20 17:35:41 +08:00
林玮 (Jade Lin)
879fe97bf2 test: fix agent audio type annotations 2026-09-20 17:34:58 +08:00
林玮 (Jade Lin)
11a53ba52f fix: keep agent audio lookups read-only 2026-09-20 17:12:35 +08:00
林玮 (Jade Lin)
c7d89658bc feat(app): package workflow inline agent resources 2026-09-20 16:51:47 +08:00
林玮 (Jade Lin)
54a0108ff1 feat: add agent text-to-audio API 2026-09-20 16:18:12 +08:00
Joel
4378401341 fix: route Agent voice previews to the Agent endpoint 2026-09-20 16:05:06 +08:00
autofix-ci[bot]
7ede2ae1fd [autofix.ci] apply automated fixes 2026-09-20 07:57:17 +00:00
Joel
df0420e036 chore: rbac 2026-09-20 15:53:24 +08:00
Joel
2f59839816 feat: agent tts config 2026-09-20 15:48:37 +08:00
林玮 (Jade Lin)
434a70a031 Merge PR #42572 into deploy/agent 2026-09-20 15:41:51 +08:00
林玮 (Jade Lin)
ac26025cca Merge remote-tracking branch 'origin/main' into deploy/agent 2026-09-20 15:41:00 +08:00
林玮 (Jade Lin)
a881f46f9d fix(api): remove omitted tenant default models 2026-09-20 15:31:05 +08:00
林玮 (Jade Lin)
fa8fc8d0f5 test(agent): annotate sandbox usage tests 2026-09-20 13:50:12 +08:00
林玮 (Jade Lin)
58a6fb54f8 Merge PR #42552 into deploy/agent 2026-09-20 13:42:10 +08:00
林玮 (Jade Lin)
36ca438c49 Merge PR #42551 into deploy/agent 2026-09-20 13:39:15 +08:00
林玮 (Jade Lin)
5e219ffa6d Merge remote-tracking branch 'origin/main' into deploy/agent 2026-09-20 13:39:15 +08:00
林玮 (Jade Lin)
c76a39a722 test(e2e): expect original app name in package downloads 2026-09-20 12:13:10 +08:00
林玮 (Jade Lin)
e1f7af292a test(e2e): expect ifpkg app exports 2026-09-20 11:54:06 +08:00
Yansong Zhang
8724dbfa3b fix(agent): preserve sandbox file error output 2026-09-20 11:49:36 +08:00
林玮 (Jade Lin)
e3e1e89f8d refactor(web): remove unused DSL import helpers 2026-09-20 11:47:30 +08:00
林玮 (Jade Lin)
3be1a58989 fix(agent): require paid plan for version restore 2026-09-20 11:30:44 +08:00
林玮 (Jade Lin)
90bb929728 fix(app): align package DSL limits with YAML imports 2026-09-20 11:29:03 +08:00
Yansong Zhang
6e8baa7d9e feat(agent): record provider-authoritative sandbox runtime usage 2026-09-20 11:14:17 +08:00
林玮 (Jade Lin)
a517065b2f feat(app): default exports to ifpkg across app types 2026-09-20 11:02:54 +08:00
林玮 (Jade Lin)
d80a462b4d Merge PR #42498 into deploy/agent 2026-09-18 16:27:43 +08:00
林玮 (Jade Lin)
7382dc616c Merge remote-tracking branch 'origin/main' into merge/deploy-agent-main-42498 2026-09-18 16:27:29 +08:00
林玮 (Jade Lin)
21f20131cf Merge PR #42485 into deploy/agent 2026-09-18 16:22:19 +08:00
林玮 (Jade Lin)
d73a3d7605 Merge remote-tracking branch 'origin/main' into deploy/agent 2026-09-18 16:22:06 +08:00
林玮 (Jade Lin)
e1a329413b Merge main into agent version export branch 2026-09-18 16:07:35 +08:00
林玮 (Jade Lin)
38471217df refactor(agent): address version export review feedback 2026-09-18 16:02:24 +08:00
Joel
42b002a8d2 feat: support exporting published agent versions 2026-09-18 15:53:26 +08:00
林玮 (Jade Lin)
1209ee74b4 fix(api): preserve saved default models when provider lookup fails 2026-09-18 15:45:18 +08:00
林玮 (Jade Lin)
dacca2c1be fix(api): resolve lint and test typing issues 2026-09-18 14:30:13 +08:00
林玮 (Jade Lin)
10204bd203 Merge PR #42485 into deploy/agent 2026-09-18 14:04:12 +08:00
林玮 (Jade Lin)
bb8dbf3583 Merge remote-tracking branch 'origin/main' into deploy/agent
# Conflicts:
#	api/tests/unit_tests/services/test_billing_service.py
2026-09-18 14:04:12 +08:00
林玮 (Jade Lin)
0476f31a16 fix(agent): distinguish optional export snapshot types 2026-09-18 12:03:11 +08:00
林玮 (Jade Lin)
174e8ab0cd feat(agent): support exporting published versions 2026-09-18 11:42:00 +08:00
林玮 (Jade Lin)
4e57341661 Merge PR #42425 into deploy/agent 2026-09-17 20:29:46 +08:00
林玮 (Jade Lin)
1fec074c6c Merge remote-tracking branch 'origin/main' into deploy/agent
# Conflicts:
#	api/services/skill_management_service.py
#	api/tests/unit_tests/controllers/console/app/test_roster_package_routes.py
#	api/tests/unit_tests/services/agent/test_roster_package_importer.py
2026-09-17 20:29:46 +08:00
Yansong Zhang
d877bb854f fix(agent): make composer configuration reads side-effect free 2026-09-17 18:09:35 +08:00
林玮 (Jade Lin)
dcdf10f118 fix(app): preserve frontend URL import quota checks 2026-09-17 17:48:37 +08:00
林玮 (Jade Lin)
94a7b6e7c6 refactor(app): detect URL import formats from content 2026-09-17 17:26:59 +08:00
林玮 (Jade Lin)
7ce4108f1d feat(agent): support roster package imports from URLs 2026-09-17 16:22:47 +08:00
林玮 (Jade Lin)
32b4d9f885 Merge branch 'linw/fprd-127-agent-ifpkg-import' into deploy/agent 2026-09-11 16:47:35 +08:00
林玮 (Jade Lin)
3900dfde75 fix(web): import ifpkg archives as multipart files 2026-09-11 16:31:11 +08:00
林玮 (Jade Lin)
f6ee50575b fix(web): download Agent exports as ifpkg archives 2026-09-11 16:11:42 +08:00
林玮 (Jade Lin)
87b887ef17 test(agent): remove unnecessary legacy route assertions 2026-09-11 15:46:48 +08:00
林玮 (Jade Lin)
5ad03563ab refactor(agent): separate file and Agent import transactions 2026-09-11 15:41:36 +08:00
林玮 (Jade Lin)
1d2190fffa test(agent): fix package importer type diagnostics 2026-09-11 15:02:42 +08:00
林玮 (Jade Lin)
7d7466869a feat(agent): default Agent exports to ifpkg 2026-09-11 14:59:20 +08:00
林玮 (Jade Lin)
d8bdbd48d1 refactor(agent): simplify package import orchestration 2026-09-11 14:59:19 +08:00
林玮 (Jade Lin)
a28e3c7361 refactor(agent): remove unused package importer injections 2026-09-11 14:10:43 +08:00
林玮 (Jade Lin)
44aa3fbb79 fix(agent): adapt package imports to indexed App DSL 2026-09-11 11:41:57 +08:00
林玮 (Jade Lin)
53b55dc536 refactor(agent): simplify roster package validation flow 2026-09-11 11:38:59 +08:00
林玮 (Jade Lin)
9c3befe61f feat(agent): reuse app routes and recover damaged skill archives 2026-09-11 11:38:37 +08:00
林玮 (Jade Lin)
7dbd2d3d08 fix(agent): check package dependencies before importing 2026-09-11 11:38:07 +08:00
林玮 (Jade Lin)
50441b36c1 fix(agent): retry package cleanup with idempotent checkpoints 2026-09-11 11:38:07 +08:00
林玮 (Jade Lin)
7e55ceb3f7 feat(agent): import roster packages with sanitized credentials 2026-09-11 11:38:07 +08:00
Joel
acc5120042 Merge branch 'chore/agent-opener-switch-enable-problem' into deploy/agent 2026-09-11 11:23:08 +08:00
林玮 (Jade Lin)
1a3dadf8eb feat(agent): index App DSL members in package manifests 2026-09-11 11:20:08 +08:00
Joel
11c6e1fafb fix: keep agent conversation opener state in sync after saving 2026-09-11 11:12:07 +08:00
林玮 (Jade Lin)
b8d7a766cf Merge main into linw/fprd-127-agent-ifpkg 2026-09-11 09:41:26 +08:00
林玮 (Jade Lin)
8caea7bc9b refactor(agent): reuse validated Skill member paths 2026-09-11 00:24:07 +08:00
林玮 (Jade Lin)
29ad30a1d3 refactor(agent): remove unreachable negative ZIP size guards 2026-09-11 00:18:32 +08:00
林玮 (Jade Lin)
947dd33663 test(agent): consolidate package validation failures 2026-09-10 23:30:29 +08:00
林玮 (Jade Lin)
86431084f9 test(agent): remove redundant skill package cases 2026-09-10 23:20:00 +08:00
林玮 (Jade Lin)
c99c218c7a test(agent): consolidate portable Soul assertions 2026-09-10 23:08:58 +08:00
林玮 (Jade Lin)
95fda2987f test(agent): consolidate skill normalization scenarios 2026-09-10 23:01:14 +08:00
林玮 (Jade Lin)
2f1ef6a654 refactor(agent): reuse sanitized Soul for portable packages 2026-09-10 22:50:21 +08:00
林玮 (Jade Lin)
63ec8aa813 refactor(agent): trim normalized skill package results 2026-09-10 22:43:07 +08:00
林玮 (Jade Lin)
af9f7d9f17 refactor(agent): return only persisted skill references 2026-09-10 22:42:01 +08:00
林玮 (Jade Lin)
1ef9f06d52 refactor(agent): remove fixed skill MIME from package manifests 2026-09-10 21:59:40 +08:00
林玮 (Jade Lin)
3c386d0f8b refactor(agent): reuse DSL skill metadata in packages 2026-09-10 20:54:07 +08:00
林玮 (Jade Lin)
703c8264f9 refactor(agent): reuse DSL file metadata in packages 2026-09-10 20:48:06 +08:00
林玮 (Jade Lin)
365f03c3c6 refactor(agent): reuse DSL file semantics in package manifests 2026-09-10 20:08:02 +08:00
Joel
0e4c86e4dd Merge branch 'main' into deploy/agent
# Conflicts:
#	api/core/workflow/nodes/agent_v2/binding_resolver.py
#	api/core/workflow/nodes/agent_v2/session_store.py
#	api/services/entities/feature_entities.py
#	api/services/workspace_service.py
#	api/tests/unit_tests/core/llm_generator/test_llm_generator.py
#	api/tests/unit_tests/core/plugin/test_plugin_runtime.py
#	api/tests/unit_tests/core/workflow/nodes/agent_v2/test_binding_resolver.py
#	api/tests/unit_tests/services/test_workspace_service.py
#	web/app/components/billing/utils/__tests__/index.spec.ts
2026-09-10 18:22:36 +08:00
林玮 (Jade Lin)
fe76265e55 refactor(agent): remove redundant member path field 2026-09-10 17:54:07 +08:00
林玮 (Jade Lin)
992eb616f2 refactor(agent): retain only download data after export 2026-09-10 17:44:47 +08:00
林玮 (Jade Lin)
a1509c876d fix(agent): align skill archive limit with documentation 2026-09-10 17:29:39 +08:00
林玮 (Jade Lin)
614b3fd5b8 refactor(agent): calculate normalized size only when needed 2026-09-10 17:25:50 +08:00
林玮 (Jade Lin)
8a559edd47 refactor(agent): require explicit package member bounds 2026-09-10 17:14:32 +08:00
林玮 (Jade Lin)
b20ae6a20d refactor(agent): avoid duplicate DSL envelope construction 2026-09-10 17:06:11 +08:00
林玮 (Jade Lin)
97308f7ca5 refactor(agent): build resource manifests during export 2026-09-10 16:56:59 +08:00
林玮 (Jade Lin)
a3e7b22c67 refactor(agent): flatten roster package resource sources 2026-09-10 16:46:05 +08:00
林玮 (Jade Lin)
1a5e895c87 refactor(agent): inspect skills without rereading exported packages 2026-09-10 16:33:28 +08:00
林玮 (Jade Lin)
8e626d0a44 refactor(agent): sanitize roster package soul once 2026-09-10 16:28:41 +08:00
林玮 (Jade Lin)
0822b17e8d test(agent): cover package reader safety boundaries 2026-09-10 16:01:54 +08:00
林玮 (Jade Lin)
c88a48a277 test(agent): cover malformed ZIP recovery metadata 2026-09-10 15:41:09 +08:00
林玮 (Jade Lin)
8a008b6873 fix(agent): avoid conflicting package payload types 2026-09-10 15:35:32 +08:00
林玮 (Jade Lin)
e986285a57 refactor(agent): replace compression ratios with size limits 2026-09-10 15:00:36 +08:00
林玮 (Jade Lin)
2b839735ae fix(agent): validate package exports before returning 2026-09-10 15:00:35 +08:00
林玮 (Jade Lin)
685aa43fce refactor(agent): simplify package reader construction 2026-09-10 15:00:35 +08:00
林玮 (Jade Lin)
3ec0d02a80 feat(agent): complete roster package reader contract 2026-09-10 15:00:35 +08:00
林玮 (Jade Lin)
ecdb634e64 fix(agent): recover malformed ZIP filenames 2026-09-10 15:00:35 +08:00
林玮 (Jade Lin)
14994753dd refactor(agent): split roster package manifest into YAML documents
Store package metadata in manifest.yaml and application configuration in
app.yaml, and parse both with a hardened YAML loader that rejects duplicate
keys, aliases, and non-string mapping keys.
2026-09-10 11:36:25 +08:00
Yansong Zhang
ac7cd31248 fix(agent): pin chatflow participant config across roster publishes 2026-09-10 11:30:03 +08:00
Zhu Lei
4f550a85f9 fix(agent): persist Chatflow Agent V2 memory by conversation_id (#41871)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
2026-09-10 11:21:57 +08:00
林玮 (Jade Lin)
8a2850e115 fix(api): strip viewport from imported workflow graph
Remove the source canvas position from imported workflow DSLs so the
destination app controls its initial view, and validate the workflow
graph is a mapping before processing.

(cherry picked from commit 56525b91eb)
2026-09-09 17:04:26 +08:00
林玮 (Jade Lin)
57354a17b1 test(agent): cover package validation branches 2026-09-09 16:44:13 +08:00
林玮 (Jade Lin)
71637fd321 fix(agent): satisfy package export type checks 2026-09-09 16:17:05 +08:00
林玮 (Jade Lin)
015776eaee test(config): suppress generated kwargs typing 2026-09-09 16:11:20 +08:00
林玮 (Jade Lin)
048941afa4 fix(agent): bound package export streaming 2026-09-09 15:53:17 +08:00
林玮 (Jade Lin)
1622023e71 fix(agent): configure package archive limits 2026-09-09 15:53:17 +08:00
林玮 (Jade Lin)
a29f58aa8f fix(agent): complete knowledge model dependencies 2026-09-09 15:53:17 +08:00
林玮 (Jade Lin)
da6e012d71 fix(agent): bound nested roster skill expansion 2026-09-09 15:53:16 +08:00
林玮 (Jade Lin)
b9c2d68bec fix(agent): enforce roster package entry limit 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
d758e9c7a6 fix(agent): store roster package members uncompressed 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
fcfe01ec89 fix(agent): require roster package discriminator 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
50778a71fd fix(agent): include follow-up model dependency 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
3033b2d736 fix(agent): bound roster manifest size 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
27959b9f7c fix(agent): resolve legacy skill archive identity 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
c9b3b82105 refactor(agent): unify roster package size limit 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
0a8aee1b04 fix(agent): export current roster skill bindings 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
736baac996 fix(agent): export legacy roster apps 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
a146439ff3 fix(agent): remove roster manifest size cap 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
52f807d2d0 refactor(agent): split roster package reader and exporter 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
76bca007ba fix(agent): bound roster package skill reads 2026-09-09 15:51:49 +08:00
林玮 (Jade Lin)
32ead26422 feat(agent): add roster package export foundation 2026-09-09 15:51:49 +08:00
Yansong Zhang
f9d308c931 feat(billing): show Tokener paid allowance 2026-09-04 16:34:13 +08:00
Yansong Zhang
9f5fdfccec fix(migration): avoid backfilling Tokener billing profiles 2026-09-03 17:29:14 +08:00
Yansong Zhang
bac4ddd0f8 fix(web): sort Tokener metering translations 2026-09-03 15:48:08 +08:00
Yansong Zhang
a54222e7fa fix(agent): surface missing tool credentials 2026-09-03 15:27:23 +08:00
Yansong Zhang
0334f4426d feat(billing): show Tokener metering in model providers 2026-09-03 15:18:25 +08:00
Yansong Zhang
23846f4349 fix(api): use resolved billing source for workspace credits 2026-09-03 12:58:45 +08:00
Yansong Zhang
f2aa2123ca test(web): normalize optional bootstrap status 2026-09-03 12:09:47 +08:00
Yansong Zhang
bb9fc1a08e test(web): accept optional Tokener bootstrap status 2026-09-03 12:08:26 +08:00
Yansong Zhang
1f939961f4 fix: preserve billing contract and core layering 2026-09-03 12:08:26 +08:00
Yansong Zhang
4580c76012 fix(api): degrade unavailable suggestion history model 2026-09-03 11:55:20 +08:00
Yansong Zhang
65bfdd3e05 fix(api): degrade unavailable suggestion models 2026-09-03 11:46:57 +08:00
Yansong Zhang
f7fb1abf4f fix(api): bound suggested question generation latency 2026-09-03 11:32:13 +08:00
Yansong Zhang
385e2c6dc9 feat: route tenant model billing by persisted source 2026-09-03 11:24:05 +08:00
Yansong Zhang
0c51942b66 feat(api): bootstrap Tokener for new tenants 2026-09-02 17:26:27 +08:00
Joel
fbe1c60e3f fix: preserve Swagger tool authorization after refresh 2026-09-02 17:01:15 +08:00
329 changed files with 22808 additions and 1047 deletions

View File

@@ -697,6 +697,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-package-service-boundary]
name = Recommended package application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_package_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden

View File

@@ -318,6 +318,59 @@ class PluginConfig(BaseSettings):
default="",
)
TOKENER_NEW_TENANT_COHORT_ENABLED: bool = Field(
description="Assign newly created tenants to the managed Tokener model-billing cohort",
default=False,
)
TOKENER_LEGACY_MIGRATION_ENABLED: bool = Field(
description="Enable explicitly admitted legacy hosted-credit migrations; never rolls active tenants back",
default=False,
)
TOKENER_LEGACY_MIGRATION_ALLOWLIST: str = Field(
description="Comma-separated tenant UUIDs allowed to prepare and claim; empty denies all new migrations",
default="",
)
TOKENER_LEGACY_MODEL_MAPPING_JSON: str = Field(
description="Versioned hosted source to Tokener model mapping registry; empty registry cannot prepare",
default="[]",
)
TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED: bool = Field(
description="Run event and worker processing for persisted Tokener tenant integrations",
default=False,
)
TOKENER_BILLING_API_URL: str = Field(
description="Internal dify-saas billing base URL used by Tokener bootstrap and metering clients",
default="",
)
TOKENER_PLUGIN_UNIQUE_IDENTIFIER: str = Field(
description="Pinned package identifier used for the managed Tokener plugin",
default="",
)
TOKENER_PLUGIN_INSTALL_SOURCE: Literal["marketplace", "package"] = Field(
description="Install the pinned Tokener plugin from Marketplace or a package pre-uploaded to plugin-daemon",
default="marketplace",
)
TOKENER_PROVIDER_NAME: str = Field(
description="Canonical model-provider name declared by the managed Tokener plugin",
default="langgenius/tokener/tokener",
)
TOKENER_DEFAULT_LLM_MODEL: str = Field(
description="Tokener LLM selected as the default for newly created tenants",
default="deepseek-v4-flash",
)
TOKENER_ENDPOINT_URL: str = Field(
description="Optional allowlisted Tokener data-plane endpoint passed to a compatible plugin package",
default="",
)
@property
def NEW_USER_DEFAULT_MODEL_LIST(self) -> list[tuple[str, str, str]]:
default_models: list[tuple[str, str, str]] = []
@@ -1424,6 +1477,22 @@ class CeleryBeatConfig(BaseSettings):
class CeleryScheduleTasksConfig(BaseSettings):
ENABLE_TOKENER_BOOTSTRAP_RECOVERY_TASK: bool = Field(
description="Enable periodic recovery of incomplete new-tenant Tokener bootstraps",
default=True,
)
ENABLE_TOKENER_MIGRATION_RECOVERY_TASK: bool = Field(
description="Recover prepared migration work and report claim-age attention every minute",
default=False,
)
TOKENER_BOOTSTRAP_RECOVERY_TASK_INTERVAL: PositiveInt = Field(
description="Minimum age and periodic recovery interval for incomplete Tokener bootstraps, in minutes",
default=5,
)
TOKENER_BOOTSTRAP_RECOVERY_BATCH_SIZE: PositiveInt = Field(
description="Maximum number of incomplete Tokener bootstraps requeued per recovery sweep",
default=100,
)
ENABLE_CONVERSATION_CLEANUP_TASK: bool = Field(
description="Enable periodic recovery of soft-deleted conversation cleanup",
default=True,

View File

@@ -0,0 +1,159 @@
{
"affected_window_ids": [
"dify.migration.v1.22222222-2222-4222-8222-222222222222.0"
],
"api_version": 1,
"billing_receipt_ref": "dify.manual.replan.77777777-7777-4777-8777-777777777777",
"business_event_ref": "dify.manual.replan.77777777-7777-4777-8777-777777777777",
"command_payloads": [
{
"command_id": "dify.correction.v2.d49e2bd98d32d9c5ecfe823b5763ebda296169a8b12c67cde72bba3b85542ee8",
"payload_hash": "sha256:8250527c813861297da80fac4fcab480c2262b7c53b7cb8f6ac22be392838cf1",
"window": {
"ordinal": 0,
"cycle_key": "dify.cycle.v1.d03379f2a78034a6b4c0adbf05611b675704fb1fffefe03a50ecfa15c018958a",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.0",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.0",
"starts_at": "2027-06-17T00:00:00Z",
"ends_at": "2027-07-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-06-16"
}
]
},
"delta_usd_micro": "-5000000",
"target_amount_usd_micro": "0"
}
],
"contract_ref": "dify.contract.v1.eb3d7bacaccaeaf767a863e51b3b63bc7033a58971010a371e28552349349ee7",
"event_type": "plan_change",
"expected_revision": 7,
"manual_replan": true,
"operation_id": "c3ac1655-d604-5693-aef7-de6307df4c63",
"replacement_manifest": {
"schema_version": 1,
"tenant_id": "11111111-1111-4111-8111-111111111111",
"migration_id": "22222222-2222-4222-8222-222222222222",
"plan_revision": 2,
"calendar_policy": "LEGACY_TRANSITION_V1",
"contract_ref": "dify.contract.v1.eb3d7bacaccaeaf767a863e51b3b63bc7033a58971010a371e28552349349ee7",
"subscription_id": "sub_fixture",
"paid_period_start": "2027-01-15T18:00:00Z",
"paid_period_end": "2028-01-15T18:00:00Z",
"funding_invoice_id": "in_fixture_annual",
"amount_policy_version": "plans-v1",
"model_mapping_version": "hosted-v1",
"windows": [
{
"ordinal": 0,
"cycle_key": "dify.cycle.v1.d03379f2a78034a6b4c0adbf05611b675704fb1fffefe03a50ecfa15c018958a",
"window_id": "dify.reanchor.v1.77777777-7777-4777-8777-777777777777.0",
"source_ref": "dify.reanchor.v1.77777777-7777-4777-8777-777777777777.0",
"starts_at": "2027-06-18T00:00:00Z",
"ends_at": "2027-07-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-06-16"
}
]
},
{
"ordinal": 1,
"cycle_key": "dify.cycle.v1.c8d99ac75032500bc94280146dd49b324ca090514a74470fce529c6b755801e0",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.1",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.1",
"starts_at": "2027-07-17T00:00:00Z",
"ends_at": "2027-08-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-07-16"
}
]
},
{
"ordinal": 2,
"cycle_key": "dify.cycle.v1.5a8eae08c2dbc0e35540c1ae365a245939b0f217971affe7405af6ce16a6ee0e",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.2",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.2",
"starts_at": "2027-08-17T00:00:00Z",
"ends_at": "2027-09-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-08-16"
}
]
},
{
"ordinal": 3,
"cycle_key": "dify.cycle.v1.1dc92d26b40a4b9293784814a2b58a581187f26064b1246830f0270bd05b9eaf",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.3",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.3",
"starts_at": "2027-09-17T00:00:00Z",
"ends_at": "2027-10-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-09-16"
}
]
},
{
"ordinal": 4,
"cycle_key": "dify.cycle.v1.64cf8db5a75b691e7f027c183c9fd084f124ea4567df782d3e168aaa5d1c8c68",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.4",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.4",
"starts_at": "2027-10-17T00:00:00Z",
"ends_at": "2027-11-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-10-16"
}
]
},
{
"ordinal": 5,
"cycle_key": "dify.cycle.v1.7f76efb3e0824fd2bb92b5f9a05e6c4775290dd4c44d819c595407304ed44561",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.5",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.5",
"starts_at": "2027-11-17T00:00:00Z",
"ends_at": "2027-12-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-11-16"
}
]
},
{
"ordinal": 6,
"cycle_key": "dify.cycle.v1.43c3afeb9bc53b9607cf47394563983762fcb410c43a6ab9d96cc179e2761c63",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.6",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.6",
"starts_at": "2027-12-17T00:00:00Z",
"ends_at": "2028-01-15T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-12-16"
}
]
}
]
},
"replacement_manifest_hash": "sha256:3261185b5b33f116690ac35d04af2363b667cd717aa00cc83affbf3fb78ca391",
"terminal": false
}

View File

@@ -0,0 +1,121 @@
{
"schema_version": 1,
"tenant_id": "11111111-1111-4111-8111-111111111111",
"migration_id": "22222222-2222-4222-8222-222222222222",
"plan_revision": 1,
"calendar_policy": "LEGACY_TRANSITION_V1",
"contract_ref": "dify.contract.v1.eb3d7bacaccaeaf767a863e51b3b63bc7033a58971010a371e28552349349ee7",
"subscription_id": "sub_fixture",
"paid_period_start": "2027-01-15T18:00:00Z",
"paid_period_end": "2028-01-15T18:00:00Z",
"funding_invoice_id": "in_fixture_annual",
"amount_policy_version": "plans-v1",
"model_mapping_version": "hosted-v1",
"windows": [
{
"ordinal": 0,
"cycle_key": "dify.cycle.v1.d03379f2a78034a6b4c0adbf05611b675704fb1fffefe03a50ecfa15c018958a",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.0",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.0",
"starts_at": "2027-06-17T00:00:00Z",
"ends_at": "2027-07-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-06-16"
}
]
},
{
"ordinal": 1,
"cycle_key": "dify.cycle.v1.c8d99ac75032500bc94280146dd49b324ca090514a74470fce529c6b755801e0",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.1",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.1",
"starts_at": "2027-07-17T00:00:00Z",
"ends_at": "2027-08-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-07-16"
}
]
},
{
"ordinal": 2,
"cycle_key": "dify.cycle.v1.5a8eae08c2dbc0e35540c1ae365a245939b0f217971affe7405af6ce16a6ee0e",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.2",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.2",
"starts_at": "2027-08-17T00:00:00Z",
"ends_at": "2027-09-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-08-16"
}
]
},
{
"ordinal": 3,
"cycle_key": "dify.cycle.v1.1dc92d26b40a4b9293784814a2b58a581187f26064b1246830f0270bd05b9eaf",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.3",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.3",
"starts_at": "2027-09-17T00:00:00Z",
"ends_at": "2027-10-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-09-16"
}
]
},
{
"ordinal": 4,
"cycle_key": "dify.cycle.v1.64cf8db5a75b691e7f027c183c9fd084f124ea4567df782d3e168aaa5d1c8c68",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.4",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.4",
"starts_at": "2027-10-17T00:00:00Z",
"ends_at": "2027-11-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-10-16"
}
]
},
{
"ordinal": 5,
"cycle_key": "dify.cycle.v1.7f76efb3e0824fd2bb92b5f9a05e6c4775290dd4c44d819c595407304ed44561",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.5",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.5",
"starts_at": "2027-11-17T00:00:00Z",
"ends_at": "2027-12-17T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-11-16"
}
]
},
{
"ordinal": 6,
"cycle_key": "dify.cycle.v1.43c3afeb9bc53b9607cf47394563983762fcb410c43a6ab9d96cc179e2761c63",
"window_id": "dify.migration.v1.22222222-2222-4222-8222-222222222222.6",
"source_ref": "dify.migration.v1.22222222-2222-4222-8222-222222222222.6",
"starts_at": "2027-12-17T00:00:00Z",
"ends_at": "2028-01-15T00:00:00Z",
"amount_usd_micro": "5000000",
"aliases": [
{
"kind": "legacy_annual_target_date",
"id": "2027-12-16"
}
]
}
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,14 +7,16 @@ import io
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
from functools import wraps
from typing import Any, Concatenate, Literal
from uuid import UUID
from flask import Response, request, send_file, url_for
from flask import Response, make_response, request, send_file, url_for
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from controllers.common.fields import BinaryFileResponse
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
from controllers.common.schema import (
query_params_from_model,
@@ -25,6 +27,8 @@ from controllers.common.schema import (
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import AppUnavailableError
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.model import App, AppMode
@@ -50,6 +58,8 @@ from services.agent_config_service import (
ConfigPushPayload,
ConfigPushSkillItem,
)
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import AppPreviewRef
class AgentConfigQuery(BaseModel):
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
register_schema_models(console_ns, AgentConfigFileUploadPayload)
register_response_schema_models(
console_ns,
BinaryFileResponse,
AgentConfigDeleteResponse,
AgentConfigDownloadResponse,
AgentConfigFileItemResponse,
@@ -239,7 +250,7 @@ register_response_schema_models(
class _ResolvedConsoleTarget:
tenant_id: str
agent_id: str
account_id: str
account_id: str | None
version_id: str
version_kind: AgentConfigVersionKind
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class TrialAgentConfigQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
path: str = Field(description="Normalized member path inside the published Skill package")
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
@wraps(view)
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
try:
state = AgentAppComposerResponse.model_validate(
application_services().app_previews.get_agent_composer(app=app)
)
snapshot = state.active_config_snapshot
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
raise AgentConfigServiceError(
"config_version_not_found", "published template version is unavailable", status_code=404
)
target = _ResolvedConsoleTarget(
tenant_id=app.tenant_id,
agent_id=state.agent.id,
account_id=None,
version_id=snapshot.id,
version_kind=AgentConfigVersionKind.SNAPSHOT,
)
response = make_response(view(self, target, query, *args, **kwargs))
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except AgentConfigServiceError as exc:
response = make_response(_handle(exc))
response.headers["Cache-Control"] = "no-store"
return response
return decorated
def _service() -> AgentConfigService:
return AgentConfigService()
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
assert target.account_id is not None
return _upload_skill_for_target(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
def _file_upload_response(
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
) -> tuple[dict[str, object], int]:
assert target.account_id is not None
manifest = _service().push_file_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
class TrialAgentConfigSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
class TrialAgentConfigFilesApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
class TrialAgentConfigSkillInspectApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_inspect_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
class TrialAgentConfigSkillDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_download_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
class TrialAgentConfigSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
return dump_response(
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
class TrialAgentConfigSkillFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
assert request.view_args is not None
return _skill_file_download_response(
target,
name=name,
path=query.path,
raw_endpoint="console.trial_agent_config_skill_file_content",
route_params={"app_id": str(request.view_args["app_id"])},
)
@console_ns.route(
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
endpoint="trial_agent_config_skill_file_content",
)
class TrialAgentConfigSkillFileContentApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.doc(
produces=["application/octet-stream"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
)
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
return _skill_file_raw_download_response(target, name, query.path)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
class TrialAgentConfigFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
class TrialAgentConfigFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _file_download_response(target, name)
# pyrefly: ignore [unresolvable-dunder-all]
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]

View File

@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.error import AppNotFoundError
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
@@ -17,6 +18,7 @@ from services.agent.errors import InvalidRosterAgentPackageError
from services.app.console_service import ConsoleAppNotFoundError
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.recommended_app_query_service import RecommendedAppNotFoundError
from .. import console_ns
@@ -86,6 +88,8 @@ class AppImportApi(Resource):
result = application_services().apps.console.import_app(context, payload, source=source)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
except RecommendedAppNotFoundError:
raise RecommendedAppNotFoundHttpError() from None
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
result.status, HTTPStatus.OK
)

View File

@@ -11,6 +11,7 @@ from controllers.console.billing.error import (
BillingOperationFailedErrorResponse,
BillingUnavailableErrorResponse,
BillingUnprocessableEntityErrorResponse,
TokenerEducationCheckoutUnsupportedErrorResponse,
to_billing_request_error,
)
from controllers.console.flask_admission import console_account_admission
@@ -56,6 +57,7 @@ register_response_schema_models(
BillingResponse,
BillingInvoiceResponse,
BillingSubscriptionResponse,
TokenerEducationCheckoutUnsupportedErrorResponse,
)
@@ -79,6 +81,11 @@ class Subscription(Resource):
"Billing unavailable",
console_ns.models[BillingUnavailableErrorResponse.__name__],
)
@console_ns.response(
409,
"Education subscriptions are not supported with Tokener billing",
console_ns.models[TokenerEducationCheckoutUnsupportedErrorResponse.__name__],
)
@console_account_admission(
editions=frozenset({DeploymentEdition.CLOUD}),
allowed_roles=_BILLING_PORTAL_ALLOWED_ROLES,

View File

@@ -7,6 +7,7 @@ from services.errors.billing import (
BillingUpstreamInvalidResponseError,
BillingUpstreamUnavailableError,
ComplianceRateLimitExceededError,
TokenerEducationCheckoutUnsupportedError,
)
@@ -34,6 +35,12 @@ class BillingUnavailableErrorResponse(ResponseModel):
status: Literal[503]
class TokenerEducationCheckoutUnsupportedErrorResponse(ResponseModel):
code: Literal["tokener_education_checkout_unsupported"]
message: str
status: Literal[409]
class ComplianceRateLimitError(BaseHTTPException):
error_code = "compliance_rate_limit"
description = "Rate limit exceeded for downloading compliance report."
@@ -52,6 +59,12 @@ class BillingUnavailableError(BaseHTTPException):
code = 503
class TokenerEducationCheckoutUnsupportedHTTPError(BaseHTTPException):
error_code = "tokener_education_checkout_unsupported"
description = "Education subscriptions are not supported with Tokener billing."
code = 409
def to_billing_request_error(error: BillingError) -> BaseHTTPException:
if isinstance(error, ComplianceRateLimitExceededError):
return ComplianceRateLimitError()
@@ -59,4 +72,6 @@ def to_billing_request_error(error: BillingError) -> BaseHTTPException:
return BillingOperationFailedError()
if isinstance(error, BillingUpstreamUnavailableError):
return BillingUnavailableError()
if isinstance(error, TokenerEducationCheckoutUnsupportedError):
return TokenerEducationCheckoutUnsupportedHTTPError()
raise TypeError(f"Unsupported billing error: {type(error).__name__}")

View File

@@ -1,9 +1,10 @@
from http import HTTPStatus
from typing import Any
from typing import Any, Literal
from uuid import UUID
from flask import make_response, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_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
@@ -11,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.app_export_fields import AppExportResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import build_icon_url, dump_response
from machinery.context import RequestContext
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
@@ -21,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
language: str = Field(default="en-US", description="Language code for recommended app localization")
class RecommendedAgentExportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID = Field(description="Current published template snapshot ID")
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
class RecommendedAppInfoResponse(ResponseModel):
id: str
name: str | None = None
@@ -78,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
mode: str
export_data: str
can_trial: bool
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAgentExportQuery,
)
register_response_schema_models(
console_ns,
@@ -91,6 +104,7 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
AppExportResponse,
)
@@ -135,3 +149,37 @@ class RecommendedAppApi(Resource):
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)
@console_ns.route("/trial-apps/<uuid:app_id>/export")
class RecommendedAgentExportApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
@console_ns.doc(
produces=["application/json", "application/zip"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
)
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
@console_ns.response(404, "Published template unavailable")
@model_validate(RecommendedAgentExportQuery)
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
# Both formats require public catalog membership and the current published version.
try:
exported = application_services().recommended_app_packages.export(
app_id=str(app_id), version_id=query.version_id, format=query.format
)
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
if isinstance(exported, str):
response = make_response(dump_response(AppExportResponse, {"data": exported}))
response.headers["Cache-Control"] = "no-store"
return response
try:
response = send_file(
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
)
except Exception:
exported.close()
raise
response.headers["Cache-Control"] = "no-store"
response.call_on_close(exported.close)
return response

View File

@@ -57,12 +57,14 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial_app_admission import get_trial_app
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
from controllers.console.flask_admission import console_account_admission
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
@@ -72,6 +74,7 @@ from core.errors.error import (
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
from fields.file_fields import FileResponse, FileWithSignedUrl
@@ -84,6 +87,7 @@ from libs.stream import close_stream
from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models.enums import CreatorUserRole
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import (
@@ -153,7 +157,7 @@ class TrialDatasetListQuery(BaseModel):
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
type TrialIconType = Literal["emoji", "image", "link"]
type JsonObject = dict[str, Any]
@@ -448,6 +452,7 @@ register_response_schema_models(
SiteResponse,
SuggestedQuestionsResponse,
TrialAppDetailResponse,
AgentAppComposerResponse,
TrialDatasetListResponse,
TrialWorkflowResponse,
)
@@ -603,6 +608,8 @@ class TrialChatApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except (AgentAppGeneratorError, AgentAppNotPublishedError):
raise AppUnavailableError() from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -620,6 +627,27 @@ class TrialChatApi(Resource):
raise InternalServerError()
@console_ns.route(
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
endpoint="trial_app_stop_chat_completion",
)
class TrialChatTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app_for_stop
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
app_mode=AppMode.value_of(trial_app.app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
@@ -629,7 +657,7 @@ class TrialMessageSuggestedQuestionApi(Resource):
@console_account_admission()
@get_trial_app
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
try:
@@ -837,7 +865,7 @@ class TrialAppParameterApi(Resource):
"""Retrieve app parameters."""
try:
parameters = application_services().app_definitions.get_parameters(app.app_id)
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
@@ -875,6 +903,18 @@ class AppApi(Resource):
return dump_response(TrialAppDetailResponse, source)
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
class TrialAgentComposerApi(Resource):
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef):
try:
preview = application_services().app_previews.get_agent_composer(app=app)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(AgentAppComposerResponse, preview)
@console_ns.route(
"/trial-apps/<uuid:app_id>/workflows",
endpoint="trial_app_workflow",

View File

@@ -28,8 +28,10 @@ def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def get_trial_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
*,
check_usage: bool = True,
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Check trial availability and inject an admitted app after Console admission."""
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
@@ -38,7 +40,7 @@ def get_trial_app[T, **P, R](
raise RuntimeError("The trial app admission route must provide app_id")
try:
trial_app = application_services().trial_apps.access.get_access(
app_id=str(app_id), account_id=request_context.account_id
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
)
except TrialAppUnavailableError as error:
raise TrialAppNotAllowed() from error
@@ -47,3 +49,10 @@ def get_trial_app[T, **P, R](
return view(self, request_context, trial_app, *args, **kwargs)
return trial_feature_enable(decorated)
def get_trial_app_for_stop[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Keep trial access checks without requiring remaining generation quota."""
return get_trial_app(view, check_usage=False)

View File

@@ -1,5 +1,5 @@
import io
from typing import Any, Literal
from typing import Annotated, Any, Literal
from flask import send_file
from flask_restx import Resource
@@ -103,7 +103,59 @@ class ModelProviderSummaryListResponse(ResponseModel):
plugins: dict[str, ModelProviderPluginSummaryResponse]
class TokenerCurrentMonthAvailableMeteringResponse(ResponseModel):
status: Literal["available"]
start_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
end_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
billed_usd_micro: str = Field(pattern=r"^\d+$")
request_count: str = Field(pattern=r"^\d+$")
class TokenerCurrentMonthUnavailableMeteringResponse(ResponseModel):
status: Literal["unavailable"]
start_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
end_date: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")
error_code: str = Field(pattern=r"^[a-z0-9_]{1,100}$")
class TokenerAllowanceMeteringResponse(ResponseModel):
window_id: str
source_ref: str
amount_usd_micro: str = Field(pattern=r"^(0|[1-9]\d*)$")
available_usd_micro: str = Field(pattern=r"^(0|[1-9]\d*)$")
starts_at: str
ends_at: str
class TokenerMeteringResponse(ResponseModel):
tenant_id: str
currency: Literal["USD"]
available_usd_micro: str = Field(pattern=r"^-?\d+$")
current_month: Annotated[
TokenerCurrentMonthAvailableMeteringResponse | TokenerCurrentMonthUnavailableMeteringResponse,
Field(discriminator="status"),
]
balance_generated_at: str
usage_generated_at: str | None = None
allowance: TokenerAllowanceMeteringResponse | None = None
entitlement_status: Literal["active", "processing", "retrying", "failed"] | None = None
entitlement_error_code: str | None = Field(default=None, pattern=r"^[a-z0-9_]{1,100}$")
class ModelProviderCreditsResponse(ResponseModel):
model_billing_source: Literal["legacy_message_credits", "tokener"] = "legacy_message_credits"
model_billing_migration_status: Literal["none", "preparing", "processing", "active"] = "none"
tokener_bootstrap_status: (
Literal[
"pending",
"installing_plugin",
"provisioning",
"configuring_provider",
"ready",
"failed",
]
| None
) = None
pool_type: Literal["paid", "trial"] | None
quota_limit: int | None = Field(description="Credit limit for the effective pool; -1 means unlimited.")
quota_used: int | None
@@ -112,6 +164,7 @@ class ModelProviderCreditsResponse(ResponseModel):
is_exhausted: bool
exhausted_at: int | None
next_credit_reset_date: int | None
tokener_metering: TokenerMeteringResponse | None = None
class ProviderCredentialsResponse(ResponseModel):
@@ -193,7 +246,7 @@ class ModelProviderCreditsApi(Resource):
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str):
credit_pool = WorkspaceService.get_effective_credit_pool(tenant_id, session=session)
credit_pool = WorkspaceService.get_model_provider_credits(tenant_id, session=session)
return dump_response(ModelProviderCreditsResponse, credit_pool)

View File

@@ -1,6 +1,7 @@
import logging
from datetime import datetime
from http import HTTPStatus
from typing import Literal
from flask import request
from flask_restx import Resource
@@ -92,6 +93,19 @@ class TenantInfoResponse(ResponseModel):
trial_credits_used: int | None = None
trial_credits_exhausted_at: int | None = None
next_credit_reset_date: int | None = None
model_billing_source: Literal["legacy_message_credits", "tokener"] = "legacy_message_credits"
model_billing_migration_status: Literal["none", "preparing", "processing", "active"] = "none"
tokener_bootstrap_status: (
Literal[
"pending",
"installing_plugin",
"provisioning",
"configuring_provider",
"ready",
"failed",
]
| None
) = None
@field_validator("status", "trial_end_reason", mode="before")
@classmethod
@@ -113,7 +127,24 @@ class CurrentWorkspaceSummaryResponse(ResponseModel):
name: str
role: TenantAccountRole
plan: CloudPlan | None
credits: int | None = Field(description="Remaining credits in the effective pool; -1 means unlimited.")
credits: int | None = Field(
description=(
"Remaining legacy credits; -1 means unlimited, null means unavailable or not managed by legacy credits."
)
)
model_billing_source: Literal["legacy_message_credits", "tokener"] = "legacy_message_credits"
model_billing_migration_status: Literal["none", "preparing", "processing", "active"] = "none"
tokener_bootstrap_status: (
Literal[
"pending",
"installing_plugin",
"provisioning",
"configuring_provider",
"ready",
"failed",
]
| None
) = None
class TenantListItemResponse(ResponseModel):

View File

@@ -16,6 +16,7 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from . import model_billing_migration as _model_billing_migration
from . import runtime_credentials as _runtime_credentials
from .agent import files as _agent_files
from .agent import llm as _agent_llm
@@ -41,6 +42,7 @@ __all__ = [
"_app_file_grants",
"_knowledge_retrieval",
"_mail",
"_model_billing_migration",
"_plugin",
"_runtime_credentials",
"_skills",

View File

@@ -0,0 +1,232 @@
"""Trusted Billing → Core migration control; no browser-authorized writes."""
from collections.abc import Callable
from typing import Annotated, Any, Literal
from flask import request
from flask_restx import Resource
from pydantic import ConfigDict, Field, ValidationError
from controllers.common.schema import query_params_from_model, 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 fields.base import ResponseModel
from services.entities.model_billing_migration import (
ActivateMigrationPayload,
CommandReceipt,
ContractEventPayload,
DecideCyclePayload,
Digest,
FundingReceiptsPayload,
LeasePayload,
PrepareMigrationPayload,
Reference,
Revision,
StrictPayload,
Timestamp,
UUIDString,
WindowManifest,
)
from services.model_billing_migration_service import MigrationError, ModelBillingMigrationService
class MigrationStatusQuery(StrictPayload):
operation_id: UUIDString | None = None
MigrationPhase = Literal["preparing", "prepared", "claimed", "granting", "activating", "active", "blocked", "cancelled"]
class MigrationOperationSummary(StrictPayload):
phase: MigrationPhase
decision: Literal["legacy_deferred", "tokener"] | None = None
cycle_key: Reference | None = None
manifest_hash: Digest | None = None
error_code: Reference | None = None
route_epoch: Revision | None = None
intent_revision: Revision | None = None
class MigrationOperationResult(StrictPayload):
operation_id: UUIDString
request_hash: Digest
result_code: Annotated[int, Field(ge=100, le=599)]
result_revision: Revision
result_summary: MigrationOperationSummary
lease_action: Literal["acquire", "renew", "release"] | None = None
lease_token: UUIDString | None = None
lease_expires_at: Timestamp | None = None
class MigrationCycleDecision(StrictPayload):
cycle_key: Reference
decision: Literal["legacy_deferred", "tokener"]
contract_ref: Reference
operation_id: UUIDString
manifest_hash: Digest | Literal[""]
class MigrationErrorResponse(ResponseModel):
model_config = ConfigDict(extra="forbid")
code: Reference
message: str
retryable: bool
operation_id: UUIDString | None = None
class MigrationStatusResponse(ResponseModel):
model_config = ConfigDict(extra="forbid")
api_version: Literal[1]
tenant_id: UUIDString
migration_id: UUIDString
phase: MigrationPhase
attention: Literal["normal", "alerted", "manual_required"]
revision: Revision
route_epoch: Revision
replayed: bool
ever_activated: bool = Field(
default=False,
description="Tokener routing/profile has been published, including zero-money terminal finalization",
)
model_mapping_version: Reference
inventory_hash: Digest
billing_receipt_refs: list[Reference]
operation_results: list[MigrationOperationResult]
replayed_operation: MigrationOperationResult | None = Field(
default=None,
description="Immutable original outcome when replayed; all other top-level status fields are current authority",
)
commands: list[CommandReceipt]
cycle_decisions: list[MigrationCycleDecision]
contract_events: dict[str, ContractEventPayload] = Field(default_factory=dict)
prior_manifests: list[WindowManifest] = Field(default_factory=list)
terminated_contract_refs: list[Reference] = Field(default_factory=list)
pending_contract_event: ContractEventPayload | None = None
intent_revision: Revision | None = None
claimed_at: Timestamp | None = None
manifest: WindowManifest | None = None
manifest_hash: Digest | None = None
preparation_binding_hash: Digest | None = None
funding_receipt_hash: Digest | None = None
blocked_from: MigrationPhase | None = None
last_error_code: Reference | None = None
lease_token: UUIDString | None = None
lease_expires_at: Timestamp | None = None
register_schema_models(
inner_api_ns,
PrepareMigrationPayload,
DecideCyclePayload,
LeasePayload,
FundingReceiptsPayload,
ContractEventPayload,
ActivateMigrationPayload,
)
register_response_schema_models(inner_api_ns, MigrationStatusResponse, MigrationErrorResponse)
def _respond(action: Callable[[], dict[str, Any]]):
try:
result = action()
return (
MigrationStatusResponse.model_validate(result).model_dump(mode="json", exclude_none=True),
202 if result["phase"] == "preparing" and not result["replayed"] else 200,
{"Cache-Control": "no-store"},
)
except ValidationError:
# Do not echo secret-adjacent unexpected input or validation locals.
return {"code": "invalid_request", "message": "Invalid migration request", "retryable": False}, 400
except MigrationError as error:
return {"code": error.code, "message": error.code, "retryable": error.retryable}, error.status
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations")
class ModelBillingMigrationStatusApi(Resource):
@inner_api_only
@inner_api_ns.doc(params=query_params_from_model(MigrationStatusQuery))
@inner_api_ns.response(200, "Migration control state", inner_api_ns.models[MigrationStatusResponse.__name__])
def get(self, tenant_id):
def action():
query = MigrationStatusQuery.model_validate(request.args.to_dict(flat=True))
return ModelBillingMigrationService.status(str(tenant_id), query.operation_id)
return _respond(action)
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations/prepare")
class PrepareModelBillingMigrationApi(Resource):
@inner_api_only
@inner_api_ns.expect(inner_api_ns.models[PrepareMigrationPayload.__name__])
@inner_api_ns.response(202, "Preparation accepted", inner_api_ns.models[MigrationStatusResponse.__name__])
def post(self, tenant_id):
return _respond(
lambda: ModelBillingMigrationService.prepare(
str(tenant_id), PrepareMigrationPayload.model_validate(inner_api_ns.payload or {})
)
)
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations/<uuid:migration_id>/decide-cycle")
class DecideModelBillingMigrationCycleApi(Resource):
@inner_api_only
@inner_api_ns.expect(inner_api_ns.models[DecideCyclePayload.__name__])
@inner_api_ns.response(200, "Immutable cycle decision", inner_api_ns.models[MigrationStatusResponse.__name__])
def post(self, tenant_id, migration_id):
return _respond(
lambda: ModelBillingMigrationService.decide_cycle(
str(tenant_id), str(migration_id), DecideCyclePayload.model_validate(inner_api_ns.payload or {})
)
)
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations/<uuid:migration_id>/lease")
class ModelBillingMigrationLeaseApi(Resource):
@inner_api_only
@inner_api_ns.expect(inner_api_ns.models[LeasePayload.__name__])
@inner_api_ns.response(200, "Execution lease", inner_api_ns.models[MigrationStatusResponse.__name__])
def post(self, tenant_id, migration_id):
return _respond(
lambda: ModelBillingMigrationService.lease(
str(tenant_id), str(migration_id), LeasePayload.model_validate(inner_api_ns.payload or {})
)
)
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations/<uuid:migration_id>/funding-receipts")
class ModelBillingMigrationFundingReceiptsApi(Resource):
@inner_api_only
@inner_api_ns.expect(inner_api_ns.models[FundingReceiptsPayload.__name__])
@inner_api_ns.response(200, "Funding receipts persisted", inner_api_ns.models[MigrationStatusResponse.__name__])
def post(self, tenant_id, migration_id):
return _respond(
lambda: ModelBillingMigrationService.funding_receipts(
str(tenant_id), str(migration_id), FundingReceiptsPayload.model_validate(inner_api_ns.payload or {})
)
)
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations/<uuid:migration_id>/contract-events")
class ModelBillingMigrationContractEventsApi(Resource):
@inner_api_only
@inner_api_ns.expect(inner_api_ns.models[ContractEventPayload.__name__])
@inner_api_ns.response(200, "Contract intent fenced", inner_api_ns.models[MigrationStatusResponse.__name__])
def post(self, tenant_id, migration_id):
return _respond(
lambda: ModelBillingMigrationService.contract_event(
str(tenant_id), str(migration_id), ContractEventPayload.model_validate(inner_api_ns.payload or {})
)
)
@inner_api_ns.route("/tenants/<uuid:tenant_id>/model-billing-migrations/<uuid:migration_id>/activate")
class ActivateModelBillingMigrationApi(Resource):
@inner_api_only
@inner_api_ns.expect(inner_api_ns.models[ActivateMigrationPayload.__name__])
@inner_api_ns.response(200, "Migration activated", inner_api_ns.models[MigrationStatusResponse.__name__])
def post(self, tenant_id, migration_id):
return _respond(
lambda: ModelBillingMigrationService.activate(
str(tenant_id), str(migration_id), ActivateMigrationPayload.model_validate(inner_api_ns.payload or {})
)
)

View File

@@ -18,6 +18,7 @@ from core.app.apps.agent_chat.app_runner import AgentChatAppRunner
from core.app.apps.agent_chat.generate_response_converter import AgentChatAppGenerateResponseConverter
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.execution_coordinator import AppExecutionState
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity, InvokeFrom
@@ -208,6 +209,8 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
session=session,
)
from core.app.llm.message_billing import begin_message_billing
# init queue manager
queue_manager = MessageBasedAppQueueManager(
task_id=application_generate_entity.task_id,
@@ -233,18 +236,23 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
},
)
worker_thread.start()
try:
begin_message_billing(application_generate_entity, message.id)
worker_thread.start()
response = self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=streaming,
)
return AgentChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
except Exception:
from core.app.llm.message_billing import release_message_billing
# return response or stream generator
response = self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=streaming,
)
return AgentChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
release_message_billing(application_generate_entity)
raise
def _generate_worker(
self,
@@ -266,6 +274,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
"""
with preserve_flask_contexts(flask_app, context_vars=context):
worker_failed = True
try:
# get conversation and message
conversation = self._get_conversation(conversation_id)
@@ -281,6 +290,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
message=message,
session=session,
)
worker_failed = False
except GenerateTaskStoppedError:
pass
except InvokeAuthorizationError:
@@ -297,3 +307,8 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
except Exception as e:
logger.exception("Unknown Error when generating")
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
finally:
if worker_failed and queue_manager.execution_state != AppExecutionState.TERMINAL:
from core.app.llm.message_billing import release_message_billing
release_message_billing(application_generate_entity)

View File

@@ -15,7 +15,7 @@ from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.db.session_factory import create_session
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.model_manager import create_model_instance
from core.moderation.base import ModerationError
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey
@@ -66,9 +66,10 @@ class AgentChatAppRunner(AppRunner):
memory = None
if application_generate_entity.conversation_id:
# get memory of conversation (read-only)
model_instance = ModelInstance(
model_instance = create_model_instance(
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,
model=application_generate_entity.model_conf.model,
credentials=application_generate_entity.model_conf.credentials,
)
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
@@ -173,9 +174,13 @@ class AgentChatAppRunner(AppRunner):
assert agent_entity is not None
# init model instance
model_instance = ModelInstance(
from core.app.llm.message_billing import require_message_billing
model_instance = create_model_instance(
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,
model=application_generate_entity.model_conf.model,
credentials=application_generate_entity.model_conf.credentials,
message_billing=require_message_billing(application_generate_entity),
)
prompt_message, _ = self.organize_prompt_messages(
app_record=app_record,

View File

@@ -125,6 +125,10 @@ class AppQueueManager(ABC):
def execution_state(self) -> AppExecutionState:
return self._execution_coordinator.state
def request_abort(self, reason: str) -> bool:
"""Abort a failed execution, independently of response listener state."""
return self._execution_coordinator.request_abort(reason)
def _publish_timeout_stop(self, reason: str) -> None:
self.publish(
QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL, reason=reason),

View File

@@ -27,7 +27,7 @@ from core.app.features.hosting_moderation.hosting_moderation import HostingModer
from core.db.session_factory import session_factory
from core.external_data_tool.external_data_fetch import ExternalDataFetch
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.model_manager import create_model_instance
from core.moderation.input_moderation import InputModeration
from core.prompt.advanced_prompt_transform import AdvancedPromptTransform
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate, MemoryConfig
@@ -57,8 +57,10 @@ class AppRunner:
self, model_config: ModelConfigWithCredentialsEntity, prompt_messages: list[PromptMessage]
):
# recalc max_tokens if sum(prompt_token + max_tokens) over model token limit
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
model_instance = create_model_instance(
provider_model_bundle=model_config.provider_model_bundle,
model=model_config.model,
credentials=model_config.credentials,
)
model_context_tokens = model_config.model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE)

View File

@@ -18,6 +18,7 @@ from core.app.apps.chat.app_config_manager import ChatAppConfigManager
from core.app.apps.chat.app_runner import ChatAppRunner
from core.app.apps.chat.generate_response_converter import ChatAppGenerateResponseConverter
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.execution_coordinator import AppExecutionState
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeFrom
@@ -204,6 +205,8 @@ class ChatAppGenerator(MessageBasedAppGenerator):
session=session,
)
from core.app.llm.message_billing import begin_message_billing
# init queue manager
queue_manager = MessageBasedAppQueueManager(
task_id=application_generate_entity.task_id,
@@ -230,19 +233,23 @@ class ChatAppGenerator(MessageBasedAppGenerator):
worker_thread = threading.Thread(target=worker_with_context)
worker_thread.start()
try:
begin_message_billing(application_generate_entity, message.id)
worker_thread.start()
response = self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=streaming,
)
return ChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
except Exception:
from core.app.llm.message_billing import release_message_billing
# return response or stream generator
response = self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=streaming,
)
return ChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
release_message_billing(application_generate_entity)
raise
def _generate_worker(
self,
@@ -262,6 +269,7 @@ class ChatAppGenerator(MessageBasedAppGenerator):
:return:
"""
with flask_app.app_context():
worker_failed = True
try:
# get conversation and message
conversation = self._get_conversation(conversation_id)
@@ -277,6 +285,7 @@ class ChatAppGenerator(MessageBasedAppGenerator):
message=message,
session=session,
)
worker_failed = False
except GenerateTaskStoppedError:
pass
except InvokeAuthorizationError:
@@ -294,4 +303,8 @@ class ChatAppGenerator(MessageBasedAppGenerator):
logger.exception("Unknown Error when generating")
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
finally:
if worker_failed and queue_manager.execution_state != AppExecutionState.TERMINAL:
from core.app.llm.message_billing import release_message_billing
release_message_billing(application_generate_entity)
db.session.close()

View File

@@ -16,7 +16,7 @@ from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
from core.db.session_factory import create_session
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.model_manager import create_model_instance
from core.moderation.base import ModerationError
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from graphon.file import File
@@ -77,9 +77,10 @@ class ChatAppRunner(AppRunner):
memory = None
if application_generate_entity.conversation_id:
# get memory of conversation (read-only)
model_instance = ModelInstance(
model_instance = create_model_instance(
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,
model=application_generate_entity.model_conf.model,
credentials=application_generate_entity.model_conf.credentials,
)
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
@@ -228,9 +229,13 @@ class ChatAppRunner(AppRunner):
self.recalc_llm_max_tokens(model_config=application_generate_entity.model_conf, prompt_messages=prompt_messages)
# Invoke model
model_instance = ModelInstance(
from core.app.llm.message_billing import require_message_billing
model_instance = create_model_instance(
provider_model_bundle=application_generate_entity.model_conf.provider_model_bundle,
model=application_generate_entity.model_conf.model,
credentials=application_generate_entity.model_conf.credentials,
message_billing=require_message_billing(application_generate_entity),
)
request_metadata: dict[str, object] = {"app_id": app_config.app_id}

View File

@@ -0,0 +1,113 @@
"""Keep Classic execution/persistence alive independently of HTTP delivery."""
import contextvars
import logging
from collections.abc import Callable, Generator
from dataclasses import dataclass
from queue import Empty, Full, Queue
from threading import Event, Thread
from typing import cast
from flask import Flask, Response, after_this_request, current_app, has_request_context
from werkzeug.local import LocalProxy
from libs.flask_utils import preserve_flask_contexts
logger = logging.getLogger(__name__)
EXECUTION_OUTPUT_BUFFER_SIZE = 64
@dataclass(frozen=True)
class _Failure:
error: Exception
class _End:
pass
def execution_owned_stream[T](
source: Generator[T, None, None],
*,
on_finished: Callable[[], None],
on_failed: Callable[[Exception], None],
) -> Generator[T, None, None]:
"""Run one execution consumer; HTTP close only detaches its bounded output.
The consumer owns source exhaustion, error handling and final persistence.
It continues draining after a client disconnects, dropping delivery frames
instead of retaining them or cancelling the execution.
"""
flask_app = cast(LocalProxy[Flask], current_app)._get_current_object()
context = contextvars.copy_context()
delivery: Queue[T | _Failure | _End] = Queue(maxsize=EXECUTION_OUTPUT_BUFFER_SIZE)
detached = Event()
def detach() -> None:
detached.set()
while True:
try:
delivery.get_nowait()
except Empty:
break
if has_request_context():
# Response converters add several lazy generators. Closing an unstarted
# outer generator need not close its inner iterator. Bind detachment to
# the real HTTP response as well, without touching execution cancellation.
@after_this_request
def bind_http_close(response: Response) -> Response:
response.call_on_close(detach)
return response
def deliver(item: T | _Failure | _End) -> None:
while not detached.is_set():
try:
delivery.put(item, timeout=0.1)
return
except Full:
continue
def consume_execution() -> None:
with preserve_flask_contexts(flask_app, context_vars=context):
try:
for item in source:
deliver(item)
except Exception as error:
try:
on_failed(error)
except Exception:
logger.error("Failed to report Classic execution consumer failure") # noqa: TRY400 - no upstream payload
deliver(_Failure(error))
finally:
try:
source.close()
finally:
on_finished()
deliver(_End())
def listen() -> Generator[T, None, None]:
try:
# Arm cleanup before returning, including close-before-first-read.
# This private marker is consumed below and is never delivered.
yield cast(T, None)
while True:
item = delivery.get()
if isinstance(item, _End):
return
if isinstance(item, _Failure):
raise item.error
yield item
finally:
detach()
listener = listen()
next(listener)
try:
Thread(target=consume_execution, name="classic-message-execution", daemon=True).start()
except Exception:
listener.close()
source.close()
on_finished()
raise
return listener

View File

@@ -2,7 +2,15 @@ from collections.abc import Mapping, Sequence
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
PrivateAttr,
ValidationInfo,
ValidatorFunctionWrapHandler,
field_validator,
)
from constants import UUID_NIL
from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig
@@ -21,6 +29,7 @@ from graphon.model_runtime.entities.model_entities import AIModelEntity
from models.model import AppMode
if TYPE_CHECKING:
from core.app.llm.message_billing import ClassicMessageBilling
from core.ops.ops_trace_manager import TraceQueueManager
@@ -165,6 +174,15 @@ class ModelConfigWithCredentialsEntity(BaseModel):
parameters: dict[str, Any] = Field(default_factory=dict)
stop: list[str] = Field(default_factory=list)
@field_validator("credentials", mode="wrap")
@classmethod
def preserve_trusted_invocation_binding(cls, value: Any, handler: ValidatorFunctionWrapHandler) -> dict[str, Any]:
from core.model_invocation_routing import LegacyModelCredentials, RoutedModelCredentials
if isinstance(value, (LegacyModelCredentials, RoutedModelCredentials)):
return value
return handler(value)
# pydantic configs
model_config = ConfigDict(protected_namespaces=())
@@ -213,6 +231,7 @@ class EasyUIBasedAppGenerateEntity(AppGenerateEntity):
# app config
app_config: EasyUIBasedAppConfig = None # type: ignore
model_conf: ModelConfigWithCredentialsEntity
_classic_message_billing: "ClassicMessageBilling | None" = PrivateAttr(default=None)
query: str = ""

View File

@@ -0,0 +1,194 @@
"""Classic Chat/Agent billing is owned by a message, never by each LLM turn.
Cloud shared-credit legacy messages reserve once before starting their worker.
The existing message-created event commits that receipt even after cutover,
including messages admitted before migration preparation exists at all.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from threading import Event, RLock, Thread
from time import monotonic
from typing import TYPE_CHECKING, Any, Literal
from configs import dify_config
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit
from core.model_invocation_routing import (
LegacyModelCredentials,
ModelInvocationReprepare,
ModelMigrationProcessing,
ModelRouteUnavailable,
RoutedModelCredentials,
validate_invocation_admission,
)
from enums import DeploymentEdition
from graphon.model_runtime.entities.model_entities import ModelType
from models.provider import ProviderType
from models.provider_ids import ModelProviderID
if TYPE_CHECKING:
from core.app.entities.app_invoke_entities import EasyUIBasedAppGenerateEntity
from core.app.llm.quota import MessageQuotaReservation
logger = logging.getLogger(__name__)
MESSAGE_RESERVATION_HEARTBEAT_SECONDS = 20
@dataclass
class ClassicMessageBilling:
tenant_id: str
message_id: str
provider: str
model: str
backend: Literal["legacy_event", "legacy_reserved", "tokener", "provider_free", "custom", "unmetered"]
reservation: MessageQuotaReservation | None = field(default=None, repr=False)
settled: bool = field(default=False, init=False)
_commit_attempted: bool = field(default=False, init=False, repr=False)
_lock: Any = field(default_factory=RLock, init=False, repr=False)
_heartbeat_stop: Event = field(default_factory=Event, init=False, repr=False)
_renew_failed: bool = field(default=False, init=False, repr=False)
_last_renewed_at: float = field(default_factory=monotonic, init=False, repr=False)
def start_heartbeat(self) -> None:
if self.reservation is None or self.reservation.reservation_id is None:
return
Thread(target=self._heartbeat, name="classic-message-reservation", daemon=True).start()
def _heartbeat(self) -> None:
while not self._heartbeat_stop.wait(MESSAGE_RESERVATION_HEARTBEAT_SECONDS):
with self._lock:
if self._heartbeat_stop.is_set() or self.settled or self.reservation is None:
return
self._renew_locked()
if self._renew_failed:
return
def _renew_locked(self) -> None:
if self.reservation is None or self.reservation.reservation_id is None:
return
try:
self.reservation.renew()
self._last_renewed_at = monotonic()
except Exception:
self._renew_failed = True
self._heartbeat_stop.set()
logger.warning("Message billing renewal failed tenant=%s message=%s", self.tenant_id, self.message_id)
def check_active(self) -> None:
with self._lock:
if self._renew_failed or self.settled:
raise ModelMigrationProcessing
# A delayed scheduler cannot launch a new Agent turn behind an
# unknown hold. Synchronously renew the original receipt first.
if monotonic() - self._last_renewed_at >= MESSAGE_RESERVATION_HEARTBEAT_SECONDS:
self._renew_locked()
if self._renew_failed:
raise ModelMigrationProcessing
def check_identity(self, tenant_id: str, provider: str, model: str) -> None:
if (tenant_id, str(ModelProviderID(provider)), model) != (self.tenant_id, self.provider, self.model):
raise ModelRouteUnavailable
def commit_reserved(self) -> None:
self._heartbeat_stop.set()
with self._lock:
if self.settled:
return
self.check_active()
if self.reservation is not None:
# If a commit result is unknown, do not turn it into a release
# from generator cleanup. The durable Billing receipt can retry.
self._commit_attempted = True
self.reservation.commit()
self.settled = True
def release_unsettled(self) -> None:
self._heartbeat_stop.set()
with self._lock:
if self.settled or self._commit_attempted:
return
if self.reservation is not None:
self.reservation.release()
self.settled = True
def begin_message_billing(entity: EasyUIBasedAppGenerateEntity, message_id: str) -> ClassicMessageBilling:
from core.app.entities.app_invoke_entities import (
AgentAppGenerateEntity,
get_credit_usage_app_type,
get_credit_usage_created_by,
)
from core.app.llm.quota import reserve_message_quota_for_model
existing = entity._classic_message_billing
if existing is not None:
existing.check_identity(entity.app_config.tenant_id, entity.model_conf.provider, entity.model_conf.model)
if existing.message_id != message_id:
raise ModelRouteUnavailable
return existing
configuration = entity.model_conf.provider_model_bundle.configuration
tenant_id = entity.app_config.tenant_id
provider = entity.model_conf.provider
model = entity.model_conf.model
credentials = entity.model_conf.credentials
owner = ClassicMessageBilling(tenant_id, message_id, str(ModelProviderID(provider)), model, "custom")
if isinstance(entity, AgentAppGenerateEntity) and entity.agent_llm_gateway_enabled:
entity._classic_message_billing = owner
return owner
if configuration.using_provider_type == ProviderType.SYSTEM:
validate_invocation_admission(
credentials, tenant_id=tenant_id, provider=provider, model_type=ModelType.LLM, model=model
)
system = configuration.system_configuration
quota = next((q for q in system.quota_configurations if q.quota_type == system.current_quota_type), None)
if isinstance(credentials, RoutedModelCredentials):
owner.backend = "tokener"
elif quota is None or quota.quota_limit == -1 or system.current_quota_type is None:
owner.backend = "unmetered"
elif system.current_quota_type == ProviderQuotaType.FREE:
owner.backend = "provider_free"
else:
owner.backend = "legacy_event"
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and quota.quota_unit in {
QuotaUnit.CREDITS,
QuotaUnit.TIMES,
}:
if not isinstance(credentials, LegacyModelCredentials):
raise ModelInvocationReprepare
owner.reservation = reserve_message_quota_for_model(
tenant_id=tenant_id,
provider=provider,
model=model,
quota_type=system.current_quota_type,
quota_unit=quota.quota_unit,
message_id=message_id,
app_type=get_credit_usage_app_type(entity.app_config.app_mode),
created_by=get_credit_usage_created_by(entity.app_config.app_mode),
)
owner.backend = "legacy_reserved" if owner.reservation is not None else "unmetered"
entity._classic_message_billing = owner
owner.start_heartbeat()
return owner
def require_message_billing(entity: EasyUIBasedAppGenerateEntity) -> ClassicMessageBilling:
owner = entity._classic_message_billing
if owner is None:
# Never silently turn a lost message-level receipt into per-LLM
# accounting and a second charge from the message-created event.
raise ModelRouteUnavailable
return owner
def release_message_billing(entity: EasyUIBasedAppGenerateEntity) -> None:
owner = entity._classic_message_billing
if owner is not None:
try:
owner.release_unsettled()
except Exception:
logger.exception(
"Failed to release message billing receipt tenant=%s message=%s", owner.tenant_id, owner.message_id
)

View File

@@ -62,7 +62,19 @@ class DifyCredentialsProvider:
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
if (provider_name, model_name) in self.credentials_cache:
return deepcopy(self.credentials_cache[(provider_name, model_name)])
from core.model_invocation_routing import ModelInvocationReprepare, validate_invocation_admission
cached = self.credentials_cache[(provider_name, model_name)]
try:
validate_invocation_admission(
cached, tenant_id=self.tenant_id, provider=provider_name, model_type=ModelType.LLM, model=model_name
)
except ModelInvocationReprepare:
self.credentials_cache.pop((provider_name, model_name), None)
self.provider_model_cache.pop((provider_name, model_name), None)
self.provider_manager.clear_configurations_cache(self.tenant_id)
else:
return deepcopy(cached)
provider_configurations = self.provider_manager.get_configurations(self.tenant_id)

View File

@@ -7,7 +7,7 @@ helpers remain LLM-specific because token-based settlement requires LLM usage.
import warnings
from dataclasses import dataclass, field
from enum import StrEnum, auto
from typing import Any
from typing import Any, Protocol
from uuid import uuid4
from sqlalchemy import select
@@ -61,6 +61,7 @@ class ModelQuotaReservation:
app_type: CreditUsageAppType | None = None
created_by: CreditUsageCreatedBy | None = None
requires_settlement: bool = False
settlement_backend: str = "legacy_message_credits"
_state: ModelQuotaReservationState = field(default=ModelQuotaReservationState.RESERVED, init=False, repr=False)
@property
@@ -69,7 +70,7 @@ class ModelQuotaReservation:
@property
def commit_before_delivery(self) -> bool:
return self.credit_pool_reservation is not None
return self.credit_pool_reservation is not None or self.settlement_backend == "tokener"
def commit(self, usage: LLMUsage | None = None) -> None:
if self._state == ModelQuotaReservationState.COMMITTED:
@@ -113,6 +114,60 @@ LLMQuotaReservationState = ModelQuotaReservationState
LLMQuotaReservation = ModelQuotaReservation
class MessageQuotaReservation(Protocol):
"""The receipt operations owned by a Classic message, not an LLM turn."""
@property
def reservation_id(self) -> str | None: ...
def renew(self) -> None: ...
def commit(self) -> None: ...
def release(self) -> None: ...
def reserve_message_quota_for_model(
*,
tenant_id: str,
provider: str,
model: str,
quota_type: ProviderQuotaType,
quota_unit: QuotaUnit,
message_id: str,
app_type: CreditUsageAppTypeInput,
created_by: CreditUsageCreatedByInput,
) -> MessageQuotaReservation | None:
"""Reserve the legacy capped charge once using the durable message ID.
Classic messages retain their capped per-message charge rather than the
strict per-invocation reservation used by workflow model nodes. A nonpositive
configured charge remains unmetered, without creating a financial receipt.
"""
if quota_type not in {ProviderQuotaType.TRIAL, ProviderQuotaType.PAID}:
raise ValueError("Message reservations require a shared credit pool.")
if quota_unit not in {QuotaUnit.CREDITS, QuotaUnit.TIMES}:
raise ValueError("Message reservations require credits or times quota.")
amount = dify_config.get_model_credits(model) if quota_unit == QuotaUnit.CREDITS else 1
if amount <= 0:
return None
return CreditPoolService.reserve_credits_capped(
tenant_id=tenant_id,
credits_required=amount,
pool_type=quota_type.value,
request_id=message_id,
session_factory=db.session,
meta={
"source": "message.created",
"provider": provider,
"model": model,
"model_type": ModelType.LLM.value,
"app_type": app_type,
"created_by": created_by,
},
)
def _get_provider_configuration(*, tenant_id: str, provider: str):
"""Resolve the tenant-bound provider configuration for quota decisions."""
provider_manager = create_plugin_provider_manager(tenant_id=tenant_id)
@@ -142,9 +197,12 @@ def reserve_model_quota_for_model(
request_id: str | None = None,
app_type: CreditUsageAppTypeInput = None,
created_by: CreditUsageCreatedByInput = None,
provider_configuration: Any = None,
invocation_credentials: dict[str, Any] | None = None,
) -> ModelQuotaReservation:
"""Reserve system-hosted model quota before invoking the provider."""
provider_configuration = _get_provider_configuration(tenant_id=tenant_id, provider=provider)
if provider_configuration is None:
provider_configuration = _get_provider_configuration(tenant_id=tenant_id, provider=provider)
effective_app_type = normalize_credit_usage_app_type(app_type)
effective_created_by = normalize_credit_usage_created_by(created_by)
reservation = ModelQuotaReservation(
@@ -159,6 +217,16 @@ def reserve_model_quota_for_model(
if provider_configuration.using_provider_type != ProviderType.SYSTEM:
return reservation
from core.model_invocation_routing import RoutedModelCredentials, routed_credentials
if invocation_credentials is None:
invocation_credentials = routed_credentials(provider_configuration, model_type, model)
if isinstance(invocation_credentials, RoutedModelCredentials):
if invocation_credentials.plan.tenant_id != tenant_id:
raise ValueError("Model route tenant mismatch")
reservation.settlement_backend = "tokener"
return reservation
provider_model = provider_configuration.get_provider_model(model_type=model_type, model=model)
if provider_model and provider_model.status == ModelStatus.QUOTA_EXCEEDED:
raise QuotaExceededError(f"Model provider {provider} quota exceeded.")

View File

@@ -48,7 +48,7 @@ from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.app.task_pipeline.message_file_utils import prepare_file_dict
from core.base.tts import AppGeneratorTTSPublisher
from core.db.session_factory import session_factory
from core.model_manager import ModelInstance
from core.model_manager import create_model_instance
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.prompt.utils.prompt_message_util import PromptMessageUtil
@@ -131,10 +131,33 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
)
generator = self._wrapper_process_stream_response(trace_manager=self._application_generate_entity.trace_manager)
execution_owned = self.stream and self._application_generate_entity._classic_message_billing is not None
if execution_owned:
from core.app.apps.execution_stream import execution_owned_stream
from core.app.llm.message_billing import release_message_billing
def consumer_failed(error: Exception) -> None:
logger.error("Classic message execution consumer failed: %s", type(error).__name__)
self.queue_manager.request_abort("Classic message persistence failed")
generator = execution_owned_stream(
generator,
on_finished=lambda: release_message_billing(self._application_generate_entity),
on_failed=consumer_failed,
)
if self.stream:
if execution_owned:
response = self._to_stream_response(generator, arm_close=True)
next(response) # Consume the private cleanup-ready marker.
return response
return self._to_stream_response(generator)
else:
return self._to_blocking_response(generator)
try:
return self._to_blocking_response(generator)
finally:
from core.app.llm.message_billing import release_message_billing
release_message_billing(self._application_generate_entity)
def _to_blocking_response(
self, generator: Generator[StreamResponse, None, None]
@@ -185,26 +208,31 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
raise RuntimeError("queue listening stopped unexpectedly.")
def _to_stream_response(
self, generator: Generator[StreamResponse, None, None]
self, generator: Generator[StreamResponse, None, None], *, arm_close: bool = False
) -> Generator[ChatbotAppStreamResponse | CompletionAppStreamResponse, None, None]:
"""
To stream response.
:return:
"""
for stream_response in generator:
if isinstance(self._application_generate_entity, CompletionAppGenerateEntity):
yield CompletionAppStreamResponse(
message_id=self._message_id,
created_at=self._message_created_at,
stream_response=stream_response,
)
else:
yield ChatbotAppStreamResponse(
conversation_id=self._conversation_id,
message_id=self._message_id,
created_at=self._message_created_at,
stream_response=stream_response,
)
try:
if arm_close:
yield cast(ChatbotAppStreamResponse, None)
for stream_response in generator:
if isinstance(self._application_generate_entity, CompletionAppGenerateEntity):
yield CompletionAppStreamResponse(
message_id=self._message_id,
created_at=self._message_created_at,
stream_response=stream_response,
)
else:
yield ChatbotAppStreamResponse(
conversation_id=self._conversation_id,
message_id=self._message_id,
created_at=self._message_created_at,
stream_response=stream_response,
)
finally:
generator.close()
def _listen_audio_msg(self, publisher: AppGeneratorTTSPublisher | None, task_id: str):
if publisher is None:
@@ -285,6 +313,9 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
match event:
case QueueErrorEvent():
from core.app.llm.message_billing import release_message_billing
release_message_billing(self._application_generate_entity)
with session_factory.create_session() as session:
err = self.handle_error(event=event, session=session, message_id=self._message_id)
session.commit()
@@ -506,8 +537,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
model_config = self._model_config
model = model_config.model
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
model_instance = create_model_instance(
provider_model_bundle=model_config.provider_model_bundle,
model=model_config.model,
credentials=model_config.credentials,
)
# calculate num tokens
@@ -519,7 +552,7 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
if event.stopped_by == QueueStopEvent.StopBy.USER_MANUAL:
completion_tokens = model_instance.get_llm_num_tokens([self._task_state.llm_result.message])
credentials = model_config.credentials
credentials = model_instance.credentials
# transform usage
model_type_instance = model_config.provider_model_bundle.model_type_instance

View File

@@ -201,6 +201,11 @@ class ProviderConfiguration(BaseModel):
raise ValueError(f"Model {model} is disabled.")
if self.using_provider_type == ProviderType.SYSTEM:
from core.model_invocation_routing import bind_legacy_credentials, routed_credentials
managed = routed_credentials(self, model_type, model)
if managed is not None:
return managed
restrict_models = []
for quota_configuration in self.system_configuration.quota_configurations:
if self.system_configuration.current_quota_type != quota_configuration.quota_type:
@@ -219,7 +224,7 @@ class ProviderConfiguration(BaseModel):
and restrict_model.base_model_name
):
copy_credentials["base_model_name"] = restrict_model.base_model_name
return copy_credentials
return bind_legacy_credentials(self, model_type, model, copy_credentials)
else:
credentials = None
current_credential_id = None
@@ -264,6 +269,20 @@ class ProviderConfiguration(BaseModel):
if self.system_configuration.enabled is False:
return SystemConfigurationStatus.UNSUPPORTED
from core.model_invocation_routing import (
SettlementOwner,
has_compatibility_route,
migration_routing_state,
settlement_owner,
)
if settlement_owner(
self.system_configuration, ProviderType.SYSTEM
) == SettlementOwner.SHARED_CREDIT_POOL and has_compatibility_route(migration_routing_state(self.tenant_id)):
# The hosted entry remains configured. Invocation admission still
# blocks processing and the target enforces its actual balance.
return SystemConfigurationStatus.ACTIVE
current_quota_type = self.system_configuration.current_quota_type
current_quota_configuration = next(
(q for q in self.system_configuration.quota_configurations if q.quota_type == current_quota_type), None
@@ -1813,11 +1832,9 @@ class ProviderConfiguration(BaseModel):
if model and restrict_model.model != model:
continue
copy_credentials = (
self.system_configuration.credentials.copy()
if self.system_configuration.credentials
else {}
)
# Listing logical hosted models is not invocation
# admission. Keep it available while cutover is processing.
copy_credentials = dict(self.system_configuration.credentials or {})
if restrict_model.base_model_name:
copy_credentials["base_model_name"] = restrict_model.base_model_name
@@ -1865,7 +1882,10 @@ class ProviderConfiguration(BaseModel):
for provider_model in provider_models:
if provider_model.model not in restrict_model_names:
provider_model.status = ModelStatus.NO_PERMISSION
elif not quota_configuration.is_valid:
elif (
not quota_configuration.is_valid
and self.get_system_configuration_status() != SystemConfigurationStatus.ACTIVE
):
provider_model.status = ModelStatus.QUOTA_EXCEEDED
return provider_models

View File

@@ -0,0 +1,29 @@
"""Stable canonical bytes for the versioned hosted-credit migration protocol."""
import hashlib
import json
from typing import Any
def canonical_hash(value: Any) -> str:
"""Hash the protocol's constrained canonical JSON (money is never a float)."""
def check(item: Any) -> None:
if isinstance(item, float) or item is None:
raise ValueError("canonical payload cannot contain float or null")
if isinstance(item, dict):
for key, child in item.items():
if not isinstance(key, str) or not key.isascii():
raise ValueError("canonical object keys must be ASCII strings")
check(child)
elif isinstance(item, list):
for child in item:
check(child)
check(value)
return (
"sha256:"
+ hashlib.sha256(
json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()
).hexdigest()
)

View File

@@ -0,0 +1,242 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import StrEnum
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import ServiceUnavailable
from core.db.session_factory import session_factory
from extensions.ext_redis import redis_client
from models.model_billing import TenantModelBillingProfile
from models.tokener import TenantTokenerIntegration, TenantTokenerIntegrationStatus
logger = logging.getLogger(__name__)
_STORED_TOKENER_SOURCE = "tokener"
_MODEL_BILLING_SOURCE_CACHE_KEY_PREFIX = "tenant:model-billing-source:v1"
_MODEL_BILLING_SOURCE_CACHE_TTL_SECONDS = 10 * 60
class ModelBillingSource(StrEnum):
LEGACY_MESSAGE_CREDITS = "legacy_message_credits"
TOKENER = "tokener"
class ModelBillingProfileResolutionError(ServiceUnavailable):
error_code = "model_billing_profile_unavailable"
description = "Model billing profile is temporarily unavailable."
class InvalidModelBillingProfileError(ModelBillingProfileResolutionError):
error_code = "model_billing_profile_invalid"
description = "Model billing profile is invalid."
class ModelBillingProfileCacheUnavailableError(RuntimeError):
"""Raised when an explicit cache invalidation cannot be completed."""
class _ModelBillingSourceCache:
"""Typed cache for the normalized tenant model-billing source only."""
@staticmethod
def key(tenant_id: str) -> str:
return f"{_MODEL_BILLING_SOURCE_CACHE_KEY_PREFIX}:{tenant_id}"
@classmethod
def get(cls, tenant_id: str) -> ModelBillingSource | None:
key = cls.key(tenant_id)
cached = redis_client.get(key)
if cached is None:
return None
if isinstance(cached, bytes):
try:
cached = cached.decode("utf-8")
except UnicodeDecodeError:
cached = ""
if isinstance(cached, str):
try:
return ModelBillingSource(cached)
except ValueError:
pass
logger.error("Ignoring invalid model billing source cache entry, tenant_id=%s", tenant_id)
try:
redis_client.delete(key)
except Exception:
logger.warning(
"Failed to delete invalid model billing source cache entry, tenant_id=%s",
tenant_id,
)
return None
@classmethod
def set(cls, tenant_id: str, source: ModelBillingSource) -> None:
redis_client.setex(
cls.key(tenant_id),
_MODEL_BILLING_SOURCE_CACHE_TTL_SECONDS,
source.value,
)
@classmethod
def invalidate(cls, tenant_id: str) -> None:
redis_client.delete(cls.key(tenant_id))
@dataclass(frozen=True, slots=True)
class TenantModelBillingResolution:
model_billing_source: ModelBillingSource
tokener_bootstrap_status: TenantTokenerIntegrationStatus | None = None
@property
def uses_legacy_message_credits(self) -> bool:
return self.model_billing_source == ModelBillingSource.LEGACY_MESSAGE_CREDITS
@property
def uses_tokener(self) -> bool:
return self.model_billing_source == ModelBillingSource.TOKENER
class ModelBillingProfileService:
"""Resolve the persisted model-billing cohort without falling open to legacy credits."""
@classmethod
def resolve(
cls,
tenant_id: str,
*,
session: Session | None = None,
) -> TenantModelBillingResolution:
from core.model_invocation_routing import has_compatibility_route, migration_routing_state
try:
migration = migration_routing_state(tenant_id)
except Exception:
logger.warning("Model billing migration authority is unavailable, tenant_id=%s", tenant_id)
raise ModelBillingProfileResolutionError from None
if migration and has_compatibility_route(migration) and migration.get("phase") in {"active", "cancelled"}:
# An old concurrent cache refill must never reopen legacy billing.
return cls._resolve_tokener_status(tenant_id, session=session)
try:
cached_source = _ModelBillingSourceCache.get(tenant_id)
except Exception:
logger.warning(
"Failed to read model billing source cache; falling back to database, tenant_id=%s",
tenant_id,
)
cached_source = None
if cached_source is not None:
if cached_source == ModelBillingSource.LEGACY_MESSAGE_CREDITS:
return TenantModelBillingResolution(model_billing_source=cached_source)
return cls._resolve_tokener_status(tenant_id, session=session)
resolution = cls._resolve_from_database(tenant_id, session=session)
try:
_ModelBillingSourceCache.set(tenant_id, resolution.model_billing_source)
except Exception:
logger.warning(
"Failed to cache model billing source, tenant_id=%s",
tenant_id,
)
return resolution
@classmethod
def _resolve_from_database(
cls,
tenant_id: str,
*,
session: Session | None,
) -> TenantModelBillingResolution:
try:
if session is not None:
return cls._resolve_with_session(tenant_id, session=session)
with session_factory.create_session() as owned_session:
return cls._resolve_with_session(tenant_id, session=owned_session)
except ModelBillingProfileResolutionError:
raise
except Exception:
logger.exception(
"Failed to resolve tenant model billing profile, tenant_id=%s",
tenant_id,
)
raise ModelBillingProfileResolutionError from None
@classmethod
def _resolve_tokener_status(
cls,
tenant_id: str,
*,
session: Session | None,
) -> TenantModelBillingResolution:
try:
if session is not None:
integration = cls._get_tokener_integration(tenant_id, session=session)
else:
with session_factory.create_session() as owned_session:
integration = cls._get_tokener_integration(tenant_id, session=owned_session)
except Exception:
logger.exception(
"Failed to resolve Tokener bootstrap status, tenant_id=%s",
tenant_id,
)
raise ModelBillingProfileResolutionError from None
return TenantModelBillingResolution(
model_billing_source=ModelBillingSource.TOKENER,
tokener_bootstrap_status=integration.status if integration is not None else None,
)
@staticmethod
def _get_tokener_integration(tenant_id: str, *, session: Session) -> TenantTokenerIntegration | None:
return session.scalar(
select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == tenant_id).limit(1)
)
@staticmethod
def _resolve_with_session(tenant_id: str, *, session: Session) -> TenantModelBillingResolution:
profile = session.scalar(
select(TenantModelBillingProfile).where(TenantModelBillingProfile.tenant_id == tenant_id).limit(1)
)
if profile is None:
return TenantModelBillingResolution(model_billing_source=ModelBillingSource.LEGACY_MESSAGE_CREDITS)
raw_source = profile.model_billing_source
if raw_source is None:
return TenantModelBillingResolution(model_billing_source=ModelBillingSource.LEGACY_MESSAGE_CREDITS)
if raw_source == _STORED_TOKENER_SOURCE:
integration = ModelBillingProfileService._get_tokener_integration(tenant_id, session=session)
return TenantModelBillingResolution(
model_billing_source=ModelBillingSource.TOKENER,
tokener_bootstrap_status=integration.status if integration is not None else None,
)
logger.error(
"Unknown tenant model billing source, tenant_id=%s",
tenant_id,
)
raise InvalidModelBillingProfileError
@classmethod
def invalidate(cls, tenant_id: str) -> None:
try:
_ModelBillingSourceCache.invalidate(tenant_id)
except Exception:
logger.warning(
"Failed to invalidate model billing source cache, tenant_id=%s",
tenant_id,
)
raise ModelBillingProfileCacheUnavailableError from None
@staticmethod
def new_tokener_profile(tenant_id: str) -> TenantModelBillingProfile:
return TenantModelBillingProfile(
tenant_id=tenant_id,
model_billing_source=_STORED_TOKENER_SOURCE,
)

View File

@@ -0,0 +1,710 @@
"""Trusted, tenant-scoped routing for migrated hosted model invocations.
App identities remain logical identities. Only SYSTEM shared-pool credentials
can produce a route; user credential dictionaries cannot request a redirect.
The frozen plan contains references, never secrets. The managed key is loaded
by its exact tenant/provider/id at the transport boundary, not the user's
currently selected Tokener credential.
"""
from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from enum import StrEnum
from operator import itemgetter
from typing import TYPE_CHECKING, Any, Literal, TypeGuard, override
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
from sqlalchemy import select
from werkzeug.exceptions import ServiceUnavailable
from configs import dify_config
from core.db.session_factory import session_factory
from core.entities.provider_entities import ProviderQuotaType, QuotaConfiguration, QuotaUnit
from core.model_billing_migration_protocol import canonical_hash
from core.repositories.model_billing_migration_repository import get_routing_state
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey, ModelType
from models.provider import ProviderCredential, ProviderType
from models.provider_ids import ModelProviderID
from models.tokener import TenantTokenerIntegration, TenantTokenerIntegrationStatus
if TYPE_CHECKING:
from core.entities.provider_configuration import ProviderConfiguration
from core.entities.provider_entities import SystemConfiguration
class ModelRouteUnavailable(ServiceUnavailable):
error_code = "model_route_unavailable"
description = "Managed model routing is temporarily unavailable."
class ModelMigrationProcessing(ModelRouteUnavailable):
error_code = "migration_processing"
description = "Model billing migration is processing. Please retry shortly."
class ModelRouteUnmapped(ModelRouteUnavailable):
error_code = "model_route_unmapped"
description = "The managed model route has not been configured."
class ModelInvocationReprepare(ModelRouteUnavailable):
error_code = "model_invocation_reprepare"
description = "Model routing changed while preparing this invocation. Please retry."
class SettlementOwner(StrEnum):
CUSTOM = "custom"
SHARED_CREDIT_POOL = "shared_credit_pool"
PROVIDER_FREE = "provider_free"
UNMETERED = "unmetered"
def settlement_owner(system: SystemConfiguration, provider_type: ProviderType) -> SettlementOwner:
"""Mirror the reservation policy without making a reservation to probe it."""
if provider_type != ProviderType.SYSTEM:
return SettlementOwner.CUSTOM
quota = next((q for q in system.quota_configurations if q.quota_type == system.current_quota_type), None)
if not quota or quota.quota_limit == -1:
return SettlementOwner.UNMETERED
if quota.quota_type in {ProviderQuotaType.PAID, ProviderQuotaType.TRIAL}:
if quota.quota_unit in {QuotaUnit.CREDITS, QuotaUnit.TIMES}:
return SettlementOwner.SHARED_CREDIT_POOL
return SettlementOwner.UNMETERED
return SettlementOwner.PROVIDER_FREE
def migration_routing_state(tenant_id: str) -> dict[str, Any] | None:
# Do not use the legacy billing-source TTL cache for cutover admission.
try:
return get_routing_state(tenant_id)
except Exception:
# Never expose SQL/connection detail, or treat a failed lookup as a
# legacy decision that could reopen admission after cutover.
raise ModelRouteUnavailable from None
def has_compatibility_route(state: dict[str, Any] | None) -> bool:
if not state:
return False
phase = state.get("blocked_from") if state.get("phase") == "blocked" else state.get("phase")
return phase in {"claimed", "granting", "activating", "active"} or (
phase == "cancelled" and state.get("claimed_at") is not None
)
def compatibility_quota_type(
provider: str, quotas: list[QuotaConfiguration], state: dict[str, Any]
) -> ProviderQuotaType:
"""Restore prepared ownership without consulting the now-closed legacy wallet."""
ownership = state.get("source_ownership", {}).get(str(ModelProviderID(provider)))
if not isinstance(ownership, dict):
raise ModelRouteUnavailable
try:
selected = ProviderQuotaType(ownership["current_quota_type"])
for quota in quotas:
if quota.quota_type == ProviderQuotaType.FREE:
continue
unmetered = ownership["quotas"][quota.quota_type.value]["unmetered"]
if not isinstance(unmetered, bool):
raise ValueError("Invalid provenance")
# These are availability metadata, not a newly issued legacy quota.
# Only a proven original unlimited pool may retain the -1 marker.
quota.quota_limit = -1 if unmetered else 0
quota.quota_used = 0
quota.is_valid = unmetered
current = next(q for q in quotas if q.quota_type == selected)
except (KeyError, TypeError, ValueError, StopIteration):
raise ModelRouteUnavailable from None
if selected != ProviderQuotaType.FREE or current.is_valid:
return selected
# Independent provider-free allowance still follows its normal exhaustion
# behavior. It may fall through to the migrated shared pool afterwards.
from core.provider_manager import ProviderManager
return ProviderManager._choice_current_using_quota_type(quotas)
def migration_display_status(tenant_id: str) -> Literal["none", "preparing", "processing", "active"]:
state = migration_routing_state(tenant_id)
if not state:
return "none"
if state.get("phase") == "active" or (
state.get("phase") == "cancelled" and state.get("claimed_at") and state.get("ever_activated")
):
return "active"
return "processing" if has_compatibility_route(state) else "preparing"
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class MappingSource(_StrictModel):
provider_id: str
model_type: ModelType
model: str = Field(min_length=1)
mode: str = Field(min_length=1)
class MappingTarget(_StrictModel):
plugin_id: Literal["langgenius/tokener"]
provider: Literal["tokener"]
model_type: ModelType
model: str = Field(min_length=1)
catalog_revision: str = Field(min_length=1)
minimum_plugin_version: str = Field(min_length=1)
class ParameterAdapter(_StrictModel):
id: Literal["identity_v1"]
revision: str = Field(min_length=1)
rules_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
class ModelMapping(_StrictModel):
schema_version: Literal[1]
mapping_revision: str = Field(min_length=1)
source: MappingSource
source_schema_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
settlement_owner: Literal["shared_credit_pool"]
pool_types: list[Literal["paid", "trial"]] = Field(min_length=1)
target: MappingTarget
parameter_adapter: ParameterAdapter
operations: list[str] = Field(min_length=1)
conformance_fixture_ids: list[str] = Field(min_length=1)
def model_mappings() -> dict[tuple[str, ModelType, str], ModelMapping]:
try:
records = TypeAdapter(list[ModelMapping]).validate_json(dify_config.TOKENER_LEGACY_MODEL_MAPPING_JSON)
result = {}
for record in records:
key = (str(ModelProviderID(record.source.provider_id)), record.source.model_type, record.source.model)
if key in result or record.target.model_type != record.source.model_type:
raise ValueError("Duplicate source or incompatible model type")
result[key] = record
return result
except Exception:
# Validation errors can contain config values. Do not log their payload.
raise ModelRouteUnmapped from None
@dataclass(frozen=True, slots=True)
class FrozenModelInvocation:
tenant_id: str
route_id: str
logical_provider: str
logical_model: str
model_type: ModelType
route_epoch: int
mapping_revision: str
credential_id: str
expected_credential_fingerprint: str
plugin_unique_identifier: str
target_model: str
operations: tuple[str, ...]
settlement_backend: str = "tokener"
@property
def cache_identity(self) -> str:
identity = [
self.tenant_id,
self.route_epoch,
self.mapping_revision,
self.credential_id,
self.expected_credential_fingerprint,
self.plugin_unique_identifier,
self.target_model,
self.model_type.value,
]
return hashlib.sha256(json.dumps(identity, separators=(",", ":")).encode()).hexdigest()
class RoutedModelCredentials(dict[str, Any]): # noqa: FURB189 - graphon requires a real dict
"""In-process trusted capability, not a serializable credential protocol.
An ordinary dict from a client cannot forge this type. Accidental generic
serialization emits an empty credential object, never the source key.
"""
plan: FrozenModelInvocation
@override
def __init__(self, *args: Any, **kwargs: Any) -> None:
if len(args) != 1 or kwargs or not isinstance(args[0], FrozenModelInvocation):
raise TypeError("A trusted frozen model invocation is required")
super().__init__()
self.plan = args[0]
def __bool__(self) -> bool:
return True
@override
def copy(self) -> RoutedModelCredentials:
return RoutedModelCredentials(self.plan)
@dataclass(frozen=True, slots=True)
class LegacyModelBinding:
tenant_id: str
logical_provider: str
logical_model: str
model_type: ModelType
route_epoch: int
class LegacyModelCredentials(dict[str, Any]): # noqa: FURB189 - runtime requires a dict
"""Server-created legacy provenance, retained until its reservation settles."""
binding: LegacyModelBinding
@override
def __init__(self, *args: Any, **kwargs: Any) -> None:
if len(args) != 2 or kwargs or not isinstance(args[0], dict) or not isinstance(args[1], LegacyModelBinding):
raise TypeError("Legacy credentials and a trusted model binding are required")
super().__init__(args[0])
self.binding = args[1]
@override
def copy(self) -> LegacyModelCredentials:
return LegacyModelCredentials(dict(self), self.binding)
def bind_legacy_credentials(
configuration: ProviderConfiguration, model_type: ModelType, model: str, values: dict[str, Any]
) -> dict[str, Any]:
if (
settlement_owner(configuration.system_configuration, configuration.using_provider_type)
!= SettlementOwner.SHARED_CREDIT_POOL
):
return values
state = migration_routing_state(configuration.tenant_id)
if has_compatibility_route(state):
# Cutover between the route check and reading the old key must not
# silently construct a legacy invocation after admission has closed.
raise ModelInvocationReprepare
return LegacyModelCredentials(
values,
LegacyModelBinding(
tenant_id=configuration.tenant_id,
logical_provider=str(ModelProviderID(configuration.provider.provider)),
logical_model=model,
model_type=model_type,
route_epoch=int(state.get("route_epoch", 0)) if state else 0,
),
)
def validate_invocation_admission(
credentials: dict[str, Any], *, tenant_id: str, provider: str, model_type: ModelType, model: str
) -> None:
"""Validate the prepared snapshot immediately before reserving/invoking.
Do not replace credentials here: schema, tokens, dispatch, and settlement
must use the same binding. Already reserved calls do not re-enter this check.
"""
if isinstance(credentials, RoutedModelCredentials):
binding = credentials.plan
elif isinstance(credentials, LegacyModelCredentials):
binding = credentials.binding
else:
return
if (tenant_id, str(ModelProviderID(provider)), model_type, model) != (
binding.tenant_id,
binding.logical_provider,
binding.model_type,
binding.logical_model,
):
raise ModelRouteUnavailable
state = migration_routing_state(tenant_id)
if isinstance(credentials, LegacyModelCredentials):
if has_compatibility_route(state) or (int(state.get("route_epoch", 0)) if state else 0) != binding.route_epoch:
raise ModelInvocationReprepare
elif (
state is None
or state.get("phase") not in {"active", "cancelled"}
or (state.get("phase") == "cancelled" and not state.get("ever_activated"))
or int(state["route_epoch"]) != binding.route_epoch
or state.get("model_mapping_version") != credentials.plan.mapping_revision
):
raise ModelInvocationReprepare
def routed_credentials(
configuration: ProviderConfiguration, model_type: ModelType, model: str
) -> RoutedModelCredentials | None:
if (
settlement_owner(configuration.system_configuration, configuration.using_provider_type)
!= SettlementOwner.SHARED_CREDIT_POOL
):
return None
state = migration_routing_state(configuration.tenant_id)
if not has_compatibility_route(state):
return None
if (
state is None
or state.get("phase") not in {"active", "cancelled"}
or (state.get("phase") == "cancelled" and not state.get("ever_activated"))
):
raise ModelMigrationProcessing
provider = str(ModelProviderID(configuration.provider.provider))
mapping = model_mappings().get((provider, model_type, model))
if mapping is None or configuration.system_configuration.current_quota_type not in mapping.pool_types:
raise ModelRouteUnmapped
if state.get("model_mapping_version") != mapping.mapping_revision:
raise ModelRouteUnmapped
with session_factory.create_session() as session:
integration = session.scalar(
select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == configuration.tenant_id)
)
if (
not integration
or integration.status != TenantTokenerIntegrationStatus.READY
or not integration.provider_credential_id
or not integration.plugin_unique_identifier
):
raise ModelRouteUnavailable
expected_credential_fingerprint = _pinned_credential_fingerprint(
state,
configuration.tenant_id,
integration.provider_credential_id,
)
plan = FrozenModelInvocation(
tenant_id=configuration.tenant_id,
route_id=str(uuid4()),
logical_provider=provider,
logical_model=model,
model_type=model_type,
route_epoch=int(state["route_epoch"]),
mapping_revision=mapping.mapping_revision,
credential_id=integration.provider_credential_id,
expected_credential_fingerprint=expected_credential_fingerprint,
plugin_unique_identifier=integration.plugin_unique_identifier,
target_model=mapping.target.model,
operations=tuple(mapping.operations),
)
return RoutedModelCredentials(plan)
def _valid_credential_fingerprint(value: Any) -> TypeGuard[str]:
return (
isinstance(value, str)
and value.startswith("sha256:")
and len(value) == 71
and all(char in "0123456789abcdef" for char in value[7:])
)
def _credential_fingerprint(encrypted_config: str) -> str:
return canonical_hash({"encrypted_config": encrypted_config})
def _pinned_credential_fingerprint(
state: dict[str, Any], tenant_id: str, credential_id: str, *, allow_provisioned: bool = False
) -> str:
pin = state.get("preparation")
if not pin and allow_provisioned:
pin = state.get("provisioned_binding")
if not isinstance(pin, dict) or (
pin.get("tenant_id"),
pin.get("provider_name"),
pin.get("provider_credential_id"),
) != (tenant_id, "langgenius/tokener/tokener", credential_id):
raise ModelRouteUnavailable
fingerprint = pin.get("credential_fingerprint")
if not _valid_credential_fingerprint(fingerprint):
raise ModelRouteUnavailable
return fingerprint
def managed_credentials(plan: FrozenModelInvocation) -> dict[str, Any]:
from core.entities import PluginCredentialType
from core.helper import encrypter
from core.helper.credential_utils import runtime_check_credential_policy_compliance
provider = "langgenius/tokener/tokener"
with session_factory.create_session() as session:
credential = session.scalar(
select(ProviderCredential).where(
ProviderCredential.tenant_id == plan.tenant_id,
ProviderCredential.provider_name == provider,
ProviderCredential.id == plan.credential_id,
)
)
if credential is None or not credential.encrypted_config:
raise ModelRouteUnavailable
encrypted = credential.encrypted_config
if (
not isinstance(encrypted, str)
or not _valid_credential_fingerprint(plan.expected_credential_fingerprint)
or not hmac.compare_digest(_credential_fingerprint(encrypted), plan.expected_credential_fingerprint)
):
# Editing a managed row cannot change where a frozen SYSTEM invocation
# is billed. Fail before decrypting or sending any key to the plugin.
raise ModelRouteUnavailable
runtime_check_credential_policy_compliance(
credential_id=plan.credential_id, provider=provider, credential_type=PluginCredentialType.MODEL
)
try:
values = json.loads(encrypted)
if not isinstance(values, dict) or not isinstance(values.get("api_key"), str):
raise ValueError("Missing managed key")
values["api_key"] = encrypter.decrypt_token(tenant_id=plan.tenant_id, token=values["api_key"])
return values
except Exception:
raise ModelRouteUnavailable from None
def redirect_payload(plan: FrozenModelInvocation, data: dict[str, Any]) -> dict[str, Any]:
if (
data.get("model") != plan.logical_model
or data.get("model_type") != plan.model_type.value
or data.get("provider") != ModelProviderID(plan.logical_provider).provider_name
):
raise ModelRouteUnavailable
return {
"version": 1,
"route_id": plan.route_id,
"target": {
"plugin_id": "langgenius/tokener",
"provider": "tokener",
"model_type": plan.model_type.value,
"model": plan.target_model,
"expected_unique_identifier": plan.plugin_unique_identifier,
"credentials": managed_credentials(plan),
},
}
def embedding_dimensions_contract(schema: AIModelEntity) -> str:
"""Use actual parameter metadata; Graphon has no embedding-dimension property.
A fixed-dimensional model without a dimensions rule needs an explicit
conformance fixture in the registry; absence is not an invented dimension.
"""
rule = next((r for r in schema.parameter_rules if r.name == "dimensions"), None)
if rule is None:
return "fixed_by_model_fixture"
contract = {
"kind": "parameter",
"type": rule.type.value,
"default": rule.default,
"min": rule.min,
"max": rule.max,
"options": list(rule.options),
}
# Parameter schemas legitimately contain null/float values; the migration
# manifest's strict money-safe canonical JSON does not. Carry a digest of
# the actual dimension rule instead of those schema values in the manifest.
return (
"sha256:"
+ hashlib.sha256(json.dumps(contract, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
)
def migration_inventory(tenant_id: str, mapping_version: str) -> dict[str, Any]:
"""Export a non-secret source inventory before preparing a target plugin."""
from core.plugin.impl.model import PluginModelClient
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
registry = model_mappings()
client = PluginModelClient()
inventory = []
source_ownership = {}
configurations = create_plugin_provider_manager(tenant_id=tenant_id).get_configurations(tenant_id)
for provider_name, configuration in configurations:
system = configuration.system_configuration
if not system.enabled:
continue
if system.current_quota_type is None:
raise ModelRouteUnavailable
source_ownership[str(ModelProviderID(provider_name))] = {
"current_quota_type": system.current_quota_type.value,
"quotas": {q.quota_type.value: {"unmetered": q.quota_limit == -1} for q in system.quota_configurations},
}
sources: set[tuple[ModelType, str]] = set()
for quota in system.quota_configurations:
if (
quota.quota_type not in {ProviderQuotaType.PAID, ProviderQuotaType.TRIAL}
or quota.quota_limit == -1
or quota.quota_unit not in {QuotaUnit.TIMES, QuotaUnit.CREDITS}
):
continue
if quota.restrict_models:
sources.update((r.model_type, r.model) for r in quota.restrict_models)
else:
sources.update((m.model_type, m.model) for m in configuration.provider.models)
for model_type, model in sorted(sources, key=lambda pair: (pair[0].value, pair[1])):
rule = registry.get((str(ModelProviderID(provider_name)), model_type, model))
if rule is None or rule.mapping_revision != mapping_version:
raise ModelRouteUnmapped
source_schema = next(
(m for m in configuration.provider.models if m.model_type == model_type and m.model == model), None
)
if source_schema is None:
source_credentials = dict(system.credentials or {})
for quota in system.quota_configurations:
for restricted in quota.restrict_models:
if (
restricted.model_type == model_type
and restricted.model == model
and restricted.base_model_name
):
source_credentials["base_model_name"] = restricted.base_model_name
source_id = ModelProviderID(provider_name)
source_schema = client.get_model_schema(
tenant_id,
None,
source_id.plugin_id,
source_id.provider_name,
model_type.value,
model,
source_credentials,
)
if source_schema is None:
raise ModelRouteUnmapped
source_digest = (
"sha256:"
+ hashlib.sha256(
json.dumps(
source_schema.model_dump(mode="json"),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode()
).hexdigest()
)
if source_digest != rule.source_schema_digest:
raise ModelRouteUnmapped
required_ops = {"model/schema"}
if model_type == ModelType.LLM:
required_ops |= {"llm/invoke", "llm/num_tokens"}
elif model_type == ModelType.TEXT_EMBEDDING:
required_ops |= {"text_embedding/invoke", "text_embedding/num_tokens"}
else:
required_ops.add(f"{model_type.value}/invoke")
if not required_ops <= set(rule.operations):
raise ModelRouteUnmapped
item = {
"provider": str(ModelProviderID(provider_name)),
"model_type": model_type.value,
"model": model,
"mapping_revision": rule.mapping_revision,
"source_schema_digest": rule.source_schema_digest,
"target_model": rule.target.model,
"source_mode": rule.source.mode,
}
if model_type == ModelType.TEXT_EMBEDDING:
item["embedding_dimensions"] = embedding_dimensions_contract(source_schema)
inventory.append(item)
if not inventory:
raise ModelRouteUnmapped
inventory.sort(key=itemgetter("provider", "model_type", "model"))
return {
"inventory_hash": canonical_hash(inventory),
"model_mapping_version": mapping_version,
"model_count": len(inventory),
"models": inventory,
"source_ownership": source_ownership,
}
def validate_migration_readiness(tenant_id: str, mapping_version: str) -> dict[str, Any]:
"""Check our installed adapters/registry; never invoke a billable model."""
from packaging.version import Version
from core.plugin.impl.model import PluginModelClient
inventory = migration_inventory(tenant_id, mapping_version)
registry = model_mappings()
client = PluginModelClient()
capabilities = client._request_with_plugin_daemon_response(
"GET", f"plugin/{tenant_id}/dispatch/redirect/v1/capabilities", dict[str, Any]
)
if "model-redirect/v1" not in capabilities.get("protocols", []):
raise ModelRouteUnavailable
operations = set(capabilities.get("operations", []))
state = migration_routing_state(tenant_id)
if state is None:
raise ModelRouteUnavailable
with session_factory.create_session() as session:
integration = session.scalar(
select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == tenant_id)
)
if not integration or not integration.provider_credential_id or not integration.plugin_unique_identifier:
raise ModelRouteUnavailable
credential_id = integration.provider_credential_id
plugin_unique_identifier = integration.plugin_unique_identifier
expected_credential_fingerprint = _pinned_credential_fingerprint(
state,
tenant_id,
credential_id,
allow_provisioned=True,
)
credential = session.scalar(
select(ProviderCredential).where(
ProviderCredential.tenant_id == tenant_id,
ProviderCredential.provider_name == "langgenius/tokener/tokener",
ProviderCredential.id == credential_id,
)
)
if (
credential is None
or not isinstance(credential.encrypted_config, str)
or not hmac.compare_digest(
_credential_fingerprint(credential.encrypted_config), expected_credential_fingerprint
)
):
raise ModelRouteUnavailable
actual_version = plugin_unique_identifier.split(":", 1)[-1].split("@", 1)[0]
for source in inventory["models"]:
model_type = ModelType(source["model_type"])
rule = registry[(source["provider"], model_type, source["model"])]
if not set(rule.operations) <= operations:
raise ModelRouteUnavailable
if Version(actual_version) < Version(rule.target.minimum_plugin_version):
raise ModelRouteUnavailable
plan = FrozenModelInvocation(
tenant_id=tenant_id,
route_id=str(uuid4()),
logical_provider=source["provider"],
logical_model=source["model"],
model_type=model_type,
route_epoch=0,
mapping_revision=mapping_version,
credential_id=credential_id,
expected_credential_fingerprint=expected_credential_fingerprint,
plugin_unique_identifier=plugin_unique_identifier,
target_model=rule.target.model,
operations=tuple(rule.operations),
)
target_schema = client.get_model_schema(
tenant_id,
None,
ModelProviderID(source["provider"]).plugin_id,
ModelProviderID(source["provider"]).provider_name,
model_type.value,
source["model"],
RoutedModelCredentials(plan),
)
if target_schema is None or target_schema.model_type != model_type:
raise ModelRouteUnmapped
if (
model_type == ModelType.LLM
and target_schema.model_properties.get(ModelPropertyKey.MODE) != rule.source.mode
):
raise ModelRouteUnmapped
if model_type == ModelType.TEXT_EMBEDDING and source["embedding_dimensions"] != embedding_dimensions_contract(
target_schema
):
raise ModelRouteUnmapped
return {
**inventory,
"daemon_protocol": "model-redirect/v1",
"credential_fingerprint": expected_credential_fingerprint,
}

View File

@@ -1,7 +1,7 @@
import logging
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
from copy import deepcopy
from typing import IO, Any, Literal, Optional, ParamSpec, TypeVar, Union, cast, overload, override
from typing import IO, TYPE_CHECKING, Any, Literal, Optional, ParamSpec, TypeVar, Union, cast, overload, override
from uuid import UUID
from configs import dify_config
@@ -18,6 +18,7 @@ from core.entities.embedding_type import EmbeddingInputType
from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle
from core.entities.provider_entities import ModelLoadBalancingConfiguration
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError
from core.model_billing_profile import ModelBillingProfileService
from core.model_context import get_credit_usage_metadata
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.provider_manager import ProviderManager
@@ -38,6 +39,8 @@ from graphon.model_runtime.model_providers.base.tts_model import TTSModel
from models.provider import ProviderType
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from core.app.llm.message_billing import ClassicMessageBilling
P = ParamSpec("P")
R = TypeVar("R")
@@ -60,6 +63,7 @@ class ModelInstance:
if credentials is None:
credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model)
self.credentials = credentials
self._message_billing: ClassicMessageBilling | None = None
self._request_metadata = dict(request_metadata) if request_metadata else None
# Runtime LLM invocation fields.
self.parameters: Mapping[str, Any] = {}
@@ -434,6 +438,9 @@ class ModelInstance:
:param kwargs: function kwargs
:return:
"""
message_billing = self._message_billing
if message_billing is not None:
message_billing.check_active()
if not self.load_balancing_manager:
return function(*args, **kwargs)
@@ -504,6 +511,15 @@ class QuotaManagedModelInstance(ModelInstance):
created_by: CreditUsageCreatedByInput = None,
):
from core.app.llm.quota import reserve_model_quota_for_model
from core.model_invocation_routing import validate_invocation_admission
validate_invocation_admission(
self.credentials,
tenant_id=self.provider_model_bundle.configuration.tenant_id,
provider=self.provider,
model_type=self.model_type_instance.model_type,
model=self.model_name,
)
if app_type is None:
app_type = self._get_reservation_app_type(self._request_metadata)
@@ -518,6 +534,8 @@ class QuotaManagedModelInstance(ModelInstance):
request_id=request_id,
app_type=app_type,
created_by=created_by,
provider_configuration=self.provider_model_bundle.configuration,
invocation_credentials=self.credentials,
)
@staticmethod
@@ -549,6 +567,8 @@ class QuotaManagedModelInstance(ModelInstance):
return normalize_credit_usage_app_type(app_type)
def _reserve_quota_for_request(self, request_metadata: Mapping[str, object] | None):
# Validate the prepared binding, never mutate shared instance credentials
# between schema/token reads and this invocation's reservation.
request_id = self._get_reservation_request_id(request_metadata)
app_type = self._get_reservation_app_type(request_metadata)
created_by = self._get_reservation_created_by(request_metadata)
@@ -819,6 +839,32 @@ class QuotaManagedModelInstance(ModelInstance):
self.release_quota_safely(reservation)
def create_model_instance(
provider_model_bundle: ProviderModelBundle,
model: str,
credentials: dict[str, Any] | None = None,
request_metadata: Mapping[str, object] | None = None,
*,
message_billing: "ClassicMessageBilling | None" = None,
) -> ModelInstance:
"""One factory for production callers, including classic Chat/Agent apps."""
if message_billing is not None:
message_billing.check_identity(
provider_model_bundle.configuration.tenant_id, provider_model_bundle.configuration.provider.provider, model
)
# A classic Agent can invoke this model many times, but its legacy
# accounting unit remains the enclosing message's one durable receipt.
instance = ModelInstance(provider_model_bundle, model, credentials, request_metadata)
instance._message_billing = message_billing
return instance
instance_type = (
QuotaManagedModelInstance
if provider_model_bundle.configuration.using_provider_type == ProviderType.SYSTEM
else ModelInstance
)
return instance_type(provider_model_bundle, model, credentials, request_metadata)
class ModelManager:
"""Resolves :class:`ModelInstance` objects for a tenant and provider.
@@ -880,6 +926,15 @@ class ModelManager:
if configuration.using_provider_type != ProviderType.SYSTEM:
return
from core.model_invocation_routing import has_compatibility_route, migration_routing_state
if ModelBillingProfileService.resolve(configuration.tenant_id).uses_tokener and not has_compatibility_route(
migration_routing_state(configuration.tenant_id)
):
raise ModelCurrentlyNotSupportError(
f"Hosted SYSTEM model {model_type.value}/{model} is disabled for this workspace."
)
# Hosted allowlists retain the existing comma-separated format. Model names
# are matched exactly; model-type-specific entries will be introduced later.
quota_configuration = next(
@@ -933,6 +988,22 @@ class ModelManager:
cred_cache_key = (tenant_id, provider, model_type.value, model)
if cred_cache_key in self._credentials_cache:
from core.model_invocation_routing import ModelInvocationReprepare, validate_invocation_admission
try:
validate_invocation_admission(
self._credentials_cache[cred_cache_key],
tenant_id=tenant_id,
provider=provider,
model_type=model_type,
model=model,
)
except ModelInvocationReprepare:
self._credentials_cache.pop(cred_cache_key, None)
self._provider_manager.clear_configurations_cache(tenant_id)
return self.get_model_instance(
tenant_id, provider, model_type, model, request_metadata=request_metadata
)
return model_instance_class(
provider_model_bundle,
model,

View File

@@ -1,6 +1,6 @@
import binascii
from collections.abc import Generator, Sequence
from typing import IO, Any
from typing import IO, Any, override
from core.plugin.entities.plugin_daemon import (
PluginBasicBooleanResponse,
@@ -30,9 +30,43 @@ _POLLING_UNSUPPORTED_ERROR_MESSAGE = "does not support polling"
class PluginModelClient(BasePluginClient):
@override
def _prepare_request(self, path, headers, data, params, files):
if isinstance(data, dict) and "redirect" in data:
prefix, separator, operation = path.partition("/dispatch/")
if not separator or operation.startswith("redirect/") or "validate" in operation:
raise ValueError("Invalid model redirect operation")
context = data.get("_route_context", {})
if prefix != f"plugin/{context.get('tenant_id')}" or operation not in context.get("operations", ()):
raise ValueError("Model redirect binding mismatch")
from models.provider_ids import ModelProviderID
logical = ModelProviderID(context["logical_provider"])
if (headers or {}).get("X-Plugin-ID") != logical.plugin_id or data["data"].get(
"provider"
) != logical.provider_name:
raise ValueError("Model redirect provider binding mismatch")
data = {key: value for key, value in data.items() if key != "_route_context"}
path = f"{prefix}/dispatch/redirect/v1/{operation}"
return super()._prepare_request(path, headers, data, params, files)
@staticmethod
def _dispatch_payload(*, user_id: str | None, data: dict[str, Any], app_id: str | None = None) -> dict[str, Any]:
from core.model_invocation_routing import RoutedModelCredentials, redirect_payload
payload: dict[str, Any] = {"data": data}
credentials = data.get("credentials")
if isinstance(credentials, RoutedModelCredentials):
payload["redirect"] = redirect_payload(credentials.plan, data)
# Transport-private admission metadata is stripped in _prepare_request.
# Never use the daemon's persistent Context for this or for keys.
payload["_route_context"] = {
"tenant_id": credentials.plan.tenant_id,
"logical_provider": credentials.plan.logical_provider,
"operations": credentials.plan.operations,
}
payload["data"] = {**data, "credentials": {}}
payload["user_id"] = user_id or ""
if user_id is not None:
payload["user_id"] = user_id
if app_id is not None:

View File

@@ -218,6 +218,8 @@ class PluginModelRuntime(ModelRuntime):
model: str,
credentials: dict[str, Any],
) -> AIModelEntity | None:
from core.model_invocation_routing import RoutedModelCredentials
cache_key = self._get_schema_cache_key(
provider=provider,
model_type=model_type,
@@ -238,7 +240,12 @@ class PluginModelRuntime(ModelRuntime):
if cached_schema_json:
try:
return AIModelEntity.model_validate_json(cached_schema_json)
cached = AIModelEntity.model_validate_json(cached_schema_json)
return (
cached.model_copy(update={"model": model})
if isinstance(credentials, RoutedModelCredentials)
else cached
)
except ValidationError:
logger.warning("Failed to validate cached plugin model schema for model %s", model, exc_info=True)
try:
@@ -262,6 +269,11 @@ class PluginModelRuntime(ModelRuntime):
credentials=credentials,
)
if schema is not None and isinstance(credentials, RoutedModelCredentials):
# Keep app/provider lookup identity logical while using the
# physical model's actual capability and parameter schema.
schema = schema.model_copy(update={"model": model})
if schema:
try:
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_SCHEMA_CACHE_TTL, schema.model_dump_json())
@@ -737,6 +749,10 @@ class PluginModelRuntime(ModelRuntime):
# caller id, so the cache must only collapse ``None`` into tenant scope.
cache_user_id = TENANT_SCOPE_SCHEMA_CACHE_USER_ID if self.user_id is None else self.user_id
cache_key = f"{self.tenant_id}:{provider}:{model_type.value}:{model}:{cache_user_id}"
from core.model_invocation_routing import RoutedModelCredentials
if isinstance(credentials, RoutedModelCredentials):
return f"{cache_key}:route:{credentials.plan.cache_identity}"
sorted_credentials = sorted(credentials.items()) if credentials else []
if not sorted_credentials:
return cache_key

View File

@@ -2,7 +2,7 @@ from typing import Any
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.model_manager import ModelInstance, create_model_instance
from core.prompt.entities.advanced_prompt_entities import MemoryConfig
from graphon.model_runtime.entities.message_entities import PromptMessage
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey
@@ -18,10 +18,11 @@ class PromptTransform:
if model_instance is None:
if model_config is None:
raise ValueError("Either model_config or model_instance must be provided.")
model_instance = ModelInstance(
provider_model_bundle=model_config.provider_model_bundle, model=model_config.model
model_instance = create_model_instance(
provider_model_bundle=model_config.provider_model_bundle,
model=model_config.model,
credentials=model_config.credentials,
)
model_instance.credentials = model_config.credentials
model_instance.parameters = model_config.parameters
model_instance.stop = model_config.stop

View File

@@ -34,6 +34,7 @@ from core.entities.provider_entities import (
from core.helper import encrypter
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.helper.position_helper import is_filtered
from core.model_billing_profile import ModelBillingProfileService
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration
from enums import DeploymentEdition
@@ -649,13 +650,16 @@ class ProviderManager:
if cached_configurations is not None:
return cached_configurations
model_billing = ModelBillingProfileService.resolve(tenant_id)
# Get all provider records of the workspace
provider_name_to_provider_records_dict = self._get_all_providers(tenant_id)
# Initialize trial provider records if not exist
provider_name_to_provider_records_dict = self._init_trial_provider_records(
tenant_id, provider_name_to_provider_records_dict
)
if model_billing.uses_legacy_message_credits:
provider_name_to_provider_records_dict = self._init_trial_provider_records(
tenant_id, provider_name_to_provider_records_dict
)
# append providers with langgenius/openai/openai
provider_name_list = list(provider_name_to_provider_records_dict.keys())
@@ -707,6 +711,15 @@ class ProviderManager:
provider_configurations = ProviderConfigurations(tenant_id=tenant_id)
from core.model_invocation_routing import (
SettlementOwner,
has_compatibility_route,
migration_routing_state,
settlement_owner,
)
compatibility_route = has_compatibility_route(migration_routing_state(tenant_id))
# Construct ProviderConfiguration objects for each provider
for provider_entity in provider_entities:
# handle include, exclude
@@ -746,12 +759,18 @@ class ProviderManager:
)
# Convert to system configuration
system_configuration = self._to_system_configuration(tenant_id, provider_entity, provider_records)
system_configuration = (
self._to_system_configuration(tenant_id, provider_entity, provider_records)
if model_billing.uses_legacy_message_credits or compatibility_route
else SystemConfiguration(enabled=False)
)
# Get preferred provider type
preferred_provider_type_record = provider_name_to_preferred_model_provider_records_dict.get(provider_name)
if preferred_provider_type_record:
if model_billing.uses_tokener and not compatibility_route:
preferred_provider_type = ProviderType.CUSTOM
elif preferred_provider_type_record:
preferred_provider_type = preferred_provider_type_record.preferred_provider_type
elif dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and system_configuration.enabled:
preferred_provider_type = ProviderType.SYSTEM
@@ -764,11 +783,19 @@ class ProviderManager:
using_provider_type = preferred_provider_type
has_valid_quota = any(quota_conf.is_valid for quota_conf in system_configuration.quota_configurations)
managed_shared_pool = (
compatibility_route
and settlement_owner(system_configuration, ProviderType.SYSTEM) == SettlementOwner.SHARED_CREDIT_POOL
)
if preferred_provider_type == ProviderType.SYSTEM:
if not system_configuration.enabled or not system_configuration.quota_configurations:
using_provider_type = ProviderType.CUSTOM
elif not has_valid_quota and (custom_configuration.provider or custom_configuration.models):
elif (
not has_valid_quota
and not managed_shared_pool
and (custom_configuration.provider or custom_configuration.models)
):
# Only configured alternatives can serve as fallbacks; otherwise downstream checks must surface
# system quota exhaustion instead of reporting missing custom credentials.
using_provider_type = ProviderType.CUSTOM
@@ -864,6 +891,10 @@ class ProviderManager:
# If it does not exist, get the first available provider model from get_configurations
# and update the TenantDefaultModel record
if not default_model:
model_billing = ModelBillingProfileService.resolve(tenant_id)
if model_billing.uses_tokener:
return None
# Get provider configurations
provider_configurations = self.get_configurations(tenant_id)
@@ -1568,6 +1599,14 @@ class ProviderManager:
:param provider_records: provider records
:return:
"""
from core.model_invocation_routing import (
compatibility_quota_type,
has_compatibility_route,
migration_routing_state,
)
routing_state = migration_routing_state(tenant_id)
compatibility_route = has_compatibility_route(routing_state)
# Get hosting configuration
hosting_configuration = ext_hosting_provider.hosting_configuration
@@ -1598,7 +1637,7 @@ class ProviderManager:
quota_type_to_provider_records_dict[provider_record.quota_type] = provider_record # type: ignore[index]
quota_configurations = []
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and not compatibility_route:
from services.credit_pool_service import CreditPoolService
with session_factory.create_session() as session:
@@ -1675,6 +1714,9 @@ class ProviderManager:
current_quota_type = self._choice_current_using_quota_type(quota_configurations)
if compatibility_route and routing_state is not None:
current_quota_type = compatibility_quota_type(provider_entity.provider, quota_configurations, routing_state)
current_using_credentials = provider_hosting_configuration.credentials
if current_quota_type == ProviderQuotaType.FREE:
provider_record_quota_free = quota_type_to_provider_records_dict.get(current_quota_type)

View File

@@ -0,0 +1,48 @@
"""Tenant-scoped admission reads, independent from migration orchestration."""
from typing import Any
from sqlalchemy import select
from core.db.session_factory import session_factory
from models.model_billing_migration import TenantModelBillingMigration
def get_routing_state(tenant_id: str) -> dict[str, Any] | None:
"""Read only the narrow admission projection, never the control journal.
PostgreSQL stores the new table's state as JSONB. Extracting these fields
avoids fetching/deserializing manifests, operations, and receipt history
on every model invocation. SQLite uses the JSON variant for unit tests.
"""
with session_factory.create_session() as session:
model = TenantModelBillingMigration
row = (
session.execute(
select(
model.tenant_id,
model.migration_id,
model.phase,
model.route_epoch,
model.claimed_at,
model.state["blocked_from"].as_string().label("blocked_from"),
model.state["model_mapping_version"].as_string().label("model_mapping_version"),
model.state["preparation"].label("preparation"),
model.state["provisioned_binding"].label("provisioned_binding"),
model.state["source_ownership"].label("source_ownership"),
model.state["ever_activated"].as_boolean().label("ever_activated"),
).where(model.tenant_id == tenant_id)
)
.mappings()
.one_or_none()
)
if row is None:
return None
result = {str(key): value for key, value in row.items()}
result["preparation"] = result["preparation"] or {}
result["provisioned_binding"] = result["provisioned_binding"] or {}
result["source_ownership"] = result["source_ownership"] or {}
result["ever_activated"] = bool(result["ever_activated"])
if result["claimed_at"] is not None:
result["claimed_at"] = result["claimed_at"].replace(microsecond=0).isoformat() + "Z"
return result

View File

@@ -0,0 +1,266 @@
"""Export the unreleased Core migration wire contract from its Pydantic owners.
Run from the repository root with ``uv run --project api python
api/dev/generate_model_billing_migration_contract.py``. This only writes static
schemas/fixtures; it performs no migration, Stripe action or financial call.
"""
from __future__ import annotations
import argparse
import copy
import json
import sys
from pathlib import Path
from typing import Any
API_ROOT = Path(__file__).resolve().parents[1]
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from controllers.inner_api.model_billing_migration import (
MigrationErrorResponse,
MigrationOperationResult,
MigrationStatusResponse,
)
from services.entities.model_billing_migration import (
ActivateMigrationPayload,
CommandReceipt,
ContractEventPayload,
DecideCyclePayload,
FundingReceiptsPayload,
LeasePayload,
MigrationWindow,
PrepareMigrationPayload,
WindowManifest,
canonical_hash,
)
MODELS = {
"PrepareRequest": PrepareMigrationPayload,
"DecideCycleRequest": DecideCyclePayload,
"LeaseRequest": LeasePayload,
"FundingReceiptsRequest": FundingReceiptsPayload,
"ContractEventRequest": ContractEventPayload,
"ActivateRequest": ActivateMigrationPayload,
"Window": MigrationWindow,
"WindowManifest": WindowManifest,
"CommandReceipt": CommandReceipt,
"OperationResult": MigrationOperationResult,
"StatusResponse": MigrationStatusResponse,
"ErrorResponse": MigrationErrorResponse,
}
def _wire_schema(value: Any) -> Any:
"""Omitted optionals are never explicit null on the actual wire.
Request StrictPayload rejects supplied null; responses use exclude_none=True.
Pydantic's internal optional defaults remain useful without advertising null
as an accepted wire value. No additionalProperties prohibition is removed.
"""
if isinstance(value, list):
return [_wire_schema(item) for item in value]
if not isinstance(value, dict):
return value
result = {key: _wire_schema(item) for key, item in value.items() if not (key == "default" and item is None)}
if "anyOf" in result:
choices = [item for item in result["anyOf"] if item != {"type": "null"}]
if len(choices) == 1:
result.pop("anyOf")
result.update(choices[0])
else:
result["anyOf"] = choices
return result
def build_schema() -> dict[str, Any]:
definitions: dict[str, Any] = {}
for alias, model in MODELS.items():
schema = model.model_json_schema(ref_template="#/$defs/{model}")
for name, definition in schema.pop("$defs", {}).items():
if name in definitions and definitions[name] != definition:
raise ValueError(f"Conflicting Pydantic schema owner: {name}")
definitions[name] = definition
definitions[model.__name__] = schema
if alias != model.__name__:
definitions[alias] = {"$ref": "#/$defs/" + model.__name__}
definitions = _wire_schema(definitions)
# These mirror the owners' model_validator cross-field shape constraints;
# date ordering, hashes, ownership and transactional checks remain runtime.
definitions["DecideCyclePayload"]["allOf"] = [
{
"if": {"properties": {"decision": {"const": "tokener"}}, "required": ["decision"]},
"then": {"required": ["manifest", "manifest_hash"]},
"else": {"not": {"anyOf": [{"required": ["manifest"]}, {"required": ["manifest_hash"]}]}},
}
]
definitions["LeasePayload"]["allOf"] = [
{
"if": {"properties": {"action": {"enum": ["renew", "release"]}}, "required": ["action"]},
"then": {"required": ["lease_token"]},
}
]
definitions["ContractEventPayload"]["dependentRequired"] = {
"replacement_manifest": ["replacement_manifest_hash"],
"replacement_manifest_hash": ["replacement_manifest"],
}
definitions["ContractEventPayload"]["allOf"] = [
{
"if": {"properties": {"terminal": {"const": True}}, "required": ["terminal"]},
"then": {
"not": {"anyOf": [{"required": ["replacement_manifest"]}, {"required": ["replacement_manifest_hash"]}]},
"properties": {
"command_payloads": {
"items": {
"properties": {
"target_amount_usd_micro": {"const": "0"},
"delta_usd_micro": {"pattern": "^(0|-[1-9][0-9]*)$"},
}
}
}
},
},
},
{
"if": {"properties": {"affected_window_ids": {"minItems": 1}}, "required": ["affected_window_ids"]},
"then": {"required": ["command_payloads"], "properties": {"command_payloads": {"minItems": 1}}},
},
{
"if": {"properties": {"manual_replan": {"const": True}}, "required": ["manual_replan"]},
"then": {
"required": ["replacement_manifest", "replacement_manifest_hash"],
"properties": {"event_type": {"const": "plan_change"}, "terminal": {"const": False}},
},
},
]
return {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Dify model-billing migration API v1 — implementation contract revision 1.1",
"x-contract-revision": "1.1",
"$comment": (
"Generated from Pydantic request/response owners. API api_version remains 1 "
"because this feature is unreleased. "
"POST returns current authoritative status; replayed_operation is the immutable original result. "
"Lease replay consumers must not adopt a successor token from current status. "
"Runtime additionally enforces date ordering, hash equality, command ownership, terminal fences and CAS."
),
"$defs": definitions,
}
def implementation_vectors(existing: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Preserve existing examples and add concrete Go-compatible v1.1 records."""
result = copy.deepcopy(existing)
for vector in result:
if vector.get("definition") == "StatusResponse" and vector.get("valid"):
state = vector["value"]
state.setdefault(
"model_mapping_version", state.get("manifest", {}).get("model_mapping_version", "hosted-v1")
)
state.setdefault("inventory_hash", "sha256:" + "1" * 64)
state.setdefault("cycle_decisions", [])
state.setdefault("ever_activated", state.get("phase") == "active")
state.setdefault("contract_events", {})
state.setdefault("prior_manifests", [])
state.setdefault("terminated_contract_refs", [])
source = next(item["value"] for item in result if item.get("id") == "valid-tokener-decision")
window = copy.deepcopy(source["manifest"]["windows"][0])
command = {
"command_id": "dify.correction.v2.fixture",
"window": window,
"delta_usd_micro": "-5000000",
"target_amount_usd_micro": "0",
}
command["payload_hash"] = canonical_hash(command)
event = {
"api_version": 1,
"operation_id": "44444444-4444-4444-8444-444444444444",
"expected_revision": 8,
"business_event_ref": "re_fixture_actual_refund",
"contract_ref": source["contract_ref"],
"billing_receipt_ref": "receipt-fixture",
"event_type": "refund",
"terminal": True,
"affected_window_ids": [window["window_id"]],
"command_payloads": [command],
}
additions = [
("implementation-terminal-frozen-command", "ContractEventRequest", True, event),
("implementation-reject-extra-event-field", "ContractEventRequest", False, {**event, "wallet_balance": "0"}),
(
"implementation-reject-event-without-type",
"ContractEventRequest",
False,
{k: v for k, v in event.items() if k != "event_type"},
),
("implementation-reject-unfrozen-correction", "ContractEventRequest", False, {**event, "command_payloads": []}),
]
state = copy.deepcopy(next(item["value"] for item in result if item.get("id") == "recoverable-status"))
state.update(
phase="blocked",
blocked_from="active",
ever_activated=True,
pending_contract_event=event,
contract_events={event["business_event_ref"]: event},
terminated_contract_refs=[event["contract_ref"]],
)
additions.append(("implementation-status-with-pending-terminal-event", "StatusResponse", True, state))
replay = copy.deepcopy(state)
replay.update(
replayed=True,
replayed_operation={
"operation_id": "55555555-5555-4555-8555-555555555555",
"request_hash": "sha256:" + "2" * 64,
"result_code": 200,
"result_revision": 5,
"result_summary": {"phase": "claimed", "route_epoch": 1},
"lease_action": "acquire",
"lease_token": "66666666-6666-4666-8666-666666666666",
"lease_expires_at": "2027-06-17T00:01:00Z",
},
)
additions.append(("implementation-replayed-original-lease-receipt", "StatusResponse", True, replay))
result = [item for item in result if not item["id"].startswith("implementation-")]
result.extend(
{"id": name, "schema": "migration.schema.json", "definition": definition, "valid": valid, "value": value}
for name, definition, valid, value in additions
)
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=API_ROOT / "contracts/model-billing-migration-v1.schema.json")
parser.add_argument("--research-pack", type=Path)
parser.add_argument("--go-fixtures-dir", type=Path)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
content = json.dumps(build_schema(), ensure_ascii=False, indent=2) + "\n"
if args.check:
if not args.output.exists() or args.output.read_text() != content:
raise SystemExit("Migration contract is stale; regenerate from its Pydantic owners")
return
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(content)
if args.research_pack:
(args.research_pack / "migration.schema.json").write_text(content)
vectors = implementation_vectors(json.loads((args.research_pack / "schema-vectors.json").read_text()))
(args.research_pack / "schema-vectors.json").write_text(
json.dumps(vectors, ensure_ascii=False, indent=2) + "\n"
)
destination = API_ROOT / "contracts/model-billing-migration-v1.fixtures.json"
destination.write_text(
json.dumps([v for v in vectors if v["schema"] == "migration.schema.json"], ensure_ascii=False, indent=2)
+ "\n"
)
if args.go_fixtures_dir:
fixtures = API_ROOT / "contracts/fixtures"
fixtures.mkdir(parents=True, exist_ok=True)
for name in ("tokener_migration_manifest_v1.json", "tokener_manual_replan_v1.json"):
value = json.loads((args.go_fixtures_dir / name).read_text())
(fixtures / name).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,128 @@
# Legacy hosted credits migration
This is an opt-in paid-tenant migration. Existing App, Workflow, Conversation and
default-model identities are not rewritten. Only a server-resolved SYSTEM
shared-credit-pool invocation redirects; BYOK, provider-owned FREE and unmetered
invocations retain their previous execution and settlement owners.
## Rollout prerequisites
1. Deploy a daemon advertising `model-redirect/v1` on every serving node. The
versioned redirect endpoint fails closed on older daemons.
2. Apply Core migrations. `tenant_model_billing_migrations` is a new control-state
table; there is no data backfill or existing-table column change. The merge
revisions contain no schema/data operations.
3. Deploy Core API/workers/beat and matching SaaS Billing server/cron/async images.
Configure authenticated Core↔Billing URLs and secrets on all required roles.
4. Load an approved `TOKENER_LEGACY_MODEL_MAPPING_JSON` registry. Each record
follows `ModelMapping` in `core/model_invocation_routing.py`: exact canonical
provider/type/model and source schema digest, catalog-bound target, adapter
revision, supported operations and conformance fixture references. Never
guess aliases or replace an unavailable model with a newer/different one.
The registry must cover the tenant's hosted source inventory. An empty
provider allowlist means unrestricted models, not an empty inventory.
5. Use the same mapping revision on Core API and workers. Enable
`ENABLE_TOKENER_MIGRATION_RECOVERY_TASK` independently of admission. Start with
both Core and Billing migration admission disabled and empty tenant allowlists.
Core admission: `TOKENER_LEGACY_MIGRATION_ENABLED` and
`TOKENER_LEGACY_MIGRATION_ALLOWLIST`. Billing has its matching migration-enabled
and tenant-allowlist settings; see the SaaS Billing runbook for exact variables.
Never turn off Core connection credentials to pause new admissions. Existing
claimed/active tenants must remain fenced and recoverable while admission is off.
## Operator commands
Commands run in the Core application environment, with credentials from its
normal configuration. They do not accept tokens on the command line or operate
Stripe. Choose a dedicated test tenant before expanding a batch.
```sh
# Read-only: validates source inventory/registry and prints a deterministic intent.
flask tokener-migration prepare --tenant-id TENANT_UUID --batch-id BATCH_ID --mapping-version MAPPING_VERSION
# Explicit write: queues preparation through Billing, without trial/signup money.
flask tokener-migration prepare --tenant-id TENANT_UUID --batch-id BATCH_ID --mapping-version MAPPING_VERSION --apply
flask tokener-migration status --tenant-id TENANT_UUID
flask tokener-migration resume --tenant-id TENANT_UUID
# Explicit manual recovery; retains original IDs, payloads and claimed_at.
flask tokener-migration resume --tenant-id TENANT_UUID --manual
# Only when the immutable journal proves the expired-start grant was NEVER dispatched.
# Reuse the operation UUID on a transport retry. This is not proof from a GET 404.
flask tokener-migration resume --tenant-id TENANT_UUID --manual --replan-unsent-operation-id OPERATION_UUID
```
Re-running the same batch uses the same migration and preparation operation IDs.
Do not choose a new ID to hide an unknown outcome or payload conflict. A changed
registry/inventory is a new semantic intent, not a transport retry.
Preparation installs the target, stores its managed credential by explicit
tenant/provider/id, validates the routing adapter and persists non-financial
credential provenance. It never changes an existing BYOK selection or default
model and never grants the new-user trial.
## Cutover and recovery
- The next eligible legacy reset competes on one Core `decide-cycle` CAS. A
committed `legacy_deferred` decision remains legacy for that cycle. A committed
Tokener decision can never fall back to another legacy grant.
- Claimed/granting/activating calls show **processing**, not zero balance. Core
validates versioned funding receipts and atomically publishes the Tokener
profile and routing epoch only after readiness. An existing Tokener BYOK
credential is not replaced by the managed credential during publication.
- Operations replay before CAS, with identical operation ID and payload hash.
Window/correction command payloads are frozen before dispatch. Timeout/404 is
not proof a late financial command cannot succeed.
- A schema prepared before cutover but not yet admitted must be re-prepared;
it cannot invoke with an old key after claim. A successfully reserved legacy
invocation finishes on its original reservation. One invocation cannot charge
both backends. Streaming target errors do not trigger legacy fallback.
- Cloud Classic Chat/Agent uses one capped reservation per message, not per LLM
iteration. This preserves the legacy amount and tail-credit cap while freezing
the settlement owner across a later cutover. An active remote hold renews every
20 seconds using the same request/amount. A lost or expired hold is not replaced
by another billing backend. BYOK, provider FREE and non-Cloud rules are unchanged.
- The API exposes `model_billing_migration_status` on authenticated `/features`
and model-provider credits responses. It is not an anonymous system feature.
- Initial claim age >5 minutes alerts. >30 minutes requires manual handling and
pauses new claims in that batch. Retry/lease takeover never reset that clock.
Later contract-event recovery for an already activated tenant is not timed
from its historical initial claim.
## Calendar and contract changes
Annual mid-contract migration retains its existing monthly legacy reset dates
until the paid term ends. Each eligible reset receives the approved full amount.
Windows are half-open UTC-midnight ranges; the final end is floored to midnight,
with no 24-hour grace. Renewal adopts the normal new calendar.
A re-anchor does not extend the current window or issue another current-cycle
grant. Old future windows are cancelled before new formal funded slots are
created. A date gap before the next formal reset is **not** a financial gift:
only other legitimate organization balance can be used in the gap. No invented
proration or extra monthly allowance is added. Paid-to-free grants neither a new
trial nor legacy trial credits.
Refund/cancellation handling resolves the original contract and its recorded
windows, including annual invoices paid before migration. It fences prior
intent, converges unknown commands and then applies frozen corrections/cancels;
an unrelated historical refund must not alter the current contract.
An initial late invoice clips the delivery start of the still-valid current
window to today's UTC midnight before claim; its original cycle identity, end
and full amount remain fixed. Once claimed, retries do not recompute dates.
If midnight passes before a grant is ever dispatched, explicit manual replan can
fence that intent and retain the original amount/end/cycle in a new window ID.
Dispatched or unknown-outcome commands require Tokener-side outcome resolution;
a missing lookup is not enough to safely create replacement money.
## Verification boundaries
Required release evidence includes Core/SaaS/daemon source SHA + image digest,
plugin unique identifier and mapping revision; isolated test-tenant preparation,
cycle/replay/failure tests; original App configuration equality; real model
schema/token/invoke and streaming; BYOK/FREE controls; UI processing/usage and
new-user signup regression. Stripe UI actions remain a separate manual test.
Do not claim live transparent-migration E2E from a fixture that maps two different
models, or from unit/contract tests alone.

View File

@@ -10,6 +10,7 @@ from .queue_credential_sync_when_tenant_created import handle as handle_queue_cr
from .queue_default_plugin_install_when_tenant_created import (
handle as handle_queue_default_plugin_install_when_tenant_created,
)
from .queue_tokener_bootstrap_when_tenant_created import handle as handle_queue_tokener_bootstrap_when_tenant_created
from .sync_plugin_trigger_when_app_created import handle as handle_sync_plugin_trigger_when_app_created
from .sync_webhook_when_app_created import handle as handle_sync_webhook_when_app_created
from .sync_workflow_schedule_when_app_published import handle as handle_sync_workflow_schedule_when_app_published
@@ -36,6 +37,7 @@ __all__ = [
"handle_delete_tool_parameters_cache_when_sync_draft_workflow",
"handle_queue_credential_sync_when_tenant_created",
"handle_queue_default_plugin_install_when_tenant_created",
"handle_queue_tokener_bootstrap_when_tenant_created",
"handle_sync_plugin_trigger_when_app_created",
"handle_sync_webhook_when_app_created",
"handle_sync_workflow_schedule_when_app_published",

View File

@@ -0,0 +1,23 @@
"""Queue managed Tokener provisioning after a tenant is committed."""
import logging
from configs import dify_config
from events.tenant_event import tenant_was_created
from tasks.bootstrap_tokener_tenant_task import bootstrap_tokener_tenant_task
logger = logging.getLogger(__name__)
@tenant_was_created.connect
def handle(sender, **kwargs) -> None:
if not dify_config.TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED:
return
try:
bootstrap_tokener_tenant_task.delay(sender.id)
except Exception:
# Registration itself is already committed. The durable integration row
# remains pending so an operator or recovery sweep can safely requeue it.
# Do not serialize broker exception details into a registration request log.
logger.error("Failed to queue Tokener bootstrap for tenant %s", sender.id) # noqa: TRY400

View File

@@ -18,6 +18,7 @@ from core.app.entities.app_invoke_entities import (
get_credit_usage_created_by,
)
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit, SystemConfiguration
from core.model_billing_profile import ModelBillingProfileResolutionError, ModelBillingProfileService
from events.message_event import message_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client, redis_fallback
@@ -92,6 +93,17 @@ class _CreditDeductionContext(TypedDict, total=False):
metadata: Mapping[str, object]
def _legacy_message_credit_billing_allowed(tenant_id: str) -> bool:
try:
return ModelBillingProfileService.resolve(tenant_id).uses_legacy_message_credits
except ModelBillingProfileResolutionError:
logger.error( # noqa: TRY400
"Skipping legacy message-credit deduction because the model billing profile is unavailable, tenant_id=%s",
tenant_id,
)
return False
@message_was_created.connect
def handle(sender: Message, **kwargs):
"""
@@ -149,9 +161,29 @@ def handle(sender: Message, **kwargs):
isinstance(application_generate_entity, AgentAppGenerateEntity)
and application_generate_entity.agent_llm_gateway_enabled
)
message_owner = application_generate_entity._classic_message_billing
if message_owner is not None:
message_owner.check_identity(tenant_id, provider_name, model_config.model)
if message_owner.message_id != message.id:
raise ValueError("Message billing receipt does not belong to this message")
if not agent_gateway_metered and message_owner.backend == "legacy_reserved":
# The message was admitted before cutover. Its existing receipt
# remains the settlement authority after the workspace changes mode.
message_owner.commit_reserved()
legacy_deduction_allowed = not message_owner.settled and (
message_owner.backend == "provider_free"
or (message_owner.backend == "legacy_event" and _legacy_message_credit_billing_allowed(tenant_id))
)
else:
legacy_deduction_allowed = (
not agent_gateway_metered
and provider_configuration.using_provider_type == ProviderType.SYSTEM
and _legacy_message_credit_billing_allowed(tenant_id)
)
if (
not agent_gateway_metered
and provider_configuration.using_provider_type == ProviderType.SYSTEM
and legacy_deduction_allowed
and provider_configuration.system_configuration
and provider_configuration.system_configuration.current_quota_type is not None
):
@@ -201,6 +233,8 @@ def handle(sender: Message, **kwargs):
start_time = time_module.perf_counter()
try:
_execute_provider_updates(updates_to_perform)
if message_owner is not None:
message_owner.settled = True
# Log successful completion with timing
duration = time_module.perf_counter() - start_time

View File

@@ -20,6 +20,7 @@ from services.app_dsl_service import AppDslService
from services.app_package_service import AppPackageService
from services.app_tracing_config_gateway import OpsTraceManagerGateway
from services.oauth_server_service import OAuthServerService
from services.recommended_app_package_service import RecommendedAppPackageService
@dataclass(frozen=True, slots=True)
@@ -33,6 +34,7 @@ def build_app_services(
*,
database_client: sessionmaker[Session],
oauth: OAuthServerService,
recommended_packages: RecommendedAppPackageService,
) -> AppServices:
repository = ConsoleAppRepository(session_factory=database_client)
transfers = AppTransferGateway(
@@ -41,6 +43,7 @@ def build_app_services(
packages=AppPackageService(),
agent_packages=RosterAgentPackageExporter(),
agent_importer=RosterAgentPackageImporter(),
recommended_packages=recommended_packages,
)
return AppServices(
imports=AppImportService(accounts=SQLAlchemyAccountRepository(database_client), definitions=transfers),

View File

@@ -147,6 +147,7 @@ from services.account_oauth_service import AccountOAuthService, OAuthProviderGat
from services.account_password_hasher import DefaultAccountPasswordHasher
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService
from services.app.api_key_service import AppApiKeyService
from services.app.mcp_server_service import AppMCPServerService
@@ -213,6 +214,7 @@ from services.recommended_app_catalog_gateway import (
RecommendedAppCatalogRouter,
RemoteRecommendedAppCatalogGateway,
)
from services.recommended_app_package_service import RecommendedAppPackageService
from services.recommended_app_query_service import RecommendedAppQueryService
from services.remote_file_service import RemoteFileService
from services.retention.workflow_run.archive_download_adapters import (
@@ -374,6 +376,7 @@ class ApplicationServices:
step_by_step_tour: StepByStepTourService
partner_tenant_bindings: PartnerTenantBindingService
recommended_app_queries: RecommendedAppQueryService
recommended_app_packages: RecommendedAppPackageService
remote_files: RemoteFileService
saved_messages: SavedMessageService
app_tasks: AppTaskControlService
@@ -580,6 +583,9 @@ def build_application_services(
trial_apps=trial_apps,
trial_enabled=trial_app_enabled,
)
recommended_app_packages = RecommendedAppPackageService(
sources=database_catalog, exporter=RosterAgentPackageExporter()
)
workspace_query_repository = WorkspaceQueryRepository(session_factory=database_client)
app_scoped_end_user_repository = AppScopedEndUserRepo(session_factory=database_client)
file_service = FileService(session_factory=database_client)
@@ -751,6 +757,7 @@ def build_application_services(
apps=build_app_services(
database_client=database_client,
oauth=oauth_server,
recommended_packages=recommended_app_packages,
),
agent_apps=build_agent_app_services(database_client=database_client),
advanced_prompt_templates=AdvancedPromptTemplateService(),
@@ -875,6 +882,7 @@ def build_application_services(
sync_bindings=BillingService.sync_partner_tenants_bindings,
),
recommended_app_queries=recommended_app_queries,
recommended_app_packages=recommended_app_packages,
remote_files=remote_file_service,
saved_messages=SavedMessageService(
saved_messages=SQLAlchemySavedMessageRepository(session_factory=database_client),

View File

@@ -177,6 +177,8 @@ def init_app(app: DifyApp) -> Celery:
"tasks.generate_summary_index_task", # summary index generation
"tasks.regenerate_summary_index_task", # summary index regeneration
"tasks.initialize_created_app_rbac_access_task", # app access initialization
"tasks.bootstrap_tokener_tenant_task", # managed Tokener setup for new tenants
"tasks.prepare_tokener_migration_task", # recover persisted legacy migration preparations
"tasks.install_default_plugins_task", # tenant default plugin installation
"tasks.new_agent_beta_task", # New Agent Beta eligibility checks
"tasks.refresh_billing_vector_space_task", # billing vector-space cache refresh
@@ -187,6 +189,12 @@ def init_app(app: DifyApp) -> Celery:
# if you add a new task, please add the switch to CeleryScheduleTasksConfig
beat_schedule: dict[str, CeleryBeatScheduleEntry] = {}
# Independent of new admission, so pausing a batch does not strand old intents.
if dify_config.ENABLE_TOKENER_MIGRATION_RECOVERY_TASK:
beat_schedule["tokener_migration_recovery_sweeper"] = {
"task": "tasks.prepare_tokener_migration_task.sweep_tokener_migrations_task",
"schedule": timedelta(minutes=1),
}
if dify_config.AGENT_SANDBOX_METERING_ENABLED:
try:
interval = int(dify_config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS)
@@ -201,6 +209,11 @@ def init_app(app: DifyApp) -> Celery:
"schedule": collection_schedule,
"options": {"expires": interval},
}
if dify_config.TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED and dify_config.ENABLE_TOKENER_BOOTSTRAP_RECOVERY_TASK:
beat_schedule["tokener_bootstrap_recovery_sweeper"] = {
"task": "tasks.bootstrap_tokener_tenant_task.sweep_pending_tokener_integrations_task",
"schedule": timedelta(minutes=dify_config.TOKENER_BOOTSTRAP_RECOVERY_TASK_INTERVAL),
}
if dify_config.ENABLE_CONVERSATION_CLEANUP_TASK:
imports.append("tasks.delete_conversation_task")
beat_schedule["conversation_cleanup_sweeper"] = {

View File

@@ -46,8 +46,10 @@ def init_app(app: DifyApp):
upgrade_db,
vdb_migrate,
)
from model_billing_migration_commands import tokener_migration
cmds_to_register = [
tokener_migration,
reset_password,
reset_email,
reset_encrypt_key_pair,

View File

@@ -0,0 +1,5 @@
from fields.base import ResponseModel
class AppExportResponse(ResponseModel):
data: str

View File

@@ -0,0 +1,63 @@
"""add tenant tokener integrations
Revision ID: c3f1a2b4d5e6
Revises: 5578e028b2f2
Create Date: 2026-09-02 17:30:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "c3f1a2b4d5e6"
down_revision = "5578e028b2f2"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"tenant_tokener_integrations",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("status", sa.String(length=40), server_default=sa.text("'pending'"), nullable=False),
sa.Column("plugin_unique_identifier", sa.String(length=255), nullable=True),
sa.Column("plugin_install_task_id", sa.String(length=255), nullable=True),
sa.Column("provider_credential_id", models.types.StringUUID(), nullable=True),
sa.Column("attempt_count", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("last_error_code", sa.String(length=100), nullable=True),
sa.Column("last_attempt_at", sa.DateTime(), nullable=True),
sa.Column("ready_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.ForeignKeyConstraint(
["provider_credential_id"],
["provider_credentials.id"],
name=op.f("tenant_tokener_integrations_provider_credential_id_fkey"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.id"],
name=op.f("tenant_tokener_integrations_tenant_id_fkey"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("tenant_tokener_integration_pkey")),
sa.UniqueConstraint("tenant_id", name=op.f("tenant_tokener_integration_tenant_id_key")),
)
op.create_index(
"tenant_tokener_integration_status_updated_at_idx",
"tenant_tokener_integrations",
["status", "updated_at"],
)
def downgrade():
op.drop_index(
"tenant_tokener_integration_status_updated_at_idx",
table_name="tenant_tokener_integrations",
)
op.drop_table("tenant_tokener_integrations")

View File

@@ -0,0 +1,43 @@
"""add tenant model billing profiles
Revision ID: d4e5f6a7b8c9
Revises: c3f1a2b4d5e6
Create Date: 2026-09-03 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "d4e5f6a7b8c9"
down_revision = "c3f1a2b4d5e6"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"tenant_model_billing_profiles",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("model_billing_source", sa.String(length=40), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["tenants.id"],
name=op.f("tenant_model_billing_profiles_tenant_id_fkey"),
ondelete="CASCADE",
),
sa.CheckConstraint(
"model_billing_source IS NULL OR model_billing_source = 'tokener'",
name=op.f("tenant_model_billing_profile_source_check"),
),
sa.PrimaryKeyConstraint("tenant_id", name=op.f("tenant_model_billing_profile_pkey")),
)
def downgrade():
op.drop_table("tenant_model_billing_profiles")

View File

@@ -0,0 +1,21 @@
"""Merge Tokener billing and normalized email migration heads.
Revision ID: e7b19c4a6d02
Revises: d4e5f6a7b8c9, d8e4a6b1c902
Create Date: 2026-09-17 12:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "e7b19c4a6d02"
down_revision = ("d4e5f6a7b8c9", "d8e4a6b1c902")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,7 +1,7 @@
"""Add independent sandbox execution accounting.
"""Add independent sandbox execution accounting and merge deployment heads.
Revision ID: e7b2a9c4d601
Revises: d8e4a6b1c902
Revises: d4e5f6a7b8c9, d8e4a6b1c902
Create Date: 2026-09-20 12:00:00
"""
@@ -11,7 +11,7 @@ from alembic import op
from models.types import StringUUID
revision = "e7b2a9c4d601"
down_revision = "d8e4a6b1c902"
down_revision = ("d4e5f6a7b8c9", "d8e4a6b1c902")
branch_labels = None
depends_on = None

View File

@@ -0,0 +1,54 @@
"""Add tenant model billing migration control state; no data backfill.
Revision ID: f8c3b0d5e712
Revises: e7b2a9c4d601
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
from models.types import StringUUID
revision = "f8c3b0d5e712"
down_revision = "e7b2a9c4d601"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"tenant_model_billing_migrations",
sa.Column("tenant_id", StringUUID(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True),
sa.Column("migration_id", StringUUID(), nullable=False),
sa.Column("batch_id", sa.String(255), nullable=False),
sa.Column("phase", sa.String(32), nullable=False),
sa.Column("attention", sa.String(32), nullable=False),
sa.Column("revision", sa.BigInteger(), nullable=False),
sa.Column("route_epoch", sa.BigInteger(), nullable=False),
sa.Column("claimed_at", sa.DateTime()),
sa.Column("lease_token", StringUUID()),
sa.Column("lease_expires_at", sa.DateTime()),
sa.Column("state", sa.JSON().with_variant(JSONB(), "postgresql"), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.UniqueConstraint("migration_id", name="model_billing_migration_id_key"),
sa.CheckConstraint("revision >= 0 AND route_epoch >= 0", name="model_billing_migration_revision_check"),
sa.CheckConstraint(
"phase IN ('preparing','prepared','claimed','granting','activating','active','blocked','cancelled')",
name="model_billing_migration_phase_check",
),
sa.CheckConstraint(
"attention IN ('normal','alerted','manual_required')",
name="model_billing_migration_attention_check",
),
)
op.create_index(
"model_billing_migration_batch_phase_idx",
"tenant_model_billing_migrations",
["batch_id", "phase", "claimed_at"],
)
def downgrade():
op.drop_table("tenant_model_billing_migrations")

View File

@@ -0,0 +1,18 @@
"""Merge the deployment and Tokener PR migration histories without data changes.
Revision ID: a9d4c1e6f823
Revises: f8c3b0d5e712, e7b19c4a6d02
"""
revision = "a9d4c1e6f823"
down_revision = ("f8c3b0d5e712", "e7b19c4a6d02")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,92 @@
"""Explicit, allowlisted migration operations. The default is a read-only plan."""
import json
from uuid import NAMESPACE_URL, UUID, uuid5
import click
from flask.cli import with_appcontext
from configs import dify_config
from core.model_invocation_routing import migration_inventory
from services.billing_service import BillingService, _BillingHTTPStatusError
from services.model_billing_migration_service import ModelBillingMigrationService
@click.group("tokener-migration")
def tokener_migration():
"""Inspect, prepare or resume an explicitly selected workspace (never Stripe)."""
@tokener_migration.command("prepare")
@click.option("--tenant-id", type=click.UUID, required=True)
@click.option("--batch-id", required=True)
@click.option("--mapping-version", required=True)
@click.option("--apply", is_flag=True, help="Persist the intent and prepare resources; never grant signup money.")
@with_appcontext
def prepare(tenant_id: UUID, batch_id: str, mapping_version: str, apply: bool):
tenant = str(tenant_id)
if apply and not ModelBillingMigrationService.admitted(tenant):
raise click.ClickException("Migration admission is disabled or tenant is not allowlisted.")
inventory = migration_inventory(tenant, mapping_version)
migration_id = str(uuid5(NAMESPACE_URL, f"dify.migration.v1:{tenant}:{batch_id}"))
payload = {
"api_version": 1,
"operation_id": str(uuid5(NAMESPACE_URL, f"dify.migration.prepare.v1:{migration_id}")),
"expected_revision": 0,
"migration_id": migration_id,
"batch_id": batch_id,
"eligibility_policy_version": "paid-legacy-v1",
"model_mapping_version": mapping_version,
"inventory_hash": inventory["inventory_hash"],
}
if not apply:
click.echo(json.dumps({"dry_run": True, "request": payload, "inventory": inventory}, sort_keys=True))
return
_post(tenant, "prepare-migration", payload)
click.echo(json.dumps({"status": "accepted", "tenant_id": tenant, "migration_id": migration_id}))
@tokener_migration.command("status")
@click.option("--tenant-id", type=click.UUID, required=True)
@with_appcontext
def status(tenant_id: UUID):
click.echo(json.dumps(ModelBillingMigrationService.status(str(tenant_id)), sort_keys=True))
@tokener_migration.command("resume")
@click.option("--tenant-id", type=click.UUID, required=True)
@click.option("--manual", is_flag=True, help="Explicit operator recovery after the initial 30-minute automatic limit.")
@click.option(
"--replan-unsent-operation-id",
type=click.UUID,
help="With --manual, replan only journal-proven never-dispatched expired-start windows. Reuse this UUID.",
)
@with_appcontext
def resume(tenant_id: UUID, manual: bool, replan_unsent_operation_id: UUID | None):
if replan_unsent_operation_id is not None and not manual:
raise click.UsageError("--replan-unsent-operation-id requires --manual")
state = ModelBillingMigrationService.status(str(tenant_id))
payload: dict[str, object] = {"api_version": 1, "migration_id": state["migration_id"], "manual": manual}
if replan_unsent_operation_id is not None:
payload.update(manual_replan=True, operation_id=str(replan_unsent_operation_id))
_post(
str(tenant_id),
"resume-migration",
payload,
)
click.echo(json.dumps({"status": "accepted", "migration_id": state["migration_id"]}))
def _post(tenant: str, operation: str, payload: dict[str, object]) -> None:
base_url = dify_config.TOKENER_BILLING_API_URL.strip().rstrip("/")
if not base_url:
raise click.ClickException("TOKENER_BILLING_API_URL is not configured.")
try:
BillingService._send_request(
"POST", f"/internal/v1/tokener/tenants/{tenant}/{operation}", json=payload, base_url=base_url
)
except _BillingHTTPStatusError as error:
if error.status_code != 202:
raise click.ClickException(
f"Migration request failed (HTTP {error.status_code}); reuse the same intent."
) from None

View File

@@ -101,6 +101,8 @@ from .model import (
TrialApp,
UploadFile,
)
from .model_billing import TenantModelBillingProfile
from .model_billing_migration import TenantModelBillingMigration
from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken
from .onboarding import AccountStepByStepTourState
from .provider import (
@@ -118,6 +120,7 @@ from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, Skil
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from .task import CeleryTask, CeleryTaskSet
from .tokener import TenantTokenerIntegration, TenantTokenerIntegrationStatus
from .tools import (
ApiToolProvider,
BuiltinToolProvider,
@@ -267,8 +270,12 @@ __all__ = [
"TenantAccountRole",
"TenantCreditPool",
"TenantDefaultModel",
"TenantModelBillingMigration",
"TenantModelBillingProfile",
"TenantPreferredModelProvider",
"TenantStatus",
"TenantTokenerIntegration",
"TenantTokenerIntegrationStatus",
"TidbAuthBinding",
"ToolConversationVariables",
"ToolFile",

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import TypeBase
from .types import StringUUID
class TenantModelBillingProfile(TypeBase):
"""Persisted tenant cohort for managed-model billing and routing."""
__tablename__ = "tenant_model_billing_profiles"
__table_args__ = (
sa.PrimaryKeyConstraint("tenant_id", name="tenant_model_billing_profile_pkey"),
sa.CheckConstraint(
"model_billing_source IS NULL OR model_billing_source = 'tokener'",
name="tenant_model_billing_profile_source_check",
),
)
tenant_id: Mapped[str] = mapped_column(
StringUUID,
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
)
# NULL intentionally means the pre-Tokener legacy message-credit path.
# Keep this as a string so the resolver can fail closed on future/invalid values.
model_billing_source: Mapped[str | None] = mapped_column(String(40), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.current_timestamp(),
init=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
)

View File

@@ -0,0 +1,60 @@
"""Durable control state for migrating a workspace's hosted credit routing.
This is not a wallet: state holds decisions, immutable plans and remote receipts,
never a locally calculated Tokener balance or consumption counter.
"""
from datetime import datetime
from typing import Any
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import TypeBase
from .types import StringUUID
class TenantModelBillingMigration(TypeBase):
__tablename__ = "tenant_model_billing_migrations"
__table_args__ = (
sa.UniqueConstraint("migration_id", name="model_billing_migration_id_key"),
sa.Index("model_billing_migration_batch_phase_idx", "batch_id", "phase", "claimed_at"),
sa.CheckConstraint("revision >= 0 AND route_epoch >= 0", name="model_billing_migration_revision_check"),
sa.CheckConstraint(
"phase IN ('preparing','prepared','claimed','granting','activating','active','blocked','cancelled')",
name="model_billing_migration_phase_check",
),
sa.CheckConstraint(
"attention IN ('normal','alerted','manual_required')",
name="model_billing_migration_attention_check",
),
)
tenant_id: Mapped[str] = mapped_column(
StringUUID, sa.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
)
migration_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
batch_id: Mapped[str] = mapped_column(sa.String(255), nullable=False)
phase: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="preparing")
attention: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="normal")
revision: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
route_epoch: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
claimed_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None)
lease_token: Mapped[str | None] = mapped_column(StringUUID, default=None)
lease_expires_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None)
state: Mapped[dict[str, Any]] = mapped_column(
sa.JSON().with_variant(JSONB(), "postgresql"),
nullable=False,
default_factory=dict,
)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False
)
updated_at: Mapped[datetime] = mapped_column(
sa.DateTime,
nullable=False,
server_default=sa.func.current_timestamp(),
onupdate=sa.func.current_timestamp(),
init=False,
)

84
api/models/tokener.py Normal file
View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
import sqlalchemy as sa
from sqlalchemy import DateTime, String, func, text
from sqlalchemy.orm import Mapped, mapped_column
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .types import EnumText, StringUUID
class TenantTokenerIntegrationStatus(StrEnum):
"""Durable stages for bootstrapping Tokener in a workspace."""
PENDING = "pending"
INSTALLING_PLUGIN = "installing_plugin"
PROVISIONING = "provisioning"
CONFIGURING_PROVIDER = "configuring_provider"
READY = "ready"
FAILED = "failed"
class TenantTokenerIntegration(TypeBase):
"""Non-secret, replayable state for a tenant's managed Tokener setup."""
__tablename__ = "tenant_tokener_integrations"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="tenant_tokener_integration_pkey"),
sa.UniqueConstraint("tenant_id", name="tenant_tokener_integration_tenant_id_key"),
sa.Index("tenant_tokener_integration_status_updated_at_idx", "status", "updated_at"),
)
id: Mapped[str] = mapped_column(
StringUUID,
primary_key=True,
insert_default=lambda: str(uuidv7()),
default_factory=lambda: str(uuidv7()),
init=False,
)
tenant_id: Mapped[str] = mapped_column(
StringUUID,
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
status: Mapped[TenantTokenerIntegrationStatus] = mapped_column(
EnumText(TenantTokenerIntegrationStatus, length=40),
nullable=False,
server_default=text("'pending'"),
default=TenantTokenerIntegrationStatus.PENDING,
)
plugin_unique_identifier: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
plugin_install_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
provider_credential_id: Mapped[str | None] = mapped_column(
StringUUID,
sa.ForeignKey("provider_credentials.id", ondelete="SET NULL"),
nullable=True,
default=None,
)
attempt_count: Mapped[int] = mapped_column(
sa.Integer,
nullable=False,
server_default=text("0"),
default=0,
)
last_error_code: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
ready_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.current_timestamp(),
init=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
init=False,
)

View File

@@ -5042,6 +5042,7 @@ Sync partner tenants bindings
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [BillingSubscriptionResponse](#billingsubscriptionresponse)<br> |
| 403 | Forbidden | |
| 409 | Education subscriptions are not supported with Tokener billing | **application/json**: [TokenerEducationCheckoutUnsupportedErrorResponse](#tokenereducationcheckoutunsupportederrorresponse)<br> |
| 422 | Invalid subscription query | **application/json**: [BillingUnprocessableEntityErrorResponse](#billingunprocessableentityerrorresponse)<br> |
| 502 | Billing operation failed | **application/json**: [BillingOperationFailedErrorResponse](#billingoperationfailederrorresponse)<br> |
| 503 | Billing unavailable | **application/json**: [BillingUnavailableErrorResponse](#billingunavailableerrorresponse)<br> |
@@ -9328,6 +9329,155 @@ Remove one or more tag bindings from a target.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialAppDetailResponse](#trialappdetailresponse)<br> |
### [GET] /trial-apps/{app_id}/agent-composer
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published Agent configuration | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files/{name}/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/octet-stream**: binary<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/inspect
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
### [POST] /trial-apps/{app_id}/audio-to-text
#### Parameters
@@ -9360,6 +9510,20 @@ Remove one or more tag bindings from a target.
| ---- | ----------- |
| 200 | Success |
### [POST] /trial-apps/{app_id}/chat-messages/{task_id}/stop
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /trial-apps/{app_id}/completion-messages
#### Parameters
@@ -9395,6 +9559,22 @@ Remove one or more tag bindings from a target.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)<br> |
### [GET] /trial-apps/{app_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| format | query | Export format; defaults to ifpkg | No | string, <br>**Available values:** "ifpkg", "yaml", <br>**Default:** ifpkg |
| version_id | query | Current published template snapshot ID | Yes | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published Agent template export | **application/json**: [AppExportResponse](#appexportresponse)<br>**application/zip**: binary<br> |
| 404 | Published template unavailable | |
### [POST] /trial-apps/{app_id}/files/upload
**Upload a file into the tenant that owns the trial app**
@@ -16246,6 +16426,9 @@ This class is used to store the schema information of an api based tool.
| icon_type | string | | No |
| mode | string | Import mode | Yes |
| name | string | | No |
| package_url | string | | No |
| template_id | string | | No |
| version_id | string | | No |
| yaml_content | string | | No |
| yaml_url | string | | No |
@@ -17781,11 +17964,14 @@ Model class for credential form schema.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| credits | integer | Remaining credits in the effective pool; -1 means unlimited. | Yes |
| credits | integer | Remaining legacy credits; -1 means unlimited, null means unavailable or not managed by legacy credits. | Yes |
| id | string | | Yes |
| model_billing_migration_status | string, <br>**Available values:** "active", "none", "preparing", "processing", <br>**Default:** none | *Enum:* `"active"`, `"none"`, `"preparing"`, `"processing"` | No |
| model_billing_source | string, <br>**Available values:** "legacy_message_credits", "tokener", <br>**Default:** legacy_message_credits | *Enum:* `"legacy_message_credits"`, `"tokener"` | No |
| name | string | | Yes |
| plan | [CloudPlan](#cloudplan) | | Yes |
| role | [TenantAccountRole](#tenantaccountrole) | | Yes |
| tokener_bootstrap_status | string, <br>**Available values:** "configuring_provider", "failed", "installing_plugin", "pending", "provisioning", "ready" | | No |
#### CustomConfigurationResponse
@@ -19461,6 +19647,8 @@ Effective feature availability and limits for the current workspace.
| is_allow_transfer_workspace | boolean, <br>**Default:** true | | Yes |
| knowledge_pipeline | [KnowledgePipeline](#knowledgepipeline) | | Yes |
| members | [LimitationModel](#limitationmodel) | | Yes |
| model_billing_migration_status | string, <br>**Available values:** "active", "none", "preparing", "processing", <br>**Default:** none | *Enum:* `"active"`, `"none"`, `"preparing"`, `"processing"` | Yes |
| model_billing_source | string, <br>**Available values:** "legacy_message_credits", "tokener", <br>**Default:** legacy_message_credits | *Enum:* `"legacy_message_credits"`, `"tokener"` | Yes |
| model_load_balancing_enabled | boolean | | Yes |
| trigger_event | [Quota](#quota) | | Yes |
| webapp_copyright_enabled | boolean | | Yes |
@@ -20632,11 +20820,15 @@ Enum class for model property key.
| exhausted_at | integer | | Yes |
| is_exhausted | boolean | | Yes |
| is_unlimited | boolean | | Yes |
| model_billing_migration_status | string, <br>**Available values:** "active", "none", "preparing", "processing", <br>**Default:** none | *Enum:* `"active"`, `"none"`, `"preparing"`, `"processing"` | No |
| model_billing_source | string, <br>**Available values:** "legacy_message_credits", "tokener", <br>**Default:** legacy_message_credits | *Enum:* `"legacy_message_credits"`, `"tokener"` | No |
| next_credit_reset_date | integer | | Yes |
| pool_type | string, <br>**Available values:** "paid", "trial" | | Yes |
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
| quota_used | integer | | Yes |
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
| tokener_bootstrap_status | string, <br>**Available values:** "configuring_provider", "failed", "installing_plugin", "pending", "provisioning", "ready" | | No |
| tokener_metering | [TokenerMeteringResponse](#tokenermeteringresponse) | | No |
#### ModelProviderCustomConfigurationSummaryResponse
@@ -22485,6 +22677,13 @@ Resource types understood by access policies.
| permission_keys | [ string ] | | No |
| updated_at | integer | | Yes |
#### RecommendedAgentExportQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| format | string, <br>**Available values:** "ifpkg", "yaml", <br>**Default:** ifpkg | Export format; defaults to ifpkg<br>*Enum:* `"ifpkg"`, `"yaml"` | No |
| version_id | string (uuid) | Current published template snapshot ID | Yes |
#### RecommendedAppDetailResponse
| Name | Type | Description | Required |
@@ -22496,6 +22695,8 @@ Resource types understood by access policies.
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| package_url | string | Download URL for a New Agent .ifpkg template | No |
| version_id | string | Published version for direct local template creation | No |
#### RecommendedAppInfoResponse
@@ -24000,11 +24201,14 @@ Tag type
| custom_config | [WorkspaceCustomConfigResponse](#workspacecustomconfigresponse) | | No |
| id | string | | Yes |
| in_trial | boolean | | No |
| model_billing_migration_status | string, <br>**Available values:** "active", "none", "preparing", "processing", <br>**Default:** none | *Enum:* `"active"`, `"none"`, `"preparing"`, `"processing"` | No |
| model_billing_source | string, <br>**Available values:** "legacy_message_credits", "tokener", <br>**Default:** legacy_message_credits | *Enum:* `"legacy_message_credits"`, `"tokener"` | No |
| name | string | | No |
| next_credit_reset_date | integer | | No |
| plan | [CloudPlan](#cloudplan) | | No |
| role | string | | No |
| status | string | | No |
| tokener_bootstrap_status | string, <br>**Available values:** "configuring_provider", "failed", "installing_plugin", "pending", "provisioning", "ready" | | No |
| trial_credits | integer | | No |
| trial_credits_exhausted_at | integer | | No |
| trial_credits_used | integer | | No |
@@ -24118,6 +24322,58 @@ Available voices
| name | string | Voice display name | Yes |
| value | string | Voice identifier | Yes |
#### TokenerAllowanceMeteringResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| amount_usd_micro | string | | Yes |
| available_usd_micro | string | | Yes |
| ends_at | string | | Yes |
| source_ref | string | | Yes |
| starts_at | string | | Yes |
| window_id | string | | Yes |
#### TokenerCurrentMonthAvailableMeteringResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| billed_usd_micro | string | | Yes |
| end_date | string | | Yes |
| request_count | string | | Yes |
| start_date | string | | Yes |
| status | string | | Yes |
#### TokenerCurrentMonthUnavailableMeteringResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| end_date | string | | Yes |
| error_code | string | | Yes |
| start_date | string | | Yes |
| status | string | | Yes |
#### TokenerEducationCheckoutUnsupportedErrorResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | string | | Yes |
| message | string | | Yes |
| status | integer | | Yes |
#### TokenerMeteringResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowance | [TokenerAllowanceMeteringResponse](#tokenerallowancemeteringresponse) | | No |
| available_usd_micro | string | | Yes |
| balance_generated_at | string | | Yes |
| currency | string | | Yes |
| current_month | [TokenerCurrentMonthAvailableMeteringResponse](#tokenercurrentmonthavailablemeteringresponse)<br>[TokenerCurrentMonthUnavailableMeteringResponse](#tokenercurrentmonthunavailablemeteringresponse) | | Yes |
| entitlement_error_code | string | | No |
| entitlement_status | string, <br>**Available values:** "active", "failed", "processing", "retrying" | | No |
| tenant_id | string | | Yes |
| usage_generated_at | string | | No |
#### TokensPerSecondStatisticItem
| Name | Type | Description | Required |
@@ -24370,6 +24626,19 @@ Enum class for tool provider
| ---- | ---- | ----------- | -------- |
| tracing_provider | string | Tracing provider name | Yes |
#### TrialAgentConfigQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| version_id | string | Must match the current published template snapshot | No |
#### TrialAgentSkillFileQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| path | string | Normalized member path inside the published Skill package | Yes |
| version_id | string | Must match the current published template snapshot | No |
#### TrialAppAgentMode
| Name | Type | Description | Required |

View File

@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import cast, override
from pydantic import JsonValue
from pydantic import JsonValue, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
@@ -12,11 +12,13 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.utils.uuid_utils import is_valid_uuid
from models.account import Account, Tenant
from models.dataset import Dataset
from models.model import App, AppModelConfig, Site, load_annotation_reply_config
from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
from models.tools import ApiToolProvider
from models.workflow import Workflow
from repositories.app_definition_query_repository import map_site_configuration
from services.account_errors import AccountNotFoundError
from services.agent.composer_service import AgentComposerService
from services.agent.errors import AgentNotFoundError, AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_details_service import (
AppPreviewAccount,
@@ -41,6 +43,24 @@ from services.app_preview_query_service import (
class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object] | None:
with self._session_factory() as session:
try:
model = self._get_app(session=session, app=app)
except AppPreviewUnavailableError:
return None
if model.mode != AppMode.AGENT:
return None
agent = model.agent_app_binding_with_session(session=session)
if agent is None:
return None
try:
return AgentComposerService.load_published_agent_composer(
session=session, tenant_id=app.tenant_id, agent_id=agent.id
)
except (AgentNotFoundError, AgentVersionNotFoundError, ValidationError, ValueError):
return None
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@@ -267,6 +287,8 @@ class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
annotation_reply=load_annotation_reply_config(session, model_config.app_id)
)
result: dict[str, JsonValue | datetime] = dict(cast(Mapping[str, JsonValue], configuration))
if not result.get("model"):
result["model"] = None
result.update(
created_by=model_config.created_by,
created_at=model_config.created_at,

View File

@@ -4,14 +4,19 @@ import json
import logging
from collections.abc import Sequence
from typing import cast, override
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from constants.languages import languages
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from extensions.ext_redis import RedisClientWrapper
from models.model import App, RecommendedApp, Site
from models.account import Tenant, TenantStatus
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus
from models.model import App, AppMode, RecommendedApp, Site
from services.app_dsl_service import AppDslService
from services.recommended_app_package_service import RecommendedAgentPackageSource
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
@@ -57,6 +62,43 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
with self._session_factory() as session:
return self._get_detail(app_id, session=session)
def get_package_source(self, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource | None:
with self._session_factory() as session:
source = self._get_agent_package_source(app_id, session=session)
return source if source is not None and source.version_id == version_id else None
@staticmethod
def _get_agent_package_source(app_id: str, *, session: Session) -> RecommendedAgentPackageSource | None:
row = session.execute(
select(Agent.tenant_id, Agent.id, Agent.active_config_snapshot_id)
.join(App, (App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id))
.join(Tenant, Tenant.id == App.tenant_id)
.join(RecommendedApp, RecommendedApp.app_id == App.id)
.join(
AgentConfigSnapshot,
(AgentConfigSnapshot.id == Agent.active_config_snapshot_id)
& (AgentConfigSnapshot.agent_id == Agent.id)
& (AgentConfigSnapshot.tenant_id == Agent.tenant_id),
)
.where(
App.id == app_id,
App.mode == AppMode.AGENT,
App.status == "normal",
App.is_public.is_(True),
RecommendedApp.is_listed.is_(True),
Tenant.status != TenantStatus.ARCHIVE,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
workflow_callable_active_snapshot_filter(),
(Agent.backing_app_id == App.id) | Agent.backing_app_id.is_(None),
)
.limit(1)
).one_or_none()
if row is None:
return None
return RecommendedAgentPackageSource(tenant_id=row[0], agent_id=row[1], version_id=UUID(row[2]))
@override
def contains(self, app_id: str) -> bool:
with self._session_factory() as session:
@@ -179,13 +221,19 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
if app is None or not app.is_public:
return None
version_id = None
if app.mode == AppMode.AGENT:
source = DatabaseRecommendedAppCatalogRepository._get_agent_package_source(app.id, session=session)
if source is not None:
version_id = str(source.version_id)
return RecommendedAppDetailRecord(
id=app.id,
name=app.name,
icon=cast(str | None, app.icon),
icon_background=app.icon_background,
mode=app.mode.value,
export_data=AppDslService.export_dsl(app_model=app, session=session),
export_data="" if app.mode == AppMode.AGENT else AppDslService.export_dsl(app_model=app, session=session),
version_id=version_id,
)
@staticmethod

View File

@@ -3,10 +3,12 @@
from collections.abc import Sequence, Set
from typing import override
from sqlalchemy import and_, select
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord, App, TrialApp
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services.recommended_app_query_service import TrialAppQuery
from services.trial_app_access_service import TrialAppAccessQuery, TrialAppAccessSnapshot, TrialAppRef
from services.trial_app_usage import TrialAppUsageRecorder
@@ -21,8 +23,42 @@ class TrialAppRepository(TrialAppQuery, TrialAppAccessQuery, TrialAppUsageRecord
if not app_ids:
return frozenset()
runnable_agent_exists = (
select(Agent.id)
.join(
AgentConfigSnapshot,
and_(
AgentConfigSnapshot.tenant_id == Agent.tenant_id,
AgentConfigSnapshot.agent_id == Agent.id,
AgentConfigSnapshot.id == Agent.active_config_snapshot_id,
),
)
.where(
Agent.tenant_id == App.tenant_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(
Agent.app_id == App.id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
),
Agent.backing_app_id == App.id,
),
workflow_callable_active_snapshot_filter(),
)
.correlate(App)
.exists()
)
query = (
select(TrialApp.app_id)
.outerjoin(App, App.id == TrialApp.app_id)
.where(
TrialApp.app_id.in_(app_ids),
or_(App.id.is_(None), App.mode != AppMode.AGENT, runnable_agent_exists),
)
)
with self._session_factory() as session:
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
return frozenset(session.scalars(query).all())
@override
def resolve(self, *, app_id: str, account_id: str) -> TrialAppAccessSnapshot | None:

View File

@@ -18,7 +18,6 @@ from libs.helper import RateLimiter, TokenManager
from libs.key_providers import generate_key_pair
from libs.passport import PassportService
from libs.token import generate_csrf_token
from models import TenantCreditPool
from models.account import (
Account,
AccountStatus,
@@ -30,7 +29,6 @@ from models.account import (
TenantPluginAutoUpgradeStrategy,
TenantStatus,
)
from models.enums import ProviderQuotaType
from services import account_errors
from services.account_activation_service import AccountActivationRepository, InvitationTokenStore
from services.account_email import normalize_email
@@ -60,6 +58,7 @@ from services.entities.account_login_entities import (
from services.entities.auth_audit_entities import LoginFailureReason
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.system_feature_service import SystemFeatureService
from services.tenant_model_billing_service import initialize_tenant_model_billing
from services.turnstile_service import (
EMAIL_CODE_SEND_ACTION,
EMAIL_CODE_VERIFY_ACTION,
@@ -412,14 +411,7 @@ class SQLAlchemyConsoleAuthProvisioningGateway(AccountProvisioningGateway, Works
include_plugins=[],
)
)
session.add(
TenantCreditPool(
tenant_id=tenant.id,
quota_limit=dify_config.HOSTED_POOL_CREDITS,
quota_used=0,
pool_type=ProviderQuotaType.TRIAL,
)
)
initialize_tenant_model_billing(tenant.id, session=session)
return tenant
def _bind_owner_rbac_role(self, tenant: Tenant, account_id: str, session: Session) -> None:

View File

@@ -82,6 +82,7 @@ from services.errors.workspace import WorkSpaceNotAllowedCreateError, Workspaces
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.system_feature_service import SystemFeatureService
from services.telemetry_service import CommunityTelemetryService
from services.tenant_model_billing_service import initialize_tenant_model_billing
from tasks.mail_change_mail_task import (
send_change_mail_completed_notification_task,
send_change_mail_task,
@@ -1016,6 +1017,7 @@ class TenantService:
tenant = Tenant(name=name)
session.add(tenant)
initialize_tenant_model_billing(tenant.id, session=session)
session.commit()
for category in TenantPluginAutoUpgradeCategory:
@@ -1034,10 +1036,6 @@ class TenantService:
tenant.encrypt_public_key = generate_key_pair(tenant.id)
session.commit()
from services.credit_pool_service import CreditPoolService
CreditPoolService.create_default_pool(tenant.id, session=session)
return tenant
@staticmethod

View File

@@ -35,6 +35,7 @@ from models.agent_config_entities import (
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.dsl_entities import make_portable_agent_soul
from services.agent.errors import (
AgentBuildSandboxNotFoundError,
AgentModelNotConfiguredError,
@@ -388,6 +389,32 @@ class AgentComposerService:
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
return cls._load_agent_composer_for_agent(session=session, tenant_id=tenant_id, agent=agent)
@classmethod
def load_published_agent_composer(cls, *, session: Session, tenant_id: str, agent_id: str) -> dict[str, Any]:
"""Read public Composer state after the caller admits template access."""
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
raise AgentVersionNotFoundError()
state = cls._load_agent_composer_for_agent(
session=session, tenant_id=tenant_id, agent=agent, published_only=True
)
soul = make_portable_agent_soul(AgentSoulConfig.model_validate(state["agent_soul"]))
# Export portability alone does not remove plaintext runtime values.
soul.env = type(soul.env)()
soul.human = type(soul.human)()
soul.sandbox = type(soul.sandbox)()
soul.memory = type(soul.memory)()
for tool in soul.tools.dify_tools:
tool.runtime_parameters = {}
for cli_tool in soul.tools.cli_tools:
cli_tool.env = type(cli_tool.env)()
cli_tool.invoke_metadata = {}
state["agent_soul"] = soul.model_dump(mode="json")
state["chat_endpoint"] = None
if state["active_config_snapshot"] is not None:
state["active_config_snapshot"]["created_by"] = None
return state
@classmethod
def prepare_agent_composer_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str
@@ -425,18 +452,25 @@ class AgentComposerService:
return AgentSoulConfig.model_validate(state["agent_soul"])
@classmethod
def _load_agent_composer_for_agent(cls, *, session: Session, tenant_id: str, agent: Agent) -> dict[str, Any]:
def _load_agent_composer_for_agent(
cls, *, session: Session, tenant_id: str, agent: Agent, published_only: bool = False
) -> dict[str, Any]:
"""Read effective configuration without creating or rebasing persisted drafts.
Stale workflow-only drafts read from the current snapshot. Their draft
metadata still describes the persisted row; write paths own rebasing.
Published-only reads never query drafts or advertise editing capabilities.
"""
draft = cls._get_agent_draft(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft = (
None
if published_only
else cls._get_agent_draft(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
)
)
version = cls._get_version_if_present(
session=session, tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
@@ -455,10 +489,11 @@ class AgentComposerService:
"variant": ComposerVariant.AGENT_APP.value,
"agent": cls._serialize_agent(agent),
"active_config_snapshot": cls._serialize_version(version),
"active_config_is_published": bool(agent.active_config_snapshot_id and agent.active_config_is_published),
"active_config_is_published": published_only
or bool(agent.active_config_snapshot_id and agent.active_config_is_published),
"draft": cls._serialize_draft(draft),
"agent_soul": agent_soul,
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"save_options": [] if published_only else [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),

View File

@@ -244,9 +244,9 @@ class AgentDslService:
name=self.unique_roster_name(tenant_id=app.tenant_id, requested=app.name or metadata.name),
description=app.description or metadata.description,
role=metadata.role,
icon_type=self._agent_icon_type(metadata.icon_type),
icon=metadata.icon,
icon_background=metadata.icon_background,
icon_type=self._agent_icon_type(app.icon_type),
icon=app.icon,
icon_background=app.icon_background,
source=AgentSource.IMPORTED,
initial_soul=soul,
revision_operation=AgentConfigRevisionOperation.IMPORT_PACKAGE,

View File

@@ -163,6 +163,80 @@ class AgentPackageResourceExporter:
)
self._workspace_sources.clear()
def read_local_resources(
self, package_ref: str
) -> tuple[dict[str, bytes], AgentPackageResources, list[PackageIcon]]:
"""Read bounded local assets without building an outer archive."""
skill_sources, file_sources = self.sources[package_ref]
if len(skill_sources) + len(file_sources) + len(self.icon_sources) + 2 > dify_config.AGENT_PACKAGE_MAX_ENTRIES:
raise RosterAgentPackageTooLargeError("Agent template has too many resources")
payloads: dict[str, bytes] = {}
total_size = 0
def read(path: str, storage_key: str, limit: int) -> tuple[int, str]:
nonlocal total_size
content = bytearray()
try:
for chunk in self._storage.load_stream(storage_key):
if len(content) + len(chunk) > min(limit, dify_config.AGENT_PACKAGE_MAX_BYTES - total_size):
raise RosterAgentPackageTooLargeError("Agent template resources exceed the size limit")
content.extend(chunk)
except RosterAgentPackageTooLargeError:
raise
except Exception as exc:
raise RosterAgentPackageExportFailedError(f"Unable to read template resource {path!r}") from exc
payload = bytes(content)
payloads[path] = payload
total_size += len(payload)
return len(payload), hashlib.sha256(payload).hexdigest()
skills: list[RosterAgentPackageSkill] = []
files: list[RosterAgentPackageFile] = []
nested_uncompressed_size = 0
skill_packages = SkillPackageService()
for item in skill_sources:
size, digest = read(item.path, item.storage_key, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
try:
inspection = skill_packages.inspect(content=payloads[item.path], filename=item.path)
except SkillPackageError as exc:
raise RosterAgentPackageExportFailedError(
f"Agent template contains unusable Skill {item.name!r}"
) from exc
if inspection.name != item.name:
raise RosterAgentPackageExportFailedError(f"Agent template Skill {item.name!r} has a name mismatch")
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError("Agent template nested Skill contents exceed the size limit")
skills.append(
RosterAgentPackageSkill(
id=item.id,
scope=item.scope,
name=item.name,
path=item.path,
size=size,
sha256=digest,
audit=RosterAgentPackageAudit(ref=item.audit_ref),
)
)
for file_source in file_sources:
size, digest = read(file_source.path, file_source.storage_key, dify_config.AGENT_PACKAGE_MAX_BYTES)
files.append(
RosterAgentPackageFile(
id=file_source.id,
path=file_source.path,
size=size,
sha256=digest,
audit=RosterAgentPackageAudit(ref=file_source.audit_ref),
)
)
icons = []
for path, key in self.icon_sources.values():
size, digest = read(path, key, dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024)
icons.append(PackageIcon(id=path.split(".")[0], path=path, size=size, sha256=digest))
resources = AgentPackageResources(skills=skills, files=files)
resources.validate_packages([self.packages[package_ref]])
return payloads, resources, icons
def write_resources(self, archive: zipfile.ZipFile) -> tuple[dict[str, AgentPackageResources], int]:
entry_count = sum(len(skills) + len(files) for skills, files in self.sources.values()) + 2
if entry_count > dify_config.AGENT_PACKAGE_MAX_ENTRIES:

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import mimetypes
import os
from collections.abc import Callable
from typing import Protocol
from uuid import uuid4
@@ -82,7 +83,26 @@ class AgentPackageResourceImporter:
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Upload outside database transactions, then commit all file records together."""
return self.materialize_resources(
read_member=lambda path, limit: self._reader.read_member_bytes(archive, path, max_bytes=limit),
invalid_skills=archive.invalid_skills,
resources=resources,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account_id,
)
def materialize_resources(
self,
*,
read_member: Callable[[str, int], bytes],
invalid_skills: dict[str, str],
resources: AgentPackageResources,
agent_package: AgentPackage,
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Materialize portable references from archive members or local resource bytes."""
files: list[ToolFile | UploadFile] = []
warnings: list[DslImportWarning] = []
soul_data = agent_package.soul.model_dump(mode="json")
@@ -112,15 +132,11 @@ class AgentPackageResourceImporter:
"mime_type": "application/zip",
}
)
reason = archive.invalid_skills.get(skill_resource.id)
reason = invalid_skills.get(skill_resource.id)
normalized = None
try:
if reason is None:
payload = self._reader.read_member_bytes(
archive,
skill_resource.path,
max_bytes=dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024,
)
payload = read_member(skill_resource.path, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
normalized = self._skill_packages.validate_and_normalize(
content=payload, filename=skill_resource.path
)
@@ -165,7 +181,7 @@ class AgentPackageResourceImporter:
file_kind = package_ref["file_kind"]
mime_type = package_ref["mime_type"] or "application/octet-stream"
extension = self._extension(package_ref["name"])
payload = self._reader.read_member_bytes(archive, file_resource.path, max_bytes=file_resource.size)
payload = read_member(file_resource.path, file_resource.size)
if file_kind == "tool_file":
storage_key = f"tools/{tenant_id}/{uuid4().hex}.{extension or 'bin'}"
row: ToolFile | UploadFile = ToolFile(
@@ -216,13 +232,26 @@ class AgentPackageResourceImporter:
def materialize_icons(
self, *, archive: PreparedPackageArchive, icons: list[PackageIcon], tenant_id: str, account_id: str
) -> dict[str, str]:
return self.materialize_icon_resources(
read_member=lambda path, limit: self._reader.read_member_bytes(archive, path, max_bytes=limit),
icons=icons,
tenant_id=tenant_id,
account_id=account_id,
)
def materialize_icon_resources(
self,
*,
read_member: Callable[[str, int], bytes],
icons: list[PackageIcon],
tenant_id: str,
account_id: str,
) -> dict[str, str]:
files: list[ToolFile | UploadFile] = []
mapping: dict[str, str] = {}
for icon in icons:
payload = self._reader.read_member_bytes(
archive, icon.path, max_bytes=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024
)
payload = read_member(icon.path, dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024)
extension = self._extension(icon.path)
file_id = str(uuid4())
key = f"upload_files/{tenant_id}/{file_id}.{extension}"

View File

@@ -1,4 +1,4 @@
"""Export active Roster Agents as portable ``.ifpkg`` archives."""
"""Export active Roster Agents as portable YAML definitions or ``.ifpkg`` archives."""
from __future__ import annotations
@@ -12,6 +12,7 @@ from uuid import UUID
import yaml
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
@@ -49,6 +50,7 @@ from services.agent.roster_package_entities import (
RosterAgentPackageManifest,
)
from services.agent.roster_service import AgentRosterService
from services.app_dsl_service import AppDslService
from services.entities.site_dsl import SiteDsl
from services.plugin.dependencies_analysis import DependenciesAnalysisService
@@ -66,31 +68,46 @@ class RosterAgentPackageExporter:
self._dependency_provider = dependency_provider or DependenciesAnalysisService.generate_dependencies
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> RosterAgentPackageExport:
"""Export a visible version, or the shared draft with an active snapshot fallback."""
app, resources, audit = self.collect(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
return self._build_archive(app=app, audit=audit, resources=resources)
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str:
"""Reuse the standard YAML export semantics, including omitted asset references."""
with session_factory.create_session() as session:
_, app_model = self._load_source(session=session, tenant_id=tenant_id, agent_id=agent_id)
return AppDslService.export_dsl(app_model=app_model, session=session, version_id=version_id)
@staticmethod
def _load_source(*, session: Session, tenant_id: str, agent_id: str) -> tuple[Agent, App]:
row = session.execute(
select(Agent, App)
.join(
App,
(App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id),
)
.where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)),
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
).one_or_none()
if row is None:
raise AgentNotFoundError()
return row[0], row[1]
def collect(
self, *, tenant_id: str, agent_id: str, version_id: UUID | None
) -> tuple[AgentAppDsl, AgentPackageResourceExporter, RosterAgentPackageAudit]:
"""Collect configuration and resource references without serializing a package."""
resources = AgentPackageResourceExporter(storage_backend=self._storage)
with session_factory.create_session() as session:
row = session.execute(
select(Agent, App)
.join(
App,
(App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id),
)
.where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
Agent.app_id.is_not(None),
or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)),
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
).one_or_none()
if row is None:
raise AgentNotFoundError()
agent, app_model = row
agent, app_model = self._load_source(session=session, tenant_id=tenant_id, agent_id=agent_id)
draft = None
snapshot_id = agent.active_config_snapshot_id
@@ -146,11 +163,7 @@ class RosterAgentPackageExporter:
resources.collect_workspace_skills()
app.dependencies = self._dependency_provider(tenant_id, dependency_ids)
return self._build_archive(
app=app,
audit=audit,
resources=resources,
)
return app, resources, audit
def _build_archive(
self,

View File

@@ -3,13 +3,17 @@
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import BinaryIO
from urllib.parse import urlsplit
from uuid import uuid4
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from configs import dify_config
from constants.model_template import default_app_templates
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
@@ -25,7 +29,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, IconType, UploadFile
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata
from services.agent.dsl_entities import AgentAppDsl, AgentPackage, AgentPackageMetadata
from services.agent.dsl_service import AgentDslService
from services.agent.errors import (
AgentNameConflictError,
@@ -35,15 +39,19 @@ from services.agent.errors import (
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_importer import AgentPackageResourceImporter, _Storage
from services.agent.roster_package_entities import AgentPackageResources, PackageIcon
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.agent.roster_service import AgentRosterService
from services.app_creation_records import create_installed_app_record, create_site_record
from services.app_dsl_service import AppDslService
from services.app_import_source import download_app_import_source
from services.app_service import AppService
from services.entities.dsl_entities import DslImportWarning
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.feature_service import FeatureService
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE, is_valid_image_icon
from services.recommended_app_package_service import RecommendedAgentPackageSource
logger = logging.getLogger(__name__)
@@ -61,101 +69,227 @@ class RosterAgentPackageImporter:
def __init__(self, *, storage_backend: _Storage = storage) -> None:
self._resources = AgentPackageResourceImporter(storage_backend=storage_backend)
def import_template(
self,
*,
source: RecommendedAgentPackageSource,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
"""Import a catalog-admitted published source without an outer archive."""
app, resources, _ = RosterAgentPackageExporter().collect(
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
)
payloads, resource_index, icons = resources.read_local_resources(app.agent.package_ref)
return self.import_collected(
app_dsl=app,
resources=resource_index,
icons=icons,
# Local payloads already passed per-resource and aggregate limits during collection.
read_member=lambda path, _limit: payloads[path],
invalid_skills={},
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_package_url(
self,
*,
url: str,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
try:
parsed = urlsplit(url)
credential_free = not parsed.username and not parsed.password
except ValueError:
credential_free = False
if not credential_free:
raise InvalidRosterAgentPackageError("Package URL must be an HTTP(S) URL without credentials")
with download_app_import_source(url, max_bytes=dify_config.AGENT_PACKAGE_MAX_BYTES) as source:
return self.import_package(
source=source,
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_package(
self,
*,
source: BinaryIO,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
with RosterAgentPackageReader().read(source) as package:
if len(package.apps) != 1:
raise InvalidRosterAgentPackageError("Import requires exactly one Agent App")
app_dsl = next(iter(package.apps.values()))
agent_package = app_dsl.package
self._resources.validate(resources=package.manifest, agent_package=agent_package)
return self.import_collected(
app_dsl=next(iter(package.apps.values())),
resources=package.manifest,
icons=package.manifest.icons,
read_member=lambda path, limit: RosterAgentPackageReader().read_member_bytes(
package, path, max_bytes=limit
),
invalid_skills=package.invalid_skills,
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_collected(
self,
*,
app_dsl: AgentAppDsl,
resources: AgentPackageResources,
icons: list[PackageIcon],
read_member: Callable[[str, int], bytes],
invalid_skills: dict[str, str],
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
if icon_type is not None:
try:
# Resolve billing before uploading package members or opening the write transaction.
allow_premium_site_settings = app_dsl.site is None or FeatureService.can_import_premium_site_settings(
tenant_id
)
icons = self._resources.materialize_icons(
archive=package, icons=package.manifest.icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in icons:
agent_package.metadata.icon = icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in icons:
app_metadata.icon = icons[app_metadata.icon]
site_data = app_dsl.site.model_copy(deep=True) if app_dsl.site is not None else None
if site_data is not None and site_data.icon_type == "image":
site_icon = site_data.icon
if site_icon is not None and site_icon in icons:
site_data = site_data.model_copy(update={"icon": icons[site_icon]})
materialized, skill_warnings = self._resources.materialize(
archive=package,
resources=package.manifest,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
)
with session_factory.create_session() as session:
resolved_soul, warnings = AgentDslService(session).resolve_package_soul(
tenant_id=tenant_id,
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
app_id, agent_id = self._persist_import(
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
site_data=site_data,
allow_premium_site_settings=allow_premium_site_settings,
soul=resolved_soul,
)
except Exception as exc:
if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc):
raise AgentNameConflictError() from exc
if isinstance(
exc,
(
AgentNameConflictError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
RosterAgentPackageResourceUnavailableError,
),
):
raise
raise RosterAgentPackageImportFailedError() from exc
IconType(icon_type)
except ValueError as exc:
raise InvalidRosterAgentPackageError("Invalid icon type") from exc
with session_factory.create_session() as session:
if not is_valid_image_icon(session=session, tenant_id=tenant_id, icon_type=icon_type, icon=icon):
raise InvalidRosterAgentPackageError("Image icon must belong to the current workspace")
agent_package = app_dsl.package
self._resources.validate(resources=resources, agent_package=agent_package)
overrides = {
key: value for key, value in {"name": name, "description": description}.items() if value is not None
}
try:
agent_package.metadata = AgentPackageMetadata.model_validate(
{**agent_package.metadata.model_dump(), **overrides}
)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("Invalid Agent metadata overrides") from exc
# Resolve billing before uploading package members or opening the write transaction.
allow_premium_site_settings = app_dsl.site is None or FeatureService.can_import_premium_site_settings(tenant_id)
try:
materialized_icons = self._resources.materialize_icon_resources(
read_member=read_member, icons=icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in materialized_icons:
agent_package.metadata.icon = materialized_icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in materialized_icons:
app_metadata.icon = materialized_icons[app_metadata.icon]
site_data = app_dsl.site.model_copy(deep=True) if app_dsl.site is not None else None
if site_data is not None and site_data.icon_type == "image":
site_icon = site_data.icon
if site_icon is not None and site_icon in materialized_icons:
site_data = site_data.model_copy(update={"icon": materialized_icons[site_icon]})
for field_name, value in {
"icon_type": icon_type,
"icon": icon,
"icon_background": icon_background,
}.items():
if value is not None:
setattr(agent_package.metadata, field_name, value)
setattr(app_metadata, field_name, value)
materialized, skill_warnings = self._resources.materialize_resources(
read_member=read_member,
invalid_skills=invalid_skills,
resources=resources,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
)
with session_factory.create_session() as session:
resolved_soul, warnings = AgentDslService(session).resolve_package_soul(
tenant_id=tenant_id,
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
app_id, agent_id = self._persist_import(
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
site_data=site_data,
allow_premium_site_settings=allow_premium_site_settings,
soul=resolved_soul,
)
except Exception as exc:
if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc):
raise AgentNameConflictError() from exc
if isinstance(
exc,
(
AgentNameConflictError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
RosterAgentPackageResourceUnavailableError,
),
):
raise
raise RosterAgentPackageImportFailedError() from exc
try:
self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account)
except Exception:
logger.warning(
"Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
if app_dsl.dependencies:
try:
self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account)
AppDslService.cache_import_dependencies(app_id=app_id, dependencies=app_dsl.dependencies)
except Exception:
logger.warning(
"Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s",
"Imported Agent App dependency check could not be cached: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
if app_dsl.dependencies:
try:
AppDslService.cache_import_dependencies(app_id=app_id, dependencies=app_dsl.dependencies)
except Exception:
logger.warning(
"Imported Agent App dependency check could not be cached: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
@staticmethod
def _persist_import(

View File

@@ -63,6 +63,7 @@ from services.entities.dsl_entities import (
)
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.recommended_app_package_service import RecommendedAppPackageService
from services.system_feature_service import SystemFeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
@@ -207,12 +208,14 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
packages: AppPackageService,
agent_packages: RosterAgentPackageExporter,
agent_importer: RosterAgentPackageImporter,
recommended_packages: RecommendedAppPackageService,
) -> None:
self._session_factory = session_factory
self._dsl_factory = dsl_factory
self._packages = packages
self._agent_packages = agent_packages
self._agent_importer = agent_importer
self._recommended_packages = recommended_packages
@override
def download_import(self, url: str) -> AbstractContextManager[BinaryIO]:
@@ -290,6 +293,38 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
assert params.template_id is not None
assert params.version_id is not None
source = self._recommended_packages.get_source(app_id=str(params.template_id), version_id=params.version_id)
result = self._agent_importer.import_template(
source=source,
tenant_id=context.active_workspace_id,
account=self._import_actor(context),
name=params.name,
description=params.description,
icon_type=params.icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
assert params.package_url is not None
result = self._agent_importer.import_package_url(
url=params.package_url,
tenant_id=context.active_workspace_id,
account=self._import_actor(context),
name=params.name,
description=params.description,
icon_type=params.icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
return AppDslService.check_app_dependencies(tenant_id=context.active_workspace_id, app_id=app_id)

View File

@@ -137,6 +137,10 @@ class AppTransfers(Protocol):
def import_agent_package(self, context: RequestContext, source: BinaryIO) -> ImportedAppPackage: ...
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage: ...
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage: ...
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult: ...
def export_dsl(self, context: RequestContext, app_id: str, options: AppExportOptions) -> str: ...
@@ -203,6 +207,30 @@ class ConsoleAppService:
def import_app(self, context: RequestContext, params: AppImportParams, *, source: BinaryIO | None = None) -> Import:
if source is not None:
return self._import_package(context, params, source)
if params.mode == "template":
if (
params.template_id is None
or params.version_id is None
or params.app_id
or any(value is not None for value in (params.package_url, params.yaml_content, params.yaml_url))
):
raise InvalidRosterAgentPackageError(
"template mode requires template_id and version_id without other sources"
)
self._access.require_import(context, "agent")
return self._import_agent_result(self._transfers.import_agent_template(context, params))
if params.mode == "ifpkg-url":
if (
not params.package_url
or params.app_id
or params.yaml_content is not None
or params.yaml_url is not None
):
raise InvalidRosterAgentPackageError(
"ifpkg-url requires package_url and does not accept YAML or app_id"
)
self._access.require_import(context, "agent")
return self._import_agent_result(self._transfers.import_agent_package_url(context, params))
if params.mode == "yaml-url" and params.yaml_url:
self._access.require_import(context, "url")
with self._transfers.download_import(params.yaml_url) as downloaded:
@@ -225,6 +253,9 @@ class ConsoleAppService:
if params.app_id:
raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App")
result = self._transfers.import_agent_package(context, source)
return self._import_agent_result(result)
def _import_agent_result(self, result: ImportedAppPackage) -> Import:
return Import(
id=str(uuid4()),
status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED,

View File

@@ -838,7 +838,7 @@ class AppDslService:
version_id: uuid.UUID | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
) -> AppDslExportData:
"""Load portable App and Site data without requesting plugin dependencies."""
"""Load portable App and Site data in the caller's transaction, without plugin lookup."""
app_mode = AppMode.value_of(app_model.mode)
if app_mode == AppMode.AGENT:

View File

@@ -17,8 +17,11 @@ from services.dsl_content import DSL_MAX_SIZE
@contextmanager
def download_app_import_source(url: str) -> Generator[BinaryIO]:
def download_app_import_source(url: str, *, max_bytes: int | None = None) -> Generator[BinaryIO]:
"""Fetch a bounded source without opening import transactions or creating resources."""
size_limit = max(dify_config.AGENT_PACKAGE_MAX_BYTES, DSL_MAX_SIZE) if max_bytes is None else max_bytes
if size_limit <= 0:
raise ValueError("Import download size limit must be positive")
url = url.strip()
try:
parsed = urlsplit(url)
@@ -49,7 +52,7 @@ def download_app_import_source(url: str) -> Generator[BinaryIO]:
size = 0
for chunk in response.iter_bytes(chunk_size=1024 * 1024):
size += len(chunk)
if size > max(dify_config.AGENT_PACKAGE_MAX_BYTES, DSL_MAX_SIZE):
if size > size_limit:
raise RosterAgentPackageTooLargeError("App import download exceeds the size limit")
source.write(chunk)
finally:

View File

@@ -1,11 +1,11 @@
"""Read-only app previews, independent of trial execution and account quotas."""
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from services.app_definition_query_service import AppSiteConfiguration
from services.app_definition_query_service import AppDefinitionUnavailableError, AppSiteConfiguration
@dataclass(frozen=True, slots=True)
@@ -33,6 +33,8 @@ class AppPreviewDataset:
class AppPreviewQuery(Protocol):
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object] | None: ...
def get_app(self, *, app_id: str) -> AppPreviewRef | None: ...
def get_site(self, *, app: AppPreviewRef) -> AppPreviewSite | None: ...
@@ -53,6 +55,12 @@ class AppPreviewOwnerUnavailableError(LookupError):
class AppPreviewQueryService:
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object]:
preview = self._apps.get_agent_composer(app=app)
if preview is None:
raise AppDefinitionUnavailableError("Agent preview is unavailable")
return preview
def __init__(self, *, apps: AppPreviewQuery, is_previewable: Callable[[str], bool]) -> None:
self._apps: AppPreviewQuery = apps
self._is_previewable: Callable[[str], bool] = is_previewable

View File

@@ -2,14 +2,16 @@ import json
import logging
import os
from collections.abc import Sequence
from typing import Any, Literal, NotRequired, TypedDict
from datetime import date, datetime
from typing import Annotated, Any, Literal, NotRequired, TypedDict
import httpx
from pydantic import TypeAdapter, ValidationError
from pydantic import Field, TypeAdapter, ValidationError
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
from typing_extensions import deprecated
from werkzeug.exceptions import InternalServerError
from configs import dify_config
from core.helper.http_client_pooling import get_pooled_http_client
from enums import CloudPlan
from extensions.ext_redis import redis_client
@@ -18,6 +20,8 @@ from services.compliance_download_service import ComplianceDownloadLink
from services.errors.billing import (
BillingUpstreamInvalidResponseError,
BillingUpstreamUnavailableError,
LegacyCreditPoolManagedByTokenerError,
TokenerEducationCheckoutUnsupportedError,
)
logger = logging.getLogger(__name__)
@@ -35,9 +39,43 @@ EmailFreezeType = Literal["freeze", "email_domain_suspended"]
class _BillingHTTPStatusError(ValueError):
def __init__(self, message: str, status_code: int) -> None:
def __init__(self, message: str, status_code: int, upstream_error_code: str | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.upstream_error_code = upstream_error_code
_TOKENER_EDUCATION_CHECKOUT_UNSUPPORTED = "TOKENER_EDUCATION_CHECKOUT_UNSUPPORTED"
_CREDIT_MANAGED_BY_TOKENER = "CREDIT_MANAGED_BY_TOKENER"
def _safe_billing_error_code(response: httpx.Response) -> str | None:
"""Keep only explicitly supported upstream codes; never retain arbitrary error bodies."""
try:
payload = response.json()
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
candidates = [payload.get("reason"), payload.get("code")]
nested_error = payload.get("error")
if isinstance(nested_error, dict):
candidates.append(nested_error.get("code"))
if _TOKENER_EDUCATION_CHECKOUT_UNSUPPORTED in candidates:
return _TOKENER_EDUCATION_CHECKOUT_UNSUPPORTED
if _CREDIT_MANAGED_BY_TOKENER in candidates:
return _CREDIT_MANAGED_BY_TOKENER
return None
class TokenerBootstrapUpstreamError(RuntimeError):
"""Sanitized failure returned by the billing-side Tokener bootstrap API."""
def __init__(self, error_code: str, *, retryable: bool) -> None:
super().__init__(error_code)
self.error_code = error_code
self.retryable = retryable
class SubscriptionPlan(TypedDict):
@@ -62,6 +100,59 @@ class EducationStatusResponseDict(TypedDict):
allow_refresh: bool
class TokenerTenantBootstrapResponse(TypedDict):
tenant_id: str
status: Literal["pending", "ready"]
data_plane_api_key: NotRequired[str]
retryable: bool
error_code: NotRequired[str]
_UnsignedDecimalString = Annotated[str, Field(pattern=r"^\d+$")]
_CanonicalUnsignedDecimalString = Annotated[str, Field(pattern=r"^(0|[1-9]\d*)$")]
_SignedDecimalString = Annotated[str, Field(pattern=r"^-?\d+$")]
_IsoDateString = Annotated[str, Field(pattern=r"^\d{4}-\d{2}-\d{2}$")]
class TokenerCurrentMonthAvailableMetering(TypedDict):
status: Literal["available"]
start_date: _IsoDateString
end_date: _IsoDateString
billed_usd_micro: _UnsignedDecimalString
request_count: _UnsignedDecimalString
class TokenerCurrentMonthUnavailableMetering(TypedDict):
status: Literal["unavailable"]
start_date: _IsoDateString
end_date: _IsoDateString
error_code: Annotated[str, Field(pattern=r"^[a-z0-9_]{1,100}$")]
TokenerCurrentMonthMetering = TokenerCurrentMonthAvailableMetering | TokenerCurrentMonthUnavailableMetering
class TokenerAllowanceMetering(TypedDict):
window_id: str
source_ref: str
amount_usd_micro: _CanonicalUnsignedDecimalString
available_usd_micro: _CanonicalUnsignedDecimalString
starts_at: str
ends_at: str
class TokenerTenantMeteringResponse(TypedDict):
tenant_id: str
currency: Literal["USD"]
available_usd_micro: _SignedDecimalString
current_month: TokenerCurrentMonthMetering
balance_generated_at: str
usage_generated_at: NotRequired[str]
allowance: NotRequired[TokenerAllowanceMetering | None]
entitlement_status: NotRequired[Literal["active", "processing", "retrying", "failed"]]
entitlement_error_code: NotRequired[Annotated[str, Field(pattern=r"^[a-z0-9_]{1,100}$")]]
class EducationAutocompleteResponseDict(TypedDict):
data: list[str]
curr_page: int
@@ -78,6 +169,7 @@ class QuotaReserveResult(TypedDict):
reservation_id: str
available: int
reserved: int
reserved_amount: NotRequired[int]
class QuotaCommitResult(TypedDict):
@@ -178,6 +270,7 @@ class BillingInfo(TypedDict):
_billing_info_adapter = TypeAdapter(BillingInfo)
_vector_space_quota_adapter = TypeAdapter(_VectorSpaceQuota)
_tokener_tenant_metering_adapter = TypeAdapter(TokenerTenantMeteringResponse)
class KnowledgeRateLimitDict(TypedDict):
@@ -233,6 +326,139 @@ class BillingService:
def ensure_new_agent_beta_workflow(cls, workflow_id: str) -> None:
cls._send_request("POST", f"/new-agent-beta/workflows/{workflow_id}/ensure")
@classmethod
def bootstrap_tokener_tenant(cls, tenant_id: str, display_name: str) -> TokenerTenantBootstrapResponse:
"""Ensure the remote Tokener org and trial allowance without logging its one-time key."""
base_url = dify_config.TOKENER_BILLING_API_URL.strip().rstrip("/")
if not base_url:
raise TokenerBootstrapUpstreamError("tokener_billing_api_not_configured", retryable=False)
try:
payload = cls._send_request(
"POST",
f"/internal/v1/tokener/tenants/{tenant_id}/bootstrap",
json={"tenant_id": tenant_id, "display_name": display_name},
base_url=base_url,
)
except _BillingHTTPStatusError as error:
retryable = error.status_code in {httpx.codes.REQUEST_TIMEOUT, httpx.codes.TOO_MANY_REQUESTS} or (
error.status_code >= 500
)
raise TokenerBootstrapUpstreamError("tokener_bootstrap_http_error", retryable=retryable) from None
except httpx.RequestError:
raise TokenerBootstrapUpstreamError("tokener_bootstrap_unavailable", retryable=True) from None
if not isinstance(payload, dict):
raise TokenerBootstrapUpstreamError("tokener_bootstrap_invalid_response", retryable=False)
raw_data_plane_api_key = payload.pop("data_plane_api_key", None)
response_tenant_id = payload.get("tenant_id")
status = payload.get("status")
retryable = payload.get("retryable", status == "pending")
raw_error_code = payload.get("error_code")
error_code = (
raw_error_code
if isinstance(raw_error_code, str)
and raw_error_code
and len(raw_error_code) <= 100
and all(character.isalnum() or character in "_-" for character in raw_error_code)
else None
)
if response_tenant_id != tenant_id or status not in {"pending", "ready"} or not isinstance(retryable, bool):
raw_data_plane_api_key = None
raise TokenerBootstrapUpstreamError("tokener_bootstrap_invalid_response", retryable=False)
response: TokenerTenantBootstrapResponse = {
"tenant_id": tenant_id,
"status": status,
"retryable": retryable,
}
if error_code:
response["error_code"] = error_code
if status == "ready":
if not isinstance(raw_data_plane_api_key, str) or not raw_data_plane_api_key:
raw_data_plane_api_key = None
raise TokenerBootstrapUpstreamError("tokener_bootstrap_key_missing", retryable=True)
response["data_plane_api_key"] = raw_data_plane_api_key
raw_data_plane_api_key = None
return response
@classmethod
def get_tokener_metering(cls, tenant_id: str) -> TokenerTenantMeteringResponse:
"""Read secret-free Tokener balance and current-month usage from the internal billing API."""
base_url = dify_config.TOKENER_BILLING_API_URL.strip().rstrip("/")
if not base_url:
raise BillingUpstreamUnavailableError
try:
payload = cls._send_request(
"GET",
f"/internal/v1/tokener/tenants/{tenant_id}/metering",
base_url=base_url,
)
except _BillingHTTPStatusError as error:
if error.status_code in {
httpx.codes.REQUEST_TIMEOUT,
httpx.codes.TOO_MANY_REQUESTS,
httpx.codes.SERVICE_UNAVAILABLE,
} or (error.status_code >= 500 and error.status_code != httpx.codes.BAD_GATEWAY):
raise BillingUpstreamUnavailableError from None
raise BillingUpstreamInvalidResponseError from None
except httpx.RequestError:
raise BillingUpstreamUnavailableError from None
except (json.JSONDecodeError, UnicodeDecodeError):
raise BillingUpstreamInvalidResponseError from None
try:
metering = _tokener_tenant_metering_adapter.validate_python(payload, strict=True)
if metering["tenant_id"] != tenant_id:
raise ValueError("tenant mismatch")
current_month = metering["current_month"]
raw_current_month = payload.get("current_month") if isinstance(payload, dict) else None
if not isinstance(raw_current_month, dict):
raise ValueError("invalid current-month usage")
if current_month["status"] == "available":
if "usage_generated_at" not in metering or "error_code" in raw_current_month:
raise ValueError("incomplete available usage")
elif (
"billed_usd_micro" in raw_current_month
or "request_count" in raw_current_month
or "usage_generated_at" in metering
):
raise ValueError("unavailable usage must not contain metrics")
start_date = date.fromisoformat(current_month["start_date"])
end_date = date.fromisoformat(current_month["end_date"])
if start_date > end_date:
raise ValueError("invalid usage period")
generated_at_values = [metering["balance_generated_at"]]
usage_generated_at = metering.get("usage_generated_at")
if usage_generated_at is not None:
generated_at_values.append(usage_generated_at)
for generated_at_value in generated_at_values:
generated_at = datetime.fromisoformat(generated_at_value)
if generated_at.utcoffset() is None:
raise ValueError("timestamp must include a timezone")
allowance = metering.get("allowance")
if allowance is not None:
allowance_start = datetime.fromisoformat(allowance["starts_at"])
allowance_end = datetime.fromisoformat(allowance["ends_at"])
if allowance_start.utcoffset() is None or allowance_end.utcoffset() is None:
raise ValueError("allowance timestamps must include a timezone")
if allowance_start >= allowance_end:
raise ValueError("invalid allowance period")
if int(allowance["available_usd_micro"]) > int(allowance["amount_usd_micro"]):
raise ValueError("allowance available amount exceeds total")
except (TypeError, ValueError, ValidationError):
raise BillingUpstreamInvalidResponseError from None
return metering
@classmethod
def get_info(cls, tenant_id: str, exclude_vector_space: bool = False) -> BillingInfo:
params = {"tenant_id": tenant_id}
@@ -274,6 +500,7 @@ class BillingService:
amount: int = 1,
meta: dict | None = None,
bucket: str = "",
capped: bool = False,
) -> QuotaReserveResult:
"""Reserve quota before task execution."""
payload: dict = {
@@ -286,6 +513,8 @@ class BillingService:
payload["bucket"] = bucket
if meta:
payload["meta"] = meta
if capped:
payload["capped"] = True
return _quota_reserve_adapter.validate_python(cls._send_quota_request("POST", "/quota/reserve", json=payload))
@classmethod
@@ -331,7 +560,17 @@ class BillingService:
params = {"tenant_id": tenant_id, "feature_key": feature_key}
if bucket:
params["bucket"] = bucket
return _quota_balance_adapter.validate_python(cls._send_quota_request("GET", "/quota/balance", params=params))
try:
result = cls._send_quota_request("GET", "/quota/balance", params=params)
except _BillingHTTPStatusError as error:
if (
feature_key == "credit_pool"
and error.status_code == httpx.codes.CONFLICT
and error.upstream_error_code == _CREDIT_MANAGED_BY_TOKENER
):
raise LegacyCreditPoolManagedByTokenerError(_CREDIT_MANAGED_BY_TOKENER) from None
raise
return _quota_balance_adapter.validate_python(result)
@classmethod
def quota_consume_capped(
@@ -461,6 +700,7 @@ class BillingService:
raise _BillingHTTPStatusError(
"Unable to retrieve billing information. Please try again later or contact support.",
response.status_code,
_safe_billing_error_code(response),
)
if method == "PUT":
if response.status_code == httpx.codes.INTERNAL_SERVER_ERROR:
@@ -490,6 +730,11 @@ class BillingService:
response = cls._send_request("GET", endpoint, params=params)
return _billing_portal_link_adapter.validate_python(response)
except _BillingHTTPStatusError as error:
if (
error.status_code == httpx.codes.CONFLICT
and error.upstream_error_code == _TOKENER_EDUCATION_CHECKOUT_UNSUPPORTED
):
raise TokenerEducationCheckoutUnsupportedError from error
if error.status_code in {httpx.codes.REQUEST_TIMEOUT, httpx.codes.TOO_MANY_REQUESTS} or (
error.status_code >= 500
):

View File

@@ -66,6 +66,8 @@ class CreditPoolReservation:
request_id: str
reservation_id: str | None
meta: dict[str, Any] = field(default_factory=dict)
capped: bool = False
requested_amount: int | None = None
_session_factory: Callable[[], Session] | None = field(default=None, repr=False)
_state: CreditPoolReservationState = field(default=CreditPoolReservationState.RESERVED, init=False, repr=False)
@@ -73,6 +75,24 @@ class CreditPoolReservation:
def state(self) -> CreditPoolReservationState:
return self._state
def renew(self) -> None:
"""Renew the same capped message hold, never acquire a replacement hold."""
if not self.capped or self.reservation_id is None or self._state != CreditPoolReservationState.RESERVED:
return
from services.billing_service import BillingService
result = BillingService.quota_reserve(
tenant_id=self.tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
bucket=self.pool_type,
request_id=self.request_id,
amount=self.requested_amount or self.amount,
meta=self.meta,
capped=True,
)
if result.get("reservation_id") != self.reservation_id or result.get("reserved_amount") != self.amount:
raise RuntimeError("The original capped message reservation could not be renewed")
def commit(self) -> None:
if self._state == CreditPoolReservationState.COMMITTED:
return
@@ -266,8 +286,13 @@ class CreditPoolService:
request_id: str,
session_factory: Callable[[], Session] | None = None,
meta: dict[str, Any] | None = None,
_capped: bool = False,
) -> CreditPoolReservation:
"""Reserve the full amount or raise before the billable operation starts."""
"""Reserve before invocation; capped mode is used only by the message owner.
Strict callers retain their existing full-amount contract. Capping is
atomic in the quota owner, never a GetBalance/min/Reserve race.
"""
if credits_required <= 0:
raise ValueError("credits_required must be greater than 0")
if not request_id:
@@ -278,6 +303,7 @@ class CreditPoolService:
if cls._use_billing_quota():
from services.billing_service import BillingService
extra: dict[str, Any] = {"capped": True} if _capped else {}
result = BillingService.quota_reserve(
tenant_id=tenant_id,
feature_key=FEATURE_KEY_CREDIT_POOL,
@@ -285,38 +311,64 @@ class CreditPoolService:
request_id=request_id,
amount=credits_required,
meta=reservation_meta,
**extra,
)
reservation_id = result.get("reservation_id", "")
if not reservation_id:
raise QuotaExceededError("Insufficient credits remaining")
reserved_amount = result.get("reserved_amount", 0) if _capped else credits_required
if (
not isinstance(reserved_amount, int)
or isinstance(reserved_amount, bool)
or not 0 < reserved_amount <= credits_required
):
raise ValueError("Invalid capped reservation amount; do not retry with a different request ID")
return CreditPoolReservation(
tenant_id=tenant_id,
pool_type=normalized_pool_type,
amount=credits_required,
amount=reserved_amount,
request_id=request_id,
reservation_id=reservation_id,
meta=reservation_meta,
capped=_capped,
requested_amount=credits_required if _capped else None,
)
if session_factory is None:
raise ValueError("session_factory is required when billing quota is disabled")
from core.model_invocation_routing import ModelMigrationProcessing
session = session_factory()
def reserve() -> int:
from models.model_billing_migration import TenantModelBillingMigration
# Serialize local legacy admission with Core's cutover CAS. An
# already committed reservation still settles to its original pool.
migration = session.scalar(
select(TenantModelBillingMigration)
.where(TenantModelBillingMigration.tenant_id == tenant_id)
.with_for_update()
)
if migration is not None and migration.claimed_at is not None:
raise ModelMigrationProcessing
pool = cls._get_locked_pool(session=session, tenant_id=tenant_id, pool_type=normalized_pool_type)
if not pool:
raise QuotaExceededError("Credit pool not found")
if not pool.has_sufficient_credits(credits_required):
reserved_amount = credits_required
if _capped and pool.quota_limit != -1:
reserved_amount = min(credits_required, max(0, pool.remaining_credits))
if reserved_amount <= 0 or not pool.has_sufficient_credits(reserved_amount):
raise QuotaExceededError("Insufficient credits remaining")
pool.quota_used += credits_required
pool.quota_used += reserved_amount
session.commit()
return credits_required
return reserved_amount
try:
cls._deduct_with_tenant_lock(tenant_id, reserve)
except QuotaExceededError:
reserved_amount = cls._deduct_with_tenant_lock(tenant_id, reserve)
except (QuotaExceededError, ModelMigrationProcessing):
session.rollback()
raise
except Exception:
@@ -327,11 +379,35 @@ class CreditPoolService:
return CreditPoolReservation(
tenant_id=tenant_id,
pool_type=normalized_pool_type,
amount=credits_required,
amount=reserved_amount,
request_id=request_id,
reservation_id=None,
meta=reservation_meta,
_session_factory=session_factory,
capped=_capped,
requested_amount=credits_required if _capped else None,
)
@classmethod
def reserve_credits_capped(
cls,
tenant_id: str,
credits_required: int,
pool_type: str | ProviderQuotaType = "trial",
*,
request_id: str,
session_factory: Callable[[], Session] | None = None,
meta: dict[str, Any] | None = None,
) -> CreditPoolReservation:
"""Freeze one legacy message charge, including its original tail-credit cap."""
return cls.reserve_credits(
tenant_id,
credits_required,
pool_type,
request_id=request_id,
session_factory=session_factory,
meta=meta,
_capped=True,
)
@classmethod

View File

@@ -1,6 +1,7 @@
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Protocol
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@@ -23,6 +24,9 @@ class ImportStatus(StrEnum):
class AppImportParams(BaseModel):
mode: str = Field(..., description="Import mode")
template_id: UUID | None = None
version_id: UUID | None = None
package_url: str | None = None
yaml_content: str | None = None
yaml_url: str | None = None
name: str | None = None

View File

@@ -1,6 +1,7 @@
"""Feature query results and policy values shared by their consumers."""
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -146,6 +147,8 @@ class PluginInstallationPermissionModel(FeatureResponseModel):
class FeatureModel(FeatureResponseModel):
"""Effective feature availability and limits for the current workspace."""
model_billing_source: Literal["legacy_message_credits", "tokener"] = "legacy_message_credits"
model_billing_migration_status: Literal["none", "preparing", "processing", "active"] = "none"
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
enable_skill: bool = True

View File

@@ -0,0 +1,220 @@
"""Versioned, strict input contracts shared by inner API and migration workers."""
from datetime import datetime
from typing import Annotated, Any, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from core.model_billing_migration_protocol import canonical_hash
UUIDString = Annotated[str, Field(pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")]
Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")]
Reference = Annotated[str, Field(min_length=1, max_length=512)]
Revision = Annotated[int, Field(strict=True, ge=0, le=9007199254740991)]
Timestamp = Annotated[str, Field(pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")]
Boundary = Annotated[str, Field(pattern=r"^\d{4}-\d{2}-\d{2}T00:00:00Z$")]
class StrictPayload(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
@model_validator(mode="before")
@classmethod
def reject_explicit_null(cls, value: Any) -> Any:
if isinstance(value, dict) and any(item is None for item in value.values()):
raise ValueError("optional fields must be omitted, not null")
return value
class OperationPayload(StrictPayload):
api_version: Literal[1]
operation_id: UUIDString
expected_revision: Revision
class MigrationSourceQuota(StrictPayload):
unmetered: bool
class MigrationSourceProviderOwnership(StrictPayload):
current_quota_type: Literal["paid", "trial", "free"]
quotas: dict[Literal["paid", "trial", "free"], MigrationSourceQuota]
class PrepareMigrationPayload(OperationPayload):
migration_id: UUIDString
batch_id: Reference
eligibility_policy_version: Reference
model_mapping_version: Reference
inventory_hash: Digest
billing_preparation_ref: Reference
class WindowAlias(StrictPayload):
kind: Literal["legacy_annual_target_date", "invoice_initial", "normal_slot", "reanchor_slot"]
id: Reference
class MigrationWindow(StrictPayload):
ordinal: Revision
cycle_key: Reference
window_id: Reference
source_ref: Reference
starts_at: Boundary
ends_at: Boundary
amount_usd_micro: Annotated[str, Field(pattern=r"^[1-9][0-9]*$")]
aliases: Annotated[list[WindowAlias], Field(min_length=1, max_length=100)]
@model_validator(mode="after")
def valid_interval(self) -> Self:
if datetime.fromisoformat(self.starts_at) >= datetime.fromisoformat(self.ends_at):
raise ValueError("invalid_window")
if self.aliases != sorted(self.aliases, key=lambda alias: (alias.kind, alias.id)):
raise ValueError("aliases must be canonical ordered")
return self
class WindowManifest(StrictPayload):
schema_version: Literal[1]
tenant_id: UUIDString
migration_id: UUIDString
plan_revision: Revision
calendar_policy: Literal["STRIPE_UTC_V1", "LEGACY_TRANSITION_V1", "REANCHOR_V1"]
contract_ref: Reference
subscription_id: Reference
paid_period_start: Timestamp
paid_period_end: Timestamp
funding_invoice_id: Reference
amount_policy_version: Reference
model_mapping_version: Reference
windows: Annotated[list[MigrationWindow], Field(min_length=1, max_length=120)]
@model_validator(mode="after")
def valid_windows(self) -> Self:
paid_start = datetime.fromisoformat(self.paid_period_start)
paid_end = datetime.fromisoformat(self.paid_period_end)
if paid_start >= paid_end:
raise ValueError("invalid paid period")
if self.windows != sorted(self.windows, key=lambda window: (window.starts_at, window.window_id)):
raise ValueError("windows must be canonical ordered")
if (
len({window.window_id for window in self.windows}) != len(self.windows)
or len({window.ordinal for window in self.windows}) != len(self.windows)
or len({window.cycle_key for window in self.windows}) != len(self.windows)
):
raise ValueError("duplicate window identity")
for index, window in enumerate(self.windows):
if datetime.fromisoformat(window.starts_at) < paid_start.replace(hour=0, minute=0, second=0):
raise ValueError("window precedes paid start")
if datetime.fromisoformat(window.ends_at) > paid_end.replace(hour=0, minute=0, second=0):
raise ValueError("window exceeds paid end")
if index and self.windows[index - 1].ends_at > window.starts_at:
raise ValueError("overlapping windows")
return self
class DecideCyclePayload(OperationPayload):
cycle_key: Reference
decision: Literal["legacy_deferred", "tokener"]
contract_ref: Reference
preparation_binding_hash: Digest
billing_receipt_refs: list[Reference]
manifest: WindowManifest | None = None
manifest_hash: Digest | None = None
@model_validator(mode="after")
def decision_fields(self) -> Self:
if self.decision == "tokener" and (self.manifest is None or self.manifest_hash is None):
raise ValueError("tokener decision requires manifest and hash")
if self.decision == "legacy_deferred" and (self.manifest is not None or self.manifest_hash is not None):
raise ValueError("legacy decision cannot include a funding manifest")
return self
class LeasePayload(OperationPayload):
action: Literal["acquire", "renew", "release"]
lease_token: UUIDString | None = None
@model_validator(mode="after")
def token_required(self) -> Self:
if self.action != "acquire" and not self.lease_token:
raise ValueError("lease token is required")
return self
class CommandReceipt(StrictPayload):
command_id: Reference
payload_hash: Digest
remote_id: Reference
state: Literal[
"registered", "dispatched", "accepted", "verified", "failed_definitive", "outcome_unknown", "superseded"
]
class FundingReceiptsPayload(OperationPayload):
lease_token: UUIDString
intent_revision: Revision
manifest_hash: Digest
commands: Annotated[list[CommandReceipt], Field(min_length=1, max_length=500)]
class MigrationCorrectionPayload(StrictPayload):
"""Frozen control command, not a balance or a local consumption projection."""
command_id: Reference
payload_hash: Digest
window: MigrationWindow
delta_usd_micro: Annotated[str, Field(pattern=r"^(0|-?[1-9][0-9]*)$")]
target_amount_usd_micro: Annotated[str, Field(pattern=r"^(0|[1-9][0-9]*)$")]
@model_validator(mode="after")
def valid_hash(self) -> Self:
data = self.model_dump(mode="json", exclude={"payload_hash"})
if canonical_hash(data) != self.payload_hash:
raise ValueError("correction payload hash mismatch")
return self
class ContractEventPayload(OperationPayload):
business_event_ref: Reference
contract_ref: Reference
billing_receipt_ref: Reference
affected_window_ids: list[Reference]
event_type: Literal["refund", "cancel", "reanchor", "plan_change", "paid_to_free"]
terminal: bool = False
manual_replan: bool = False
replacement_manifest: WindowManifest | None = None
replacement_manifest_hash: Digest | None = None
command_payloads: Annotated[list[MigrationCorrectionPayload], Field(max_length=500)] = Field(default_factory=list)
@model_validator(mode="after")
def replacement_fields(self) -> Self:
if (self.replacement_manifest is None) != (self.replacement_manifest_hash is None):
raise ValueError("replacement manifest and hash must be supplied together")
if self.terminal and self.replacement_manifest is not None:
raise ValueError("terminal events cannot replace the funding plan")
if self.manual_replan and (
self.terminal or self.event_type != "plan_change" or self.replacement_manifest is None
):
raise ValueError("manual replan requires a nonterminal plan_change replacement")
if self.terminal and any(
command.target_amount_usd_micro != "0" or int(command.delta_usd_micro) > 0
for command in self.command_payloads
):
raise ValueError("terminal events cannot grant or increase allowance")
if self.affected_window_ids or self.command_payloads:
if len({command.command_id for command in self.command_payloads}) != len(self.command_payloads):
raise ValueError("duplicate correction command")
if {command.window.window_id for command in self.command_payloads} != set(self.affected_window_ids):
raise ValueError("correction commands must cover exactly the affected windows")
if len({command.window.window_id for command in self.command_payloads}) != len(self.command_payloads):
raise ValueError("only one frozen correction per affected window is allowed")
return self
class ActivateMigrationPayload(OperationPayload):
lease_token: UUIDString
intent_revision: Revision
manifest_hash: Digest
funding_receipt_hash: Digest
preparation_binding_hash: Digest

View File

@@ -12,3 +12,11 @@ class BillingUpstreamInvalidResponseError(BillingError):
class BillingUpstreamUnavailableError(BillingError):
pass
class LegacyCreditPoolManagedByTokenerError(BillingError):
"""A legacy balance read crossed the irreversible Tokener cutover fence."""
class TokenerEducationCheckoutUnsupportedError(BillingError):
pass

View File

@@ -1,4 +1,5 @@
from configs import dify_config
from core.model_billing_profile import ModelBillingProfileService
from enums import CloudPlan, DeploymentEdition, HostedTrialProvider
from services.account_education_service import EDUCATION_EDITIONS
from services.billing_service import BillingInfo, BillingService
@@ -47,6 +48,13 @@ class FeatureService:
tenant_id=tenant_id,
)
if tenant_id:
model_billing = ModelBillingProfileService.resolve(tenant_id)
features.model_billing_source = model_billing.model_billing_source.value
from core.model_invocation_routing import migration_display_status
features.model_billing_migration_status = migration_display_status(tenant_id)
return features
@classmethod

View File

@@ -0,0 +1,853 @@
"""Core's sole authority for hosted-credit migration decisions and admission.
All writes lock one tenant-owned row and commit before external work. Operation
replay precedes CAS, a cycle decision never flips, and funds are represented only
by remote receipts. Network operations belong to Billing/the preparation task.
"""
from __future__ import annotations
import copy
import logging
from collections.abc import Callable
from datetime import datetime, timedelta
from operator import itemgetter
from typing import Any
from uuid import uuid4
from pydantic import TypeAdapter
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from configs import dify_config
from core.db.session_factory import session_factory
from core.model_billing_migration_protocol import canonical_hash
from core.model_billing_profile import ModelBillingProfileService
from core.repositories.model_billing_migration_repository import get_routing_state
from libs.datetime_utils import naive_utc_now
from models.account import Tenant
from models.model_billing import TenantModelBillingProfile
from models.model_billing_migration import TenantModelBillingMigration
from models.provider import Provider, ProviderCredential, ProviderType
from models.tokener import TenantTokenerIntegration, TenantTokenerIntegrationStatus
from services.entities.model_billing_migration import (
ActivateMigrationPayload,
ContractEventPayload,
DecideCyclePayload,
FundingReceiptsPayload,
LeasePayload,
MigrationSourceProviderOwnership,
OperationPayload,
PrepareMigrationPayload,
)
logger = logging.getLogger(__name__)
PROCESSING_PHASES = frozenset({"claimed", "granting", "activating"})
UNSETTLED_COMMANDS = frozenset({"registered", "dispatched", "accepted", "outcome_unknown"})
class MigrationError(RuntimeError):
"""Stable secret-free error translated only at the HTTP boundary."""
def __init__(self, code: str, status: int = 409, *, retryable: bool = False):
super().__init__(code)
self.code = code
self.status = status
self.retryable = retryable
def _timestamp(value: datetime) -> str:
return value.replace(microsecond=0).isoformat() + "Z"
def _attention(row: TenantModelBillingMigration, now: datetime) -> str:
if row.claimed_at is None or row.phase == "active" or row.state.get("ever_activated"):
return "normal"
elapsed = (now - row.claimed_at).total_seconds()
if elapsed > 1800:
return "manual_required"
return "alerted" if elapsed > 300 else "normal"
class ModelBillingMigrationService:
@staticmethod
def admitted(tenant_id: str) -> bool:
return dify_config.TOKENER_LEGACY_MIGRATION_ENABLED and tenant_id in {
tenant.strip() for tenant in dify_config.TOKENER_LEGACY_MIGRATION_ALLOWLIST.split(",") if tenant.strip()
}
@staticmethod
def _row(session: Session, tenant_id: str, migration_id: str | None = None) -> TenantModelBillingMigration:
row = session.scalar(
select(TenantModelBillingMigration)
.where(TenantModelBillingMigration.tenant_id == tenant_id)
.with_for_update()
)
if row is None:
raise MigrationError("migration_not_found", 404)
if migration_id is not None and row.migration_id != migration_id:
raise MigrationError("migration_not_found", 404)
return row
@staticmethod
def _status(row: TenantModelBillingMigration, *, replayed: bool = False) -> dict[str, Any]:
state = row.state
result: dict[str, Any] = {
"api_version": 1,
"tenant_id": row.tenant_id,
"migration_id": row.migration_id,
"phase": row.phase,
"attention": _attention(row, naive_utc_now()),
"revision": row.revision,
"route_epoch": row.route_epoch,
"replayed": replayed,
"ever_activated": bool(state.get("ever_activated", False)),
"model_mapping_version": state["model_mapping_version"],
"inventory_hash": state["inventory_hash"],
"billing_receipt_refs": state.get("billing_receipt_refs", []),
"commands": list(state.get("commands", {}).values()),
"cycle_decisions": list(state.get("cycle_decisions", {}).values()),
"contract_events": state.get("contract_events", {}),
"prior_manifests": state.get("prior_manifests", []),
"terminated_contract_refs": state.get("terminated_contract_refs", []),
"operation_results": [
{key: value for key, value in operation.items() if key != "response"}
for operation in state.get("operations", {}).values()
],
}
for key in (
"intent_revision",
"manifest",
"manifest_hash",
"preparation_binding_hash",
"blocked_from",
"last_error_code",
"funding_receipt_hash",
"pending_contract_event",
):
if key in state:
result[key] = state[key]
if row.claimed_at is not None:
result["claimed_at"] = _timestamp(row.claimed_at)
if row.lease_token is not None and row.lease_expires_at is not None:
result.update(lease_token=row.lease_token, lease_expires_at=_timestamp(row.lease_expires_at))
return result
@classmethod
def status(cls, tenant_id: str, operation_id: str | None = None) -> dict[str, Any]:
with session_factory.create_session() as session:
row = session.get(TenantModelBillingMigration, tenant_id)
if row is None:
raise MigrationError("migration_not_found", 404)
result = cls._status(row)
if operation_id is not None:
operation = row.state.get("operations", {}).get(operation_id)
if operation is None:
raise MigrationError("operation_not_found", 404)
result["operation_results"] = [{key: value for key, value in operation.items() if key != "response"}]
return result
@classmethod
def get_routing_state(cls, tenant_id: str) -> dict[str, Any] | None:
"""Delegate the shared runtime projection without loading control state."""
return get_routing_state(tenant_id)
@classmethod
def _replay(cls, row: TenantModelBillingMigration, payload: OperationPayload) -> dict[str, Any] | None:
request_hash = canonical_hash(payload.model_dump(mode="json", exclude_none=True))
prior = row.state.get("operations", {}).get(payload.operation_id)
if prior is None:
return None
if prior["request_hash"] != request_hash:
raise MigrationError("idempotency_conflict")
# The status is explicitly current; the immutable receipt preserves the
# original operation outcome. Never misrepresent a successor's lease as
# the replayed acquire/renew result: clients must inspect the original
# token in replayed_operation before using the current lease.
result = cls._status(row, replayed=True)
original = {key: copy.deepcopy(value) for key, value in prior.items() if key != "response"}
legacy_response = prior.get("response", {})
for key in ("lease_token", "lease_expires_at"):
if key not in original and key in legacy_response:
original[key] = legacy_response[key]
result["replayed_operation"] = original
return result
@classmethod
def _record(cls, row: TenantModelBillingMigration, payload: OperationPayload) -> dict[str, Any]:
summary: dict[str, Any] = {"phase": row.phase, "route_epoch": row.route_epoch}
for key in ("manifest_hash", "intent_revision"):
if key in row.state:
summary[key] = row.state[key]
if isinstance(payload, DecideCyclePayload):
summary.update(decision=payload.decision, cycle_key=payload.cycle_key)
operation = {
"operation_id": payload.operation_id,
"request_hash": canonical_hash(payload.model_dump(mode="json", exclude_none=True)),
"result_code": 202 if row.phase == "preparing" else 200,
"result_revision": row.revision,
"result_summary": summary,
}
if isinstance(payload, LeasePayload):
operation["lease_action"] = payload.action
if row.lease_token is not None and row.lease_expires_at is not None:
operation["lease_token"] = row.lease_token
operation["lease_expires_at"] = _timestamp(row.lease_expires_at)
row.state.setdefault("operations", {})[payload.operation_id] = operation
flag_modified(row, "state")
return cls._status(row)
@classmethod
def prepare(cls, tenant_id: str, payload: PrepareMigrationPayload) -> dict[str, Any]:
with session_factory.create_session() as session, session.begin():
# Lock the existing tenant first, serializing the absent-row case.
tenant = session.scalar(select(Tenant).where(Tenant.id == tenant_id).with_for_update())
if tenant is None:
raise MigrationError("tenant_not_found", 404)
row = session.get(TenantModelBillingMigration, tenant_id)
if row is not None:
if row.migration_id != payload.migration_id:
raise MigrationError("migration_conflict")
replay = cls._replay(row, payload)
if replay is not None:
return replay
raise MigrationError("migration_conflict")
if not cls.admitted(tenant_id):
raise MigrationError("migration_not_admitted", 403)
if payload.expected_revision != 0:
raise MigrationError("revision_conflict")
profile = session.get(TenantModelBillingProfile, tenant_id)
if profile is not None and profile.model_billing_source is not None:
raise MigrationError("ineligible_contract", 422)
row = TenantModelBillingMigration(
tenant_id=tenant_id,
migration_id=payload.migration_id,
batch_id=payload.batch_id,
revision=1,
state=payload.model_dump(mode="json", exclude={"operation_id", "expected_revision"}),
)
session.add(row)
if (
session.scalar(select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == tenant_id))
is None
):
session.add(
TenantTokenerIntegration(
tenant_id=tenant_id,
plugin_unique_identifier=dify_config.TOKENER_PLUGIN_UNIQUE_IDENTIFIER.strip() or None,
)
)
result = cls._record(row, payload)
cls.enqueue_prepare(tenant_id, payload.migration_id)
return result
@staticmethod
def enqueue_prepare(tenant_id: str, migration_id: str) -> None:
from tasks.prepare_tokener_migration_task import prepare_tokener_migration_task
try:
prepare_tokener_migration_task.delay(tenant_id, migration_id)
except Exception:
# Metadata is committed; recovery sweep, not a changed operation ID,
# repairs broker failures.
logger.error( # noqa: TRY400 - secret-adjacent broker details must not enter traces
"Migration prepare enqueue failed tenant=%s migration=%s", tenant_id, migration_id
)
@classmethod
def _mutate(
cls,
tenant_id: str,
migration_id: str,
payload: OperationPayload,
action: Callable[[Session, TenantModelBillingMigration, datetime], None],
) -> dict[str, Any]:
with session_factory.create_session() as session, session.begin():
row = cls._row(session, tenant_id, migration_id)
replay = cls._replay(row, payload)
if replay is not None:
return replay
if row.revision != payload.expected_revision:
raise MigrationError("revision_conflict", retryable=True)
row.state = copy.deepcopy(row.state)
action(session, row, naive_utc_now())
row.revision += 1
return cls._record(row, payload)
@staticmethod
def _binding(session: Session, row: TenantModelBillingMigration) -> dict[str, Any]:
integration = session.scalar(
select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == row.tenant_id)
)
if integration is None or integration.status != TenantTokenerIntegrationStatus.READY:
raise MigrationError("readiness_changed")
credential = session.scalar(
select(ProviderCredential).where(
ProviderCredential.id == integration.provider_credential_id,
ProviderCredential.tenant_id == row.tenant_id,
ProviderCredential.provider_name == dify_config.TOKENER_PROVIDER_NAME,
)
)
if credential is None or not credential.encrypted_config:
raise MigrationError("readiness_changed")
return {
"tenant_id": row.tenant_id,
"migration_id": row.migration_id,
"plugin_unique_identifier": integration.plugin_unique_identifier or "",
"provider_name": credential.provider_name,
"provider_credential_id": credential.id,
"credential_fingerprint": canonical_hash({"encrypted_config": credential.encrypted_config}),
"model_mapping_version": row.state["model_mapping_version"],
"inventory_hash": row.state["inventory_hash"],
"billing_preparation_ref": row.state["billing_preparation_ref"],
"source_ownership_hash": canonical_hash(row.state["source_ownership"]),
}
@classmethod
def complete_preparation(
cls,
tenant_id: str,
migration_id: str,
*,
source_ownership: dict[str, Any],
expected_credential_fingerprint: str,
) -> None:
ownership = TypeAdapter(dict[str, MigrationSourceProviderOwnership]).validate_python(source_ownership)
snapshot = {key: value.model_dump(mode="json") for key, value in ownership.items()}
with session_factory.create_session() as session, session.begin():
row = cls._row(session, tenant_id, migration_id)
if row.phase != "preparing":
return
row.state = copy.deepcopy(row.state)
row.state["source_ownership"] = snapshot
binding = cls._binding(session, row)
if binding["credential_fingerprint"] != expected_credential_fingerprint:
raise MigrationError("readiness_changed")
pin = row.state.get("provisioned_binding")
if pin and any(binding.get(key) != value for key, value in pin.items()):
raise MigrationError("readiness_changed")
row.state["preparation"] = binding
row.state["preparation_binding_hash"] = canonical_hash(row.state["preparation"])
row.state.pop("last_error_code", None)
flag_modified(row, "state")
row.phase = "prepared"
row.revision += 1
@classmethod
def preparation_error(cls, tenant_id: str, migration_id: str, code: str) -> None:
with session_factory.create_session() as session, session.begin():
row = cls._row(session, tenant_id, migration_id)
if row.phase == "preparing":
row.state = {**row.state, "last_error_code": code}
@classmethod
def decide_cycle(cls, tenant_id: str, migration_id: str, payload: DecideCyclePayload) -> dict[str, Any]:
def action(session: Session, row: TenantModelBillingMigration, now: datetime) -> None:
decisions = row.state.setdefault("cycle_decisions", {})
prior = decisions.get(payload.cycle_key)
decision = {
"cycle_key": payload.cycle_key,
"decision": payload.decision,
"contract_ref": payload.contract_ref,
"manifest_hash": payload.manifest_hash or "",
"operation_id": payload.operation_id,
}
if prior is not None:
if any(prior[key] != decision[key] for key in ("decision", "contract_ref", "manifest_hash")):
raise MigrationError("cycle_decision_conflict")
return
if payload.decision == "legacy_deferred":
if row.claimed_at is not None:
raise MigrationError("cycle_decision_conflict")
decisions[payload.cycle_key] = decision
return
if row.phase != "prepared" or not cls.admitted(tenant_id):
raise MigrationError("readiness_changed")
paused = session.scalar(
select(TenantModelBillingMigration.tenant_id)
.where(
TenantModelBillingMigration.batch_id == row.batch_id,
TenantModelBillingMigration.claimed_at < now - timedelta(minutes=30),
TenantModelBillingMigration.phase != "active",
TenantModelBillingMigration.state["ever_activated"].as_boolean().is_not(True),
)
.limit(1)
)
if paused is not None:
raise MigrationError("batch_paused", retryable=True)
if canonical_hash(cls._binding(session, row)) != payload.preparation_binding_hash:
raise MigrationError("readiness_changed")
manifest = payload.manifest
if manifest is None:
raise MigrationError("invalid_window", 422)
data = manifest.model_dump(mode="json")
initial_cycle_alias = "dify.cycle.v1." + canonical_hash(
[
tenant_id,
payload.contract_ref,
"invoice_initial",
manifest.paid_period_start,
]
).removeprefix("sha256:")
if (
manifest.tenant_id != tenant_id
or manifest.migration_id != migration_id
or manifest.contract_ref != payload.contract_ref
or manifest.model_mapping_version != row.state["model_mapping_version"]
or canonical_hash(data) != payload.manifest_hash
or payload.cycle_key not in {initial_cycle_alias, *(window.cycle_key for window in manifest.windows)}
):
raise MigrationError("manifest_conflict")
if not any(window.starts_at <= _timestamp(now) < window.ends_at for window in manifest.windows):
raise MigrationError("expired_plan", 422)
for window in manifest.windows:
old = decisions.get(window.cycle_key)
if old is not None and old["decision"] != "tokener":
raise MigrationError("cycle_decision_conflict")
# Freeze every covered annual slot now; later legacy reset retries may
# not independently choose a second backend for a scheduled window.
for window in manifest.windows:
decisions[window.cycle_key] = {**decision, "cycle_key": window.cycle_key}
# A late initial invoice may have skipped its expired first slot.
# Preserve that original business identity as well as the live slots.
decisions[payload.cycle_key] = decision
row.state.update(
manifest=data,
manifest_hash=payload.manifest_hash,
intent_revision=1,
billing_receipt_refs=list(payload.billing_receipt_refs),
commands={},
)
row.phase = "claimed"
row.claimed_at = now
row.route_epoch += 1
return cls._mutate(tenant_id, migration_id, payload, action)
@staticmethod
def _require_lease(row: TenantModelBillingMigration, token: str, now: datetime) -> None:
if row.lease_token != token or row.lease_expires_at is None or row.lease_expires_at <= now:
raise MigrationError("lease_lost")
@classmethod
def lease(cls, tenant_id: str, migration_id: str, payload: LeasePayload) -> dict[str, Any]:
def action(_session: Session, row: TenantModelBillingMigration, now: datetime) -> None:
if row.claimed_at is None:
raise MigrationError("readiness_changed")
if payload.action == "acquire":
if row.lease_token is not None and row.lease_expires_at is not None and row.lease_expires_at > now:
raise MigrationError("lease_lost")
row.lease_token = str(uuid4())
row.lease_expires_at = now + timedelta(seconds=60)
else:
cls._require_lease(row, payload.lease_token or "", now)
if payload.action == "renew":
row.lease_expires_at = now + timedelta(seconds=60)
else:
row.lease_token = None
row.lease_expires_at = None
return cls._mutate(tenant_id, migration_id, payload, action)
@classmethod
def funding_receipts(
cls,
tenant_id: str,
migration_id: str,
payload: FundingReceiptsPayload,
) -> dict[str, Any]:
def action(_session: Session, row: TenantModelBillingMigration, now: datetime) -> None:
cls._require_lease(row, payload.lease_token, now)
if row.phase in {"active", "cancelled"} and not row.state.get("pending_contract_event"):
raise MigrationError("readiness_changed")
if payload.intent_revision != row.state.get("intent_revision") or payload.manifest_hash != row.state.get(
"manifest_hash"
):
raise MigrationError("contract_event_pending")
commands = row.state.setdefault("commands", {})
event = row.state.get("pending_contract_event")
terminated = row.state.get("terminated_contract_refs", [])
if row.state["manifest"]["contract_ref"] in terminated and not (event and event["terminal"]):
raise MigrationError("contract_terminated")
planned = {item["command_id"]: item for item in (event or {}).get("command_payloads", [])}
manifest = row.state["manifest"]
if (
manifest["tenant_id"] != tenant_id
or manifest["migration_id"] != migration_id
or canonical_hash(manifest) != payload.manifest_hash
):
raise MigrationError("manifest_conflict")
replacement = (event or {}).get("replacement_manifest")
replacement_hash = (event or {}).get("replacement_manifest_hash")
if replacement is not None and (replacement != manifest or canonical_hash(replacement) != replacement_hash):
raise MigrationError("manifest_conflict")
# The frozen current manifest authorizes grants independently of the
# old-window correction journal. New reanchor windows intentionally
# are not in affected_window_ids/command_payloads and must not be
# rejected simply because that correction event is still pending.
manifest_grants = {"grant:" + window["window_id"]: window for window in manifest["windows"]}
recovery_grants = {
"grant:" + item["window"]["window_id"]: item["window"]
for item in planned.values()
if item["target_amount_usd_micro"] != "0"
}
for receipt in payload.commands:
data = receipt.model_dump(mode="json")
if receipt.command_id in planned:
command = planned[receipt.command_id]
if (
receipt.payload_hash != command["payload_hash"]
or receipt.remote_id != command["window"]["window_id"]
):
raise MigrationError("idempotency_conflict")
elif receipt.command_id not in row.state.get("fenced_command_ids", []):
recovery_window = manifest_grants.get(receipt.command_id) or recovery_grants.get(receipt.command_id)
if (
recovery_window is None
or receipt.payload_hash != canonical_hash(recovery_window)
or receipt.remote_id != recovery_window["window_id"]
):
raise MigrationError("unplanned_command")
if manifest["contract_ref"] in terminated:
raise MigrationError("contract_terminated")
old = commands.get(receipt.command_id)
if old is not None:
if old["payload_hash"] != receipt.payload_hash or old["remote_id"] != receipt.remote_id:
raise MigrationError("idempotency_conflict")
if (
old["state"] in {"verified", "failed_definitive", "superseded"}
and old["state"] != receipt.state
):
raise MigrationError("command_state_conflict")
if receipt.state == "registered" and old["state"] != "registered":
raise MigrationError("command_state_conflict")
if receipt.state == "dispatched" and old["state"] in {"accepted", "outcome_unknown"}:
raise MigrationError("command_state_conflict")
commands[receipt.command_id] = data
unresolved = any(command["state"] in UNSETTLED_COMMANDS for command in commands.values())
# Correction receipts refer to a window too, but cannot stand in for
# the verified immutable creation of that window.
verified = {
command["remote_id"]
for command_id, command in commands.items()
if command["state"] == "verified"
and command_id in manifest_grants
and command["payload_hash"] == canonical_hash(manifest_grants[command_id])
and command["remote_id"] == manifest_grants[command_id]["window_id"]
}
all_windows = {window["window_id"] for window in row.state["manifest"]["windows"]}
row.state["funding_receipt_hash"] = canonical_hash(sorted(commands.values(), key=itemgetter("command_id")))
event = row.state.get("pending_contract_event")
if event is not None:
# Event receipts must be explicitly bound to each affected remote
# window, rather than accepting unchanged original grant receipts.
event_commands = row.state.setdefault("event_command_ids", [])
for receipt in payload.commands:
if (
receipt.command_id not in row.state.get("fenced_command_ids", [])
and receipt.command_id not in event_commands
and (not planned or receipt.command_id in planned)
):
event_commands.append(receipt.command_id)
corrected = {
commands[key]["remote_id"] for key in event_commands if commands[key]["state"] == "verified"
}
if unresolved or not set(event["affected_window_ids"]).issubset(corrected):
row.phase = "blocked"
return
row.state.pop("pending_contract_event")
row.state.pop("blocked_from", None)
if event["terminal"]:
# Cancel the original entitlement contract permanently, not
# the tenant's Tokener routing/org/other legitimate money.
historical_contract = event["contract_ref"] != row.state["manifest"]["contract_ref"]
row.phase = "active" if historical_contract and row.state.get("ever_activated") else "cancelled"
row.route_epoch += 1
return
if row.state.get("ever_activated") and "replacement_manifest" not in event:
# Routing cutover is permanent. An old invoice refund must
# not require its expired original window to activate again.
row.phase = "active"
row.route_epoch += 1
return
row.phase = "activating" if not unresolved and all_windows.issubset(verified) else "granting"
return cls._mutate(tenant_id, migration_id, payload, action)
@classmethod
def contract_event(cls, tenant_id: str, migration_id: str, payload: ContractEventPayload) -> dict[str, Any]:
def action(_session: Session, row: TenantModelBillingMigration, now: datetime) -> None:
manifest = row.state.get("manifest")
owned_manifest = next(
(
candidate
for candidate in [manifest, *row.state.get("prior_manifests", [])]
if candidate is not None and candidate["contract_ref"] == payload.contract_ref
),
None,
)
if manifest is None or owned_manifest is None:
raise MigrationError("ineligible_contract", 422)
terminated = row.state.setdefault("terminated_contract_refs", [])
if payload.contract_ref in terminated and not payload.terminal:
raise MigrationError("contract_terminated")
if not set(payload.affected_window_ids).issubset(
{window["window_id"] for window in owned_manifest["windows"]}
):
raise MigrationError("manifest_conflict")
if row.state.get("pending_contract_event") is not None:
raise MigrationError("contract_event_pending")
if payload.business_event_ref in row.state.get("contract_events", {}):
raise MigrationError("idempotency_conflict")
event = payload.model_dump(mode="json", exclude_none=True)
owned_windows = {window["window_id"]: window for window in owned_manifest["windows"]}
for command in payload.command_payloads:
if command.window.model_dump(mode="json") != owned_windows[command.window.window_id]:
raise MigrationError("manifest_conflict")
row.state.setdefault("contract_events", {})[payload.business_event_ref] = event
row.state["pending_contract_event"] = event
row.state["blocked_from"] = row.phase
row.state["fenced_command_ids"] = list(row.state.get("commands", {}))
row.state["event_command_ids"] = []
row.state["intent_revision"] += 1
row.state.setdefault("billing_receipt_refs", []).append(payload.billing_receipt_ref)
if payload.terminal and payload.contract_ref not in terminated:
terminated.append(payload.contract_ref)
if payload.replacement_manifest is not None:
replacement = payload.replacement_manifest.model_dump(mode="json")
if replacement["contract_ref"] in terminated:
raise MigrationError("contract_terminated")
if (
replacement["tenant_id"] != tenant_id
or replacement["migration_id"] != migration_id
or replacement["plan_revision"] <= manifest["plan_revision"]
or replacement["model_mapping_version"] != row.state["model_mapping_version"]
or canonical_hash(replacement) != payload.replacement_manifest_hash
):
raise MigrationError("manifest_conflict")
replacement_windows = {window["window_id"]: window for window in replacement["windows"]}
manual_old_id: str | None = None
if payload.manual_replan:
manual_old_id = cls._validate_manual_unsent_replan(row, manifest, replacement, now)
for window in manifest["windows"]:
next_window = replacement_windows.get(window["window_id"])
if window != next_window and window["window_id"] not in payload.affected_window_ids:
raise MigrationError("manifest_conflict")
if window["starts_at"] <= _timestamp(now) < window["ends_at"] and (
next_window is None
or next_window["starts_at"] != window["starts_at"]
or next_window["ends_at"] != window["ends_at"]
):
if manual_old_id != window["window_id"]:
raise MigrationError("active_window_boundary_changed")
decisions = row.state.setdefault("cycle_decisions", {})
for window in replacement["windows"]:
previous = decisions.get(window["cycle_key"])
if previous is not None and previous["decision"] != "tokener":
raise MigrationError("cycle_decision_conflict")
decisions.setdefault(
window["cycle_key"],
{
"cycle_key": window["cycle_key"],
"decision": "tokener",
"contract_ref": replacement["contract_ref"],
"manifest_hash": payload.replacement_manifest_hash,
"operation_id": payload.operation_id,
},
)
row.state.setdefault("prior_manifests", []).append(manifest)
row.state["manifest"] = replacement
row.state["manifest_hash"] = payload.replacement_manifest_hash
row.phase = "blocked"
row.route_epoch += 1
return cls._mutate(tenant_id, migration_id, payload, action)
@staticmethod
def _validate_manual_unsent_replan(
row: TenantModelBillingMigration,
manifest: dict[str, Any],
replacement: dict[str, Any],
now: datetime,
) -> str:
"""Prove a current-window repair has never crossed the dispatch boundary.
Runs under the same row lock/CAS as the intent fence. A racing worker's
dispatched receipt either wins first and rejects this repair, or loses
the old revision and cannot authorize its external create afterward.
"""
fixed = (
"tenant_id",
"migration_id",
"calendar_policy",
"contract_ref",
"subscription_id",
"paid_period_start",
"paid_period_end",
"funding_invoice_id",
"amount_policy_version",
"model_mapping_version",
)
if any(manifest[key] != replacement[key] for key in fixed):
raise MigrationError("invalid_manual_replan")
if replacement["plan_revision"] != manifest["plan_revision"] + 1:
raise MigrationError("invalid_manual_replan")
if len(manifest["windows"]) != len(replacement["windows"]):
raise MigrationError("invalid_manual_replan")
candidates = {window["cycle_key"]: window for window in replacement["windows"]}
repaired: str | None = None
today = _timestamp(now.replace(hour=0, minute=0, second=0, microsecond=0))
for old in manifest["windows"]:
new = candidates.get(old["cycle_key"])
if new == old:
continue
if (
new is None
or repaired is not None
or not old["starts_at"] <= _timestamp(now) < old["ends_at"]
or new["starts_at"] != today
or new["starts_at"] <= old["starts_at"]
or new["window_id"] == old["window_id"]
or new["source_ref"] != new["window_id"]
or any(
new[key] != old[key] for key in ("ends_at", "amount_usd_micro", "cycle_key", "ordinal", "aliases")
)
):
raise MigrationError("invalid_manual_replan")
prior = row.state.get("commands", {}).get("grant:" + old["window_id"])
if prior is not None and prior["state"] != "registered":
raise MigrationError("manual_replan_already_dispatched")
repaired = old["window_id"]
if repaired is None:
raise MigrationError("invalid_manual_replan")
return repaired
@classmethod
def activate(cls, tenant_id: str, migration_id: str, payload: ActivateMigrationPayload) -> dict[str, Any]:
# Revalidate protocol/mapping outside the transaction, but never make a
# committed transport replay dependent on today's runtime availability.
with session_factory.create_session() as session:
row = session.get(TenantModelBillingMigration, tenant_id)
if row is None or row.migration_id != migration_id:
raise MigrationError("migration_not_found", 404)
replay = cls._replay(row, payload)
if replay is not None:
return replay
mapping_version = row.state["model_mapping_version"]
inventory_hash = row.state["inventory_hash"]
ownership_hash = canonical_hash(row.state["source_ownership"])
cls.validate_runtime_readiness(tenant_id, mapping_version, inventory_hash, ownership_hash)
def action(session: Session, row: TenantModelBillingMigration, now: datetime) -> None:
cls._require_lease(row, payload.lease_token, now)
terminal_publication = row.phase == "cancelled"
if row.state.get("pending_contract_event"):
raise MigrationError("contract_event_pending")
if terminal_publication:
contract_ref = row.state["manifest"]["contract_ref"]
terminal_events = [
event
for event in row.state.get("contract_events", {}).values()
if event["terminal"] and event["contract_ref"] == contract_ref
]
if contract_ref not in row.state.get("terminated_contract_refs", []) or not terminal_events:
raise MigrationError("contract_event_pending")
if any(command["state"] in UNSETTLED_COMMANDS for command in row.state.get("commands", {}).values()):
raise MigrationError("contract_event_pending")
commands = row.state.get("commands", {})
if any(
commands.get(command["command_id"], {}).get("state") != "verified"
for event in terminal_events
for command in event["command_payloads"]
):
raise MigrationError("contract_event_pending")
elif row.phase != "activating":
raise MigrationError("contract_event_pending")
activation_bindings = {
"intent_revision": payload.intent_revision,
"manifest_hash": payload.manifest_hash,
"funding_receipt_hash": payload.funding_receipt_hash,
"preparation_binding_hash": payload.preparation_binding_hash,
}
for key, value in activation_bindings.items():
if value != row.state.get(key):
raise MigrationError("manifest_conflict")
if canonical_hash(cls._binding(session, row)) != payload.preparation_binding_hash:
raise MigrationError("readiness_changed")
if (
not terminal_publication
and not row.state.get("ever_activated")
and not any(
window["starts_at"] <= _timestamp(now) < window["ends_at"]
for window in row.state["manifest"]["windows"]
)
):
raise MigrationError("expired_plan", 422)
profile = session.get(TenantModelBillingProfile, tenant_id)
if profile is None:
session.add(TenantModelBillingProfile(tenant_id=tenant_id, model_billing_source="tokener"))
else:
profile.model_billing_source = "tokener"
provider = session.scalar(
select(Provider)
.where(
Provider.tenant_id == tenant_id,
Provider.provider_name == dify_config.TOKENER_PROVIDER_NAME,
Provider.provider_type == ProviderType.CUSTOM,
)
.with_for_update()
)
if provider is None:
session.add(
Provider(
tenant_id=tenant_id,
provider_name=dify_config.TOKENER_PROVIDER_NAME,
provider_type=ProviderType.CUSTOM,
is_valid=True,
credential_id=row.state["preparation"]["provider_credential_id"],
)
)
elif provider.credential_id is None:
provider.credential_id = row.state["preparation"]["provider_credential_id"]
provider.is_valid = True
# Existing BYOK credentials, preferred provider and all default model
# selections are deliberately untouched by publishing the managed key.
# A fully converged refund/cancellation can publish routing with no
# allowance at all. Keep the original contract terminal so a stale
# plan update cannot resurrect it; a later paid contract uses the
# existing native Tokener path and the same org.
row.phase = "cancelled" if terminal_publication else "active"
row.state["ever_activated"] = True
row.route_epoch += 1
result = cls._mutate(tenant_id, migration_id, payload, action)
try:
ModelBillingProfileService.invalidate(tenant_id)
from core.provider_manager import ProviderManager
ProviderManager.invalidate_configurations_cache(tenant_id)
except Exception:
# Admission uses the authoritative migration row before this cache.
logger.warning("Migration profile cache invalidation failed tenant=%s", tenant_id)
return result
@staticmethod
def validate_runtime_readiness(
tenant_id: str,
mapping_version: str,
inventory_hash: str,
ownership_hash: str,
) -> None:
from core.model_invocation_routing import validate_migration_readiness
try:
readiness = validate_migration_readiness(tenant_id, mapping_version)
if readiness["inventory_hash"] != inventory_hash:
raise MigrationError("readiness_changed")
if canonical_hash(readiness["source_ownership"]) != ownership_hash:
raise MigrationError("readiness_changed")
except MigrationError:
raise
except Exception:
raise MigrationError("readiness_changed", retryable=True) from None

View File

@@ -15,6 +15,7 @@ from core.db.session_factory import session_factory
from core.entities.model_entities import DefaultModelSetting, ModelWithProviderEntity, ProviderModelWithStatusEntity
from core.entities.provider_entities import CredentialConfiguration
from core.helper.position_helper import is_filtered
from core.model_billing_profile import ModelBillingProfileService
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.entities.plugin_daemon import PluginModelProviderBinding
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
@@ -343,6 +344,10 @@ class ModelProviderService:
bindings = PluginService.list_model_provider_bindings(tenant_id)
provider_entities = PluginService.fetch_plugin_model_providers(tenant_id=tenant_id)
states = self._load_provider_summary_states(tenant_id)
model_billing = ModelBillingProfileService.resolve(tenant_id)
from core.model_invocation_routing import has_compatibility_route, migration_routing_state
compatibility_route = has_compatibility_route(migration_routing_state(tenant_id))
bindings_by_provider: dict[str, PluginModelProviderBinding] = {}
for binding in bindings:
@@ -380,15 +385,20 @@ class ModelProviderService:
custom_present = state.has_custom_provider or state.has_custom_models
provider_binding = bindings_by_provider.get(provider_name)
system_enabled = bool(
provider_binding
(model_billing.uses_legacy_message_credits or compatibility_route)
and provider_binding
and self._has_system_provider_hosting_configuration(provider_name)
and provider_binding.source != PluginInstallationSource.Package
and provider_binding.verified
)
preferred_provider_type = self._get_preferred_provider_type(
state,
custom_present=custom_present,
system_enabled=system_enabled,
preferred_provider_type = (
ProviderType.CUSTOM
if model_billing.uses_tokener and not compatibility_route
else self._get_preferred_provider_type(
state,
custom_present=custom_present,
system_enabled=system_enabled,
)
)
provider_summaries.append(
@@ -911,10 +921,18 @@ class ModelProviderService:
:param preferred_provider_type: preferred provider type
:return:
"""
provider_configuration = self._get_provider_configuration(tenant_id, provider)
# Convert preferred_provider_type to ProviderType
preferred_provider_type_enum = ProviderType.value_of(preferred_provider_type)
if (
preferred_provider_type_enum == ProviderType.SYSTEM
and ModelBillingProfileService.resolve(tenant_id).uses_tokener
):
from core.model_invocation_routing import has_compatibility_route, migration_routing_state
if not has_compatibility_route(migration_routing_state(tenant_id)):
raise ValueError("Hosted SYSTEM providers are disabled for this workspace.")
provider_configuration = self._get_provider_configuration(tenant_id, provider)
# Switch preferred provider type
provider_configuration.switch_preferred_provider_type(preferred_provider_type_enum)

View File

@@ -328,7 +328,11 @@ def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoReco
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
app_id = source["id"]
name = source["name"]
export_data = source["export_data"]
mode = _enum_string(source["mode"], field="mode")
package_url = source.get("package_url")
if package_url is not None and (not isinstance(package_url, str) or not package_url.strip()):
raise TypeError("package_url must be a non-empty string")
export_data = source.get("export_data", "") if mode == "agent" and package_url else source["export_data"]
if not isinstance(app_id, str):
raise TypeError("id must be a string")
if not isinstance(name, str):
@@ -336,7 +340,6 @@ def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
if not isinstance(export_data, str):
raise TypeError("export_data must be a string")
mode = _enum_string(source["mode"], field="mode")
if mode is None:
raise TypeError("mode must be a string or string enum")
return RecommendedAppDetailRecord(
@@ -346,6 +349,7 @@ def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
icon_background=cast(str | None, source.get("icon_background")),
mode=mode,
export_data=export_data,
package_url=package_url,
)

View File

@@ -0,0 +1,53 @@
"""Resolve publicly listed Agent templates for direct creation and published exports."""
from typing import BinaryIO, Literal, NamedTuple, Protocol
from uuid import UUID
from services.recommended_app_query_service import RecommendedAppNotFoundError
class RecommendedAgentPackageSource(NamedTuple):
tenant_id: str
agent_id: str
version_id: UUID
class RecommendedAgentPackageQuery(Protocol):
def get_package_source(self, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource | None: ...
class AgentPackageDownload(Protocol):
@property
def archive(self) -> BinaryIO: ...
@property
def filename(self) -> str: ...
def close(self) -> None: ...
class AgentPackageExporter(Protocol):
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> AgentPackageDownload: ...
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str: ...
class RecommendedAppPackageService:
def __init__(self, *, sources: RecommendedAgentPackageQuery, exporter: AgentPackageExporter) -> None:
self._sources = sources
self._exporter = exporter
def get_source(self, *, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource:
source = self._sources.get_package_source(app_id, version_id)
if source is None:
raise RecommendedAppNotFoundError
return source
def export(self, *, app_id: str, version_id: UUID, format: Literal["yaml", "ifpkg"]) -> str | AgentPackageDownload:
source = self.get_source(app_id=app_id, version_id=version_id)
# Resolve template admission and close the read session before export performs storage I/O.
if format == "yaml":
return self._exporter.export_yaml(
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
)
return self._exporter.export(tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id)

View File

@@ -41,6 +41,8 @@ class RecommendedAppDetailRecord(NamedTuple):
icon_background: str | None
mode: str
export_data: str
package_url: str | None = None
version_id: str | None = None
class RecommendedAppCatalogQuery(Protocol):
@@ -89,6 +91,8 @@ class RecommendedAppDetailSummary(NamedTuple):
mode: str
export_data: str
can_trial: bool
package_url: str | None = None
version_id: str | None = None
class RecommendedAppNotFoundError(Exception):
@@ -154,6 +158,8 @@ class RecommendedAppQueryService:
mode=detail.mode,
export_data=detail.export_data,
can_trial=can_trial,
package_url=detail.package_url,
version_id=detail.version_id,
)
def _with_trial_status(self, apps: Sequence[RecommendedAppRecord]) -> tuple[RecommendedAppSummary, ...]:

View File

@@ -0,0 +1,39 @@
"""Initialize managed-model billing for a newly created workspace."""
from sqlalchemy.orm import Session
from configs import dify_config
from models import TenantCreditPool
from models.enums import ProviderQuotaType
from models.model_billing import TenantModelBillingProfile
from models.tokener import TenantTokenerIntegration
def initialize_tenant_model_billing(tenant_id: str, *, session: Session) -> None:
"""Stage a new tenant's billing records in the caller-owned transaction.
Call once while creating the tenant, before its creation event is emitted.
This is not a migration or repair entrypoint for existing tenants. The
caller owns commit/rollback; asynchronous Tokener provisioning starts only
after the tenant and its explicit billing profile have been committed.
"""
if dify_config.TOKENER_NEW_TENANT_COHORT_ENABLED:
session.add_all(
[
TenantModelBillingProfile(tenant_id=tenant_id, model_billing_source="tokener"),
TenantTokenerIntegration(
tenant_id=tenant_id,
plugin_unique_identifier=dify_config.TOKENER_PLUGIN_UNIQUE_IDENTIFIER.strip() or None,
),
]
)
return
session.add(
TenantCreditPool(
tenant_id=tenant_id,
quota_limit=dify_config.HOSTED_POOL_CREDITS,
quota_used=0,
pool_type=ProviderQuotaType.TRIAL,
)
)

View File

@@ -34,13 +34,13 @@ class TrialAppAccessService:
def __init__(self, *, apps: TrialAppAccessQuery) -> None:
self._apps: TrialAppAccessQuery = apps
def get_access(self, *, app_id: str, account_id: str) -> TrialAppRef:
def get_access(self, *, app_id: str, account_id: str, check_usage: bool = True) -> TrialAppRef:
access = self._apps.resolve(app_id=app_id, account_id=account_id)
if access is None:
raise TrialAppUnavailableError(f"App {app_id} is unavailable for trial execution")
# Preserve the first-use allowance, including when the configured limit is zero.
if access.used_count is not None and access.used_count >= access.trial_limit:
if check_usage and access.used_count is not None and access.used_count >= access.trial_limit:
raise TrialAppUsageLimitExceededError(
f"Account {account_id} has used {access.used_count} trials of app {app_id} "
f"with a limit of {access.trial_limit}"

View File

@@ -52,7 +52,7 @@ class TrialAppGenerationService:
def generate_chat(
self, *, trial_app: TrialAppRef, account_id: str, args: Mapping[str, object]
) -> GenerationResponse:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise TrialAppNotChatError(f"App {trial_app.app_id} is not a chat app")
return self._generate(
trial_app=trial_app, account_id=account_id, args={**args, "auto_generate_name": False}, streaming=True

View File

@@ -1,4 +1,5 @@
from dataclasses import dataclass
import logging
from dataclasses import dataclass, replace
from typing import Literal
from flask_login import current_user
@@ -6,21 +7,30 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.model_billing_profile import ModelBillingProfileService, ModelBillingSource
from enums import CloudPlan, DeploymentEdition
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
from models.tokener import TenantTokenerIntegrationStatus
from services.account_service import TenantService
from services.billing_service import BillingService
from services.billing_service import BillingService, TokenerTenantMeteringResponse
from services.errors.billing import BillingError, LegacyCreditPoolManagedByTokenerError
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EffectiveCreditPool:
model_billing_source: ModelBillingSource = ModelBillingSource.LEGACY_MESSAGE_CREDITS
model_billing_migration_status: Literal["none", "preparing", "processing", "active"] = "none"
tokener_bootstrap_status: str | None = None
plan: CloudPlan | None = None
pool_type: Literal["paid", "trial"] | None = None
quota_limit: int | None = None
quota_used: int | None = None
exhausted_at: int | None = None
next_credit_reset_date: int | None = None
tokener_metering: TokenerTenantMeteringResponse | None = None
@property
def remaining_credits(self) -> int | None:
@@ -36,6 +46,11 @@ class EffectiveCreditPool:
@property
def is_exhausted(self) -> bool:
if (
self.model_billing_source == ModelBillingSource.TOKENER
or self.model_billing_migration_status == "processing"
):
return False
remaining_credits = self.remaining_credits
return not self.is_unlimited and (remaining_credits is None or remaining_credits <= 0)
@@ -52,30 +67,91 @@ def _set_credit_pool_info(
class WorkspaceService:
@classmethod
def get_effective_credit_pool(cls, tenant_id: str, *, session: Session) -> EffectiveCreditPool:
"""Read display credits without crossing an already claimed legacy fence."""
from core.model_invocation_routing import migration_display_status
migration_status = migration_display_status(tenant_id)
model_billing = ModelBillingProfileService.resolve(tenant_id, session=session)
display_source = (
ModelBillingSource.TOKENER if migration_status == "active" else model_billing.model_billing_source
)
tokener_bootstrap_status = (
model_billing.tokener_bootstrap_status.value if model_billing.tokener_bootstrap_status else None
)
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return EffectiveCreditPool()
return EffectiveCreditPool(
model_billing_source=display_source,
model_billing_migration_status=migration_status,
tokener_bootstrap_status=tokener_bootstrap_status,
)
billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True)
subscription_plan = CloudPlan(billing_info["subscription"]["plan"])
if migration_status in {"processing", "active"} or model_billing.uses_tokener:
# Claim irrevocably fences legacy GetBalance before profile
# publication. Null means unavailable/transitioning, never zero.
# The authoritative active state also wins over a stale legacy cache.
return EffectiveCreditPool(
model_billing_source=display_source,
model_billing_migration_status=migration_status,
tokener_bootstrap_status=tokener_bootstrap_status,
plan=subscription_plan,
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
)
return cls._read_legacy_display_pool(
tenant_id,
context=EffectiveCreditPool(
model_billing_source=model_billing.model_billing_source,
model_billing_migration_status=migration_status,
tokener_bootstrap_status=tokener_bootstrap_status,
plan=subscription_plan,
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
),
session=session,
)
@classmethod
def _read_legacy_display_pool(
cls,
tenant_id: str,
*,
context: EffectiveCreditPool,
session: Session,
) -> EffectiveCreditPool:
"""Preserve the financial denial if trusted migration state cannot explain it."""
from core.model_invocation_routing import migration_display_status
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
effective_pool = None
effective_pool_type: Literal["paid", "trial"] = "trial"
if subscription_plan != CloudPlan.SANDBOX:
paid_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="paid", session=session)
if paid_pool is not None and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used):
effective_pool = paid_pool
effective_pool_type = "paid"
if effective_pool is None:
effective_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial", session=session)
if effective_pool is None:
return EffectiveCreditPool(
plan=subscription_plan,
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
try:
if context.plan != CloudPlan.SANDBOX:
paid_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="paid", session=session)
if paid_pool is not None and (
paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used
):
effective_pool = paid_pool
effective_pool_type = "paid"
if effective_pool is None:
effective_pool = CreditPoolService.get_pool(tenant_id=tenant_id, pool_type="trial", session=session)
except LegacyCreditPoolManagedByTokenerError:
# Claim can win after the initial display-state read. Refresh Core
# authority only for this exact fence; never mask another 409/outage
# or consult local/stale legacy balances as a fallback.
current_status = migration_display_status(tenant_id)
if current_status not in {"processing", "active"}:
raise
return replace(
context,
model_billing_migration_status=current_status,
model_billing_source=ModelBillingSource.TOKENER
if current_status == "active"
else context.model_billing_source,
)
if effective_pool is None:
return context
exhausted_at = effective_pool.exhausted_at if isinstance(effective_pool, CreditPoolBalance) else None
if not (
@@ -86,15 +162,41 @@ class WorkspaceService:
):
exhausted_at = None
return EffectiveCreditPool(
plan=subscription_plan,
return replace(
context,
pool_type=effective_pool_type,
quota_limit=effective_pool.quota_limit,
quota_used=effective_pool.quota_used,
exhausted_at=exhausted_at,
next_credit_reset_date=billing_info.get("next_credit_reset_date"),
)
@classmethod
def get_model_provider_credits(cls, tenant_id: str, *, session: Session) -> EffectiveCreditPool:
"""Return legacy credits or enrich a ready Tokener cohort with metering usage."""
from core.model_invocation_routing import migration_display_status
migration_status = migration_display_status(tenant_id)
if migration_status == "processing":
# No old-credit zero/remaining value masquerading as the new wallet.
return EffectiveCreditPool(model_billing_migration_status="processing")
credit_pool = cls.get_effective_credit_pool(tenant_id, session=session)
# The nested read may observe a later claim/activation. Never overwrite
# that newer authority with the initial display snapshot.
if (
dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD
or credit_pool.model_billing_migration_status == "processing"
or credit_pool.model_billing_source != ModelBillingSource.TOKENER
or credit_pool.tokener_bootstrap_status != TenantTokenerIntegrationStatus.READY.value
):
return credit_pool
try:
tokener_metering = BillingService.get_tokener_metering(tenant_id)
except BillingError:
logger.warning("Tokener metering usage is unavailable for tenant %s", tenant_id)
return credit_pool
return replace(credit_pool, tokener_metering=tokener_metering)
@classmethod
def get_current_workspace_summary(cls, tenant: Tenant, account_id: str, *, session: Session) -> dict[str, object]:
tenant_account_join = session.scalar(
@@ -112,6 +214,9 @@ class WorkspaceService:
"role": tenant_account_join.role,
"plan": effective_pool.plan,
"credits": effective_pool.remaining_credits,
"model_billing_source": effective_pool.model_billing_source.value,
"model_billing_migration_status": effective_pool.model_billing_migration_status,
"tokener_bootstrap_status": effective_pool.tokener_bootstrap_status,
}
@classmethod
@@ -140,6 +245,17 @@ class WorkspaceService:
tenant_info["plan"] = (
feature.billing.subscription.plan if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD else None
)
model_billing = ModelBillingProfileService.resolve(tenant.id, session=session)
migration_status = feature.model_billing_migration_status
tenant_info["model_billing_source"] = (
ModelBillingSource.TOKENER.value
if migration_status == "active"
else model_billing.model_billing_source.value
)
tenant_info["model_billing_migration_status"] = migration_status
tenant_info["tokener_bootstrap_status"] = (
model_billing.tokener_bootstrap_status.value if model_billing.tokener_bootstrap_status else None
)
can_replace_logo = feature.can_replace_logo
if can_replace_logo and TenantService.has_roles(
@@ -157,34 +273,31 @@ class WorkspaceService:
"remove_webapp_brand": remove_webapp_brand,
"replace_webapp_logo": replace_webapp_logo,
}
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
tenant_info["next_credit_reset_date"] = feature.next_credit_reset_date
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
paid_pool = CreditPoolService.get_pool(tenant_id=tenant.id, pool_type="paid", session=session)
# if the tenant is not on the sandbox plan and the paid pool is not full, use the paid pool
if (
feature.billing.subscription.plan != CloudPlan.SANDBOX
and paid_pool is not None
and (paid_pool.quota_limit == -1 or paid_pool.quota_limit > paid_pool.quota_used)
):
exhausted_at = paid_pool.exhausted_at if isinstance(paid_pool, CreditPoolBalance) else None
if (
dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD
and model_billing.uses_legacy_message_credits
and migration_status not in {"processing", "active"}
):
pool = cls._read_legacy_display_pool(
tenant.id,
session=session,
context=EffectiveCreditPool(
model_billing_source=model_billing.model_billing_source,
model_billing_migration_status=migration_status,
plan=feature.billing.subscription.plan,
next_credit_reset_date=feature.next_credit_reset_date,
),
)
tenant_info["model_billing_migration_status"] = pool.model_billing_migration_status
tenant_info["model_billing_source"] = pool.model_billing_source.value
if pool.model_billing_migration_status not in {"processing", "active"}:
tenant_info["next_credit_reset_date"] = pool.next_credit_reset_date
if pool.quota_limit is not None and pool.quota_used is not None:
_set_credit_pool_info(
tenant_info,
quota_limit=paid_pool.quota_limit,
quota_used=paid_pool.quota_used,
exhausted_at=exhausted_at,
quota_limit=pool.quota_limit,
quota_used=pool.quota_used,
exhausted_at=pool.exhausted_at,
)
else:
trial_pool = CreditPoolService.get_pool(tenant_id=tenant.id, pool_type="trial", session=session)
if trial_pool:
exhausted_at = trial_pool.exhausted_at if isinstance(trial_pool, CreditPoolBalance) else None
_set_credit_pool_info(
tenant_info,
quota_limit=trial_pool.quota_limit,
quota_used=trial_pool.quota_used,
exhausted_at=exhausted_at,
)
return tenant_info

View File

@@ -0,0 +1,534 @@
"""Provision and configure the managed Tokener provider for a new tenant."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import timedelta
from typing import cast
from celery import shared_task
from sqlalchemy import select
from configs import dify_config
from core.db.session_factory import session_factory
from core.model_billing_profile import (
InvalidModelBillingProfileError,
ModelBillingProfileResolutionError,
ModelBillingProfileService,
)
from core.plugin.entities.plugin_daemon import PluginInstallTaskStatus
from core.plugin.plugin_service import PluginService
from extensions.ext_redis import redis_client
from libs.datetime_utils import naive_utc_now
from models.account import Tenant
from models.model_billing import TenantModelBillingProfile
from models.provider import Provider, ProviderCredential, ProviderType
from models.tokener import TenantTokenerIntegration, TenantTokenerIntegrationStatus
from services.billing_service import BillingService, TokenerBootstrapUpstreamError
from services.model_provider_service import ModelProviderService
logger = logging.getLogger(__name__)
TOKENER_BOOTSTRAP_QUEUE = "plugin"
MANAGED_TOKENER_CREDENTIAL_NAME = "__dify_managed_tokener_v1__"
_MAX_RETRIES = 60
_RETRY_DELAY_SECONDS = 5
_MAX_RETRY_DELAY_SECONDS = 60
_LOCK_TIMEOUT_SECONDS = 15 * 60
_UNSET = object()
@dataclass(frozen=True, slots=True)
class _IntegrationSnapshot:
tenant_id: str
tenant_name: str
status: TenantTokenerIntegrationStatus
plugin_unique_identifier: str | None
plugin_install_task_id: str | None
@dataclass(frozen=True, slots=True)
class _CredentialWriteResult:
"""Secret-free result returned after consuming a one-time data-plane key."""
credential_id: str | None = None
error_code: str | None = None
class _BootstrapStepError(RuntimeError):
"""A secret-free task failure safe to persist and hand to Celery."""
def __init__(
self,
error_code: str,
*,
retryable: bool,
stage: TenantTokenerIntegrationStatus,
) -> None:
super().__init__(error_code)
self.error_code = error_code
self.retryable = retryable
self.stage = stage
def _begin_attempt(tenant_id: str) -> _IntegrationSnapshot | None:
now = naive_utc_now()
with session_factory.create_session() as session, session.begin():
integration = session.scalar(
select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == tenant_id).with_for_update()
)
if integration is None:
return None
tenant = session.get(Tenant, tenant_id)
if tenant is None:
return None
if integration.status != TenantTokenerIntegrationStatus.READY:
integration.attempt_count += 1
integration.last_attempt_at = now
integration.last_error_code = None
return _IntegrationSnapshot(
tenant_id=tenant_id,
tenant_name=tenant.name,
status=integration.status,
plugin_unique_identifier=integration.plugin_unique_identifier,
plugin_install_task_id=integration.plugin_install_task_id,
)
def _update_integration(
tenant_id: str,
*,
status: TenantTokenerIntegrationStatus,
plugin_install_task_id: str | None | object = _UNSET,
provider_credential_id: str | None = None,
error_code: str | None = None,
ready: bool = False,
) -> None:
with session_factory.create_session() as session, session.begin():
integration = session.scalar(
select(TenantTokenerIntegration).where(TenantTokenerIntegration.tenant_id == tenant_id).with_for_update()
)
if integration is None:
return
integration.status = status
if plugin_install_task_id is not _UNSET:
integration.plugin_install_task_id = cast(str | None, plugin_install_task_id)
integration.last_error_code = error_code
if provider_credential_id is not None:
integration.provider_credential_id = provider_credential_id
if ready:
integration.ready_at = naive_utc_now()
def _installed_plugin_matches(tenant_id: str, plugin_unique_identifier: str) -> bool:
plugin_id = plugin_unique_identifier.split(":", 1)[0]
installed_plugins = PluginService.list(tenant_id)
exact_match = any(
plugin.plugin_id == plugin_id and plugin.plugin_unique_identifier == plugin_unique_identifier
for plugin in installed_plugins
)
if exact_match:
return True
if any(plugin.plugin_id == plugin_id for plugin in installed_plugins):
raise _BootstrapStepError(
"tokener_plugin_version_conflict",
retryable=False,
stage=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
)
return False
def _ensure_plugin_installed(snapshot: _IntegrationSnapshot) -> None:
plugin_unique_identifier = snapshot.plugin_unique_identifier
if not plugin_unique_identifier:
raise _BootstrapStepError(
"tokener_plugin_not_configured",
retryable=False,
stage=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
)
_update_integration(
snapshot.tenant_id,
status=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
plugin_install_task_id=snapshot.plugin_install_task_id,
)
if _installed_plugin_matches(snapshot.tenant_id, plugin_unique_identifier):
_update_integration(
snapshot.tenant_id,
status=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
plugin_install_task_id=None,
)
return
if snapshot.plugin_install_task_id:
install_task = PluginService.fetch_install_task(snapshot.tenant_id, snapshot.plugin_install_task_id)
if install_task.status in {PluginInstallTaskStatus.Pending, PluginInstallTaskStatus.Running}:
raise _BootstrapStepError(
"tokener_plugin_install_pending",
retryable=True,
stage=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
)
if install_task.status == PluginInstallTaskStatus.Failed:
_update_integration(
snapshot.tenant_id,
status=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
plugin_install_task_id=None,
error_code="tokener_plugin_install_failed",
)
raise _BootstrapStepError(
"tokener_plugin_install_failed",
retryable=True,
stage=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
)
if _installed_plugin_matches(snapshot.tenant_id, plugin_unique_identifier):
return
if dify_config.TOKENER_PLUGIN_INSTALL_SOURCE == "package":
response = PluginService.install_from_local_pkg(snapshot.tenant_id, [plugin_unique_identifier])
else:
response = PluginService.install_from_marketplace_pkg(snapshot.tenant_id, [plugin_unique_identifier])
if response.all_installed:
return
if not response.task_id:
raise _BootstrapStepError(
"tokener_plugin_install_task_missing",
retryable=True,
stage=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
)
_update_integration(
snapshot.tenant_id,
status=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
plugin_install_task_id=response.task_id,
)
raise _BootstrapStepError(
"tokener_plugin_install_pending",
retryable=True,
stage=TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
)
def _find_managed_credential(tenant_id: str) -> tuple[str | None, bool]:
provider_name = dify_config.TOKENER_PROVIDER_NAME
with session_factory.create_session() as session:
provider = session.scalar(
select(Provider).where(
Provider.tenant_id == tenant_id,
Provider.provider_name == provider_name,
Provider.provider_type == ProviderType.CUSTOM,
)
)
credentials = list(
session.scalars(
select(ProviderCredential)
.where(
ProviderCredential.tenant_id == tenant_id,
ProviderCredential.provider_name == provider_name,
ProviderCredential.credential_name == MANAGED_TOKENER_CREDENTIAL_NAME,
)
.order_by(ProviderCredential.created_at, ProviderCredential.id)
)
)
if not credentials:
return None, False
if provider is not None:
active = next((credential for credential in credentials if credential.id == provider.credential_id), None)
if active is not None and provider.is_valid:
return active.id, True
return credentials[0].id, False
def _activate_managed_credential(tenant_id: str, credential_id: str) -> None:
try:
ModelProviderService().switch_active_provider_credential(
tenant_id=tenant_id,
provider=dify_config.TOKENER_PROVIDER_NAME,
credential_id=credential_id,
)
except Exception:
raise _BootstrapStepError(
"tokener_provider_credential_activation_failed",
retryable=True,
stage=TenantTokenerIntegrationStatus.CONFIGURING_PROVIDER,
) from None
def _consume_data_plane_api_key(tenant_id: str, data_plane_api_key: str) -> _CredentialWriteResult:
"""Consume a one-time key without allowing an exception to escape this frame.
Any exception raised while validating or persisting the credential is converted
to a secret-free value before this frame returns. The caller clears its own
reference before raising the corresponding task error, keeping plaintext out
of traceback locals and Sentry events.
"""
credentials = {"api_key": data_plane_api_key}
endpoint_url = dify_config.TOKENER_ENDPOINT_URL.strip()
if endpoint_url:
credentials["endpoint_url"] = endpoint_url
try:
ModelProviderService().create_provider_credential(
tenant_id=tenant_id,
provider=dify_config.TOKENER_PROVIDER_NAME,
credentials=credentials,
credential_name=MANAGED_TOKENER_CREDENTIAL_NAME,
)
except Exception:
return _CredentialWriteResult(error_code="tokener_provider_credential_rejected")
finally:
credentials.clear()
data_plane_api_key = ""
try:
credential_id, active = _find_managed_credential(tenant_id)
if credential_id is None:
return _CredentialWriteResult(error_code="tokener_provider_credential_not_persisted")
if not active:
_activate_managed_credential(tenant_id, credential_id)
return _CredentialWriteResult(credential_id=credential_id)
except _BootstrapStepError as error:
return _CredentialWriteResult(error_code=error.error_code)
except Exception:
return _CredentialWriteResult(error_code="tokener_provider_credential_activation_failed")
def _ensure_managed_credential(snapshot: _IntegrationSnapshot) -> str:
credential_id, active = _find_managed_credential(snapshot.tenant_id)
if credential_id is not None:
if not active:
_activate_managed_credential(snapshot.tenant_id, credential_id)
return credential_id
_update_integration(
snapshot.tenant_id,
status=TenantTokenerIntegrationStatus.PROVISIONING,
plugin_install_task_id=None,
)
try:
response = BillingService.bootstrap_tokener_tenant(snapshot.tenant_id, snapshot.tenant_name)
except TokenerBootstrapUpstreamError as error:
raise _BootstrapStepError(
error.error_code,
retryable=error.retryable,
stage=TenantTokenerIntegrationStatus.PROVISIONING,
) from None
if response["status"] == "pending":
raise _BootstrapStepError(
response.get("error_code", "tokener_bootstrap_pending"),
retryable=response["retryable"],
stage=TenantTokenerIntegrationStatus.PROVISIONING,
)
data_plane_api_key = response.pop("data_plane_api_key", "")
write_result = _consume_data_plane_api_key(snapshot.tenant_id, data_plane_api_key)
data_plane_api_key = ""
_update_integration(snapshot.tenant_id, status=TenantTokenerIntegrationStatus.CONFIGURING_PROVIDER)
if write_result.credential_id is not None:
return write_result.credential_id
raise _BootstrapStepError(
write_result.error_code or "tokener_provider_credential_rejected",
retryable=True,
stage=TenantTokenerIntegrationStatus.CONFIGURING_PROVIDER,
)
def _set_default_llm(tenant_id: str) -> None:
try:
ModelProviderService().update_default_model_of_model_type(
tenant_id=tenant_id,
model_type="llm",
provider=dify_config.TOKENER_PROVIDER_NAME,
model=dify_config.TOKENER_DEFAULT_LLM_MODEL,
)
except Exception:
raise _BootstrapStepError(
"tokener_default_model_configuration_failed",
retryable=True,
stage=TenantTokenerIntegrationStatus.CONFIGURING_PROVIDER,
) from None
def _run_bootstrap(tenant_id: str) -> None:
try:
model_billing = ModelBillingProfileService.resolve(tenant_id)
except InvalidModelBillingProfileError:
raise _BootstrapStepError(
"tokener_model_billing_profile_invalid",
retryable=False,
stage=TenantTokenerIntegrationStatus.FAILED,
) from None
except ModelBillingProfileResolutionError:
raise _BootstrapStepError(
"tokener_model_billing_profile_unavailable",
retryable=True,
stage=TenantTokenerIntegrationStatus.PENDING,
) from None
if not model_billing.uses_tokener:
return
snapshot = _begin_attempt(tenant_id)
if snapshot is None or snapshot.status == TenantTokenerIntegrationStatus.READY:
return
_ensure_plugin_installed(snapshot)
credential_id = _ensure_managed_credential(snapshot)
_set_default_llm(snapshot.tenant_id)
_update_integration(
snapshot.tenant_id,
status=TenantTokenerIntegrationStatus.READY,
plugin_install_task_id=None,
provider_credential_id=credential_id,
ready=True,
)
@shared_task(
queue=TOKENER_BOOTSTRAP_QUEUE,
bind=True,
max_retries=_MAX_RETRIES,
default_retry_delay=_RETRY_DELAY_SECONDS,
acks_late=True,
reject_on_worker_lost=True,
)
def bootstrap_tokener_tenant_task(self, tenant_id: str) -> None:
"""Drive the idempotent bootstrap while serializing work per tenant."""
if not dify_config.TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED:
return
try:
lock = redis_client.lock(
f"tokener:new-tenant-bootstrap:{tenant_id}",
timeout=_LOCK_TIMEOUT_SECONDS,
blocking_timeout=0,
thread_local=False,
)
acquired = lock.acquire(blocking=False)
except Exception:
lock_error = _BootstrapStepError(
"tokener_bootstrap_lock_unavailable",
retryable=True,
stage=TenantTokenerIntegrationStatus.PENDING,
)
logger.warning("Tokener bootstrap lock is unavailable for tenant %s; scheduling retry", tenant_id)
raise self.retry(exc=lock_error, countdown=_RETRY_DELAY_SECONDS) from None
if not acquired:
raise self.retry(
exc=_BootstrapStepError(
"tokener_bootstrap_locked",
retryable=True,
stage=TenantTokenerIntegrationStatus.PENDING,
),
countdown=_RETRY_DELAY_SECONDS,
)
try:
_run_bootstrap(tenant_id)
except _BootstrapStepError as step_error:
exhausted = self.request.retries >= _MAX_RETRIES
_update_integration(
tenant_id,
status=TenantTokenerIntegrationStatus.FAILED if exhausted or not step_error.retryable else step_error.stage,
error_code=step_error.error_code,
)
if step_error.retryable and not exhausted:
countdown = min(_RETRY_DELAY_SECONDS * (2**self.request.retries), _MAX_RETRY_DELAY_SECONDS)
logger.warning(
"Tokener bootstrap will retry for tenant %s, error_code=%s, retry=%s/%s",
tenant_id,
step_error.error_code,
self.request.retries + 1,
_MAX_RETRIES,
)
raise self.retry(exc=step_error, countdown=countdown)
# Deliberately omit exception serialization from this secret-adjacent task.
logger.error( # noqa: TRY400
"Tokener bootstrap stopped for tenant %s, error_code=%s",
tenant_id,
step_error.error_code,
)
raise
except Exception:
sanitized_error = _BootstrapStepError(
"tokener_bootstrap_internal_error",
retryable=True,
stage=TenantTokenerIntegrationStatus.FAILED,
)
exhausted = self.request.retries >= _MAX_RETRIES
_update_integration(
tenant_id,
status=TenantTokenerIntegrationStatus.FAILED,
error_code=sanitized_error.error_code,
)
if not exhausted:
countdown = min(_RETRY_DELAY_SECONDS * (2**self.request.retries), _MAX_RETRY_DELAY_SECONDS)
logger.warning(
"Tokener bootstrap hit an internal error for tenant %s; scheduling retry %s/%s",
tenant_id,
self.request.retries + 1,
_MAX_RETRIES,
)
raise self.retry(exc=sanitized_error, countdown=countdown) from None
# Deliberately omit exception serialization from this secret-adjacent task.
logger.error("Tokener bootstrap retry budget exhausted for tenant %s", tenant_id) # noqa: TRY400
raise sanitized_error from None
finally:
try:
lock.release()
except Exception:
logger.warning("Tokener bootstrap lock expired before release for tenant %s", tenant_id)
@shared_task(queue=TOKENER_BOOTSTRAP_QUEUE)
def sweep_pending_tokener_integrations_task() -> int:
"""Recover bootstraps whose initial broker dispatch or retry chain was lost."""
if not dify_config.TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED:
return 0
stale_before = naive_utc_now() - timedelta(
minutes=dify_config.TOKENER_BOOTSTRAP_RECOVERY_TASK_INTERVAL,
)
recoverable_statuses = (
TenantTokenerIntegrationStatus.PENDING,
TenantTokenerIntegrationStatus.INSTALLING_PLUGIN,
TenantTokenerIntegrationStatus.PROVISIONING,
TenantTokenerIntegrationStatus.CONFIGURING_PROVIDER,
)
with session_factory.create_session() as session:
tenant_ids = list(
session.scalars(
select(TenantTokenerIntegration.tenant_id)
.join(
TenantModelBillingProfile,
TenantModelBillingProfile.tenant_id == TenantTokenerIntegration.tenant_id,
)
.where(
TenantModelBillingProfile.model_billing_source == "tokener",
TenantTokenerIntegration.status.in_(recoverable_statuses),
TenantTokenerIntegration.updated_at < stale_before,
)
.order_by(TenantTokenerIntegration.updated_at, TenantTokenerIntegration.id)
.limit(dify_config.TOKENER_BOOTSTRAP_RECOVERY_BATCH_SIZE)
)
)
dispatched = 0
for tenant_id in tenant_ids:
try:
bootstrap_tokener_tenant_task.delay(tenant_id)
except Exception:
# Do not attach broker exception details; the next beat will retry.
logger.error("Failed to recover Tokener bootstrap for tenant %s", tenant_id) # noqa: TRY400
else:
dispatched += 1
return dispatched

View File

@@ -0,0 +1,354 @@
"""Prepare migration resources without a trial grant, default-model or BYOK switch."""
import json
import logging
from datetime import timedelta
from typing import Any
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
from extensions.ext_redis import redis_client
from libs.datetime_utils import naive_utc_now
from models.account import Tenant
from models.model_billing_migration import TenantModelBillingMigration
from models.provider import ProviderCredential
from models.tokener import TenantTokenerIntegration, TenantTokenerIntegrationStatus
from services.billing_service import BillingService
from services.entities.model_billing_migration import canonical_hash
from services.model_billing_migration_service import ModelBillingMigrationService
from services.model_provider_service import ModelProviderService
from tasks.bootstrap_tokener_tenant_task import (
MANAGED_TOKENER_CREDENTIAL_NAME,
_begin_attempt,
_ensure_plugin_installed,
_update_integration,
)
logger = logging.getLogger(__name__)
class PreparationStepError(RuntimeError):
"""Only a safe error code may cross this key-handling boundary."""
def _bound_credential_id(session: Session, tenant_id: str) -> str | None:
"""Only the platform integration reference is authority; labels are editable."""
bound = session.execute(
select(ProviderCredential.id, ProviderCredential.encrypted_config)
.join(TenantTokenerIntegration, TenantTokenerIntegration.provider_credential_id == ProviderCredential.id)
.where(
TenantTokenerIntegration.tenant_id == tenant_id,
ProviderCredential.tenant_id == tenant_id,
ProviderCredential.provider_name == dify_config.TOKENER_PROVIDER_NAME,
ProviderCredential.encrypted_config != "",
)
).one_or_none()
if bound is None:
return None
pin = session.scalar(
select(TenantModelBillingMigration.state["provisioned_binding"]).where(
TenantModelBillingMigration.tenant_id == tenant_id,
)
)
if pin and (
pin.get("provider_credential_id") != bound.id
or pin.get("provider_name") != dify_config.TOKENER_PROVIDER_NAME
or pin.get("tenant_id") != tenant_id
or pin.get("credential_fingerprint") != canonical_hash({"encrypted_config": bound.encrypted_config})
):
return None
return bound.id
def _find_bound_credential(tenant_id: str) -> str | None:
with session_factory.create_session() as session:
return _bound_credential_id(session, tenant_id)
def _persist_key(tenant_id: str, migration_id: str, api_key: str) -> str | None:
"""Validate/encrypt, then atomically persist credential and trusted binding.
The usual create_provider_credential method also changes provider preference;
migration preparation intentionally must not invoke it. A duplicate delivery
or crash after commit reuses the bound ID, never a user-controlled name.
"""
credentials = {"api_key": api_key}
if dify_config.TOKENER_ENDPOINT_URL.strip():
credentials["endpoint_url"] = dify_config.TOKENER_ENDPOINT_URL.strip()
encrypted: dict[str, Any] = {}
try:
with session_factory.create_session() as session:
migration = session.get(TenantModelBillingMigration, tenant_id)
if migration is None or migration.migration_id != migration_id:
return None
existing_id = _bound_credential_id(session, tenant_id)
if existing_id is not None:
return existing_id
if migration.phase != "preparing":
return None
configuration = ModelProviderService()._get_provider_configuration(tenant_id, dify_config.TOKENER_PROVIDER_NAME)
encrypted = configuration.validate_provider_credentials(credentials)
with session_factory.create_session() as session, session.begin():
# Serialize against duplicate workers; this is a short, local-only transaction.
if session.scalar(select(Tenant).where(Tenant.id == tenant_id).with_for_update()) is None:
return None
migration = session.scalar(
select(TenantModelBillingMigration)
.where(
TenantModelBillingMigration.tenant_id == tenant_id,
TenantModelBillingMigration.migration_id == migration_id,
)
.with_for_update()
)
integration = session.scalar(
select(TenantTokenerIntegration)
.where(TenantTokenerIntegration.tenant_id == tenant_id)
.with_for_update()
)
if migration is None or integration is None:
return None
existing_id = _bound_credential_id(session, tenant_id)
if existing_id is not None:
return existing_id
if migration.phase != "preparing":
return None
base_name = f"{MANAGED_TOKENER_CREDENTIAL_NAME}:{migration_id}"
names = set(
session.scalars(
select(ProviderCredential.credential_name).where(
ProviderCredential.tenant_id == tenant_id,
ProviderCredential.provider_name == dify_config.TOKENER_PROVIDER_NAME,
ProviderCredential.credential_name.startswith(base_name, autoescape=True),
)
)
)
credential_name = base_name
suffix = 0
while credential_name in names:
suffix += 1
credential_name = f"{base_name}:{suffix}"
credential = ProviderCredential(
tenant_id=tenant_id,
provider_name=dify_config.TOKENER_PROVIDER_NAME,
credential_name=credential_name,
encrypted_config=json.dumps(encrypted),
)
session.add(credential)
session.flush()
integration.provider_credential_id = credential.id
integration.status = TenantTokenerIntegrationStatus.CONFIGURING_PROVIDER
migration.state = {
**migration.state,
"provisioned_binding": {
"tenant_id": tenant_id,
"migration_id": migration_id,
"provider_name": dify_config.TOKENER_PROVIDER_NAME,
"provider_credential_id": credential.id,
"credential_fingerprint": canonical_hash({"encrypted_config": credential.encrypted_config}),
},
}
migration.revision += 1
return credential.id
except Exception:
return None
finally:
credentials.clear()
encrypted.clear()
api_key = ""
def _prepare_resources(tenant_id: str, request: dict[str, Any]) -> str | None:
"""Consume a one-time key in-frame; never let upstream exceptions carry it."""
response: dict[str, Any] = {}
key = ""
try:
base_url = dify_config.TOKENER_BILLING_API_URL.strip().rstrip("/")
if not base_url:
return None
raw = BillingService._send_request(
"POST",
f"/internal/v1/tokener/tenants/{tenant_id}/prepare-migration",
json=request,
base_url=base_url,
)
if not isinstance(raw, dict):
return None
response = raw
key = response.pop("data_plane_api_key", "")
if (
response.get("tenant_id") != tenant_id
or response.get("status") != "ready"
or response.get("external_ref") != tenant_id
or not isinstance(key, str)
or not key
):
return None
migration_id = request.get("migration_id")
if not isinstance(migration_id, str) or not migration_id:
return None
return _persist_key(tenant_id, migration_id, key)
except Exception:
return None
finally:
key = ""
response.clear()
def _run_preparation(tenant_id: str, migration_id: str) -> None:
with session_factory.create_session() as session:
row = session.get(TenantModelBillingMigration, tenant_id)
tenant = session.get(Tenant, tenant_id)
if row is None or row.migration_id != migration_id or row.phase != "preparing" or tenant is None:
return
state = dict(row.state)
display_name = tenant.name
# Reuse the original operation identity, never derive it from retry time.
operation_id = next(iter(state["operations"]))
snapshot = _begin_attempt(tenant_id)
if snapshot is None:
raise PreparationStepError("migration_integration_missing")
_ensure_plugin_installed(snapshot)
credential_id = _find_bound_credential(tenant_id)
if credential_id is None:
request = {
"api_version": 1,
"expected_revision": 0,
"operation_id": operation_id,
"migration_id": migration_id,
"batch_id": state["batch_id"],
"eligibility_policy_version": state["eligibility_policy_version"],
"model_mapping_version": state["model_mapping_version"],
"inventory_hash": state["inventory_hash"],
"billing_preparation_ref": state["billing_preparation_ref"],
"display_name": display_name,
"phase": "resources",
}
credential_id = _prepare_resources(tenant_id, request)
if credential_id is None:
raise PreparationStepError("migration_resources_pending")
_update_integration(
tenant_id,
status=TenantTokenerIntegrationStatus.READY,
provider_credential_id=credential_id,
plugin_install_task_id=None,
ready=True,
)
from core.model_invocation_routing import validate_migration_readiness
readiness = validate_migration_readiness(tenant_id, state["model_mapping_version"])
if readiness["inventory_hash"] != state["inventory_hash"]:
raise PreparationStepError("migration_inventory_changed")
ModelBillingMigrationService.complete_preparation(
tenant_id,
migration_id,
source_ownership=readiness["source_ownership"],
expected_credential_fingerprint=readiness["credential_fingerprint"],
)
@shared_task(queue="plugin", bind=True, max_retries=60, acks_late=True, reject_on_worker_lost=True)
def prepare_tokener_migration_task(self, tenant_id: str, migration_id: str) -> None:
"""Resume a persisted preparation; disabling admission never loses recovery."""
lock = redis_client.lock(f"tokener:migration-prepare:{tenant_id}", timeout=300, blocking_timeout=0)
acquired = False
try:
acquired = lock.acquire(blocking=False)
if not acquired:
raise PreparationStepError("migration_prepare_locked")
_run_preparation(tenant_id, migration_id)
except Exception:
ModelBillingMigrationService.preparation_error(tenant_id, migration_id, "migration_prepare_pending")
logger.warning("Migration preparation pending tenant=%s migration=%s", tenant_id, migration_id)
raise self.retry(exc=PreparationStepError("migration_prepare_pending"), countdown=30) from None
finally:
if acquired:
try:
lock.release()
except Exception:
logger.warning("Migration preparation lease expired tenant=%s", tenant_id)
@shared_task(queue="plugin")
def resume_tokener_migration_task(tenant_id: str, migration_id: str) -> None:
"""Ask Billing to replay a durable financial intent; never invent an amount."""
status = ModelBillingMigrationService.status(tenant_id)
if (
status["migration_id"] != migration_id
or status["phase"] not in {"claimed", "granting", "activating", "blocked", "cancelled"}
or (status["phase"] == "cancelled" and status["ever_activated"])
or status["attention"] == "manual_required"
):
return
try:
base_url = dify_config.TOKENER_BILLING_API_URL.strip().rstrip("/")
if not base_url:
return
BillingService._send_request(
"POST",
f"/internal/v1/tokener/tenants/{tenant_id}/resume-migration",
json={"api_version": 1, "migration_id": migration_id},
base_url=base_url,
)
except Exception:
# Next minute retries the same durable intent; no retry-derived identities.
logger.warning("Migration financial recovery pending tenant=%s migration=%s", tenant_id, migration_id)
@shared_task(queue="plugin")
def sweep_tokener_migrations_task() -> int:
"""Recover dropped prepare work and emit attention without resetting claimed_at."""
now = naive_utc_now()
pending: list[tuple[str, str]] = []
financial: list[tuple[str, str]] = []
with session_factory.create_session() as session, session.begin():
rows = session.scalars(
select(TenantModelBillingMigration)
.where(
(TenantModelBillingMigration.phase != "active")
& (
(TenantModelBillingMigration.phase != "cancelled")
| TenantModelBillingMigration.state["ever_activated"].as_boolean().is_not(True)
),
TenantModelBillingMigration.attention != "manual_required",
)
.order_by(TenantModelBillingMigration.updated_at)
.limit(500)
.with_for_update(skip_locked=True)
)
for row in rows:
if row.phase == "preparing" and row.updated_at < now - timedelta(minutes=1):
pending.append((row.tenant_id, row.migration_id))
if row.claimed_at is None:
continue
age = (now - row.claimed_at).total_seconds()
activated = row.state.get("ever_activated", False)
attention = (
"normal" if activated else "manual_required" if age > 1800 else "alerted" if age > 300 else "normal"
)
if attention != "manual_required" and (
row.phase in {"claimed", "granting", "activating", "blocked"}
or (row.phase == "cancelled" and not activated)
):
financial.append((row.tenant_id, row.migration_id))
if row.attention != attention:
row.attention = attention
logger.warning(
"Tokener migration attention=%s tenant=%s migration=%s batch=%s age_seconds=%s",
attention,
row.tenant_id,
row.migration_id,
row.batch_id,
int(age),
)
for tenant_id, migration_id in pending:
ModelBillingMigrationService.enqueue_prepare(tenant_id, migration_id)
for tenant_id, migration_id in financial:
try:
resume_tokener_migration_task.delay(tenant_id, migration_id)
except Exception:
logger.warning("Migration financial recovery enqueue failed tenant=%s", tenant_id)
return len(pending) + len(financial)

View File

@@ -783,18 +783,18 @@ class TestWorkspaceService:
feature.billing.subscription.plan = CloudPlan.SANDBOX
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
paid_pool = MagicMock(quota_limit=1000, quota_used=0)
trial_pool = MagicMock(quota_limit=200, quota_used=20)
with (
patch("services.workspace_service.current_user", account),
patch("services.credit_pool_service.CreditPoolService.get_pool", side_effect=[paid_pool, trial_pool]),
patch("services.credit_pool_service.CreditPoolService.get_pool", return_value=trial_pool) as get_pool,
):
result = WorkspaceService.get_tenant_info(tenant, db_session_with_containers)
assert result is not None
assert result["trial_credits"] == 200
assert result["trial_credits_used"] == 20
get_pool.assert_called_once_with(tenant_id=tenant.id, pool_type="trial", session=db_session_with_containers)
def test_get_tenant_info_cloud_both_pools_none(
self, db_session_with_containers: Session, mock_external_service_dependencies

View File

@@ -0,0 +1,57 @@
import json
from flask import Flask
import model_billing_migration_commands as commands
TENANT = "11111111-1111-4111-8111-111111111111"
def runner(mocker, *, admitted=True):
mocker.patch.object(commands.ModelBillingMigrationService, "admitted", return_value=admitted)
mocker.patch.object(commands, "migration_inventory", return_value={"inventory_hash": "sha256:" + "a" * 64})
app = Flask(__name__)
app.cli.add_command(commands.tokener_migration)
return app.test_cli_runner()
def args(*extra):
return [
"tokener-migration",
"prepare",
"--tenant-id",
TENANT,
"--batch-id",
"test-batch",
"--mapping-version",
"v1",
*extra,
]
def test_prepare_is_read_only_by_default_and_identity_does_not_drift(mocker):
cli = runner(mocker)
post = mocker.patch.object(commands, "_post")
first = cli.invoke(args=args())
second = cli.invoke(args=args())
assert first.exit_code == second.exit_code == 0
assert json.loads(first.output) == json.loads(second.output)
assert json.loads(first.output)["dry_run"]
post.assert_not_called()
def test_apply_only_prepares_not_trial_or_activation(mocker):
cli = runner(mocker)
post = mocker.patch.object(commands, "_post")
result = cli.invoke(args=args("--apply"))
assert result.exit_code == 0
assert post.call_args.args[:2] == (TENANT, "prepare-migration")
assert post.call_args.args[2]["expected_revision"] == 0
assert "amount" not in result.output
def test_prepare_rejects_unlisted_tenant_before_external_work(mocker):
cli = runner(mocker, admitted=False)
post = mocker.patch.object(commands, "_post")
assert cli.invoke(args=args("--apply")).exit_code == 1
post.assert_not_called()

View File

@@ -213,6 +213,27 @@ def test_new_user_default_models_reject_duplicate_model_types() -> None:
_ = config.NEW_USER_DEFAULT_MODEL_LIST
def test_tokener_new_tenant_bootstrap_config_is_parsed() -> None:
config = _make_config(
TOKENER_NEW_TENANT_COHORT_ENABLED="true",
TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED="true",
TOKENER_BILLING_API_URL="http://dify-saas-billing:8081",
TOKENER_PLUGIN_UNIQUE_IDENTIFIER="langgenius/tokener:0.1.2@checksum",
TOKENER_PLUGIN_INSTALL_SOURCE="package",
TOKENER_ENDPOINT_URL="https://api-staging.tokener.dev/v1",
TOKENER_BOOTSTRAP_RECOVERY_TASK_INTERVAL="7",
TOKENER_BOOTSTRAP_RECOVERY_BATCH_SIZE="50",
)
assert config.TOKENER_NEW_TENANT_COHORT_ENABLED is True
assert config.TOKENER_NEW_TENANT_BOOTSTRAP_ENABLED is True
assert config.TOKENER_BILLING_API_URL == "http://dify-saas-billing:8081"
assert config.TOKENER_PLUGIN_INSTALL_SOURCE == "package"
assert config.TOKENER_ENDPOINT_URL == "https://api-staging.tokener.dev/v1"
assert config.TOKENER_BOOTSTRAP_RECOVERY_TASK_INTERVAL == 7
assert config.TOKENER_BOOTSTRAP_RECOVERY_BATCH_SIZE == 50
def test_http_timeout_defaults():
"""Test that HTTP timeout defaults are correctly set"""
config = _make_config()

View File

@@ -250,13 +250,17 @@ def persist_service_api_dataset_owner(
@pytest.fixture
def app_services(sqlite_session_factory: sessionmaker[Session]) -> AppServices:
from unittest.mock import Mock
from extensions.application_services.app import build_app_services
from extensions.ext_application_services import _build_oauth_server_service
from extensions.ext_redis import redis_client
from services.recommended_app_package_service import RecommendedAppPackageService
return build_app_services(
database_client=sqlite_session_factory,
oauth=_build_oauth_server_service(database_client=sqlite_session_factory, redis=redis_client),
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
)

View File

@@ -4,7 +4,8 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from inspect import unwrap
from typing import BinaryIO, cast
from uuid import uuid4
from unittest.mock import Mock
from uuid import UUID, uuid4
import httpx
import pytest
@@ -25,6 +26,7 @@ from services.app.console_gateway import AppTransferGateway
from services.app_package_service import AppPackageService, PreparedAppPackage
from services.entities.dsl_entities import AppImportPackage, AppImportParams, DslImportWarning, Import, ImportStatus
from services.entities.feature_entities import FeatureModel, LimitationModel
from services.recommended_app_package_service import RecommendedAgentPackageSource, RecommendedAppPackageService
from tests.unit_tests.controllers.conftest import ControllerTestServices
@@ -331,6 +333,157 @@ def test_export_query_rejects_conflicting_version_selectors() -> None:
)
def test_template_import_resolves_catalog_source_and_preserves_overrides(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
template_id = UUID("11111111-1111-4111-8111-111111111111")
version_id = UUID("22222222-2222-4222-8222-222222222222")
source = RecommendedAgentPackageSource("source-tenant", "source-agent", version_id)
catalog = Mock()
catalog.get_package_source.return_value = source
transfers._recommended_packages = RecommendedAppPackageService(sources=catalog, exporter=Mock())
import_template = Mock(return_value=RosterAgentPackageImportResult("new-app", "new-agent", []))
monkeypatch.setattr(transfers._agent_importer, "import_template", import_template)
data, status = _post(
app,
imports,
json={
"mode": "template",
"template_id": str(template_id),
"version_id": str(version_id),
"name": "My Agent",
"app_id": "",
},
)
assert status == 200
assert data["app_id"] == "new-app"
assert data["app_mode"] == "agent"
catalog.get_package_source.assert_called_once_with(str(template_id), version_id)
import_template.assert_called_once()
assert import_template.call_args.kwargs["source"] == source
assert import_template.call_args.kwargs["tenant_id"] == imports.context.active_workspace_id
assert import_template.call_args.kwargs["account"].id == imports.context.account_id
assert import_template.call_args.kwargs["name"] == "My Agent"
def test_template_import_rejects_unlisted_source(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
catalog = Mock()
catalog.get_package_source.return_value = None
transfers._recommended_packages = RecommendedAppPackageService(sources=catalog, exporter=Mock())
import_template = Mock()
monkeypatch.setattr(transfers._agent_importer, "import_template", import_template)
with pytest.raises(import_module.RecommendedAppNotFoundHttpError):
_post(
app,
imports,
json={
"mode": "template",
"template_id": "11111111-1111-4111-8111-111111111111",
"version_id": "22222222-2222-4222-8222-222222222222",
},
)
import_template.assert_not_called()
def test_package_url_import_preserves_overrides(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
import_url = Mock(return_value=RosterAgentPackageImportResult("new-app", "new-agent", []))
monkeypatch.setattr(transfers._agent_importer, "import_package_url", import_url)
data, status = _post(
app,
imports,
json={"mode": "ifpkg-url", "package_url": "https://example.com/agent.ifpkg", "name": "My Agent"},
)
assert status == 200
assert data["app_id"] == "new-app"
import_url.assert_called_once()
assert import_url.call_args.kwargs["url"] == "https://example.com/agent.ifpkg"
assert import_url.call_args.kwargs["name"] == "My Agent"
def test_package_url_retains_legacy_optional_selectors(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
import_url = Mock(return_value=RosterAgentPackageImportResult("new-app", "new-agent", []))
monkeypatch.setattr(transfers._agent_importer, "import_package_url", import_url)
data, status = _post(
app,
imports,
json={
"mode": "ifpkg-url",
"package_url": "https://example.com/agent.ifpkg",
"app_id": "",
"template_id": "11111111-1111-4111-8111-111111111111",
"version_id": "22222222-2222-4222-8222-222222222222",
},
)
assert status == 200
assert data["app_id"] == "new-app"
import_url.assert_called_once()
assert import_url.call_args.kwargs["url"] == "https://example.com/agent.ifpkg"
@pytest.mark.parametrize("mode", ["template", "ifpkg-url"])
def test_template_import_checks_permissions_before_accessing_source(
app: Flask,
imports: Imports,
app_query_services: ControllerTestServices,
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
mode: str,
) -> None:
config_overrides(RBAC_ENABLED=True)
monkeypatch.setattr(
"services.app.console_gateway.rbac_service.RBACService.CheckAccess.check", lambda *_a, **_k: False
)
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
importer = Mock()
monkeypatch.setattr(transfers, "_agent_importer", importer)
payload: dict[str, object] = (
{
"mode": "template",
"template_id": "11111111-1111-4111-8111-111111111111",
"version_id": "22222222-2222-4222-8222-222222222222",
}
if mode == "template"
else {"mode": "ifpkg-url", "package_url": "https://example.com/agent.ifpkg"}
)
with pytest.raises(Forbidden):
_post(app, imports, json=payload)
importer.assert_not_called()
@pytest.mark.parametrize(
"extra",
[
{"app_id": "existing"},
{"yaml_content": "app: {}"},
],
)
def test_package_url_rejects_ambiguous_sources(app: Flask, imports: Imports, extra: dict[str, str]) -> None:
with pytest.raises(InvalidRosterAgentPackageError):
_post(app, imports, json={"mode": "ifpkg-url", "package_url": "https://example.com/agent.ifpkg", **extra})
@pytest.mark.parametrize("quota", [False, True])
def test_ordinary_package_import_checks_permission_and_quota(
app: Flask, imports: Imports, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], quota: bool

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