mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
350 lines
15 KiB
Python
350 lines
15 KiB
Python
"""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 ( # noqa: E402 - standalone CLI first adds the API import root
|
|
CurrentMigrationStatusResponse,
|
|
MigrationErrorResponse,
|
|
MigrationOperationResult,
|
|
MigrationStatusResponse,
|
|
)
|
|
from services.entities.model_billing_migration import ( # noqa: E402 - standalone CLI first adds the API import root
|
|
ActivateMigrationPayload,
|
|
ClaimFreeMigrationPayload,
|
|
CommandReceipt,
|
|
ContractEventPayload,
|
|
DecideCyclePayload,
|
|
FreeGrantManifest,
|
|
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,
|
|
}
|
|
|
|
# The paid v1 model map and emitted snapshot remain byte-for-byte frozen. Free
|
|
# funding extends current status through a separate owner and additive contract.
|
|
FREE_MODELS = {
|
|
"ClaimFreeRequest": ClaimFreeMigrationPayload,
|
|
"FreeGrantManifest": FreeGrantManifest,
|
|
"FreeStatusResponse": CurrentMigrationStatusResponse,
|
|
"FundingReceiptsRequest": FundingReceiptsPayload,
|
|
"ActivateRequest": ActivateMigrationPayload,
|
|
}
|
|
|
|
|
|
def build_free_schema() -> dict[str, Any]:
|
|
definitions: dict[str, Any] = {}
|
|
for alias, model in FREE_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 free contract owner: {name}")
|
|
definitions[name] = definition
|
|
definitions[model.__name__] = schema
|
|
if alias != model.__name__:
|
|
definitions[alias] = {"$ref": "#/$defs/" + model.__name__}
|
|
# Mirror the current status owner's cross-field validator without changing
|
|
# the historical paid StatusResponse definition or its snapshot bytes.
|
|
definitions["CurrentMigrationStatusResponse"]["not"] = {"required": ["manifest", "free_grant_manifest"]}
|
|
return {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"title": "Dify one-time free model-billing migration manifest v2",
|
|
"x-contract-revision": "1.2",
|
|
"$comment": (
|
|
"Additive free grant contract; HTTP api_version remains 1. Paid WindowManifest v1 is unchanged. "
|
|
"Runtime additionally enforces exact grant identity/body hash, lease, row-lock/CAS, "
|
|
"mutually exclusive funding kinds and verified readback before activation."
|
|
),
|
|
"$defs": _wire_schema(definitions),
|
|
}
|
|
|
|
|
|
def free_fixture() -> dict[str, Any]:
|
|
manifest = FreeGrantManifest(
|
|
schema_version=2,
|
|
funding_kind="one_time_trial_grant",
|
|
tenant_id="11111111-1111-4111-8111-111111111111",
|
|
migration_id="22222222-2222-4222-8222-222222222222",
|
|
plan_revision=1,
|
|
grant_id="trial.v1.11111111-1111-4111-8111-111111111111",
|
|
external_ref="11111111-1111-4111-8111-111111111111",
|
|
amount_usd_micro="250000",
|
|
occurred_at="2026-09-02T01:02:03.456Z",
|
|
amount_policy_version="signup-trial-v1",
|
|
model_mapping_version="hosted-v1",
|
|
)
|
|
data = manifest.model_dump(mode="json")
|
|
return {
|
|
"manifest": data,
|
|
"manifest_hash": canonical_hash(data),
|
|
"partner_payload": manifest.partner_payload(),
|
|
"partner_payload_hash": canonical_hash(manifest.partner_payload()),
|
|
"command_id": "grant:" + manifest.grant_id,
|
|
"remote_id": manifest.grant_id,
|
|
}
|
|
|
|
|
|
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(
|
|
"--free-output", type=Path, default=API_ROOT / "contracts/model-billing-migration-free-v2.schema.json"
|
|
)
|
|
parser.add_argument(
|
|
"--free-fixture-output",
|
|
type=Path,
|
|
default=API_ROOT / "contracts/fixtures/tokener_migration_free_grant_v2.json",
|
|
)
|
|
parser.add_argument("--check", action="store_true")
|
|
args = parser.parse_args()
|
|
content = json.dumps(build_schema(), ensure_ascii=False, indent=2) + "\n"
|
|
free_content = json.dumps(build_free_schema(), ensure_ascii=False, indent=2) + "\n"
|
|
fixture_content = json.dumps(free_fixture(), 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")
|
|
if not args.free_output.exists() or args.free_output.read_text() != free_content:
|
|
raise SystemExit("Free migration contract is stale; regenerate from its Pydantic owners")
|
|
if not args.free_fixture_output.exists() or args.free_fixture_output.read_text() != fixture_content:
|
|
raise SystemExit("Free migration fixture is stale; regenerate from its Pydantic owners")
|
|
return
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(content)
|
|
args.free_output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.free_output.write_text(free_content)
|
|
args.free_fixture_output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.free_fixture_output.write_text(fixture_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()
|