From 068287e38c57e6e091244aa374eb18eef08455ab Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:35:46 +0800 Subject: [PATCH 01/17] chore(spec-sync): add fetch/normalize script and V1 spec snapshot baseline Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spec-sync/fetch-normalize.sh | 12 + specs/v1-ade.json | 2848 ++++++++++++++++++++++++++ 2 files changed, 2860 insertions(+) create mode 100755 scripts/spec-sync/fetch-normalize.sh create mode 100644 specs/v1-ade.json diff --git a/scripts/spec-sync/fetch-normalize.sh b/scripts/spec-sync/fetch-normalize.sh new file mode 100755 index 0000000..2c0931c --- /dev/null +++ b/scripts/spec-sync/fetch-normalize.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Fetch a live OpenAPI spec and emit normalized (stable, key-sorted) JSON to stdout. +# Normalization makes byte-diffs meaningful: same spec content -> identical bytes. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: fetch-normalize.sh " >&2 + exit 2 +fi + +url="$1" +curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url" | jq -S . diff --git a/specs/v1-ade.json b/specs/v1-ade.json new file mode 100644 index 0000000..7ef061f --- /dev/null +++ b/specs/v1-ade.json @@ -0,0 +1,2848 @@ +{ + "components": { + "schemas": { + "AsyncExtractRequest": { + "description": "Request model for async extract endpoint.\n\nExtends ExtractRequest with output_save_url for ZDR support.", + "properties": { + "markdown": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The Markdown file or Markdown content to extract data from.", + "title": "Markdown" + }, + "markdown_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL to the Markdown file to extract data from.", + "title": "Markdown Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The version of the model to use for extraction. Use `extract-latest` to use the latest version.", + "title": "Model" + }, + "output_save_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "If zero data retention (ZDR) is enabled, you must enter a URL for the extracted output to be saved to. When ZDR is enabled, the extracted content will not be in the API response.", + "title": "Output Save Url" + }, + "schema": { + "description": "JSON schema for field extraction. This schema determines what key-values pairs are extracted from the Markdown. The schema must be a valid JSON object and will be validated before processing the document.", + "title": "Schema", + "type": "string" + }, + "strict": { + "default": false, + "description": "If True, reject schemas with unsupported fields (HTTP 422). If False, prune unsupported fields and continue. Only applies to extract versions that support schema validation.", + "title": "Strict", + "type": "boolean" + } + }, + "required": [ + "schema" + ], + "title": "AsyncExtractRequest", + "type": "object" + }, + "AsyncParseRequestWithEncryptedPassword": { + "properties": { + "custom_prompts": { + "anyOf": [ + { + "contentMediaType": "application/json", + "contentSchema": { + "$ref": "#/components/schemas/CustomPrompts" + }, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional JSON string mapping chunk types to custom parsing prompts. Only the `figure` key is supported, for example '{\"figure\":\"Describe axis labels in detail.\"}'.", + "title": "Custom Prompts" + }, + "document": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document_url` parameter must be provided.", + "title": "Document" + }, + "document_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL to the file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document` parameter must be provided.", + "title": "Document Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The version of the model to use for parsing.", + "title": "Model" + }, + "output_save_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "If zero data retention (ZDR) is enabled, you must enter a URL for the parsed output to be saved to. When ZDR is enabled, the parsed content will not be in the API response.", + "title": "Output Save Url" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Password for encrypted document files. If the document is password-protected, provide the password to decrypt and process the document. Ignored for unencrypted documents.", + "title": "Password" + }, + "split": { + "anyOf": [ + { + "$ref": "#/components/schemas/SplitType" + }, + { + "type": "null" + } + ], + "description": "If you want to split documents into smaller sections, include the split parameter. Set the parameter to page to split documents at the page level. The splits object in the API output will contain a set of data for each page." + } + }, + "title": "AsyncParseRequestWithEncryptedPassword", + "type": "object" + }, + "BuildSchemaMetadata": { + "properties": { + "credit_usage": { + "default": 0.0, + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "default": 0, + "title": "Duration Ms", + "type": "integer" + }, + "filename": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filename" + }, + "job_id": { + "default": "", + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + }, + "warnings": { + "description": "Structured warnings from the extraction process. Each warning is an instance of ExtractWarning with 'code' (e.g. 'nonconformant_schema') and 'msg' (human-readable description). Present only for extract versions from extract-20260314 and above that support structured warnings.", + "items": { + "$ref": "#/components/schemas/ExtractWarning" + }, + "title": "Warnings", + "type": "array" + } + }, + "title": "BuildSchemaMetadata", + "type": "object" + }, + "BuildSchemaRequest": { + "properties": { + "markdown_urls": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "URLs to Markdown files to analyze for schema generation.", + "title": "Markdown Urls" + }, + "markdowns": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "Markdown files or inline content strings to analyze for schema generation. Multiple documents can be provided for better schema coverage.", + "title": "Markdowns" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The version of the model to use for schema generation. Use `extract-latest` to use the latest version.", + "title": "Model" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Instructions for how to generate or modify the schema.", + "title": "Prompt" + }, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Existing JSON schema to iterate on or refine.", + "title": "Schema" + } + }, + "title": "BuildSchemaRequest", + "type": "object" + }, + "BuildSchemaResponse": { + "properties": { + "extraction_schema": { + "description": "The generated JSON schema as a string.", + "title": "Extraction Schema", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/BuildSchemaMetadata", + "description": "The metadata for the schema generation process." + } + }, + "required": [ + "extraction_schema", + "metadata" + ], + "title": "BuildSchemaResponse", + "type": "object" + }, + "ClassificationItem": { + "description": "A single page-level classification result.", + "properties": { + "class": { + "description": "Predicted class label or 'unknown'.", + "title": "Class", + "type": "string" + }, + "page": { + "description": "Page number (0-based).", + "title": "Page", + "type": "integer" + }, + "reason": { + "default": "", + "description": "Reason for the classification (for debugging).", + "title": "Reason", + "type": "string" + }, + "suggested_class": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Proposed class when the prediction is 'unknown'.", + "title": "Suggested Class" + } + }, + "required": [ + "class", + "page" + ], + "title": "ClassificationItem", + "type": "object" + }, + "ClassifyClass": { + "description": "A single classification option: a class name plus optional description.", + "properties": { + "class": { + "description": "Name of the class.", + "title": "Class", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Detailed description of what this class represents.", + "title": "Description" + } + }, + "required": [ + "class" + ], + "title": "ClassifyClass", + "type": "object" + }, + "ClassifyMetadata": { + "description": "Metadata for the classify response.", + "properties": { + "credit_usage": { + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "title": "Duration Ms", + "type": "integer" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "job_id": { + "default": "", + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + }, + "page_count": { + "title": "Page Count", + "type": "integer" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "filename", + "page_count", + "duration_ms", + "credit_usage" + ], + "title": "ClassifyMetadata", + "type": "object" + }, + "ClassifyRequest": { + "properties": { + "classes": { + "description": "The possible classes that can be assigned to pages in the document. Each entry is an object with a `class` name and an optional `description`. Only one class is assigned per page; unclassifiable pages receive 'unknown'. Can be provided as a JSON string in form data.", + "items": { + "$ref": "#/components/schemas/ClassifyClass" + }, + "title": "Classes", + "type": "array" + }, + "document": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A file to be classified. Either this parameter or the `document_url` parameter must be provided.", + "title": "Document" + }, + "document_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL of the document to be classified. Either this parameter or the `document` parameter must be provided.", + "title": "Document Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Classification model version. Defaults to the latest.", + "title": "Model" + } + }, + "required": [ + "classes" + ], + "title": "ClassifyRequest", + "type": "object" + }, + "ClassifyResponse": { + "description": "Response model for the classify endpoint.", + "properties": { + "classification": { + "items": { + "$ref": "#/components/schemas/ClassificationItem" + }, + "title": "Classification", + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/ClassifyMetadata" + } + }, + "required": [ + "classification", + "metadata" + ], + "title": "ClassifyResponse", + "type": "object" + }, + "CustomPromptKey": { + "const": "figure", + "description": "Chunk types that support custom prompts.", + "enum": [ + "figure" + ], + "title": "CustomPromptKey", + "type": "string" + }, + "CustomPrompts": { + "additionalProperties": { + "type": "string" + }, + "description": "Map of chunk type to custom prompt string.", + "title": "CustomPrompts", + "type": "object" + }, + "ExtractJobStatusResponse": { + "description": "The status of an extract job, plus the results once it completes.", + "properties": { + "created_at": { + "default": 0, + "description": "Unix timestamp (in seconds) for when the job was created.", + "title": "Created At", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExtractResponse" + }, + { + "type": "null" + } + ], + "description": "The extraction results, returned here when the job is complete and you did not set an `output_save_url`. Large results are returned through `output_url` instead." + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "If the job failed, a message describing what went wrong.", + "title": "Failure Reason" + }, + "job_id": { + "description": "A unique identifier for this extract job.", + "title": "Job Id", + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExtractMetadata" + }, + { + "type": "null" + } + ], + "description": "Information about the extraction, such as the model version, duration, credit usage, and any schema warnings." + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Organization ID.", + "title": "Org Id" + }, + "output_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A URL to download the extraction results. Provided when the job is complete and either you set an `output_save_url` or the result is larger than 1 MB. URLs for large results are temporary and expire one hour after you request the job.", + "title": "Output Url" + }, + "progress": { + "description": "Job completion. Either 0.0 (not yet complete) or 1.0 (complete).", + "maximum": 1.0, + "minimum": 0.0, + "title": "Progress", + "type": "number" + }, + "received_at": { + "description": "Unix timestamp (in seconds) for when the job was received.", + "title": "Received At", + "type": "integer" + }, + "status": { + "description": "The current state of the job: `pending`, `processing`, `completed`, `failed`, or `cancelled`.", + "title": "Status", + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The exact model snapshot used for the extraction.", + "title": "Version" + } + }, + "required": [ + "job_id", + "status", + "received_at", + "progress" + ], + "title": "ExtractJobStatusResponse", + "type": "object" + }, + "ExtractMetadata": { + "properties": { + "credit_usage": { + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "title": "Duration Ms", + "type": "integer" + }, + "fallback_model_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The extract model that was actually used to extract the data when the initial extraction attempt failed with the requested version.", + "title": "Fallback Model Version" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "job_id": { + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + }, + "schema_violation_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A detailed error message shows why the extracted data does not fully conform to the input schema. Null means the extraction result is consistent with the input schema.", + "title": "Schema Violation Error" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + }, + "warnings": { + "description": "Structured warnings from the extraction process. Each warning is an instance of ExtractWarning with 'code' (e.g. 'nonconformant_schema') and 'msg' (human-readable description). Present only for extract versions from extract-20260314 and above that support structured warnings.", + "items": { + "$ref": "#/components/schemas/ExtractWarning" + }, + "title": "Warnings", + "type": "array" + } + }, + "required": [ + "filename", + "org_id", + "duration_ms", + "credit_usage", + "job_id", + "version" + ], + "title": "ExtractMetadata", + "type": "object" + }, + "ExtractRequest": { + "properties": { + "markdown": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The Markdown file or Markdown content to extract data from.", + "title": "Markdown" + }, + "markdown_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL to the Markdown file to extract data from.", + "title": "Markdown Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The version of the model to use for extraction. Use `extract-latest` to use the latest version.", + "title": "Model" + }, + "schema": { + "description": "JSON schema for field extraction. This schema determines what key-values pairs are extracted from the Markdown. The schema must be a valid JSON object and will be validated before processing the document.", + "title": "Schema", + "type": "string" + }, + "strict": { + "default": false, + "description": "If True, reject schemas with unsupported fields (HTTP 422). If False, prune unsupported fields and continue. Only applies to extract versions that support schema validation.", + "title": "Strict", + "type": "boolean" + } + }, + "required": [ + "schema" + ], + "title": "ExtractRequest", + "type": "object" + }, + "ExtractResponse": { + "properties": { + "extraction": { + "description": "The extracted key-value pairs.", + "title": "Extraction", + "type": "object" + }, + "extraction_metadata": { + "description": "The extracted key-value pairs and the chunk_reference for each one.", + "title": "Extraction Metadata", + "type": "object" + }, + "metadata": { + "$ref": "#/components/schemas/ExtractMetadata", + "description": "The metadata for the extraction process." + } + }, + "required": [ + "extraction", + "extraction_metadata", + "metadata" + ], + "title": "ExtractResponse", + "type": "object" + }, + "ExtractWarning": { + "properties": { + "code": { + "$ref": "#/components/schemas/ExtractWarningCode", + "description": "The type of warning, used to translate to a status code downstream" + }, + "msg": { + "description": "Human-readable description of the warning with more details", + "title": "Msg", + "type": "string" + } + }, + "required": [ + "code", + "msg" + ], + "title": "ExtractWarning", + "type": "object" + }, + "ExtractWarningCode": { + "enum": [ + "nonconformant_schema", + "nonconformant_output" + ], + "title": "ExtractWarningCode", + "type": "string" + }, + "GroundingType": { + "enum": [ + "chunkLogo", + "chunkCard", + "chunkAttestation", + "chunkScanCode", + "chunkForm", + "chunkTable", + "chunkFigure", + "chunkText", + "chunkMarginalia", + "chunkTitle", + "chunkPageHeader", + "chunkPageFooter", + "chunkPageNumber", + "chunkKeyValue", + "table", + "tableCell" + ], + "title": "GroundingType", + "type": "string" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "JobCreationResponse": { + "properties": { + "job_id": { + "title": "Job Id", + "type": "string" + } + }, + "required": [ + "job_id" + ], + "title": "JobCreationResponse", + "type": "object" + }, + "JobStatusResponse": { + "description": "Unified response for job status endpoint.", + "properties": { + "created_at": { + "default": 0, + "description": "Unix timestamp (seconds) for when the job was created. Mirrors received_at; exposed so clients have an explicit creation time.", + "title": "Created At", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ParseResponse" + }, + { + "$ref": "#/components/schemas/SpreadsheetParseResponse" + }, + { + "type": "null" + } + ], + "description": "The parsed output (ParseResponse for documents, SpreadsheetParseResponse for spreadsheets), if the job is complete and the `output_save_url` parameter was not used.", + "title": "Data" + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Reason" + }, + "job_id": { + "title": "Job Id", + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/ParseMetadata" + }, + { + "type": "null" + } + ] + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + }, + "output_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL to the parsed content. This field contains a URL when the job is complete and either you specified the `output_save_url` parameter or the result is larger than 1MB. When the result exceeds 1MB, the URL is a presigned S3 URL that expires after 1 hour. Each time you GET the job, a new presigned URL is generated.", + "title": "Output Url" + }, + "progress": { + "description": "Job completion progress as a decimal from 0 to 1, where 0 is not started, 1 is finished, and values between 0 and 1 indicate work in progress.", + "maximum": 1.0, + "minimum": 0.0, + "title": "Progress", + "type": "number" + }, + "received_at": { + "title": "Received At", + "type": "integer" + }, + "status": { + "title": "Status", + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "job_id", + "status", + "received_at", + "progress" + ], + "title": "JobStatusResponse", + "type": "object" + }, + "JobSummary": { + "description": "Summary of a job for listing.", + "properties": { + "created_at": { + "default": 0, + "description": "Unix timestamp (seconds) for when the job was created. Mirrors received_at; exposed so clients have an explicit creation time.", + "title": "Created At", + "type": "integer" + }, + "failure_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Failure Reason" + }, + "job_id": { + "title": "Job Id", + "type": "string" + }, + "progress": { + "description": "Job completion as a decimal from 0 (not started) to 1 (complete).", + "maximum": 1.0, + "minimum": 0.0, + "title": "Progress", + "type": "number" + }, + "received_at": { + "title": "Received At", + "type": "integer" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "job_id", + "status", + "received_at", + "progress" + ], + "title": "JobSummary", + "type": "object" + }, + "JobsListResponse": { + "description": "Response for listing jobs.", + "properties": { + "has_more": { + "default": false, + "title": "Has More", + "type": "boolean" + }, + "jobs": { + "items": { + "$ref": "#/components/schemas/JobSummary" + }, + "title": "Jobs", + "type": "array" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + } + }, + "required": [ + "jobs" + ], + "title": "JobsListResponse", + "type": "object" + }, + "ParseChunk": { + "properties": { + "grounding": { + "$ref": "#/components/schemas/ParseGrounding" + }, + "id": { + "title": "Id", + "type": "string" + }, + "markdown": { + "title": "Markdown", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "markdown", + "type", + "id", + "grounding" + ], + "title": "ParseChunk", + "type": "object" + }, + "ParseGrounding": { + "properties": { + "box": { + "$ref": "#/components/schemas/ParseGroundingBox" + }, + "page": { + "title": "Page", + "type": "integer" + } + }, + "required": [ + "box", + "page" + ], + "title": "ParseGrounding", + "type": "object" + }, + "ParseGroundingBox": { + "properties": { + "bottom": { + "title": "Bottom", + "type": "number" + }, + "left": { + "title": "Left", + "type": "number" + }, + "right": { + "title": "Right", + "type": "number" + }, + "top": { + "title": "Top", + "type": "number" + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ], + "title": "ParseGroundingBox", + "type": "object" + }, + "ParseMetadata": { + "properties": { + "credit_usage": { + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "title": "Duration Ms", + "type": "integer" + }, + "failed_pages": { + "items": { + "type": "integer" + }, + "title": "Failed Pages", + "type": "array" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "job_id": { + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + }, + "page_count": { + "title": "Page Count", + "type": "integer" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "filename", + "org_id", + "page_count", + "duration_ms", + "credit_usage", + "job_id", + "version" + ], + "title": "ParseMetadata", + "type": "object" + }, + "ParseRequestWithEncryptedPassword": { + "properties": { + "custom_prompts": { + "anyOf": [ + { + "contentMediaType": "application/json", + "contentSchema": { + "$ref": "#/components/schemas/CustomPrompts" + }, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional JSON string mapping chunk types to custom parsing prompts. Only the `figure` key is supported, for example '{\"figure\":\"Describe axis labels in detail.\"}'.", + "title": "Custom Prompts" + }, + "document": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document_url` parameter must be provided.", + "title": "Document" + }, + "document_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL to the file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document` parameter must be provided.", + "title": "Document Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The version of the model to use for parsing.", + "title": "Model" + }, + "password": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Password for encrypted document files. If the document is password-protected, provide the password to decrypt and process the document. Ignored for unencrypted documents.", + "title": "Password" + }, + "split": { + "anyOf": [ + { + "$ref": "#/components/schemas/SplitType" + }, + { + "type": "null" + } + ], + "description": "If you want to split documents into smaller sections, include the split parameter. Set the parameter to page to split documents at the page level. The splits object in the API output will contain a set of data for each page." + } + }, + "title": "ParseRequestWithEncryptedPassword", + "type": "object" + }, + "ParseResponse": { + "properties": { + "chunks": { + "items": { + "$ref": "#/components/schemas/ParseChunk" + }, + "title": "Chunks", + "type": "array" + }, + "grounding": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/components/schemas/ParseResponseGrounding" + }, + { + "$ref": "#/components/schemas/ParseResponseTableCellGrounding" + } + ] + }, + "title": "Grounding", + "type": "object" + }, + "markdown": { + "title": "Markdown", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/ParseMetadata" + }, + "splits": { + "items": { + "$ref": "#/components/schemas/ParseSplit" + }, + "title": "Splits", + "type": "array" + } + }, + "required": [ + "markdown", + "chunks", + "splits", + "metadata" + ], + "title": "ParseResponse", + "type": "object" + }, + "ParseResponseGrounding": { + "properties": { + "box": { + "$ref": "#/components/schemas/ParseGroundingBox" + }, + "confidence": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Confidence" + }, + "low_confidence_spans": { + "items": { + "$ref": "#/components/schemas/Patch" + }, + "title": "Low Confidence Spans", + "type": "array" + }, + "page": { + "title": "Page", + "type": "integer" + }, + "type": { + "$ref": "#/components/schemas/GroundingType" + } + }, + "required": [ + "box", + "page", + "type" + ], + "title": "ParseResponseGrounding", + "type": "object" + }, + "ParseResponseTableCellGrounding": { + "properties": { + "box": { + "$ref": "#/components/schemas/ParseGroundingBox" + }, + "confidence": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Confidence" + }, + "page": { + "title": "Page", + "type": "integer" + }, + "position": { + "anyOf": [ + { + "$ref": "#/components/schemas/ParseResponseTableCellGroundingPosition" + }, + { + "type": "null" + } + ] + }, + "type": { + "$ref": "#/components/schemas/GroundingType" + } + }, + "required": [ + "box", + "page", + "type" + ], + "title": "ParseResponseTableCellGrounding", + "type": "object" + }, + "ParseResponseTableCellGroundingPosition": { + "properties": { + "chunk_id": { + "title": "Chunk Id", + "type": "string" + }, + "col": { + "title": "Col", + "type": "integer" + }, + "colspan": { + "title": "Colspan", + "type": "integer" + }, + "row": { + "title": "Row", + "type": "integer" + }, + "rowspan": { + "title": "Rowspan", + "type": "integer" + } + }, + "required": [ + "row", + "col", + "rowspan", + "colspan", + "chunk_id" + ], + "title": "ParseResponseTableCellGroundingPosition", + "type": "object" + }, + "ParseSplit": { + "properties": { + "chunks": { + "items": { + "type": "string" + }, + "title": "Chunks", + "type": "array" + }, + "class": { + "title": "Class", + "type": "string" + }, + "identifier": { + "title": "Identifier", + "type": "string" + }, + "markdown": { + "title": "Markdown", + "type": "string" + }, + "pages": { + "items": { + "type": "integer" + }, + "title": "Pages", + "type": "array" + } + }, + "required": [ + "class", + "identifier", + "pages", + "markdown", + "chunks" + ], + "title": "ParseSplit", + "type": "object" + }, + "Patch": { + "properties": { + "confidence": { + "title": "Confidence", + "type": "number" + }, + "span": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "integer" + } + ], + "title": "Span", + "type": "array" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text", + "span", + "confidence" + ], + "title": "Patch", + "type": "object" + }, + "SectionMetadata": { + "description": "Public metadata for section response.", + "properties": { + "credit_usage": { + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "title": "Duration Ms", + "type": "integer" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "job_id": { + "default": "", + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org Id" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "required": [ + "filename", + "duration_ms", + "credit_usage" + ], + "title": "SectionMetadata", + "type": "object" + }, + "SectionRequest": { + "description": "Request model for section endpoint.", + "properties": { + "guidelines": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Natural-language instructions to control hierarchy. Examples: 'Group by topic', 'Treat each numbered section as a top-level entry'.", + "title": "Guidelines" + }, + "markdown": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Parsed markdown with reference anchors (). This is the markdown field from a parse response.", + "title": "Markdown" + }, + "markdown_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL to fetch the markdown from.", + "title": "Markdown Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Section model version. Defaults to latest.", + "title": "Model" + } + }, + "title": "SectionRequest", + "type": "object" + }, + "SectionResponse": { + "description": "Response model for section endpoint.", + "properties": { + "metadata": { + "$ref": "#/components/schemas/SectionMetadata" + }, + "table_of_contents": { + "items": { + "$ref": "#/components/schemas/SectionTOCEntry" + }, + "title": "Table Of Contents", + "type": "array" + }, + "table_of_contents_md": { + "title": "Table Of Contents Md", + "type": "string" + } + }, + "required": [ + "table_of_contents", + "table_of_contents_md", + "metadata" + ], + "title": "SectionResponse", + "type": "object" + }, + "SectionTOCEntry": { + "description": "A single entry in the flat table of contents.", + "properties": { + "level": { + "title": "Level", + "type": "integer" + }, + "section_number": { + "title": "Section Number", + "type": "string" + }, + "start_reference": { + "title": "Start Reference", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "level", + "section_number", + "start_reference" + ], + "title": "SectionTOCEntry", + "type": "object" + }, + "SplitClass": { + "description": "Model for split classification option.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Detailed description of what this split type represents", + "title": "Description" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Identifier to partition/group the splits by", + "title": "Identifier" + }, + "name": { + "description": "Name of the split classification type", + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "SplitClass", + "type": "object" + }, + "SplitData": { + "description": "Split data for split classification endpoint.", + "properties": { + "classification": { + "title": "Classification", + "type": "string" + }, + "identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Identifier" + }, + "markdowns": { + "items": { + "type": "string" + }, + "title": "Markdowns", + "type": "array" + }, + "pages": { + "items": { + "type": "integer" + }, + "title": "Pages", + "type": "array" + } + }, + "required": [ + "classification", + "identifier", + "pages", + "markdowns" + ], + "title": "SplitData", + "type": "object" + }, + "SplitMetadata": { + "description": "Metadata for split classification response.", + "properties": { + "credit_usage": { + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "title": "Duration Ms", + "type": "integer" + }, + "filename": { + "title": "Filename", + "type": "string" + }, + "job_id": { + "default": "", + "description": "Inference history job ID", + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Organization ID", + "title": "Org Id" + }, + "page_count": { + "title": "Page Count", + "type": "integer" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Model version used for split classification", + "title": "Version" + } + }, + "required": [ + "filename", + "page_count", + "duration_ms", + "credit_usage" + ], + "title": "SplitMetadata", + "type": "object" + }, + "SplitRequest": { + "description": "Request model for split classification endpoint.", + "properties": { + "markdown": { + "anyOf": [ + { + "format": "binary", + "type": "string" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The Markdown file or Markdown content to split.", + "title": "Markdown" + }, + "markdown_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The URL to the Markdown file to split.", + "title": "Markdown Url" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "split-20251105", + "description": "Model version to use for split classification. Defaults to the latest version.", + "title": "Model" + }, + "split_class": { + "description": "List of split classification options/configuration. Can be provided as JSON string in form data.", + "items": { + "$ref": "#/components/schemas/SplitClass" + }, + "title": "Split Class", + "type": "array" + } + }, + "required": [ + "split_class" + ], + "title": "SplitRequest", + "type": "object" + }, + "SplitResponse": { + "description": "Response model for split classification endpoint.", + "properties": { + "metadata": { + "$ref": "#/components/schemas/SplitMetadata" + }, + "splits": { + "items": { + "$ref": "#/components/schemas/SplitData" + }, + "title": "Splits", + "type": "array" + } + }, + "required": [ + "splits", + "metadata" + ], + "title": "SplitResponse", + "type": "object" + }, + "SplitType": { + "const": "page", + "enum": [ + "page" + ], + "title": "SplitType", + "type": "string" + }, + "SpreadsheetChunk": { + "description": "Chunk from spreadsheet parsing.\n\nCan represent:\n- Table chunks from spreadsheet cells\n- Parsed content chunks from embedded images (text, table, figure, etc.)", + "properties": { + "grounding": { + "anyOf": [ + { + "$ref": "#/components/schemas/ParseGrounding" + }, + { + "type": "null" + } + ], + "description": "Visual grounding coordinates from /parse API (only for chunks derived from embedded images)" + }, + "id": { + "description": "Chunk ID - format: '{sheet_name}-{cell_range}' for tables, '{sheet_name}-image-{index}-{anchor_cell}-chunk-{i}-{type}' for parsed image chunks", + "title": "Id", + "type": "string" + }, + "markdown": { + "description": "Chunk content as HTML table with anchor tag (for tables) or parsed markdown content (for chunks from images)", + "title": "Markdown", + "type": "string" + }, + "type": { + "description": "Chunk type: 'table' for spreadsheet tables, or types from /parse (text, table, figure, form, etc.) for chunks derived from embedded images", + "title": "Type", + "type": "string" + } + }, + "required": [ + "markdown", + "type", + "id" + ], + "title": "SpreadsheetChunk", + "type": "object" + }, + "SpreadsheetParseMetadata": { + "description": "Metadata for spreadsheet parsing result.", + "properties": { + "credit_usage": { + "default": 0.0, + "description": "Credits charged", + "title": "Credit Usage", + "type": "number" + }, + "duration_ms": { + "description": "Processing duration in milliseconds", + "title": "Duration Ms", + "type": "integer" + }, + "filename": { + "description": "Original filename", + "title": "Filename", + "type": "string" + }, + "job_id": { + "default": "", + "description": "Inference history job ID", + "title": "Job Id", + "type": "string" + }, + "org_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Organization ID", + "title": "Org Id" + }, + "sheet_count": { + "description": "Number of sheets processed", + "title": "Sheet Count", + "type": "integer" + }, + "total_cells": { + "description": "Total non-empty cells across all sheets", + "title": "Total Cells", + "type": "integer" + }, + "total_chunks": { + "description": "Total chunks (tables + images) extracted", + "title": "Total Chunks", + "type": "integer" + }, + "total_images": { + "default": 0, + "description": "Total images extracted", + "title": "Total Images", + "type": "integer" + }, + "total_rows": { + "description": "Total rows across all sheets", + "title": "Total Rows", + "type": "integer" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Model version for parsing images", + "title": "Version" + } + }, + "required": [ + "filename", + "sheet_count", + "total_rows", + "total_cells", + "total_chunks", + "duration_ms" + ], + "title": "SpreadsheetParseMetadata", + "type": "object" + }, + "SpreadsheetParseResponse": { + "description": "Response from /ade/parse-spreadsheet endpoint.\n\nSimilar structure to ParseResponse but without grounding.", + "properties": { + "chunks": { + "description": "List of table chunks (HTML)", + "items": { + "$ref": "#/components/schemas/SpreadsheetChunk" + }, + "title": "Chunks", + "type": "array" + }, + "markdown": { + "description": "Full document as HTML with anchor tags and tables", + "title": "Markdown", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/SpreadsheetParseMetadata", + "description": "Parsing metadata" + }, + "splits": { + "description": "Sheet-based splits", + "items": { + "$ref": "#/components/schemas/SpreadsheetSplit" + }, + "title": "Splits", + "type": "array" + } + }, + "required": [ + "markdown", + "chunks", + "splits", + "metadata" + ], + "title": "SpreadsheetParseResponse", + "type": "object" + }, + "SpreadsheetSplit": { + "description": "Sheet-based split from spreadsheet parsing.\n\nSimilar to ParseSplit but grouped by sheet instead of page.\nSupports both 'page' (per-sheet) and 'full' (all sheets) split types.", + "properties": { + "chunks": { + "description": "Chunk IDs in this split", + "items": { + "type": "string" + }, + "title": "Chunks", + "type": "array" + }, + "class": { + "description": "Split class: 'page' for per-sheet splits, 'full' for single split with all content", + "title": "Class", + "type": "string" + }, + "identifier": { + "description": "Split identifier: sheet name for 'page' splits, 'full' for full split", + "title": "Identifier", + "type": "string" + }, + "markdown": { + "description": "Combined markdown for this split", + "title": "Markdown", + "type": "string" + }, + "sheets": { + "description": "Sheet indices: single element for 'page' splits, all indices for 'full' split", + "items": { + "type": "integer" + }, + "title": "Sheets", + "type": "array" + } + }, + "required": [ + "class", + "identifier", + "sheets", + "markdown", + "chunks" + ], + "title": "SpreadsheetSplit", + "type": "object" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + }, + "securitySchemes": { + "Basic Auth": { + "bearerFormat": "Basic", + "description": "Your unique API key for authentication.\n\nGet your API key here: https://va.landing.ai/settings/api-key.\n\nIf using the EU endpoint, get your API key here: https://va.eu-west-1.landing.ai/settings/api-key.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "OpenAPI schema for ADE endpoints only", + "title": "ADE API", + "version": "0.1.0" + }, + "openapi": "3.1.0", + "paths": { + "/v1/ade/classify": { + "post": { + "description": "Classify the pages of a document into classes you define.\n\nThis endpoint accepts PDFs, images, and other supported file types\n(either as a `document` upload or `document_url`) together with a\nlist of `classes`, and returns a classification result for each page.\n\nFor EU users, use this endpoint:\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/classify`.", + "operationId": "tool_ade_classify_v1_ade_classify_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ClassifyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClassifyResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE Classify", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/extract": { + "post": { + "description": "Extract structured data from Markdown using a JSON schema.\n\nThis endpoint\n processes Markdown content and extracts structured data according to the provided\n JSON schema.\n\nFor EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/extract`.", + "operationId": "tool_ade_extract_v1_ade_extract_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ExtractRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtractResponse" + } + } + }, + "description": "Successful Response" + }, + "206": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtractResponse" + } + } + }, + "description": "Extraction completed with success but there was a schema validation error." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "summary": "ADE Extract", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/extract/build-schema": { + "post": { + "description": "Generate a JSON schema from Markdown using AI.\n\nThis endpoint analyzes Markdown\n content and generates a JSON schema suitable for use with the extract endpoint.\n It can also refine an existing schema based on new documents or iterate on a schema\n based on prompt instructions.\n\nFor EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/extract/build-schema`.", + "operationId": "tool_ade_build_schema_v1_ade_extract_build_schema_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/BuildSchemaRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildSchemaResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE Build Extract Schema", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/extract/jobs": { + "get": { + "description": "List all async extract jobs associated with your API key.\n\nReturns the list of jobs or an error response. For EU users, use this endpoint:\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/extract/jobs`.", + "operationId": "tool_ade_list_extract_jobs_v1_ade_extract_jobs_get", + "parameters": [ + { + "description": "Page number (0-indexed)", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 0, + "description": "Page number (0-indexed)", + "minimum": 0, + "title": "Page", + "type": "integer" + } + }, + { + "description": "Number of items per page", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 10, + "description": "Number of items per page", + "maximum": 100, + "minimum": 1, + "title": "Pagesize", + "type": "integer" + } + }, + { + "description": "Filter by job status.", + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "cancelled", + "completed", + "failed", + "pending", + "processing" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by job status.", + "title": "Status" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobsListResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE List Extract Jobs", + "tags": [ + "Tools" + ] + }, + "post": { + "description": "Extract structured data asynchronously.\n\nThis endpoint creates a job that handles the processing for large markdown\ndocuments.\n\nFor EU users, use this endpoint:\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/extract/jobs`.", + "operationId": "tool_ade_extract_jobs_v1_ade_extract_jobs_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AsyncExtractRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "example": { + "job_id": "12345678-1234-1234-1234-123456789012" + }, + "schema": { + "$ref": "#/components/schemas/JobCreationResponse" + } + } + }, + "description": "Job queued successfully" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "summary": "ADE Extract Jobs", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/extract/jobs/{job_id}": { + "get": { + "description": "Get the status for an async extract job.\n\nReturns the job status or an error\n response. For EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/extract/jobs/{job_id}`.", + "operationId": "tool_ade_get_extract_jobs_v1_ade_extract_jobs__job_id__get", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtractJobStatusResponse" + } + } + }, + "description": "Successful Response" + }, + "206": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtractJobStatusResponse" + } + } + }, + "description": "Extraction completed with success but there was a schema validation error." + }, + "404": { + "description": "Job ID not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE Get Extract Jobs", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/parse": { + "post": { + "description": "Parse a document or spreadsheet.\n\nThis endpoint parses documents (PDF, images)\n and spreadsheets (XLSX, CSV) into structured Markdown, chunks, and metadata.\n \n\n For EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/parse`.", + "operationId": "tool_ade_parse_v1_ade_parse_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ParseRequestWithEncryptedPassword" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseResponse" + } + } + }, + "description": "Successful Response" + }, + "206": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ParseResponse" + } + } + }, + "description": "There were some pages that failed to be parsed." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "summary": "ADE Parse", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/parse/jobs": { + "get": { + "description": "List all async parse jobs associated with your API key. Returns the list of jobs\nor an error response. For EU users, use this endpoint:\n\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/parse/jobs`.", + "operationId": "tool_ade_list_parse_jobs_v1_ade_parse_jobs_get", + "parameters": [ + { + "description": "Page number (0-indexed)", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 0, + "description": "Page number (0-indexed)", + "minimum": 0, + "title": "Page", + "type": "integer" + } + }, + { + "description": "Number of items per page", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 10, + "description": "Number of items per page", + "maximum": 100, + "minimum": 1, + "title": "Pagesize", + "type": "integer" + } + }, + { + "description": "Filter by job status.", + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "cancelled", + "completed", + "failed", + "pending", + "processing" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by job status.", + "title": "Status" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobsListResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE List Parse Jobs", + "tags": [ + "Tools" + ] + }, + "post": { + "description": "Parse documents asynchronously.\n\nThis endpoint creates a job that handles the\n processing for both large documents and large batches of documents.\n\n For EU\n users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/parse/jobs`.", + "operationId": "tool_ade_parse_jobs_v1_ade_parse_jobs_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/AsyncParseRequestWithEncryptedPassword" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "example": { + "job_id": "12345678-1234-1234-1234-123456789012" + }, + "schema": { + "$ref": "#/components/schemas/JobCreationResponse" + } + } + }, + "description": "Job queued successfully" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + }, + "429": { + "description": "Rate limit exceeded" + } + }, + "summary": "ADE Parse Jobs", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/parse/jobs/{job_id}": { + "get": { + "description": "Get the status for an async parse job.\n\nReturns the job status or an error\n response. For EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/parse/jobs/{job_id}`.", + "operationId": "tool_ade_get_parse_jobs_v1_ade_parse_jobs__job_id__get", + "parameters": [ + { + "in": "path", + "name": "job_id", + "required": true, + "schema": { + "title": "Job Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatusResponse" + } + } + }, + "description": "Successful Response" + }, + "206": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobStatusResponse" + } + } + }, + "description": "There were some pages that failed to be parsed." + }, + "404": { + "description": "Job ID not found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE Get Parse Jobs", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/section": { + "post": { + "description": "Section parsed markdown into a hierarchical table of contents.\n\nThis endpoint accepts the markdown output from /ade/parse\n(with reference anchors) and returns a flat, reading-order list of\nsections with hierarchy levels and reference ranges.\n\nFor EU users, use this endpoint:\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/section`.", + "operationId": "tool_ade_section_v1_ade_section_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SectionResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE Section", + "tags": [ + "Tools" + ] + } + }, + "/v1/ade/split": { + "post": { + "description": "Split classification for documents.\n\nThis endpoint classifies document sections\n based on markdown content and split options.\n\nFor EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/split`.", + "operationId": "tool_ade_split_v1_ade_split_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SplitRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SplitResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "ADE Split", + "tags": [ + "Tools" + ] + } + } + } +} From 92ee58b6dddd1ad99cb29a78f6f048cae0a433dd Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:36:14 +0800 Subject: [PATCH 02/17] chore(spec-sync): add drift detector Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spec-sync/check-drift.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100755 scripts/spec-sync/check-drift.sh diff --git a/scripts/spec-sync/check-drift.sh b/scripts/spec-sync/check-drift.sh new file mode 100755 index 0000000..b39b1c0 --- /dev/null +++ b/scripts/spec-sync/check-drift.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Compare a live spec against its committed snapshot. +# exit 0 -> no drift +# exit 10 -> drift detected; updated in place with the live spec +# other -> operational error (e.g. fetch failure) +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: check-drift.sh " >&2 + exit 2 +fi + +url="$1" +committed="$2" +here="$(cd "$(dirname "$0")" && pwd)" +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT + +"$here/fetch-normalize.sh" "$url" > "$tmp" + +if [ -f "$committed" ] && diff -q "$committed" "$tmp" >/dev/null; then + echo "no drift: $committed" + exit 0 +fi + +cp "$tmp" "$committed" +echo "drift detected -> updated $committed" +exit 10 From ee48f45b18cbc67c5138d20d46b46c6674d90ba2 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:38:52 +0800 Subject: [PATCH 03/17] chore(spec-sync): add reference-model codegen (datamodel-code-generator) Reference models under specs/_generated/ are an input to the AI wiring phase and for review; not shipped. requirements-dev.lock to be refreshed via rye sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 + scripts/spec-sync/gen-models.sh | 23 + specs/_generated/v1_models.py | 852 ++++++++++++++++++++++++++++++++ 3 files changed, 877 insertions(+) create mode 100755 scripts/spec-sync/gen-models.sh create mode 100644 specs/_generated/v1_models.py diff --git a/pyproject.toml b/pyproject.toml index 54f387f..427643f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,8 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", + "datamodel-code-generator>=0.25", + "griffe>=0.49", ] [tool.rye.scripts] diff --git a/scripts/spec-sync/gen-models.sh b/scripts/spec-sync/gen-models.sh new file mode 100755 index 0000000..74e6632 --- /dev/null +++ b/scripts/spec-sync/gen-models.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Generate REFERENCE pydantic models from a committed spec snapshot. +# Output is committed under specs/_generated/ as an input for the AI wiring phase +# and for human review — it is NOT shipped and does not replace src/landingai_ade/types/*. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: gen-models.sh " >&2 + exit 2 +fi + +spec="$1" +out="$2" +mkdir -p "$(dirname "$out")" +rye run datamodel-codegen \ + --input "$spec" \ + --input-file-type openapi \ + --output "$out" \ + --output-model-type pydantic_v2.BaseModel \ + --use-standard-collections \ + --use-schema-description \ + --field-constraints \ + --disable-timestamp diff --git a/specs/_generated/v1_models.py b/specs/_generated/v1_models.py new file mode 100644 index 0000000..ad093e4 --- /dev/null +++ b/specs/_generated/v1_models.py @@ -0,0 +1,852 @@ +# generated by datamodel-codegen: +# filename: v1-ade.json + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field, RootModel + + +class AsyncExtractRequest(BaseModel): + """ + Request model for async extract endpoint. + + Extends ExtractRequest with output_save_url for ZDR support. + """ + + markdown: bytes | str | None = Field( + None, + description='The Markdown file or Markdown content to extract data from.', + title='Markdown', + ) + markdown_url: str | None = Field( + None, + description='The URL to the Markdown file to extract data from.', + title='Markdown Url', + ) + model: str | None = Field( + None, + description='The version of the model to use for extraction. Use `extract-latest` to use the latest version.', + title='Model', + ) + output_save_url: str | None = Field( + None, + description='If zero data retention (ZDR) is enabled, you must enter a URL for the extracted output to be saved to. When ZDR is enabled, the extracted content will not be in the API response.', + title='Output Save Url', + ) + schema_: str = Field( + ..., + alias='schema', + description='JSON schema for field extraction. This schema determines what key-values pairs are extracted from the Markdown. The schema must be a valid JSON object and will be validated before processing the document.', + title='Schema', + ) + strict: bool | None = Field( + False, + description='If True, reject schemas with unsupported fields (HTTP 422). If False, prune unsupported fields and continue. Only applies to extract versions that support schema validation.', + title='Strict', + ) + + +class BuildSchemaRequest(BaseModel): + markdown_urls: list[str] | None = Field( + None, + description='URLs to Markdown files to analyze for schema generation.', + title='Markdown Urls', + ) + markdowns: list[bytes | str] | None = Field( + None, + description='Markdown files or inline content strings to analyze for schema generation. Multiple documents can be provided for better schema coverage.', + title='Markdowns', + ) + model: str | None = Field( + None, + description='The version of the model to use for schema generation. Use `extract-latest` to use the latest version.', + title='Model', + ) + prompt: str | None = Field( + None, + description='Instructions for how to generate or modify the schema.', + title='Prompt', + ) + schema_: str | None = Field( + None, + alias='schema', + description='Existing JSON schema to iterate on or refine.', + title='Schema', + ) + + +class ClassificationItem(BaseModel): + """ + A single page-level classification result. + """ + + class_: str = Field( + ..., + alias='class', + description="Predicted class label or 'unknown'.", + title='Class', + ) + page: int = Field(..., description='Page number (0-based).', title='Page') + reason: str | None = Field( + '', description='Reason for the classification (for debugging).', title='Reason' + ) + suggested_class: str | None = Field( + None, + description="Proposed class when the prediction is 'unknown'.", + title='Suggested Class', + ) + + +class ClassifyClass(BaseModel): + """ + A single classification option: a class name plus optional description. + """ + + class_: str = Field( + ..., alias='class', description='Name of the class.', title='Class' + ) + description: str | None = Field( + None, + description='Detailed description of what this class represents.', + title='Description', + ) + + +class ClassifyMetadata(BaseModel): + """ + Metadata for the classify response. + """ + + credit_usage: float = Field(..., title='Credit Usage') + duration_ms: int = Field(..., title='Duration Ms') + filename: str = Field(..., title='Filename') + job_id: str | None = Field('', title='Job Id') + org_id: str | None = Field(None, title='Org Id') + page_count: int = Field(..., title='Page Count') + version: str | None = Field(None, title='Version') + + +class ClassifyRequest(BaseModel): + classes: list[ClassifyClass] = Field( + ..., + description="The possible classes that can be assigned to pages in the document. Each entry is an object with a `class` name and an optional `description`. Only one class is assigned per page; unclassifiable pages receive 'unknown'. Can be provided as a JSON string in form data.", + title='Classes', + ) + document: bytes | None = Field( + None, + description='A file to be classified. Either this parameter or the `document_url` parameter must be provided.', + title='Document', + ) + document_url: str | None = Field( + None, + description='The URL of the document to be classified. Either this parameter or the `document` parameter must be provided.', + title='Document Url', + ) + model: str | None = Field( + None, + description='Classification model version. Defaults to the latest.', + title='Model', + ) + + +class ClassifyResponse(BaseModel): + """ + Response model for the classify endpoint. + """ + + classification: list[ClassificationItem] = Field(..., title='Classification') + metadata: ClassifyMetadata + + +class CustomPromptKey(Enum): + """ + Chunk types that support custom prompts. + """ + + figure = 'figure' + + +class CustomPrompts(RootModel[dict[str, str]]): + """ + Map of chunk type to custom prompt string. + """ + + root: dict[str, str] + + +class ExtractRequest(BaseModel): + markdown: bytes | str | None = Field( + None, + description='The Markdown file or Markdown content to extract data from.', + title='Markdown', + ) + markdown_url: str | None = Field( + None, + description='The URL to the Markdown file to extract data from.', + title='Markdown Url', + ) + model: str | None = Field( + None, + description='The version of the model to use for extraction. Use `extract-latest` to use the latest version.', + title='Model', + ) + schema_: str = Field( + ..., + alias='schema', + description='JSON schema for field extraction. This schema determines what key-values pairs are extracted from the Markdown. The schema must be a valid JSON object and will be validated before processing the document.', + title='Schema', + ) + strict: bool | None = Field( + False, + description='If True, reject schemas with unsupported fields (HTTP 422). If False, prune unsupported fields and continue. Only applies to extract versions that support schema validation.', + title='Strict', + ) + + +class ExtractWarningCode(Enum): + nonconformant_schema = 'nonconformant_schema' + nonconformant_output = 'nonconformant_output' + + +class GroundingType(Enum): + chunkLogo = 'chunkLogo' + chunkCard = 'chunkCard' + chunkAttestation = 'chunkAttestation' + chunkScanCode = 'chunkScanCode' + chunkForm = 'chunkForm' + chunkTable = 'chunkTable' + chunkFigure = 'chunkFigure' + chunkText = 'chunkText' + chunkMarginalia = 'chunkMarginalia' + chunkTitle = 'chunkTitle' + chunkPageHeader = 'chunkPageHeader' + chunkPageFooter = 'chunkPageFooter' + chunkPageNumber = 'chunkPageNumber' + chunkKeyValue = 'chunkKeyValue' + table = 'table' + tableCell = 'tableCell' + + +class JobCreationResponse(BaseModel): + job_id: str = Field(..., title='Job Id') + + +class JobSummary(BaseModel): + """ + Summary of a job for listing. + """ + + created_at: int | None = Field( + 0, + description='Unix timestamp (seconds) for when the job was created. Mirrors received_at; exposed so clients have an explicit creation time.', + title='Created At', + ) + failure_reason: str | None = Field(None, title='Failure Reason') + job_id: str = Field(..., title='Job Id') + progress: float = Field( + ..., + description='Job completion as a decimal from 0 (not started) to 1 (complete).', + ge=0.0, + le=1.0, + title='Progress', + ) + received_at: int = Field(..., title='Received At') + status: str = Field(..., title='Status') + + +class JobsListResponse(BaseModel): + """ + Response for listing jobs. + """ + + has_more: bool | None = Field(False, title='Has More') + jobs: list[JobSummary] = Field(..., title='Jobs') + org_id: str | None = Field(None, title='Org Id') + + +class ParseGroundingBox(BaseModel): + bottom: float = Field(..., title='Bottom') + left: float = Field(..., title='Left') + right: float = Field(..., title='Right') + top: float = Field(..., title='Top') + + +class ParseMetadata(BaseModel): + credit_usage: float = Field(..., title='Credit Usage') + duration_ms: int = Field(..., title='Duration Ms') + failed_pages: list[int] | None = Field(None, title='Failed Pages') + filename: str = Field(..., title='Filename') + job_id: str = Field(..., title='Job Id') + org_id: str | None = Field(..., title='Org Id') + page_count: int = Field(..., title='Page Count') + version: str | None = Field(..., title='Version') + + +class ParseResponseTableCellGroundingPosition(BaseModel): + chunk_id: str = Field(..., title='Chunk Id') + col: int = Field(..., title='Col') + colspan: int = Field(..., title='Colspan') + row: int = Field(..., title='Row') + rowspan: int = Field(..., title='Rowspan') + + +class ParseSplit(BaseModel): + chunks: list[str] = Field(..., title='Chunks') + class_: str = Field(..., alias='class', title='Class') + identifier: str = Field(..., title='Identifier') + markdown: str = Field(..., title='Markdown') + pages: list[int] = Field(..., title='Pages') + + +class Patch(BaseModel): + confidence: float = Field(..., title='Confidence') + span: tuple[int, int] = Field(..., title='Span') + text: str = Field(..., title='Text') + + +class SectionMetadata(BaseModel): + """ + Public metadata for section response. + """ + + credit_usage: float = Field(..., title='Credit Usage') + duration_ms: int = Field(..., title='Duration Ms') + filename: str = Field(..., title='Filename') + job_id: str | None = Field('', title='Job Id') + org_id: str | None = Field(None, title='Org Id') + version: str | None = Field(None, title='Version') + + +class SectionRequest(BaseModel): + """ + Request model for section endpoint. + """ + + guidelines: str | None = Field( + None, + description="Natural-language instructions to control hierarchy. Examples: 'Group by topic', 'Treat each numbered section as a top-level entry'.", + title='Guidelines', + ) + markdown: bytes | str | None = Field( + None, + description="Parsed markdown with reference anchors (). This is the markdown field from a parse response.", + title='Markdown', + ) + markdown_url: str | None = Field( + None, description='URL to fetch the markdown from.', title='Markdown Url' + ) + model: str | None = Field( + None, description='Section model version. Defaults to latest.', title='Model' + ) + + +class SectionTOCEntry(BaseModel): + """ + A single entry in the flat table of contents. + """ + + level: int = Field(..., title='Level') + section_number: str = Field(..., title='Section Number') + start_reference: str = Field(..., title='Start Reference') + title: str = Field(..., title='Title') + + +class SplitClass(BaseModel): + """ + Model for split classification option. + """ + + description: str | None = Field( + None, + description='Detailed description of what this split type represents', + title='Description', + ) + identifier: str | None = Field( + None, + description='Identifier to partition/group the splits by', + title='Identifier', + ) + name: str = Field( + ..., description='Name of the split classification type', title='Name' + ) + + +class SplitData(BaseModel): + """ + Split data for split classification endpoint. + """ + + classification: str = Field(..., title='Classification') + identifier: str | None = Field(..., title='Identifier') + markdowns: list[str] = Field(..., title='Markdowns') + pages: list[int] = Field(..., title='Pages') + + +class SplitMetadata(BaseModel): + """ + Metadata for split classification response. + """ + + credit_usage: float = Field(..., title='Credit Usage') + duration_ms: int = Field(..., title='Duration Ms') + filename: str = Field(..., title='Filename') + job_id: str | None = Field( + '', description='Inference history job ID', title='Job Id' + ) + org_id: str | None = Field(None, description='Organization ID', title='Org Id') + page_count: int = Field(..., title='Page Count') + version: str | None = Field( + None, description='Model version used for split classification', title='Version' + ) + + +class SplitRequest(BaseModel): + """ + Request model for split classification endpoint. + """ + + markdown: bytes | str | None = Field( + None, + description='The Markdown file or Markdown content to split.', + title='Markdown', + ) + markdown_url: str | None = Field( + None, description='The URL to the Markdown file to split.', title='Markdown Url' + ) + model: str | None = Field( + 'split-20251105', + description='Model version to use for split classification. Defaults to the latest version.', + title='Model', + ) + split_class: list[SplitClass] = Field( + ..., + description='List of split classification options/configuration. Can be provided as JSON string in form data.', + title='Split Class', + ) + + +class SplitResponse(BaseModel): + """ + Response model for split classification endpoint. + """ + + metadata: SplitMetadata + splits: list[SplitData] = Field(..., title='Splits') + + +class SplitType(Enum): + page = 'page' + + +class SpreadsheetParseMetadata(BaseModel): + """ + Metadata for spreadsheet parsing result. + """ + + credit_usage: float | None = Field( + 0.0, description='Credits charged', title='Credit Usage' + ) + duration_ms: int = Field( + ..., description='Processing duration in milliseconds', title='Duration Ms' + ) + filename: str = Field(..., description='Original filename', title='Filename') + job_id: str | None = Field( + '', description='Inference history job ID', title='Job Id' + ) + org_id: str | None = Field(None, description='Organization ID', title='Org Id') + sheet_count: int = Field( + ..., description='Number of sheets processed', title='Sheet Count' + ) + total_cells: int = Field( + ..., description='Total non-empty cells across all sheets', title='Total Cells' + ) + total_chunks: int = Field( + ..., + description='Total chunks (tables + images) extracted', + title='Total Chunks', + ) + total_images: int | None = Field( + 0, description='Total images extracted', title='Total Images' + ) + total_rows: int = Field( + ..., description='Total rows across all sheets', title='Total Rows' + ) + version: str | None = Field( + None, description='Model version for parsing images', title='Version' + ) + + +class SpreadsheetSplit(BaseModel): + """ + Sheet-based split from spreadsheet parsing. + + Similar to ParseSplit but grouped by sheet instead of page. + Supports both 'page' (per-sheet) and 'full' (all sheets) split types. + """ + + chunks: list[str] = Field( + ..., description='Chunk IDs in this split', title='Chunks' + ) + class_: str = Field( + ..., + alias='class', + description="Split class: 'page' for per-sheet splits, 'full' for single split with all content", + title='Class', + ) + identifier: str = Field( + ..., + description="Split identifier: sheet name for 'page' splits, 'full' for full split", + title='Identifier', + ) + markdown: str = Field( + ..., description='Combined markdown for this split', title='Markdown' + ) + sheets: list[int] = Field( + ..., + description="Sheet indices: single element for 'page' splits, all indices for 'full' split", + title='Sheets', + ) + + +class ValidationError(BaseModel): + loc: list[str | int] = Field(..., title='Location') + msg: str = Field(..., title='Message') + type: str = Field(..., title='Error Type') + + +class AsyncParseRequestWithEncryptedPassword(BaseModel): + custom_prompts: str | None = Field( + None, + description='Optional JSON string mapping chunk types to custom parsing prompts. Only the `figure` key is supported, for example \'{"figure":"Describe axis labels in detail."}\'.', + title='Custom Prompts', + ) + document: bytes | None = Field( + None, + description='A file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document_url` parameter must be provided.', + title='Document', + ) + document_url: str | None = Field( + None, + description='The URL to the file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document` parameter must be provided.', + title='Document Url', + ) + model: str | None = Field( + None, description='The version of the model to use for parsing.', title='Model' + ) + output_save_url: str | None = Field( + None, + description='If zero data retention (ZDR) is enabled, you must enter a URL for the parsed output to be saved to. When ZDR is enabled, the parsed content will not be in the API response.', + title='Output Save Url', + ) + password: str | None = Field( + None, + description='Password for encrypted document files. If the document is password-protected, provide the password to decrypt and process the document. Ignored for unencrypted documents.', + title='Password', + ) + split: SplitType | None = Field( + None, + description='If you want to split documents into smaller sections, include the split parameter. Set the parameter to page to split documents at the page level. The splits object in the API output will contain a set of data for each page.', + ) + + +class ExtractWarning(BaseModel): + code: ExtractWarningCode = Field( + ..., + description='The type of warning, used to translate to a status code downstream', + ) + msg: str = Field( + ..., + description='Human-readable description of the warning with more details', + title='Msg', + ) + + +class HTTPValidationError(BaseModel): + detail: list[ValidationError] | None = Field(None, title='Detail') + + +class ParseGrounding(BaseModel): + box: ParseGroundingBox + page: int = Field(..., title='Page') + + +class ParseRequestWithEncryptedPassword(BaseModel): + custom_prompts: str | None = Field( + None, + description='Optional JSON string mapping chunk types to custom parsing prompts. Only the `figure` key is supported, for example \'{"figure":"Describe axis labels in detail."}\'.', + title='Custom Prompts', + ) + document: bytes | None = Field( + None, + description='A file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document_url` parameter must be provided.', + title='Document', + ) + document_url: str | None = Field( + None, + description='The URL to the file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document` parameter must be provided.', + title='Document Url', + ) + model: str | None = Field( + None, description='The version of the model to use for parsing.', title='Model' + ) + password: str | None = Field( + None, + description='Password for encrypted document files. If the document is password-protected, provide the password to decrypt and process the document. Ignored for unencrypted documents.', + title='Password', + ) + split: SplitType | None = Field( + None, + description='If you want to split documents into smaller sections, include the split parameter. Set the parameter to page to split documents at the page level. The splits object in the API output will contain a set of data for each page.', + ) + + +class ParseResponseGrounding(BaseModel): + box: ParseGroundingBox + confidence: float | None = Field(None, title='Confidence') + low_confidence_spans: list[Patch] | None = Field(None, title='Low Confidence Spans') + page: int = Field(..., title='Page') + type: GroundingType + + +class ParseResponseTableCellGrounding(BaseModel): + box: ParseGroundingBox + confidence: float | None = Field(None, title='Confidence') + page: int = Field(..., title='Page') + position: ParseResponseTableCellGroundingPosition | None = None + type: GroundingType + + +class SectionResponse(BaseModel): + """ + Response model for section endpoint. + """ + + metadata: SectionMetadata + table_of_contents: list[SectionTOCEntry] = Field(..., title='Table Of Contents') + table_of_contents_md: str = Field(..., title='Table Of Contents Md') + + +class SpreadsheetChunk(BaseModel): + """ + Chunk from spreadsheet parsing. + + Can represent: + - Table chunks from spreadsheet cells + - Parsed content chunks from embedded images (text, table, figure, etc.) + """ + + grounding: ParseGrounding | None = Field( + None, + description='Visual grounding coordinates from /parse API (only for chunks derived from embedded images)', + ) + id: str = Field( + ..., + description="Chunk ID - format: '{sheet_name}-{cell_range}' for tables, '{sheet_name}-image-{index}-{anchor_cell}-chunk-{i}-{type}' for parsed image chunks", + title='Id', + ) + markdown: str = Field( + ..., + description='Chunk content as HTML table with anchor tag (for tables) or parsed markdown content (for chunks from images)', + title='Markdown', + ) + type: str = Field( + ..., + description="Chunk type: 'table' for spreadsheet tables, or types from /parse (text, table, figure, form, etc.) for chunks derived from embedded images", + title='Type', + ) + + +class SpreadsheetParseResponse(BaseModel): + """ + Response from /ade/parse-spreadsheet endpoint. + + Similar structure to ParseResponse but without grounding. + """ + + chunks: list[SpreadsheetChunk] = Field( + ..., description='List of table chunks (HTML)', title='Chunks' + ) + markdown: str = Field( + ..., + description='Full document as HTML with anchor tags and tables', + title='Markdown', + ) + metadata: SpreadsheetParseMetadata = Field(..., description='Parsing metadata') + splits: list[SpreadsheetSplit] = Field( + ..., description='Sheet-based splits', title='Splits' + ) + + +class BuildSchemaMetadata(BaseModel): + credit_usage: float | None = Field(0.0, title='Credit Usage') + duration_ms: int | None = Field(0, title='Duration Ms') + filename: str | None = Field(None, title='Filename') + job_id: str | None = Field('', title='Job Id') + org_id: str | None = Field(None, title='Org Id') + version: str | None = Field(None, title='Version') + warnings: list[ExtractWarning] | None = Field( + None, + description="Structured warnings from the extraction process. Each warning is an instance of ExtractWarning with 'code' (e.g. 'nonconformant_schema') and 'msg' (human-readable description). Present only for extract versions from extract-20260314 and above that support structured warnings.", + title='Warnings', + ) + + +class BuildSchemaResponse(BaseModel): + extraction_schema: str = Field( + ..., + description='The generated JSON schema as a string.', + title='Extraction Schema', + ) + metadata: BuildSchemaMetadata = Field( + ..., description='The metadata for the schema generation process.' + ) + + +class ExtractMetadata(BaseModel): + credit_usage: float = Field(..., title='Credit Usage') + duration_ms: int = Field(..., title='Duration Ms') + fallback_model_version: str | None = Field( + None, + description='The extract model that was actually used to extract the data when the initial extraction attempt failed with the requested version.', + title='Fallback Model Version', + ) + filename: str = Field(..., title='Filename') + job_id: str = Field(..., title='Job Id') + org_id: str | None = Field(..., title='Org Id') + schema_violation_error: str | None = Field( + None, + description='A detailed error message shows why the extracted data does not fully conform to the input schema. Null means the extraction result is consistent with the input schema.', + title='Schema Violation Error', + ) + version: str | None = Field(..., title='Version') + warnings: list[ExtractWarning] | None = Field( + None, + description="Structured warnings from the extraction process. Each warning is an instance of ExtractWarning with 'code' (e.g. 'nonconformant_schema') and 'msg' (human-readable description). Present only for extract versions from extract-20260314 and above that support structured warnings.", + title='Warnings', + ) + + +class ExtractResponse(BaseModel): + extraction: dict[str, Any] = Field( + ..., description='The extracted key-value pairs.', title='Extraction' + ) + extraction_metadata: dict[str, Any] = Field( + ..., + description='The extracted key-value pairs and the chunk_reference for each one.', + title='Extraction Metadata', + ) + metadata: ExtractMetadata = Field( + ..., description='The metadata for the extraction process.' + ) + + +class ParseChunk(BaseModel): + grounding: ParseGrounding + id: str = Field(..., title='Id') + markdown: str = Field(..., title='Markdown') + type: str = Field(..., title='Type') + + +class ParseResponse(BaseModel): + chunks: list[ParseChunk] = Field(..., title='Chunks') + grounding: ( + dict[str, ParseResponseGrounding | ParseResponseTableCellGrounding] | None + ) = Field(None, title='Grounding') + markdown: str = Field(..., title='Markdown') + metadata: ParseMetadata + splits: list[ParseSplit] = Field(..., title='Splits') + + +class ExtractJobStatusResponse(BaseModel): + """ + The status of an extract job, plus the results once it completes. + """ + + created_at: int | None = Field( + 0, + description='Unix timestamp (in seconds) for when the job was created.', + title='Created At', + ) + data: ExtractResponse | None = Field( + None, + description='The extraction results, returned here when the job is complete and you did not set an `output_save_url`. Large results are returned through `output_url` instead.', + ) + failure_reason: str | None = Field( + None, + description='If the job failed, a message describing what went wrong.', + title='Failure Reason', + ) + job_id: str = Field( + ..., description='A unique identifier for this extract job.', title='Job Id' + ) + metadata: ExtractMetadata | None = Field( + None, + description='Information about the extraction, such as the model version, duration, credit usage, and any schema warnings.', + ) + org_id: str | None = Field(None, description='Organization ID.', title='Org Id') + output_url: str | None = Field( + None, + description='A URL to download the extraction results. Provided when the job is complete and either you set an `output_save_url` or the result is larger than 1 MB. URLs for large results are temporary and expire one hour after you request the job.', + title='Output Url', + ) + progress: float = Field( + ..., + description='Job completion. Either 0.0 (not yet complete) or 1.0 (complete).', + ge=0.0, + le=1.0, + title='Progress', + ) + received_at: int = Field( + ..., + description='Unix timestamp (in seconds) for when the job was received.', + title='Received At', + ) + status: str = Field( + ..., + description='The current state of the job: `pending`, `processing`, `completed`, `failed`, or `cancelled`.', + title='Status', + ) + version: str | None = Field( + None, + description='The exact model snapshot used for the extraction.', + title='Version', + ) + + +class JobStatusResponse(BaseModel): + """ + Unified response for job status endpoint. + """ + + created_at: int | None = Field( + 0, + description='Unix timestamp (seconds) for when the job was created. Mirrors received_at; exposed so clients have an explicit creation time.', + title='Created At', + ) + data: ParseResponse | SpreadsheetParseResponse | None = Field( + None, + description='The parsed output (ParseResponse for documents, SpreadsheetParseResponse for spreadsheets), if the job is complete and the `output_save_url` parameter was not used.', + title='Data', + ) + failure_reason: str | None = Field(None, title='Failure Reason') + job_id: str = Field(..., title='Job Id') + metadata: ParseMetadata | None = None + org_id: str | None = Field(None, title='Org Id') + output_url: str | None = Field( + None, + description='The URL to the parsed content. This field contains a URL when the job is complete and either you specified the `output_save_url` parameter or the result is larger than 1MB. When the result exceeds 1MB, the URL is a presigned S3 URL that expires after 1 hour. Each time you GET the job, a new presigned URL is generated.', + title='Output Url', + ) + progress: float = Field( + ..., + description='Job completion progress as a decimal from 0 to 1, where 0 is not started, 1 is finished, and values between 0 and 1 indicate work in progress.', + ge=0.0, + le=1.0, + title='Progress', + ) + received_at: int = Field(..., title='Received At') + status: str = Field(..., title='Status') + version: str | None = Field(None, title='Version') From 0d98f604d8abac3b7c2df2c47cd769044c8c4cd0 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:39:47 +0800 Subject: [PATCH 04/17] chore(spec-sync): add griffe surface-lock gate (baseline=last release tag) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spec-sync/surface-lock.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100755 scripts/spec-sync/surface-lock.sh diff --git a/scripts/spec-sync/surface-lock.sh b/scripts/spec-sync/surface-lock.sh new file mode 100755 index 0000000..a217010 --- /dev/null +++ b/scripts/spec-sync/surface-lock.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Fail if the RELEASED public API of landingai_ade changed in a breaking way. +# Baseline = last release tag: released surface is the promise to users; merged-but- +# unreleased surface stays mutable. +set -euo pipefail + +baseline="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)" +if [ -z "$baseline" ]; then + echo "surface-lock: no release tag found; skipping (nothing released yet)." + exit 0 +fi + +echo "surface-lock: checking landingai_ade against $baseline" +rye run griffe check landingai_ade --against "$baseline" --search src From 9ce063a552f77f9457d6f0314bda53dfb9b64271 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:41:31 +0800 Subject: [PATCH 05/17] test(spec-sync): add staging contract-test scaffold (contract marker) Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 3 +++ tests/contract/__init__.py | 0 tests/contract/test_v1_smoke.py | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 tests/contract/__init__.py create mode 100644 tests/contract/test_v1_smoke.py diff --git a/pyproject.toml b/pyproject.toml index 427643f..a4be7bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,9 @@ addopts = "--tb=short -n auto" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" +markers = [ + "contract: hits the live staging API; requires LANDINGAI_ADE_STAGING_APIKEY", +] filterwarnings = [ "error" ] diff --git a/tests/contract/__init__.py b/tests/contract/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contract/test_v1_smoke.py b/tests/contract/test_v1_smoke.py new file mode 100644 index 0000000..569efb0 --- /dev/null +++ b/tests/contract/test_v1_smoke.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import os + +import pytest + +from landingai_ade import LandingAIADE + +pytestmark = pytest.mark.contract + +STAGING_KEY = os.environ.get("LANDINGAI_ADE_STAGING_APIKEY") + + +@pytest.fixture() +def staging_client() -> LandingAIADE: + if not STAGING_KEY: + pytest.skip("LANDINGAI_ADE_STAGING_APIKEY not set") + return LandingAIADE(apikey=STAGING_KEY, environment="staging") + + +def test_parse_jobs_list_reachable(staging_client: LandingAIADE) -> None: + """The implemented V1 surface answers on staging (auth + routing sanity).""" + result = staging_client.parse_jobs.list(page=0, page_size=1) + assert hasattr(result, "jobs") From af14062a0b42c547dda2d623965cc665ea64a00a Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:42:31 +0800 Subject: [PATCH 06/17] ci(spec-sync): add two-phase spec-sync workflow (mechanical + AI) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/spec-sync.yml | 102 ++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/spec-sync.yml diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml new file mode 100644 index 0000000..5b9029b --- /dev/null +++ b/.github/workflows/spec-sync.yml @@ -0,0 +1,102 @@ +name: Spec Sync +on: + schedule: + - cron: '0 */6 * * *' # every ~6h; cron covers correctness, dispatch cuts latency + workflow_dispatch: {} + +permissions: + contents: read # writes happen via the app token, not GITHUB_TOKEN + +jobs: + spec-sync: + if: github.repository == 'landing-ai/ade-python' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + V1_SPEC_URL: https://api.va.staging.landing.ai/v1/ade/openapi.json # staging drives the loop + steps: + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.SPEC_SYNC_APP_ID }} + private-key: ${{ secrets.SPEC_SYNC_APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 # tags needed for surface-lock baseline + + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> "$GITHUB_PATH" + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Detect drift + id: drift + run: | + set +e + ./scripts/spec-sync/check-drift.sh "$V1_SPEC_URL" specs/v1-ade.json + echo "code=$?" >> "$GITHUB_OUTPUT" + + - name: No drift + if: steps.drift.outputs.code == '0' + run: echo "specs in sync; nothing to do." + + - name: Fail on fetch error + if: steps.drift.outputs.code != '0' && steps.drift.outputs.code != '10' + run: | + echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" + exit 1 + + # ---- Phase 1: mechanical ---- + - name: Mechanical commit (snapshot + reference models) + if: steps.drift.outputs.code == '10' + id: mech + run: | + git config user.name "spec-sync[bot]" + git config user.email "spec-sync@users.noreply.github.com" + branch="spec-sync/v1-$(date -u +%Y%m%dT%H%M%SZ)" + git checkout -b "$branch" + ./scripts/spec-sync/gen-models.sh specs/v1-ade.json specs/_generated/v1_models.py + git add specs/v1-ade.json specs/_generated/v1_models.py + git commit -m "chore(spec-sync): update V1 spec snapshot + regenerated reference models" + git push -u origin "$branch" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + # ---- Phase 2: AI wiring (same checkout/branch) ---- + - name: AI wiring commit + if: steps.drift.outputs.code == '10' + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ steps.app-token.outputs.token }} + prompt: | + The previous commit updated specs/v1-ade.json and regenerated reference + models in specs/_generated/v1_models.py. Update the SDK to match the spec + diff: add or adjust resource classes, method signatures, param TypedDicts, + response models, tests, api.md, and README examples, mirroring the existing + conventions in src/landingai_ade/resources/parse_jobs.py. + Rules: PURELY ADDITIVE — never modify or remove an existing public signature + (the surface-lock CI job will fail the PR); follow existing code style; + run ./scripts/format; commit to the current branch. + claude_args: | + --max-turns 40 + --allowedTools "Edit,Write,Read,Bash(git *),Bash(rye *),Bash(./scripts/*)" + + - name: Open sync PR + if: steps.drift.outputs.code == '10' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr create \ + --base main \ + --head "${{ steps.mech.outputs.branch }}" \ + --title "spec-sync: track V1 spec drift" \ + --body $'Automated spec-sync PR.\n\n- **Commit 1 (mechanical):** normalized spec snapshot + regenerated reference models.\n- **Commit 2 (AI):** resources/methods/tests/docs wired from the spec diff.\n\nGates (surface-lock, contract tests, lint/test/typecheck) must pass. **Human review required before merge.**' From abddece1d11392edd41ea1d78dc1425261ee2a76 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:42:31 +0800 Subject: [PATCH 07/17] ci(spec-sync): add PR gates (surface-lock + staging contract tests) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pr-gates.yml | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/pr-gates.yml diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml new file mode 100644 index 0000000..1d4c4e5 --- /dev/null +++ b/.github/workflows/pr-gates.yml @@ -0,0 +1,43 @@ +name: PR Gates +on: + pull_request: + branches: [main] + +jobs: + surface-lock: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # tags for the release-tag baseline + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> "$GITHUB_PATH" + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + - name: Install dependencies + run: rye sync --all-features + - name: Surface lock (no breaking change to released API) + run: ./scripts/spec-sync/surface-lock.sh + + contract-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> "$GITHUB_PATH" + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + - name: Install dependencies + run: rye sync --all-features + - name: Contract tests vs staging + env: + LANDINGAI_ADE_STAGING_APIKEY: ${{ secrets.LANDINGAI_ADE_STAGING_APIKEY }} + run: rye run pytest tests/contract -m contract -v From 40d8efebf3d9f3f1e862b4f7606cd8125bfd7680 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:43:00 +0800 Subject: [PATCH 08/17] ci(spec-sync): add production-spec release gate (staging-in, production-out) Wiring into the self-hosted release-please workflow depends on Problem 1. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spec-sync/release-gate.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100755 scripts/spec-sync/release-gate.sh diff --git a/scripts/spec-sync/release-gate.sh b/scripts/spec-sync/release-gate.sh new file mode 100755 index 0000000..b22c4a2 --- /dev/null +++ b/scripts/spec-sync/release-gate.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Block a release while the SDK implements a route that is not yet in PRODUCTION. +# (staging-only features hold the release train until they reach prod or are reverted.) +set -euo pipefail + +prod_url="https://api.va.landing.ai/v1/ade/openapi.json" +here="$(cd "$(dirname "$0")" && pwd)" +prod="$(mktemp)" +trap 'rm -f "$prod"' EXIT +"$here/fetch-normalize.sh" "$prod_url" > "$prod" + +# The committed staging snapshot is the surface we ship code for; every path in it +# must also exist in production before we release. +missing="$(jq -r --slurpfile p "$prod" ' + .paths | keys[] as $k | select(($p[0].paths | has($k)) | not) | $k +' specs/v1-ade.json)" + +if [ -n "$missing" ]; then + echo "release-gate: these implemented routes are not in production yet:" >&2 + echo "$missing" >&2 + exit 1 +fi +echo "release-gate: all implemented routes exist in production." From c30c4953f58b52788e288d5346edda79fbc991d5 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 16:43:30 +0800 Subject: [PATCH 09/17] docs(spec-sync): document the spec-sync pipeline and TS-port follow-up Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05466a6..306513f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,3 +126,41 @@ You can release to package managers by using [the `Publish PyPI` GitHub action]( If you need to manually release a package, you can run the `bin/publish-pypi` script with a `PYPI_TOKEN` set on the environment. + +## Spec-sync pipeline + +The SDK tracks the live ADE OpenAPI spec automatically via `.github/workflows/spec-sync.yml` +(cron ~6h + manual `workflow_dispatch`). It is driven by the **staging** spec; releases gate on +the **production** spec ("staging in, production out"). + +On each run it fetches and normalizes the live spec (`scripts/spec-sync/fetch-normalize.sh`) and +diffs it against the committed snapshot `specs/v1-ade.json` (`scripts/spec-sync/check-drift.sh`). +On drift it opens one PR with two attributed commits: + +1. **Mechanical** — updated `specs/v1-ade.json` snapshot plus regenerated *reference* models in + `specs/_generated/v1_models.py` (`scripts/spec-sync/gen-models.sh`, `datamodel-code-generator`). + These reference models are an input for the AI step and for review — they are **not** shipped and + do **not** replace `src/landingai_ade/types/*`. +2. **AI** — `anthropics/claude-code-action` (automation mode) wires the resources, methods, param + types, tests, and docs from the spec diff, following existing conventions. + +Every spec-sync PR (and any PR to `main`) must pass `.github/workflows/pr-gates.yml`: + +- **surface-lock** (`scripts/spec-sync/surface-lock.sh`, `griffe`) — baseline is the **last release + tag**, so any change to *released* public surface fails mechanically. Merged-but-unreleased surface + stays mutable. +- **contract-tests** — `tests/contract` (marker `contract`) run against staging when + `LANDINGAI_ADE_STAGING_APIKEY` is set; skipped otherwise. + +Spec-sync PRs are AI-drafted and **require human review** before merge. + +**Secrets required:** `SPEC_SYNC_APP_ID` + `SPEC_SYNC_APP_PRIVATE_KEY` (a GitHub App with +`contents: write` and `pull-requests: write` — needed because commits pushed with the default +`GITHUB_TOKEN` do not trigger the gate workflows), `ANTHROPIC_API_KEY`, and +`LANDINGAI_ADE_STAGING_APIKEY`. + +**V2:** once the aide gateway serves its curated spec unauthenticated, add `specs/v2-aide.json` and +its URL as a second drift-check in the workflow; the two-phase machinery is unchanged. + +The same pipeline shape ports to `ade-typescript` with `openapi-typescript` (mechanical) and +`api-extractor` (surface-lock), tracked separately. From 2afbbb618138559d3d85cc1b44ccd7493b618869 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 17:18:47 +0800 Subject: [PATCH 10/17] fix(spec-sync): exclude generated specs from linters; lock deps; staging via base_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff/pyright/mypy now exclude specs/ (generated reference models are not shipped and are not held to SDK lint/type standards) — fixes the lint CI failure - requirements-dev.lock regenerated to pin datamodel-code-generator + griffe - contract test reaches staging via base_url instead of environment='staging' (the 4-environment map is Problem 2 scope, not yet available) Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 6 ++++- requirements-dev.lock | 39 +++++++++++++++++++++++++++++++++ tests/contract/test_v1_smoke.py | 5 ++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4be7bb..d00a3e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,7 @@ exclude = [ ".venv", ".nox", ".git", + "specs", ] reportImplicitOverride = true @@ -173,7 +174,7 @@ show_error_codes = true # # We also exclude our `tests` as mypy doesn't always infer # types correctly and Pyright will still catch any type errors. -exclude = ['src/landingai_ade/_files.py', '_dev/.*.py', 'tests/.*'] +exclude = ['src/landingai_ade/_files.py', '_dev/.*.py', 'tests/.*', 'specs/.*'] strict_equality = true implicit_reexport = true @@ -219,6 +220,9 @@ ignore_missing_imports = true line-length = 120 output-format = "grouped" target-version = "py38" +# specs/_generated holds datamodel-code-generator reference models (not shipped); +# they are not held to the SDK's lint standards. +extend-exclude = ["specs"] [tool.ruff.format] docstring-code-format = true diff --git a/requirements-dev.lock b/requirements-dev.lock index f07940f..f899736 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -23,6 +23,7 @@ anyio==4.12.1 # via httpx # via landingai-ade argcomplete==3.6.3 + # via datamodel-code-generator # via nox async-timeout==5.0.1 # via aiohttp @@ -31,11 +32,18 @@ attrs==25.4.0 # via nox backports-asyncio-runner==1.2.0 # via pytest-asyncio +black==25.11.0 + # via datamodel-code-generator certifi==2026.1.4 # via httpcore # via httpx +click==8.1.8 + # via black +colorama==0.4.6 + # via griffe colorlog==6.10.1 # via nox +datamodel-code-generator==0.45.0 dependency-groups==1.3.1 # via nox dirty-equals==0.11 @@ -53,6 +61,9 @@ filelock==3.19.1 frozenlist==1.8.0 # via aiohttp # via aiosignal +genson==1.3.1 + # via datamodel-code-generator +griffe==1.14.0 h11==0.16.0 # via httpcore httpcore==1.0.9 @@ -70,28 +81,45 @@ idna==3.11 # via httpx # via yarl importlib-metadata==8.7.1 + # via isort + # via typeguard +inflect==7.5.0 + # via datamodel-code-generator iniconfig==2.1.0 # via pytest +isort==6.1.0 + # via datamodel-code-generator +jinja2==3.1.6 + # via datamodel-code-generator markdown-it-py==3.0.0 # via rich +markupsafe==3.0.3 + # via jinja2 mdurl==0.1.2 # via markdown-it-py +more-itertools==10.8.0 + # via inflect multidict==6.7.0 # via aiohttp # via yarl mypy==1.17.0 mypy-extensions==1.1.0 + # via black # via mypy nodeenv==1.10.0 # via pyright nox==2025.11.12 packaging==25.0 + # via black + # via datamodel-code-generator # via dependency-groups # via nox # via pytest pathspec==1.0.3 + # via black # via mypy platformdirs==4.4.0 + # via black # via virtualenv pluggy==1.6.0 # via pytest @@ -99,6 +127,7 @@ propcache==0.4.1 # via aiohttp # via yarl pydantic==2.12.5 + # via datamodel-code-generator # via landingai-ade pydantic-core==2.41.5 # via pydantic @@ -113,6 +142,10 @@ pytest-asyncio==1.2.0 pytest-xdist==3.8.0 python-dateutil==2.9.0.post0 # via time-machine +pytokens==0.4.1 + # via black +pyyaml==6.0.3 + # via datamodel-code-generator respx==0.22.0 rich==14.2.0 ruff==0.14.13 @@ -122,13 +155,18 @@ sniffio==1.3.1 # via landingai-ade time-machine==2.19.0 tomli==2.4.0 + # via black + # via datamodel-code-generator # via dependency-groups # via mypy # via nox # via pytest +typeguard==4.5.2 + # via inflect typing-extensions==4.15.0 # via aiosignal # via anyio + # via black # via exceptiongroup # via landingai-ade # via multidict @@ -137,6 +175,7 @@ typing-extensions==4.15.0 # via pydantic-core # via pyright # via pytest-asyncio + # via typeguard # via typing-inspection # via virtualenv typing-inspection==0.4.2 diff --git a/tests/contract/test_v1_smoke.py b/tests/contract/test_v1_smoke.py index 569efb0..782915e 100644 --- a/tests/contract/test_v1_smoke.py +++ b/tests/contract/test_v1_smoke.py @@ -9,13 +9,16 @@ pytestmark = pytest.mark.contract STAGING_KEY = os.environ.get("LANDINGAI_ADE_STAGING_APIKEY") +# V1 staging host. (Once Problem 2 adds the 4-environment map, this can become +# environment="staging"; until then we target staging via base_url.) +STAGING_V1_BASE_URL = "https://api.va.staging.landing.ai" @pytest.fixture() def staging_client() -> LandingAIADE: if not STAGING_KEY: pytest.skip("LANDINGAI_ADE_STAGING_APIKEY not set") - return LandingAIADE(apikey=STAGING_KEY, environment="staging") + return LandingAIADE(apikey=STAGING_KEY, base_url=STAGING_V1_BASE_URL) def test_parse_jobs_list_reachable(staging_client: LandingAIADE) -> None: From 98e7bb80399492f78ce3232850e45fcacc631ab4 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 17:47:42 +0800 Subject: [PATCH 11/17] fix(spec-sync): pin codegen/griffe to validated versions; regen models with pinned toolchain - datamodel-code-generator>=0.45 (supports the pydantic_v2 target; matches the locked/validated version) and griffe>=1.14 - regenerate specs/_generated/v1_models.py with the rye toolchain (0.45.0) so the committed reference matches what the spec-sync workflow will produce (the prior file was generated by a newer local version and would have diffed on first run) Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 4 +- specs/_generated/v1_models.py | 206 +++++++++++++++++----------------- 2 files changed, 106 insertions(+), 104 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d00a3e6..fde35ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,8 +59,8 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", - "datamodel-code-generator>=0.25", - "griffe>=0.49", + "datamodel-code-generator>=0.45", + "griffe>=1.14", ] [tool.rye.scripts] diff --git a/specs/_generated/v1_models.py b/specs/_generated/v1_models.py index ad093e4..ca30b21 100644 --- a/specs/_generated/v1_models.py +++ b/specs/_generated/v1_models.py @@ -4,7 +4,7 @@ from __future__ import annotations from enum import Enum -from typing import Any +from typing import Any, Optional, Union from pydantic import BaseModel, Field, RootModel @@ -16,22 +16,22 @@ class AsyncExtractRequest(BaseModel): Extends ExtractRequest with output_save_url for ZDR support. """ - markdown: bytes | str | None = Field( + markdown: Optional[Union[bytes, str]] = Field( None, description='The Markdown file or Markdown content to extract data from.', title='Markdown', ) - markdown_url: str | None = Field( + markdown_url: Optional[str] = Field( None, description='The URL to the Markdown file to extract data from.', title='Markdown Url', ) - model: str | None = Field( + model: Optional[str] = Field( None, description='The version of the model to use for extraction. Use `extract-latest` to use the latest version.', title='Model', ) - output_save_url: str | None = Field( + output_save_url: Optional[str] = Field( None, description='If zero data retention (ZDR) is enabled, you must enter a URL for the extracted output to be saved to. When ZDR is enabled, the extracted content will not be in the API response.', title='Output Save Url', @@ -42,7 +42,7 @@ class AsyncExtractRequest(BaseModel): description='JSON schema for field extraction. This schema determines what key-values pairs are extracted from the Markdown. The schema must be a valid JSON object and will be validated before processing the document.', title='Schema', ) - strict: bool | None = Field( + strict: Optional[bool] = Field( False, description='If True, reject schemas with unsupported fields (HTTP 422). If False, prune unsupported fields and continue. Only applies to extract versions that support schema validation.', title='Strict', @@ -50,27 +50,27 @@ class AsyncExtractRequest(BaseModel): class BuildSchemaRequest(BaseModel): - markdown_urls: list[str] | None = Field( + markdown_urls: Optional[list[str]] = Field( None, description='URLs to Markdown files to analyze for schema generation.', title='Markdown Urls', ) - markdowns: list[bytes | str] | None = Field( + markdowns: Optional[list[Union[bytes, str]]] = Field( None, description='Markdown files or inline content strings to analyze for schema generation. Multiple documents can be provided for better schema coverage.', title='Markdowns', ) - model: str | None = Field( + model: Optional[str] = Field( None, description='The version of the model to use for schema generation. Use `extract-latest` to use the latest version.', title='Model', ) - prompt: str | None = Field( + prompt: Optional[str] = Field( None, description='Instructions for how to generate or modify the schema.', title='Prompt', ) - schema_: str | None = Field( + schema_: Optional[str] = Field( None, alias='schema', description='Existing JSON schema to iterate on or refine.', @@ -90,10 +90,10 @@ class ClassificationItem(BaseModel): title='Class', ) page: int = Field(..., description='Page number (0-based).', title='Page') - reason: str | None = Field( + reason: Optional[str] = Field( '', description='Reason for the classification (for debugging).', title='Reason' ) - suggested_class: str | None = Field( + suggested_class: Optional[str] = Field( None, description="Proposed class when the prediction is 'unknown'.", title='Suggested Class', @@ -108,7 +108,7 @@ class ClassifyClass(BaseModel): class_: str = Field( ..., alias='class', description='Name of the class.', title='Class' ) - description: str | None = Field( + description: Optional[str] = Field( None, description='Detailed description of what this class represents.', title='Description', @@ -123,10 +123,10 @@ class ClassifyMetadata(BaseModel): credit_usage: float = Field(..., title='Credit Usage') duration_ms: int = Field(..., title='Duration Ms') filename: str = Field(..., title='Filename') - job_id: str | None = Field('', title='Job Id') - org_id: str | None = Field(None, title='Org Id') + job_id: Optional[str] = Field('', title='Job Id') + org_id: Optional[str] = Field(None, title='Org Id') page_count: int = Field(..., title='Page Count') - version: str | None = Field(None, title='Version') + version: Optional[str] = Field(None, title='Version') class ClassifyRequest(BaseModel): @@ -135,17 +135,17 @@ class ClassifyRequest(BaseModel): description="The possible classes that can be assigned to pages in the document. Each entry is an object with a `class` name and an optional `description`. Only one class is assigned per page; unclassifiable pages receive 'unknown'. Can be provided as a JSON string in form data.", title='Classes', ) - document: bytes | None = Field( + document: Optional[bytes] = Field( None, description='A file to be classified. Either this parameter or the `document_url` parameter must be provided.', title='Document', ) - document_url: str | None = Field( + document_url: Optional[str] = Field( None, description='The URL of the document to be classified. Either this parameter or the `document` parameter must be provided.', title='Document Url', ) - model: str | None = Field( + model: Optional[str] = Field( None, description='Classification model version. Defaults to the latest.', title='Model', @@ -178,17 +178,17 @@ class CustomPrompts(RootModel[dict[str, str]]): class ExtractRequest(BaseModel): - markdown: bytes | str | None = Field( + markdown: Optional[Union[bytes, str]] = Field( None, description='The Markdown file or Markdown content to extract data from.', title='Markdown', ) - markdown_url: str | None = Field( + markdown_url: Optional[str] = Field( None, description='The URL to the Markdown file to extract data from.', title='Markdown Url', ) - model: str | None = Field( + model: Optional[str] = Field( None, description='The version of the model to use for extraction. Use `extract-latest` to use the latest version.', title='Model', @@ -199,7 +199,7 @@ class ExtractRequest(BaseModel): description='JSON schema for field extraction. This schema determines what key-values pairs are extracted from the Markdown. The schema must be a valid JSON object and will be validated before processing the document.', title='Schema', ) - strict: bool | None = Field( + strict: Optional[bool] = Field( False, description='If True, reject schemas with unsupported fields (HTTP 422). If False, prune unsupported fields and continue. Only applies to extract versions that support schema validation.', title='Strict', @@ -239,12 +239,12 @@ class JobSummary(BaseModel): Summary of a job for listing. """ - created_at: int | None = Field( + created_at: Optional[int] = Field( 0, description='Unix timestamp (seconds) for when the job was created. Mirrors received_at; exposed so clients have an explicit creation time.', title='Created At', ) - failure_reason: str | None = Field(None, title='Failure Reason') + failure_reason: Optional[str] = Field(None, title='Failure Reason') job_id: str = Field(..., title='Job Id') progress: float = Field( ..., @@ -262,9 +262,9 @@ class JobsListResponse(BaseModel): Response for listing jobs. """ - has_more: bool | None = Field(False, title='Has More') + has_more: Optional[bool] = Field(False, title='Has More') jobs: list[JobSummary] = Field(..., title='Jobs') - org_id: str | None = Field(None, title='Org Id') + org_id: Optional[str] = Field(None, title='Org Id') class ParseGroundingBox(BaseModel): @@ -277,12 +277,12 @@ class ParseGroundingBox(BaseModel): class ParseMetadata(BaseModel): credit_usage: float = Field(..., title='Credit Usage') duration_ms: int = Field(..., title='Duration Ms') - failed_pages: list[int] | None = Field(None, title='Failed Pages') + failed_pages: Optional[list[int]] = Field(None, title='Failed Pages') filename: str = Field(..., title='Filename') job_id: str = Field(..., title='Job Id') - org_id: str | None = Field(..., title='Org Id') + org_id: Optional[str] = Field(..., title='Org Id') page_count: int = Field(..., title='Page Count') - version: str | None = Field(..., title='Version') + version: Optional[str] = Field(..., title='Version') class ParseResponseTableCellGroundingPosition(BaseModel): @@ -303,7 +303,7 @@ class ParseSplit(BaseModel): class Patch(BaseModel): confidence: float = Field(..., title='Confidence') - span: tuple[int, int] = Field(..., title='Span') + span: list[Any] = Field(..., max_length=2, min_length=2, title='Span') text: str = Field(..., title='Text') @@ -315,9 +315,9 @@ class SectionMetadata(BaseModel): credit_usage: float = Field(..., title='Credit Usage') duration_ms: int = Field(..., title='Duration Ms') filename: str = Field(..., title='Filename') - job_id: str | None = Field('', title='Job Id') - org_id: str | None = Field(None, title='Org Id') - version: str | None = Field(None, title='Version') + job_id: Optional[str] = Field('', title='Job Id') + org_id: Optional[str] = Field(None, title='Org Id') + version: Optional[str] = Field(None, title='Version') class SectionRequest(BaseModel): @@ -325,20 +325,20 @@ class SectionRequest(BaseModel): Request model for section endpoint. """ - guidelines: str | None = Field( + guidelines: Optional[str] = Field( None, description="Natural-language instructions to control hierarchy. Examples: 'Group by topic', 'Treat each numbered section as a top-level entry'.", title='Guidelines', ) - markdown: bytes | str | None = Field( + markdown: Optional[Union[bytes, str]] = Field( None, description="Parsed markdown with reference anchors (). This is the markdown field from a parse response.", title='Markdown', ) - markdown_url: str | None = Field( + markdown_url: Optional[str] = Field( None, description='URL to fetch the markdown from.', title='Markdown Url' ) - model: str | None = Field( + model: Optional[str] = Field( None, description='Section model version. Defaults to latest.', title='Model' ) @@ -359,12 +359,12 @@ class SplitClass(BaseModel): Model for split classification option. """ - description: str | None = Field( + description: Optional[str] = Field( None, description='Detailed description of what this split type represents', title='Description', ) - identifier: str | None = Field( + identifier: Optional[str] = Field( None, description='Identifier to partition/group the splits by', title='Identifier', @@ -380,7 +380,7 @@ class SplitData(BaseModel): """ classification: str = Field(..., title='Classification') - identifier: str | None = Field(..., title='Identifier') + identifier: Optional[str] = Field(..., title='Identifier') markdowns: list[str] = Field(..., title='Markdowns') pages: list[int] = Field(..., title='Pages') @@ -393,12 +393,12 @@ class SplitMetadata(BaseModel): credit_usage: float = Field(..., title='Credit Usage') duration_ms: int = Field(..., title='Duration Ms') filename: str = Field(..., title='Filename') - job_id: str | None = Field( + job_id: Optional[str] = Field( '', description='Inference history job ID', title='Job Id' ) - org_id: str | None = Field(None, description='Organization ID', title='Org Id') + org_id: Optional[str] = Field(None, description='Organization ID', title='Org Id') page_count: int = Field(..., title='Page Count') - version: str | None = Field( + version: Optional[str] = Field( None, description='Model version used for split classification', title='Version' ) @@ -408,15 +408,15 @@ class SplitRequest(BaseModel): Request model for split classification endpoint. """ - markdown: bytes | str | None = Field( + markdown: Optional[Union[bytes, str]] = Field( None, description='The Markdown file or Markdown content to split.', title='Markdown', ) - markdown_url: str | None = Field( + markdown_url: Optional[str] = Field( None, description='The URL to the Markdown file to split.', title='Markdown Url' ) - model: str | None = Field( + model: Optional[str] = Field( 'split-20251105', description='Model version to use for split classification. Defaults to the latest version.', title='Model', @@ -446,17 +446,17 @@ class SpreadsheetParseMetadata(BaseModel): Metadata for spreadsheet parsing result. """ - credit_usage: float | None = Field( + credit_usage: Optional[float] = Field( 0.0, description='Credits charged', title='Credit Usage' ) duration_ms: int = Field( ..., description='Processing duration in milliseconds', title='Duration Ms' ) filename: str = Field(..., description='Original filename', title='Filename') - job_id: str | None = Field( + job_id: Optional[str] = Field( '', description='Inference history job ID', title='Job Id' ) - org_id: str | None = Field(None, description='Organization ID', title='Org Id') + org_id: Optional[str] = Field(None, description='Organization ID', title='Org Id') sheet_count: int = Field( ..., description='Number of sheets processed', title='Sheet Count' ) @@ -468,13 +468,13 @@ class SpreadsheetParseMetadata(BaseModel): description='Total chunks (tables + images) extracted', title='Total Chunks', ) - total_images: int | None = Field( + total_images: Optional[int] = Field( 0, description='Total images extracted', title='Total Images' ) total_rows: int = Field( ..., description='Total rows across all sheets', title='Total Rows' ) - version: str | None = Field( + version: Optional[str] = Field( None, description='Model version for parsing images', title='Version' ) @@ -512,41 +512,41 @@ class SpreadsheetSplit(BaseModel): class ValidationError(BaseModel): - loc: list[str | int] = Field(..., title='Location') + loc: list[Union[str, int]] = Field(..., title='Location') msg: str = Field(..., title='Message') type: str = Field(..., title='Error Type') class AsyncParseRequestWithEncryptedPassword(BaseModel): - custom_prompts: str | None = Field( + custom_prompts: Optional[str] = Field( None, description='Optional JSON string mapping chunk types to custom parsing prompts. Only the `figure` key is supported, for example \'{"figure":"Describe axis labels in detail."}\'.', title='Custom Prompts', ) - document: bytes | None = Field( + document: Optional[bytes] = Field( None, description='A file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document_url` parameter must be provided.', title='Document', ) - document_url: str | None = Field( + document_url: Optional[str] = Field( None, description='The URL to the file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document` parameter must be provided.', title='Document Url', ) - model: str | None = Field( + model: Optional[str] = Field( None, description='The version of the model to use for parsing.', title='Model' ) - output_save_url: str | None = Field( + output_save_url: Optional[str] = Field( None, description='If zero data retention (ZDR) is enabled, you must enter a URL for the parsed output to be saved to. When ZDR is enabled, the parsed content will not be in the API response.', title='Output Save Url', ) - password: str | None = Field( + password: Optional[str] = Field( None, description='Password for encrypted document files. If the document is password-protected, provide the password to decrypt and process the document. Ignored for unencrypted documents.', title='Password', ) - split: SplitType | None = Field( + split: Optional[SplitType] = Field( None, description='If you want to split documents into smaller sections, include the split parameter. Set the parameter to page to split documents at the page level. The splits object in the API output will contain a set of data for each page.', ) @@ -565,7 +565,7 @@ class ExtractWarning(BaseModel): class HTTPValidationError(BaseModel): - detail: list[ValidationError] | None = Field(None, title='Detail') + detail: Optional[list[ValidationError]] = Field(None, title='Detail') class ParseGrounding(BaseModel): @@ -574,30 +574,30 @@ class ParseGrounding(BaseModel): class ParseRequestWithEncryptedPassword(BaseModel): - custom_prompts: str | None = Field( + custom_prompts: Optional[str] = Field( None, description='Optional JSON string mapping chunk types to custom parsing prompts. Only the `figure` key is supported, for example \'{"figure":"Describe axis labels in detail."}\'.', title='Custom Prompts', ) - document: bytes | None = Field( + document: Optional[bytes] = Field( None, description='A file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document_url` parameter must be provided.', title='Document', ) - document_url: str | None = Field( + document_url: Optional[str] = Field( None, description='The URL to the file to be parsed. The file can be a PDF or an image. See the list of supported file types here: https://docs.landing.ai/ade/ade-file-types. Either this parameter or the `document` parameter must be provided.', title='Document Url', ) - model: str | None = Field( + model: Optional[str] = Field( None, description='The version of the model to use for parsing.', title='Model' ) - password: str | None = Field( + password: Optional[str] = Field( None, description='Password for encrypted document files. If the document is password-protected, provide the password to decrypt and process the document. Ignored for unencrypted documents.', title='Password', ) - split: SplitType | None = Field( + split: Optional[SplitType] = Field( None, description='If you want to split documents into smaller sections, include the split parameter. Set the parameter to page to split documents at the page level. The splits object in the API output will contain a set of data for each page.', ) @@ -605,17 +605,19 @@ class ParseRequestWithEncryptedPassword(BaseModel): class ParseResponseGrounding(BaseModel): box: ParseGroundingBox - confidence: float | None = Field(None, title='Confidence') - low_confidence_spans: list[Patch] | None = Field(None, title='Low Confidence Spans') + confidence: Optional[float] = Field(None, title='Confidence') + low_confidence_spans: Optional[list[Patch]] = Field( + None, title='Low Confidence Spans' + ) page: int = Field(..., title='Page') type: GroundingType class ParseResponseTableCellGrounding(BaseModel): box: ParseGroundingBox - confidence: float | None = Field(None, title='Confidence') + confidence: Optional[float] = Field(None, title='Confidence') page: int = Field(..., title='Page') - position: ParseResponseTableCellGroundingPosition | None = None + position: Optional[ParseResponseTableCellGroundingPosition] = None type: GroundingType @@ -638,7 +640,7 @@ class SpreadsheetChunk(BaseModel): - Parsed content chunks from embedded images (text, table, figure, etc.) """ - grounding: ParseGrounding | None = Field( + grounding: Optional[ParseGrounding] = Field( None, description='Visual grounding coordinates from /parse API (only for chunks derived from embedded images)', ) @@ -681,13 +683,13 @@ class SpreadsheetParseResponse(BaseModel): class BuildSchemaMetadata(BaseModel): - credit_usage: float | None = Field(0.0, title='Credit Usage') - duration_ms: int | None = Field(0, title='Duration Ms') - filename: str | None = Field(None, title='Filename') - job_id: str | None = Field('', title='Job Id') - org_id: str | None = Field(None, title='Org Id') - version: str | None = Field(None, title='Version') - warnings: list[ExtractWarning] | None = Field( + credit_usage: Optional[float] = Field(0.0, title='Credit Usage') + duration_ms: Optional[int] = Field(0, title='Duration Ms') + filename: Optional[str] = Field(None, title='Filename') + job_id: Optional[str] = Field('', title='Job Id') + org_id: Optional[str] = Field(None, title='Org Id') + version: Optional[str] = Field(None, title='Version') + warnings: Optional[list[ExtractWarning]] = Field( None, description="Structured warnings from the extraction process. Each warning is an instance of ExtractWarning with 'code' (e.g. 'nonconformant_schema') and 'msg' (human-readable description). Present only for extract versions from extract-20260314 and above that support structured warnings.", title='Warnings', @@ -708,21 +710,21 @@ class BuildSchemaResponse(BaseModel): class ExtractMetadata(BaseModel): credit_usage: float = Field(..., title='Credit Usage') duration_ms: int = Field(..., title='Duration Ms') - fallback_model_version: str | None = Field( + fallback_model_version: Optional[str] = Field( None, description='The extract model that was actually used to extract the data when the initial extraction attempt failed with the requested version.', title='Fallback Model Version', ) filename: str = Field(..., title='Filename') job_id: str = Field(..., title='Job Id') - org_id: str | None = Field(..., title='Org Id') - schema_violation_error: str | None = Field( + org_id: Optional[str] = Field(..., title='Org Id') + schema_violation_error: Optional[str] = Field( None, description='A detailed error message shows why the extracted data does not fully conform to the input schema. Null means the extraction result is consistent with the input schema.', title='Schema Violation Error', ) - version: str | None = Field(..., title='Version') - warnings: list[ExtractWarning] | None = Field( + version: Optional[str] = Field(..., title='Version') + warnings: Optional[list[ExtractWarning]] = Field( None, description="Structured warnings from the extraction process. Each warning is an instance of ExtractWarning with 'code' (e.g. 'nonconformant_schema') and 'msg' (human-readable description). Present only for extract versions from extract-20260314 and above that support structured warnings.", title='Warnings', @@ -752,9 +754,9 @@ class ParseChunk(BaseModel): class ParseResponse(BaseModel): chunks: list[ParseChunk] = Field(..., title='Chunks') - grounding: ( - dict[str, ParseResponseGrounding | ParseResponseTableCellGrounding] | None - ) = Field(None, title='Grounding') + grounding: Optional[ + dict[str, Union[ParseResponseGrounding, ParseResponseTableCellGrounding]] + ] = Field(None, title='Grounding') markdown: str = Field(..., title='Markdown') metadata: ParseMetadata splits: list[ParseSplit] = Field(..., title='Splits') @@ -765,16 +767,16 @@ class ExtractJobStatusResponse(BaseModel): The status of an extract job, plus the results once it completes. """ - created_at: int | None = Field( + created_at: Optional[int] = Field( 0, description='Unix timestamp (in seconds) for when the job was created.', title='Created At', ) - data: ExtractResponse | None = Field( + data: Optional[ExtractResponse] = Field( None, description='The extraction results, returned here when the job is complete and you did not set an `output_save_url`. Large results are returned through `output_url` instead.', ) - failure_reason: str | None = Field( + failure_reason: Optional[str] = Field( None, description='If the job failed, a message describing what went wrong.', title='Failure Reason', @@ -782,12 +784,12 @@ class ExtractJobStatusResponse(BaseModel): job_id: str = Field( ..., description='A unique identifier for this extract job.', title='Job Id' ) - metadata: ExtractMetadata | None = Field( + metadata: Optional[ExtractMetadata] = Field( None, description='Information about the extraction, such as the model version, duration, credit usage, and any schema warnings.', ) - org_id: str | None = Field(None, description='Organization ID.', title='Org Id') - output_url: str | None = Field( + org_id: Optional[str] = Field(None, description='Organization ID.', title='Org Id') + output_url: Optional[str] = Field( None, description='A URL to download the extraction results. Provided when the job is complete and either you set an `output_save_url` or the result is larger than 1 MB. URLs for large results are temporary and expire one hour after you request the job.', title='Output Url', @@ -809,7 +811,7 @@ class ExtractJobStatusResponse(BaseModel): description='The current state of the job: `pending`, `processing`, `completed`, `failed`, or `cancelled`.', title='Status', ) - version: str | None = Field( + version: Optional[str] = Field( None, description='The exact model snapshot used for the extraction.', title='Version', @@ -821,21 +823,21 @@ class JobStatusResponse(BaseModel): Unified response for job status endpoint. """ - created_at: int | None = Field( + created_at: Optional[int] = Field( 0, description='Unix timestamp (seconds) for when the job was created. Mirrors received_at; exposed so clients have an explicit creation time.', title='Created At', ) - data: ParseResponse | SpreadsheetParseResponse | None = Field( + data: Optional[Union[ParseResponse, SpreadsheetParseResponse]] = Field( None, description='The parsed output (ParseResponse for documents, SpreadsheetParseResponse for spreadsheets), if the job is complete and the `output_save_url` parameter was not used.', title='Data', ) - failure_reason: str | None = Field(None, title='Failure Reason') + failure_reason: Optional[str] = Field(None, title='Failure Reason') job_id: str = Field(..., title='Job Id') - metadata: ParseMetadata | None = None - org_id: str | None = Field(None, title='Org Id') - output_url: str | None = Field( + metadata: Optional[ParseMetadata] = None + org_id: Optional[str] = Field(None, title='Org Id') + output_url: Optional[str] = Field( None, description='The URL to the parsed content. This field contains a URL when the job is complete and either you specified the `output_save_url` parameter or the result is larger than 1MB. When the result exceeds 1MB, the URL is a presigned S3 URL that expires after 1 hour. Each time you GET the job, a new presigned URL is generated.', title='Output Url', @@ -849,4 +851,4 @@ class JobStatusResponse(BaseModel): ) received_at: int = Field(..., title='Received At') status: str = Field(..., title='Status') - version: str | None = Field(None, title='Version') + version: Optional[str] = Field(None, title='Version') From 93666d5a3f5eae66cc24f761a1174546b5f14bba Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 20:46:33 +0800 Subject: [PATCH 12/17] ci(spec-sync): use fine-grained PAT (SPEC_SYNC_TOKEN) instead of a GitHub App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org-owned App creation needs org-owner permission; a repo-scoped fine-grained PAT (Contents+PRs RW) is the self-serve workaround and still satisfies the anti-recursion requirement (not GITHUB_TOKEN, so gate workflows fire). All three writes — checkout, claude-code-action, gh pr create — use the same token. Swappable for an org App later. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/spec-sync.yml | 16 ++++++---------- CONTRIBUTING.md | 11 +++++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index 5b9029b..b852ab5 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -15,16 +15,12 @@ jobs: env: V1_SPEC_URL: https://api.va.staging.landing.ai/v1/ade/openapi.json # staging drives the loop steps: - - name: Mint GitHub App token - id: app-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.SPEC_SYNC_APP_ID }} - private-key: ${{ secrets.SPEC_SYNC_APP_PRIVATE_KEY }} - - uses: actions/checkout@v6 with: - token: ${{ steps.app-token.outputs.token }} + # SPEC_SYNC_TOKEN is a fine-grained PAT (Contents: RW, Pull requests: RW) + # scoped to this repo. It must NOT be the default GITHUB_TOKEN: pushes/PRs + # authored by GITHUB_TOKEN do not trigger the gate workflows (anti-recursion). + token: ${{ secrets.SPEC_SYNC_TOKEN }} fetch-depth: 0 # tags needed for surface-lock baseline - name: Install Rye @@ -76,7 +72,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ steps.app-token.outputs.token }} + github_token: ${{ secrets.SPEC_SYNC_TOKEN }} prompt: | The previous commit updated specs/v1-ade.json and regenerated reference models in specs/_generated/v1_models.py. Update the SDK to match the spec @@ -93,7 +89,7 @@ jobs: - name: Open sync PR if: steps.drift.outputs.code == '10' env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} run: | gh pr create \ --base main \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 306513f..547e161 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,10 +154,13 @@ Every spec-sync PR (and any PR to `main`) must pass `.github/workflows/pr-gates. Spec-sync PRs are AI-drafted and **require human review** before merge. -**Secrets required:** `SPEC_SYNC_APP_ID` + `SPEC_SYNC_APP_PRIVATE_KEY` (a GitHub App with -`contents: write` and `pull-requests: write` — needed because commits pushed with the default -`GITHUB_TOKEN` do not trigger the gate workflows), `ANTHROPIC_API_KEY`, and -`LANDINGAI_ADE_STAGING_APIKEY`. +**Secrets required:** `SPEC_SYNC_TOKEN` (a fine-grained PAT scoped to this repo with +`Contents: Read and write` and `Pull requests: Read and write`), `ANTHROPIC_API_KEY`, and +`LANDINGAI_ADE_STAGING_APIKEY`. `SPEC_SYNC_TOKEN` must **not** be the default `GITHUB_TOKEN`: +pushes and PRs authored by `GITHUB_TOKEN` do not trigger the gate workflows (GitHub +anti-recursion), so the gates would never run on the sync PR. A GitHub App installation token +(org-owned) is the cleaner long-term choice and can replace the PAT without other workflow +changes. **V2:** once the aide gateway serves its curated spec unauthenticated, add `specs/v2-aide.json` and its URL as a second drift-check in the workflow; the two-phase machinery is unchanged. From e94036cbc9ca6f71b0fb5f259d45b2371c3ad6b9 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 21:09:39 +0800 Subject: [PATCH 13/17] fix(spec-sync): method-granular release gate; mkdir -p snapshot parent - release-gate compares (path, method) pairs, not just path keys, so a path present in prod but missing a specific method (e.g. a new POST) still blocks the release - check-drift creates the snapshot's parent dir before writing (usable with arbitrary paths; robust if the dir is cleaned in CI) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/spec-sync/check-drift.sh | 1 + scripts/spec-sync/release-gate.sh | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/spec-sync/check-drift.sh b/scripts/spec-sync/check-drift.sh index b39b1c0..1a788cf 100755 --- a/scripts/spec-sync/check-drift.sh +++ b/scripts/spec-sync/check-drift.sh @@ -23,6 +23,7 @@ if [ -f "$committed" ] && diff -q "$committed" "$tmp" >/dev/null; then exit 0 fi +mkdir -p "$(dirname "$committed")" cp "$tmp" "$committed" echo "drift detected -> updated $committed" exit 10 diff --git a/scripts/spec-sync/release-gate.sh b/scripts/spec-sync/release-gate.sh index b22c4a2..4fa8cf5 100755 --- a/scripts/spec-sync/release-gate.sh +++ b/scripts/spec-sync/release-gate.sh @@ -9,10 +9,19 @@ prod="$(mktemp)" trap 'rm -f "$prod"' EXIT "$here/fetch-normalize.sh" "$prod_url" > "$prod" -# The committed staging snapshot is the surface we ship code for; every path in it -# must also exist in production before we release. +# The committed staging snapshot is the surface we ship code for; every route in it +# must also exist in production before we release. A route is a (path, method) pair, +# so we compare at method granularity — a path present in prod but missing a specific +# method (e.g. a newly added POST) must still block the release. missing="$(jq -r --slurpfile p "$prod" ' - .paths | keys[] as $k | select(($p[0].paths | has($k)) | not) | $k + ($p[0].paths) as $prod + | .paths + | to_entries[] + | .key as $path + | (.value | keys[]) as $method + | select(["get","put","post","delete","patch","options","head","trace"] | index($method)) + | select(($prod[$path] // {} | has($method)) | not) + | "\($method | ascii_upcase) \($path)" ' specs/v1-ade.json)" if [ -n "$missing" ]; then From 0ad5bea9daec0d70d5afee0d7c6427ef01b38515 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Tue, 7 Jul 2026 21:39:46 +0800 Subject: [PATCH 14/17] chore(spec-sync): re-baseline V1 snapshot to the SDK's implemented surface Drop /v1/ade/extract/jobs and /v1/ade/extract/jobs/{job_id} from the committed snapshot: the SDK does not implement them yet, so the honest 'last-synced' baseline excludes them. This makes the snapshot truthful and lets the pipeline's first run legitimately detect the extract/jobs gap as drift (rather than appearing fully synced). Reference models are unchanged (those schemas live in components/schemas). Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/v1-ade.json | 188 ---------------------------------------------- 1 file changed, 188 deletions(-) diff --git a/specs/v1-ade.json b/specs/v1-ade.json index 7ef061f..be7d01e 100644 --- a/specs/v1-ade.json +++ b/specs/v1-ade.json @@ -2329,194 +2329,6 @@ ] } }, - "/v1/ade/extract/jobs": { - "get": { - "description": "List all async extract jobs associated with your API key.\n\nReturns the list of jobs or an error response. For EU users, use this endpoint:\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/extract/jobs`.", - "operationId": "tool_ade_list_extract_jobs_v1_ade_extract_jobs_get", - "parameters": [ - { - "description": "Page number (0-indexed)", - "in": "query", - "name": "page", - "required": false, - "schema": { - "default": 0, - "description": "Page number (0-indexed)", - "minimum": 0, - "title": "Page", - "type": "integer" - } - }, - { - "description": "Number of items per page", - "in": "query", - "name": "pageSize", - "required": false, - "schema": { - "default": 10, - "description": "Number of items per page", - "maximum": 100, - "minimum": 1, - "title": "Pagesize", - "type": "integer" - } - }, - { - "description": "Filter by job status.", - "in": "query", - "name": "status", - "required": false, - "schema": { - "anyOf": [ - { - "enum": [ - "cancelled", - "completed", - "failed", - "pending", - "processing" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Filter by job status.", - "title": "Status" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobsListResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "ADE List Extract Jobs", - "tags": [ - "Tools" - ] - }, - "post": { - "description": "Extract structured data asynchronously.\n\nThis endpoint creates a job that handles the processing for large markdown\ndocuments.\n\nFor EU users, use this endpoint:\n\n`https://api.va.eu-west-1.landing.ai/v1/ade/extract/jobs`.", - "operationId": "tool_ade_extract_jobs_v1_ade_extract_jobs_post", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/AsyncExtractRequest" - } - } - }, - "required": true - }, - "responses": { - "202": { - "content": { - "application/json": { - "example": { - "job_id": "12345678-1234-1234-1234-123456789012" - }, - "schema": { - "$ref": "#/components/schemas/JobCreationResponse" - } - } - }, - "description": "Job queued successfully" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - }, - "429": { - "description": "Rate limit exceeded" - } - }, - "summary": "ADE Extract Jobs", - "tags": [ - "Tools" - ] - } - }, - "/v1/ade/extract/jobs/{job_id}": { - "get": { - "description": "Get the status for an async extract job.\n\nReturns the job status or an error\n response. For EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/extract/jobs/{job_id}`.", - "operationId": "tool_ade_get_extract_jobs_v1_ade_extract_jobs__job_id__get", - "parameters": [ - { - "in": "path", - "name": "job_id", - "required": true, - "schema": { - "title": "Job Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtractJobStatusResponse" - } - } - }, - "description": "Successful Response" - }, - "206": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtractJobStatusResponse" - } - } - }, - "description": "Extraction completed with success but there was a schema validation error." - }, - "404": { - "description": "Job ID not found" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "ADE Get Extract Jobs", - "tags": [ - "Tools" - ] - } - }, "/v1/ade/parse": { "post": { "description": "Parse a document or spreadsheet.\n\nThis endpoint parses documents (PDF, images)\n and spreadsheets (XLSX, CSV) into structured Markdown, chunks, and metadata.\n \n\n For EU users, use this endpoint:\n\n\n `https://api.va.eu-west-1.landing.ai/v1/ade/parse`.", From dfce66d603517464384d48b89a3363e82bcc86bd Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 10:44:20 +0800 Subject: [PATCH 15/17] ci(spec-sync): deterministic AI push, no duplicate PRs, injection hardening Addresses review (MingruiZhang): - AI commit now pushed by a deterministic step (claude-code-action doesn't push); the agent runs edits-only with NO git/push capability (prompt-injection hardening) - skip the run entirely when a spec-sync PR is already open (kills duplicate PRs and hourly Claude API spend during review); fixed branch spec-sync/v1 + concurrency group - open the PR before the AI step so a failed phase-2 leaves a visible PR, not an orphan branch - cron hourly Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/spec-sync.yml | 104 ++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 33 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index b852ab5..2970c1a 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -1,25 +1,33 @@ name: Spec Sync on: schedule: - - cron: '0 */6 * * *' # every ~6h; cron covers correctness, dispatch cuts latency + - cron: '0 * * * *' # hourly; cron covers correctness, dispatch cuts latency workflow_dispatch: {} +# Serialize runs so a cron tick and a manual dispatch can't race on the sync branch. +concurrency: + group: spec-sync + cancel-in-progress: false + permissions: - contents: read # writes happen via the app token, not GITHUB_TOKEN + contents: read # all writes go through SPEC_SYNC_TOKEN, never GITHUB_TOKEN jobs: spec-sync: + # Guard so the scheduled/dispatch job only runs on the canonical repo — forks lack the + # secrets and would just produce noisy failing runs. if: github.repository == 'landing-ai/ade-python' runs-on: ubuntu-latest timeout-minutes: 30 env: V1_SPEC_URL: https://api.va.staging.landing.ai/v1/ade/openapi.json # staging drives the loop + SYNC_BRANCH: spec-sync/v1 # fixed branch: reruns update one PR in place, never a pile of them steps: - uses: actions/checkout@v6 with: - # SPEC_SYNC_TOKEN is a fine-grained PAT (Contents: RW, Pull requests: RW) - # scoped to this repo. It must NOT be the default GITHUB_TOKEN: pushes/PRs - # authored by GITHUB_TOKEN do not trigger the gate workflows (anti-recursion). + # SPEC_SYNC_TOKEN is a fine-grained PAT (Contents: RW, Pull requests: RW) scoped to this + # repo. It must NOT be the default GITHUB_TOKEN: pushes/PRs authored by GITHUB_TOKEN do + # not trigger the gate workflows (anti-recursion). token: ${{ secrets.SPEC_SYNC_TOKEN }} fetch-depth: 0 # tags needed for surface-lock baseline @@ -51,48 +59,78 @@ jobs: echo "spec fetch/normalize failed (exit ${{ steps.drift.outputs.code }})" exit 1 - # ---- Phase 1: mechanical ---- - - name: Mechanical commit (snapshot + reference models) + # If a sync PR is already open, do nothing: it's awaiting human review, and re-running + # would duplicate gate runs and Claude API spend every hour until it merges. + - name: Check for an open sync PR + id: existing if: steps.drift.outputs.code == '10' - id: mech + env: + GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} + run: | + if gh pr list --state open --head "$SYNC_BRANCH" --json number --jq '.[0].number' | grep -q .; then + echo "open=true" >> "$GITHUB_OUTPUT" + else + echo "open=false" >> "$GITHUB_OUTPUT" + fi + + - name: Sync PR already open — skip + if: steps.drift.outputs.code == '10' && steps.existing.outputs.open == 'true' + run: echo "A spec-sync PR is already open; skipping until it is merged or closed." + + # ---- Phase 1: mechanical (deterministic) ---- + - name: Mechanical commit + push + if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' run: | git config user.name "spec-sync[bot]" git config user.email "spec-sync@users.noreply.github.com" - branch="spec-sync/v1-$(date -u +%Y%m%dT%H%M%SZ)" - git checkout -b "$branch" + git checkout -B "$SYNC_BRANCH" ./scripts/spec-sync/gen-models.sh specs/v1-ade.json specs/_generated/v1_models.py git add specs/v1-ade.json specs/_generated/v1_models.py git commit -m "chore(spec-sync): update V1 spec snapshot + regenerated reference models" - git push -u origin "$branch" - echo "branch=$branch" >> "$GITHUB_OUTPUT" + git push --force origin "$SYNC_BRANCH" - # ---- Phase 2: AI wiring (same checkout/branch) ---- - - name: AI wiring commit - if: steps.drift.outputs.code == '10' + # Open the PR now — before the AI step — so a run that dies in phase 2 leaves a visible PR + # (with just the mechanical commit) instead of an orphan branch. + - name: Open sync PR + if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' + env: + GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} + run: | + gh pr create --base main --head "$SYNC_BRANCH" \ + --title "spec-sync: track V1 spec drift" \ + --body $'Automated spec-sync PR.\n\n- **Commit 1 (mechanical):** normalized spec snapshot + regenerated reference models.\n- **Commit 2 (AI):** resources/methods/tests/docs wired from the spec diff (added after this PR opened).\n\nGates (surface-lock, contract tests, lint/test/typecheck) must pass. **Human review required before merge.**' + + # ---- Phase 2: AI wiring — edits ONLY. The agent holds no git/push capability, so a + # prompt-injected spec description cannot route around review to push to a branch. ---- + - name: AI wiring (edits only) + if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.SPEC_SYNC_TOKEN }} prompt: | - The previous commit updated specs/v1-ade.json and regenerated reference - models in specs/_generated/v1_models.py. Update the SDK to match the spec - diff: add or adjust resource classes, method signatures, param TypedDicts, - response models, tests, api.md, and README examples, mirroring the existing - conventions in src/landingai_ade/resources/parse_jobs.py. - Rules: PURELY ADDITIVE — never modify or remove an existing public signature - (the surface-lock CI job will fail the PR); follow existing code style; - run ./scripts/format; commit to the current branch. + The previous commit updated specs/v1-ade.json and regenerated reference models in + specs/_generated/v1_models.py. Edit the SDK to match the spec diff: add or adjust + resource classes, method signatures, param TypedDicts, response models, tests, api.md, + and README examples, mirroring the conventions in + src/landingai_ade/resources/parse_jobs.py. Then run `./scripts/format`. + Rules: PURELY ADDITIVE — never modify or remove an existing public signature (the + surface-lock CI job will fail the PR). Do NOT run git or push; a later workflow step + commits and pushes your edits. claude_args: | --max-turns 40 - --allowedTools "Edit,Write,Read,Bash(git *),Bash(rye *),Bash(./scripts/*)" + --allowedTools "Edit,Write,Read,Bash(rye *),Bash(./scripts/*)" - - name: Open sync PR - if: steps.drift.outputs.code == '10' - env: - GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} + # Deterministic: commit whatever the AI edited and push to the sync branch. This guarantees + # the AI changes actually reach the PR (the action's automation mode does not push), and it + # is the only step with push capability. + - name: Commit + push AI wiring + if: steps.drift.outputs.code == '10' && steps.existing.outputs.open != 'true' run: | - gh pr create \ - --base main \ - --head "${{ steps.mech.outputs.branch }}" \ - --title "spec-sync: track V1 spec drift" \ - --body $'Automated spec-sync PR.\n\n- **Commit 1 (mechanical):** normalized spec snapshot + regenerated reference models.\n- **Commit 2 (AI):** resources/methods/tests/docs wired from the spec diff.\n\nGates (surface-lock, contract tests, lint/test/typecheck) must pass. **Human review required before merge.**' + git add -A + if git diff --cached --quiet; then + echo "AI step produced no changes." + else + git commit -m "feat(spec-sync): wire SDK to spec diff (AI)" + git push origin HEAD:"$SYNC_BRANCH" + fi From 513fb06902135a3e00605981d6f79b8e6aa30e22 Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 10:44:20 +0800 Subject: [PATCH 16/17] ci(spec-sync): scope live-staging gate, exact codegen pins, opt-in contract tests Addresses review (MingruiZhang / Copilot): - contract-tests only gate spec-sync PRs (not every PR repo-wide) and run serially (-n 0) - surface-lock escape hatch: skip on 'breaking-change-approved' label - pin datamodel-code-generator==0.45.0 / griffe==1.14.0 (committed models match CI regen) - addopts '-m not contract' so plain pytest never hits live staging - document jq -S array-ordering assumption in fetch-normalize.sh Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pr-gates.yml | 11 ++++++++++- pyproject.toml | 11 ++++++++--- scripts/spec-sync/fetch-normalize.sh | 4 ++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml index 1d4c4e5..3aba0b7 100644 --- a/.github/workflows/pr-gates.yml +++ b/.github/workflows/pr-gates.yml @@ -5,6 +5,9 @@ on: jobs: surface-lock: + # Escape hatch for intentional breaking changes (major releases): label the PR + # `breaking-change-approved` to skip the released-surface lock. + if: ${{ !contains(github.event.pull_request.labels.*.name, 'breaking-change-approved') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -24,6 +27,10 @@ jobs: run: ./scripts/spec-sync/surface-lock.sh contract-tests: + # Only spec-sync PRs are gated on the live staging API. Gating *every* PR would let a + # staging outage or transient 5xx block all merges repo-wide. Keep this job OUT of the + # required-checks list for ordinary PRs (it is skipped there); require it only where it runs. + if: startsWith(github.head_ref, 'spec-sync/') runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -40,4 +47,6 @@ jobs: - name: Contract tests vs staging env: LANDINGAI_ADE_STAGING_APIKEY: ${{ secrets.LANDINGAI_ADE_STAGING_APIKEY }} - run: rye run pytest tests/contract -m contract -v + # -n 0 forces serial execution: these hit the live API, and the repo's default + # `-n auto` (xdist) would run them in parallel, inviting flakiness/rate-limits. + run: rye run pytest tests/contract -m contract -n 0 -v diff --git a/pyproject.toml b/pyproject.toml index fde35ee..7186974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,8 +59,10 @@ dev-dependencies = [ "importlib-metadata>=6.7.0", "rich>=13.7.1", "pytest-xdist>=3.6.1", - "datamodel-code-generator>=0.45", - "griffe>=1.14", + # Exact pins: codegen output is version-sensitive, so the committed reference models + # under specs/_generated/ must be generated by the same version CI installs. + "datamodel-code-generator==0.45.0", + "griffe==1.14.0", ] [tool.rye.scripts] @@ -132,7 +134,10 @@ replacement = '[\1](https://github.com/landing-ai/ade-python/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--tb=short -n auto" +# Contract tests (marker `contract`) hit the live staging API — opt-in only, so a plain +# `rye run pytest` (or the pydantic-v1 nox session) never silently calls staging even when +# LANDINGAI_ADE_STAGING_APIKEY is exported. CI's explicit `-m contract` overrides this. +addopts = "--tb=short -n auto -m 'not contract'" xfail_strict = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" diff --git a/scripts/spec-sync/fetch-normalize.sh b/scripts/spec-sync/fetch-normalize.sh index 2c0931c..82213ce 100755 --- a/scripts/spec-sync/fetch-normalize.sh +++ b/scripts/spec-sync/fetch-normalize.sh @@ -9,4 +9,8 @@ if [ "$#" -ne 1 ]; then fi url="$1" +# `jq -S` sorts object keys only; array element order (e.g. `required`, `enum`, `tags`) is +# preserved as emitted by the backend. This assumes the gateway emits arrays deterministically. +# If it ever reorders them, drift detection would fire on cosmetic churn (phantom PRs) — start +# debugging false drift here. curl -fsSL --max-time 30 --retry 3 --retry-delay 2 "$url" | jq -S . From 5942ca6df1abe621354be7100a06f4596d06e4fc Mon Sep 17 00:00:00 2001 From: Zhichao Date: Wed, 8 Jul 2026 11:03:19 +0800 Subject: [PATCH 17/17] fix(spec-sync): close staging client in fixture; align docs to hourly cron Addresses Copilot review: - contract test uses a yield-fixture inside a context manager so the HTTP client is closed in teardown (no socket leak) - CONTRIBUTING now says hourly cron, matching spec-sync.yml (was stale '~6h') Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 2 +- tests/contract/test_v1_smoke.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 547e161..7aa75e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,7 +130,7 @@ the environment. ## Spec-sync pipeline The SDK tracks the live ADE OpenAPI spec automatically via `.github/workflows/spec-sync.yml` -(cron ~6h + manual `workflow_dispatch`). It is driven by the **staging** spec; releases gate on +(hourly cron + manual `workflow_dispatch`). It is driven by the **staging** spec; releases gate on the **production** spec ("staging in, production out"). On each run it fetches and normalizes the live spec (`scripts/spec-sync/fetch-normalize.sh`) and diff --git a/tests/contract/test_v1_smoke.py b/tests/contract/test_v1_smoke.py index 782915e..99a6a1b 100644 --- a/tests/contract/test_v1_smoke.py +++ b/tests/contract/test_v1_smoke.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import Iterator import pytest @@ -15,10 +16,12 @@ @pytest.fixture() -def staging_client() -> LandingAIADE: +def staging_client() -> Iterator[LandingAIADE]: if not STAGING_KEY: pytest.skip("LANDINGAI_ADE_STAGING_APIKEY not set") - return LandingAIADE(apikey=STAGING_KEY, base_url=STAGING_V1_BASE_URL) + # Context-managed so the underlying HTTP client is closed in teardown (no socket leak). + with LandingAIADE(apikey=STAGING_KEY, base_url=STAGING_V1_BASE_URL) as client: + yield client def test_parse_jobs_list_reachable(staging_client: LandingAIADE) -> None: