Compare commits
271 Commits
test/real-
...
deploy/age
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f1efddf8 | ||
|
|
7224a668c1 | ||
|
|
11d06e9b9b | ||
|
|
19761f4ae1 | ||
|
|
1c7f31a93d | ||
|
|
f6ad937a0a | ||
|
|
f93db6391d | ||
|
|
c65e593715 | ||
|
|
625d80b467 | ||
|
|
755d07227f | ||
|
|
79048e83c0 | ||
|
|
38946927ae | ||
|
|
24d5f651a9 | ||
|
|
9de1ba560d | ||
|
|
155f170543 | ||
|
|
a6ca4dcb28 | ||
|
|
b62f8a6b2f | ||
|
|
c68b2e5476 | ||
|
|
96660fb013 | ||
|
|
add43e9e47 | ||
|
|
d076cf8407 | ||
|
|
9fbadfabf3 | ||
|
|
67ffa9e767 | ||
|
|
c5fb28086a | ||
|
|
31d69e8b1d | ||
|
|
d9a20b2f94 | ||
|
|
f5271f2405 | ||
|
|
c2cd488656 | ||
|
|
9246fc8cdd | ||
|
|
2b92b3b114 | ||
|
|
905768666b | ||
|
|
ca98122d42 | ||
|
|
b27f734099 | ||
|
|
20cc7ba6f0 | ||
|
|
2fadd5bcab | ||
|
|
8eb62a2526 | ||
|
|
81c5e3e531 | ||
|
|
16ebec5782 | ||
|
|
a368af92ad | ||
|
|
020a3ea107 | ||
|
|
f5c90ecbaa | ||
|
|
f26a7eac9f | ||
|
|
f8ae28cc11 | ||
|
|
0b0786637f | ||
|
|
4f40a83d75 | ||
|
|
ceead8f488 | ||
|
|
c5d7109310 | ||
|
|
f18d98a6b5 | ||
|
|
bc5cf3fa18 | ||
|
|
f5ca1f565e | ||
|
|
08e380dc44 | ||
|
|
f590082ead | ||
|
|
6bbeec968d | ||
|
|
e0a61f32ea | ||
|
|
8a2e03053e | ||
|
|
bda58a77cf | ||
|
|
7e0d6308d1 | ||
|
|
220ca914f8 | ||
|
|
12c00556d4 | ||
|
|
da0f3eac05 | ||
|
|
084f52998f | ||
|
|
6114b2e86a | ||
|
|
7ac5d3c30e | ||
|
|
6b631ee990 | ||
|
|
31b31c765c | ||
|
|
40e88140de | ||
|
|
6ac8d34453 | ||
|
|
49687d7183 | ||
|
|
9accabbe6c | ||
|
|
c23cb8c703 | ||
|
|
01d2fc49de | ||
|
|
2679bf1880 | ||
|
|
e0a76a8645 | ||
|
|
195fc5299b | ||
|
|
7842252964 | ||
|
|
992d89c518 | ||
|
|
5ac2748c7b | ||
|
|
66d989a063 | ||
|
|
c5c8753e12 | ||
|
|
c48bdd32ad | ||
|
|
74d67ca072 | ||
|
|
629d418ae8 | ||
|
|
d147e52a33 | ||
|
|
5bfbd5e48b | ||
|
|
bf5724a3b6 | ||
|
|
fced61a505 | ||
|
|
040e9b56d9 | ||
|
|
049fdb88da | ||
|
|
55eaafdfc5 | ||
|
|
5404df5339 | ||
|
|
ce43812da6 | ||
|
|
498c98fc05 | ||
|
|
9bcfd614fb | ||
|
|
283538bff9 | ||
|
|
a3157e2ede | ||
|
|
1678c10481 | ||
|
|
fb2d1413c2 | ||
|
|
66cf320dd0 | ||
|
|
7cb501332e | ||
|
|
5ebd4bf132 | ||
|
|
01ea676576 | ||
|
|
4df04cb7fb | ||
|
|
00fdcbe910 | ||
|
|
2456073556 | ||
|
|
16d7826514 | ||
|
|
cce98ca401 | ||
|
|
d247415a05 | ||
|
|
1d2dbe71f0 | ||
|
|
ab4e262bcb | ||
|
|
eaa8750e47 | ||
|
|
6c2439d996 | ||
|
|
7c92504b9f | ||
|
|
d60f0a6fd6 | ||
|
|
682b298f8d | ||
|
|
acf51ce258 | ||
|
|
0a41494c3c | ||
|
|
8acb2b47d3 | ||
|
|
6178ceb2fd | ||
|
|
887662f714 | ||
|
|
51069c8f05 | ||
|
|
54a91d9c71 | ||
|
|
14b79c48fc | ||
|
|
9d60f80758 | ||
|
|
0717dbe949 | ||
|
|
796b09962a | ||
|
|
509d5508bb | ||
|
|
2e9c9ca055 | ||
|
|
39da6c4ff3 | ||
|
|
f0618be386 | ||
|
|
4d8eefa9af | ||
|
|
d76f4759a4 | ||
|
|
46eb284016 | ||
|
|
326ccb0551 | ||
|
|
7799af2053 | ||
|
|
1a9b97d8f2 | ||
|
|
a7b139e64c | ||
|
|
b9c000d9f0 | ||
|
|
b28f71a1ca | ||
|
|
eb92a4480b | ||
|
|
879fe97bf2 | ||
|
|
11a53ba52f | ||
|
|
c7d89658bc | ||
|
|
54a0108ff1 | ||
|
|
4378401341 | ||
|
|
7ede2ae1fd | ||
|
|
df0420e036 | ||
|
|
2f59839816 | ||
|
|
434a70a031 | ||
|
|
ac26025cca | ||
|
|
a881f46f9d | ||
|
|
fa8fc8d0f5 | ||
|
|
58a6fb54f8 | ||
|
|
36ca438c49 | ||
|
|
5e219ffa6d | ||
|
|
c76a39a722 | ||
|
|
e1f7af292a | ||
|
|
8724dbfa3b | ||
|
|
e3e1e89f8d | ||
|
|
3be1a58989 | ||
|
|
90bb929728 | ||
|
|
6e8baa7d9e | ||
|
|
a517065b2f | ||
|
|
d80a462b4d | ||
|
|
7382dc616c | ||
|
|
21f20131cf | ||
|
|
d73a3d7605 | ||
|
|
e1a329413b | ||
|
|
38471217df | ||
|
|
42b002a8d2 | ||
|
|
1209ee74b4 | ||
|
|
dacca2c1be | ||
|
|
10204bd203 | ||
|
|
bb8dbf3583 | ||
|
|
0476f31a16 | ||
|
|
174e8ab0cd | ||
|
|
4e57341661 | ||
|
|
1fec074c6c | ||
|
|
d877bb854f | ||
|
|
dcdf10f118 | ||
|
|
94a7b6e7c6 | ||
|
|
7ce4108f1d | ||
|
|
32b4d9f885 | ||
|
|
3900dfde75 | ||
|
|
f6ee50575b | ||
|
|
87b887ef17 | ||
|
|
5ad03563ab | ||
|
|
1d2190fffa | ||
|
|
7d7466869a | ||
|
|
d8bdbd48d1 | ||
|
|
a28e3c7361 | ||
|
|
44aa3fbb79 | ||
|
|
53b55dc536 | ||
|
|
9c3befe61f | ||
|
|
7dbd2d3d08 | ||
|
|
50441b36c1 | ||
|
|
7e55ceb3f7 | ||
|
|
acc5120042 | ||
|
|
1a3dadf8eb | ||
|
|
11c6e1fafb | ||
|
|
b8d7a766cf | ||
|
|
8caea7bc9b | ||
|
|
29ad30a1d3 | ||
|
|
947dd33663 | ||
|
|
86431084f9 | ||
|
|
c99c218c7a | ||
|
|
95fda2987f | ||
|
|
2f1ef6a654 | ||
|
|
63ec8aa813 | ||
|
|
af9f7d9f17 | ||
|
|
1ef9f06d52 | ||
|
|
3c386d0f8b | ||
|
|
703c8264f9 | ||
|
|
365f03c3c6 | ||
|
|
0e4c86e4dd | ||
|
|
fe76265e55 | ||
|
|
992eb616f2 | ||
|
|
a1509c876d | ||
|
|
614b3fd5b8 | ||
|
|
8a559edd47 | ||
|
|
b20ae6a20d | ||
|
|
97308f7ca5 | ||
|
|
a3e7b22c67 | ||
|
|
1a5e895c87 | ||
|
|
8e626d0a44 | ||
|
|
0822b17e8d | ||
|
|
c88a48a277 | ||
|
|
8a008b6873 | ||
|
|
e986285a57 | ||
|
|
2b839735ae | ||
|
|
685aa43fce | ||
|
|
3ec0d02a80 | ||
|
|
ecdb634e64 | ||
|
|
14994753dd | ||
|
|
ac7cd31248 | ||
|
|
4f550a85f9 | ||
|
|
8a2850e115 | ||
|
|
57354a17b1 | ||
|
|
71637fd321 | ||
|
|
015776eaee | ||
|
|
048941afa4 | ||
|
|
1622023e71 | ||
|
|
a29f58aa8f | ||
|
|
da6e012d71 | ||
|
|
b9c2d68bec | ||
|
|
d758e9c7a6 | ||
|
|
fcfe01ec89 | ||
|
|
50778a71fd | ||
|
|
3033b2d736 | ||
|
|
27959b9f7c | ||
|
|
c9b3b82105 | ||
|
|
0a8aee1b04 | ||
|
|
736baac996 | ||
|
|
a146439ff3 | ||
|
|
52f807d2d0 | ||
|
|
76bca007ba | ||
|
|
32ead26422 | ||
|
|
f9d308c931 | ||
|
|
9f5fdfccec | ||
|
|
bac4ddd0f8 | ||
|
|
a54222e7fa | ||
|
|
0334f4426d | ||
|
|
23846f4349 | ||
|
|
f2aa2123ca | ||
|
|
bb9fc1a08e | ||
|
|
1f939961f4 | ||
|
|
4580c76012 | ||
|
|
65bfdd3e05 | ||
|
|
f7fb1abf4f | ||
|
|
385e2c6dc9 | ||
|
|
0c51942b66 | ||
|
|
fbe1c60e3f |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
api/contracts/fixtures/tokener_manual_replan_v1.json
Normal file
159
api/contracts/fixtures/tokener_manual_replan_v1.json
Normal 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
|
||||
}
|
||||
121
api/contracts/fixtures/tokener_migration_manifest_v1.json
Normal file
121
api/contracts/fixtures/tokener_migration_manifest_v1.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1383
api/contracts/model-billing-migration-v1.fixtures.json
Normal file
1383
api/contracts/model-billing-migration-v1.fixtures.json
Normal file
File diff suppressed because it is too large
Load Diff
1285
api/contracts/model-billing-migration-v1.schema.json
Normal file
1285
api/contracts/model-billing-migration-v1.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
232
api/controllers/inner_api/model_billing_migration.py
Normal file
232
api/controllers/inner_api/model_billing_migration.py
Normal 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 {})
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
113
api/core/app/apps/execution_stream.py
Normal file
113
api/core/app/apps/execution_stream.py
Normal 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
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
194
api/core/app/llm/message_billing.py
Normal file
194
api/core/app/llm/message_billing.py
Normal 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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
api/core/model_billing_migration_protocol.py
Normal file
29
api/core/model_billing_migration_protocol.py
Normal 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()
|
||||
)
|
||||
242
api/core/model_billing_profile.py
Normal file
242
api/core/model_billing_profile.py
Normal 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,
|
||||
)
|
||||
710
api/core/model_invocation_routing.py
Normal file
710
api/core/model_invocation_routing.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
48
api/core/repositories/model_billing_migration_repository.py
Normal file
48
api/core/repositories/model_billing_migration_repository.py
Normal 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
|
||||
266
api/dev/generate_model_billing_migration_contract.py
Normal file
266
api/dev/generate_model_billing_migration_contract.py
Normal 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()
|
||||
128
api/docs/tokener-legacy-migration.md
Normal file
128
api/docs/tokener-legacy-migration.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
api/fields/app_export_fields.py
Normal file
5
api/fields/app_export_fields.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from fields.base import ResponseModel
|
||||
|
||||
|
||||
class AppExportResponse(ResponseModel):
|
||||
data: str
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
92
api/model_billing_migration_commands.py
Normal file
92
api/model_billing_migration_commands.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
46
api/models/model_billing.py
Normal file
46
api/models/model_billing.py
Normal 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,
|
||||
)
|
||||
60
api/models/model_billing_migration.py
Normal file
60
api/models/model_billing_migration.py
Normal 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
84
api/models/tokener.py
Normal 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,
|
||||
)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
220
api/services/entities/model_billing_migration.py
Normal file
220
api/services/entities/model_billing_migration.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
853
api/services/model_billing_migration_service.py
Normal file
853
api/services/model_billing_migration_service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
53
api/services/recommended_app_package_service.py
Normal file
53
api/services/recommended_app_package_service.py
Normal 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)
|
||||
@@ -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, ...]:
|
||||
|
||||
39
api/services/tenant_model_billing_service.py
Normal file
39
api/services/tenant_model_billing_service.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
534
api/tasks/bootstrap_tokener_tenant_task.py
Normal file
534
api/tasks/bootstrap_tokener_tenant_task.py
Normal 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
|
||||
354
api/tasks/prepare_tokener_migration_task.py
Normal file
354
api/tasks/prepare_tokener_migration_task.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user