CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-io-quarkiverse-langchain4j--quarkus-langchain4j-watsonx

Quarkus extension for integrating IBM watsonx.ai foundation models with LangChain4j. Provides chat models, generation models, streaming models, embedding models, and scoring models for IBM watsonx.ai. Includes comprehensive configuration options, support for tool/function calling, text extraction from documents in Cloud Object Storage, and experimental built-in services for Google search, weather, and web crawling. Designed for enterprise Java applications using the Quarkus framework with built-in dependency injection and native compilation support.

Overview
Eval results
Files

exceptions.mddocs/

Exception Handling

Comprehensive exception hierarchy for different error sources including Watsonx API errors, IBM Cloud IAM authentication errors, Cloud Object Storage errors, text extraction errors, and built-in service errors. All exceptions provide detailed error information for debugging and error handling.

Exception Hierarchy

RuntimeException
├── WatsonxException
├── TextExtractionException
├── COSException
└── BuiltinServiceException

Exception
└── WeatherServiceException
    └── NoCityFoundException

Watsonx API Exceptions

WatsonxException

Main exception for Watsonx API errors including model invocation, chat, generation, embedding, and scoring errors.

public class WatsonxException extends RuntimeException {
    public WatsonxException(String message, Integer statusCode, WatsonxError details);
    public WatsonxException(String message, Integer statusCode);
    public WatsonxException(Integer statusCode, WatsonxError details);
    public WatsonxException(Throwable cause, Integer statusCode);

    public Integer statusCode();
    public WatsonxError details();
}

Common Status Codes:

  • 400: Bad request - Invalid parameters or malformed request
  • 401: Unauthorized - Invalid or expired API key/token
  • 403: Forbidden - Insufficient permissions
  • 404: Not found - Model or resource not found
  • 429: Too many requests - Rate limit exceeded
  • 500: Internal server error - Watsonx service error
  • 503: Service unavailable - Watsonx service temporarily unavailable

Example Usage:

import io.quarkiverse.langchain4j.watsonx.exception.WatsonxException;
import io.quarkiverse.langchain4j.watsonx.bean.WatsonxError;

try {
    ChatResponse response = chatModel.chat(UserMessage.from("Hello"));
} catch (WatsonxException e) {
    System.err.println("Status code: " + e.statusCode());
    System.err.println("Message: " + e.getMessage());

    WatsonxError details = e.details();
    if (details != null) {
        System.err.println("Error code: " + details.code());
        System.err.println("Error message: " + details.message());
    }

    // Handle specific status codes
    switch (e.statusCode()) {
        case 401:
            // Re-authenticate or refresh token
            break;
        case 429:
            // Implement backoff and retry
            break;
        case 500:
        case 503:
            // Retry with exponential backoff
            break;
        default:
            // Log and handle as appropriate
            break;
    }
}

Token Expiration Handling:

import io.quarkiverse.langchain4j.watsonx.WatsonxUtils;

try {
    ChatResponse response = chatModel.chat(UserMessage.from("Hello"));
} catch (WatsonxException e) {
    if (WatsonxUtils.isTokenExpired(e)) {
        // Token expired, automatic retry will happen
        System.err.println("Token expired, retrying...");
    }
}

// Or use built-in retry helper
ChatResponse response = WatsonxUtils.retryOn(() ->
    chatModel.chat(UserMessage.from("Hello"))
);

WatsonxError

Detailed error information from Watsonx API responses.

public record WatsonxError(Integer statusCode, String trace, List<Error> errors) {
    public static record Error(String code, String message) {
        public Optional<Code> codeToEnum();
    }

    public static enum Code {
        AUTHORIZATION_REJECTED,
        JSON_TYPE_ERROR,
        MODEL_NOT_SUPPORTED,
        MODEL_NO_SUPPORT_FOR_FUNCTION,
        USER_AUTHORIZATION_FAILED,
        JSON_VALIDATION_ERROR,
        INVALID_REQUEST_ENTITY,
        INVALID_INPUT_ARGUMENT,
        TOKEN_QUOTA_REACHED,
        AUTHENTICATION_TOKEN_EXPIRED,
        TEXT_EXTRACTION_EVENT_DOES_NOT_EXIST
    }
}

Error Code Descriptions:

  • AUTHORIZATION_REJECTED: User authorization was rejected
  • JSON_TYPE_ERROR: Invalid JSON type in request
  • MODEL_NOT_SUPPORTED: Requested model is not supported
  • MODEL_NO_SUPPORT_FOR_FUNCTION: Model does not support requested function/tool calling
  • USER_AUTHORIZATION_FAILED: User authentication failed
  • JSON_VALIDATION_ERROR: JSON schema validation failed
  • INVALID_REQUEST_ENTITY: Request entity is invalid
  • INVALID_INPUT_ARGUMENT: One or more input arguments are invalid
  • TOKEN_QUOTA_REACHED: Token quota limit has been reached
  • AUTHENTICATION_TOKEN_EXPIRED: IBM Cloud IAM token has expired (triggers automatic retry)
  • TEXT_EXTRACTION_EVENT_DOES_NOT_EXIST: Text extraction job ID does not exist

Example:

try {
    ChatResponse response = chatModel.chat(request);
} catch (WatsonxException e) {
    WatsonxError details = e.details();
    if (details != null && details.errors() != null) {
        for (WatsonxError.Error error : details.errors()) {
            System.err.println("Error code: " + error.code());
            System.err.println("Error message: " + error.message());

            // Check for specific error codes
            Optional<WatsonxError.Code> codeEnum = error.codeToEnum();
            if (codeEnum.isPresent()) {
                switch (codeEnum.get()) {
                    case AUTHENTICATION_TOKEN_EXPIRED:
                        System.err.println("Token expired - will retry");
                        break;
                    case MODEL_NOT_SUPPORTED:
                        System.err.println("Invalid model ID");
                        break;
                    case TOKEN_QUOTA_REACHED:
                        System.err.println("Quota exceeded - try later");
                        break;
                    default:
                        System.err.println("Other error: " + codeEnum.get());
                }
            }
        }
    }
}

Text Extraction Exceptions

TextExtractionException

Exception for text extraction operations from Cloud Object Storage documents.

public class TextExtractionException extends RuntimeException {
    public TextExtractionException(String code, String message);
    public TextExtractionException(String code, String message, Throwable cause);

    public String getCode();
    public String getMessage();
}

Common Error Codes:

  • invalid_document_format: Unsupported document format
  • document_not_found: Document not found in COS
  • extraction_failed: Text extraction failed
  • timeout: Extraction operation timed out
  • invalid_parameters: Invalid extraction parameters

Example Usage:

import io.quarkiverse.langchain4j.watsonx.exception.TextExtractionException;
import io.quarkiverse.langchain4j.watsonx.runtime.TextExtraction;
import java.io.File;

try {
    String extractedText = textExtraction.uploadExtractAndFetch(file);
} catch (TextExtractionException e) {
    System.err.println("Error code: " + e.getCode());
    System.err.println("Message: " + e.getMessage());

    switch (e.getCode()) {
        case "invalid_document_format":
            System.err.println("File format not supported");
            break;
        case "document_not_found":
            System.err.println("Document not found in storage");
            break;
        case "timeout":
            System.err.println("Extraction took too long");
            break;
        default:
            System.err.println("Extraction failed: " + e.getMessage());
            break;
    }
}

Async Extraction Error Handling:

String jobId = textExtraction.uploadAndStartExtraction(file);

// Check status
TextExtractionResponse status = textExtraction.checkExtractionStatus(jobId);

if (status.metadata().status() == TextExtractionResponse.Status.FAILED) {
    TextExtractionResponse.ServiceError error = status.entity().serviceError();
    throw new TextExtractionException(
        error.code(),
        error.message()
    );
}

Cloud Object Storage Exceptions

COSException

Exception for Cloud Object Storage operations including file upload, download, and deletion.

public class COSException extends RuntimeException {
    public COSException(Integer statusCode, CosError details);
    public COSException(String message, Integer statusCode);

    public Integer statusCode();
    public CosError details();
}

Common Status Codes:

  • 400: Bad request - Invalid bucket or path
  • 403: Forbidden - Insufficient COS permissions
  • 404: Not found - Bucket or file not found
  • 409: Conflict - Resource already exists
  • 500: Internal server error - COS service error
  • 503: Service unavailable - COS temporarily unavailable

Example Usage:

import io.quarkiverse.langchain4j.watsonx.exception.COSException;
import io.quarkiverse.langchain4j.watsonx.bean.CosError;

try {
    textExtraction.deleteFile("bucket-name", "/path/to/file.pdf");
} catch (COSException e) {
    System.err.println("Status code: " + e.statusCode());
    System.err.println("Message: " + e.getMessage());

    CosError details = e.details();
    if (details != null) {
        System.err.println("Error code: " + details.code());
        System.err.println("Error message: " + details.message());
    }

    // Handle specific status codes
    if (e.statusCode() == 404) {
        System.err.println("File not found in COS");
    } else if (e.statusCode() == 403) {
        System.err.println("Permission denied");
    }
}

CosError

Detailed error information from Cloud Object Storage API.

public class CosError {
    public Code getCode();
    public String getMessage();
    public String getResource();
    public String getRequestId();
    public int getHttpStatusCode();

    public static enum Code {
        ACCESS_DENIED,
        BAD_DIGEST,
        BUCKET_ALREADY_EXISTS,
        BUCKET_ALREADY_OWNED_BY_YOU,
        BUCKET_NOT_EMPTY,
        CREDENTIALS_NOT_SUPPORTED,
        ENTITY_TOO_SMALL,
        ENTITY_TOO_LARGE,
        INCOMPLETE_BODY,
        INCORRECT_NUMBER_OF_FILES_IN_POST_REQUEST,
        INLINE_DATA_TOO_LARGE,
        INTERNAL_ERROR,
        INVALID_ACCESS_KEY_ID,
        INVALID_ARGUMENT,
        INVALID_BUCKET_NAME,
        INVALID_BUCKET_STATE,
        INVALID_DIGEST,
        INVALID_LOCATION_CONSTRAINT,
        INVALID_OBJECT_STATE,
        INVALID_PART,
        INVALID_PART_ORDER,
        INVALID_RANGE,
        INVALID_REQUEST,
        INVALID_SECURITY,
        INVALID_URI,
        KEY_TOO_LONG,
        MALFORMED_POST_REQUEST,
        MALFORMED_XML,
        MAX_MESSAGE_LENGTH_EXCEEDED,
        MAX_POST_PRE_DATA_LENGTH_EXCEEDED_ERROR,
        METADATA_TOO_LARGE,
        METHOD_NOT_ALLOWED,
        MISSING_CONTENT_LENGTH,
        MISSING_REQUEST_BODY_ERROR,
        NO_SUCH_BUCKET,
        NO_SUCH_KEY,
        NO_SUCH_UPLOAD,
        NOT_IMPLEMENTED,
        OPERATION_ABORTED,
        PRECONDITION_FAILED,
        REDIRECT,
        REQUEST_IS_NOT_MULTIPART_CONTENT,
        REQUEST_TIMEOUT,
        REQUEST_TIME_TOO_SKEWED,
        SERVICE_UNAVAILABLE,
        SLOW_DOWN,
        TEMPORARY_REDIRECT,
        TOO_MANY_BUCKETS,
        UNEXPECTED_CONTENT,
        USER_KEY_MUST_BE_SPECIFIED
    }
}

Common COS Error Codes:

  • ACCESS_DENIED: Insufficient permissions to access resource
  • NO_SUCH_BUCKET: Bucket does not exist
  • NO_SUCH_KEY: File/key does not exist
  • BUCKET_NOT_EMPTY: Cannot delete non-empty bucket
  • ENTITY_TOO_LARGE: Upload size exceeds limit
  • INVALID_BUCKET_NAME: Bucket name format is invalid
  • REQUEST_TIMEOUT: Request took too long
  • SERVICE_UNAVAILABLE: COS service temporarily unavailable
  • INTERNAL_ERROR: COS internal error

Example:

try {
    textExtraction.deleteFile(bucket, path);
} catch (COSException e) {
    CosError error = e.details();
    if (error != null) {
        System.err.println("Error code: " + error.getCode());
        System.err.println("Message: " + error.getMessage());
        System.err.println("Resource: " + error.getResource());
        System.err.println("Request ID: " + error.getRequestId());
        System.err.println("HTTP status: " + error.getHttpStatusCode());

        // Handle specific error codes
        switch (error.getCode()) {
            case ACCESS_DENIED:
                System.err.println("Check COS permissions");
                break;
            case NO_SUCH_BUCKET:
            case NO_SUCH_KEY:
                System.err.println("Resource not found");
                break;
            case SERVICE_UNAVAILABLE:
                System.err.println("Retry later");
                break;
            default:
                System.err.println("COS error: " + error.getCode());
        }
    }
}

Built-in Service Exceptions

BuiltinServiceException

Exception for built-in service operations (Google search, weather, web crawler).

public class BuiltinServiceException extends RuntimeException {
    public BuiltinServiceException(String message, Integer statusCode);

    public Integer statusCode();
    public String details();
}

Common Status Codes:

  • 400: Bad request - Invalid parameters
  • 403: Forbidden - Service access denied
  • 404: Not found - Resource not found
  • 429: Too many requests - Rate limit exceeded
  • 500: Internal server error - Service error
  • 503: Service unavailable - Service temporarily unavailable

Example Usage:

import io.quarkiverse.langchain4j.watsonx.exception.BuiltinServiceException;
import io.quarkiverse.langchain4j.watsonx.services.WebCrawlerService;

try {
    WebCrawlerResult result = webCrawler.process(url);
} catch (BuiltinServiceException e) {
    System.err.println("Status code: " + e.statusCode());
    System.err.println("Details: " + e.details());

    if (e.statusCode() == 404) {
        System.err.println("Web page not found");
    } else if (e.statusCode() == 429) {
        System.err.println("Rate limit exceeded, retry later");
    } else if (e.statusCode() >= 500) {
        System.err.println("Service error, retry with backoff");
    }
}

WeatherServiceException

Exception for weather service operations.

public class WeatherServiceException extends Exception {
    public WeatherServiceException(String message);
    public WeatherServiceException(String message, Throwable cause);
}

Example Usage:

import io.quarkiverse.langchain4j.watsonx.services.WeatherService.WeatherServiceException;

try {
    String weather = weatherService.find(city, country);
} catch (WeatherServiceException e) {
    System.err.println("Weather service error: " + e.getMessage());
}

NoCityFoundException

Exception thrown when a city is not found in the weather service.

public class NoCityFoundException extends WeatherServiceException {
    public NoCityFoundException(String message);
}

Example Usage:

import io.quarkiverse.langchain4j.watsonx.services.WeatherService.NoCityFoundException;

try {
    String weather = weatherService.find("InvalidCity", "XX");
} catch (NoCityFoundException e) {
    System.err.println("City not found: " + e.getMessage());
} catch (WeatherServiceException e) {
    System.err.println("Weather service error: " + e.getMessage());
}

Error Handling Patterns

Basic Try-Catch

@ApplicationScoped
public class SafeChatService {
    @Inject
    ChatModel chatModel;

    public Optional<String> chatSafely(String message) {
        try {
            ChatResponse response = chatModel.chat(UserMessage.from(message));
            return Optional.of(response.aiMessage().text());
        } catch (WatsonxException e) {
            logger.error("Chat failed: " + e.getMessage(), e);
            return Optional.empty();
        }
    }
}

Specific Exception Handling

@ApplicationScoped
public class RobustService {
    @Inject
    ChatModel chatModel;

    @Inject
    TextExtraction textExtraction;

    @Inject
    WebCrawlerService webCrawler;

    public String processWithFallback(String input) {
        try {
            return chatModel.chat(UserMessage.from(input)).aiMessage().text();
        } catch (WatsonxException e) {
            if (e.statusCode() == 401) {
                // Re-authenticate and retry
                return retryAfterAuth(input);
            } else if (e.statusCode() == 429) {
                // Wait and retry
                return retryWithBackoff(input);
            } else {
                throw e;
            }
        }
    }

    public String extractWithFallback(File file) {
        try {
            return textExtraction.uploadExtractAndFetch(file);
        } catch (TextExtractionException e) {
            if ("invalid_document_format".equals(e.getCode())) {
                // Try alternative extraction method
                return extractAlternative(file);
            }
            throw e;
        } catch (COSException e) {
            if (e.statusCode() == 403) {
                // Permission issue, use different bucket
                return extractFromAlternativeBucket(file);
            }
            throw e;
        }
    }

    public WebCrawlerResult fetchWithRetry(String url) {
        try {
            return webCrawler.process(url);
        } catch (BuiltinServiceException e) {
            if (e.statusCode() == 429) {
                // Rate limited, wait and retry
                Thread.sleep(5000);
                return webCrawler.process(url);
            } else if (e.statusCode() >= 500) {
                // Server error, retry with backoff
                return retryWebCrawl(url);
            }
            throw e;
        }
    }
}

Retry with Exponential Backoff

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@ApplicationScoped
public class RetryService {
    @Inject
    ChatModel chatModel;

    public ChatResponse chatWithRetry(String message, int maxAttempts) {
        int attempt = 0;
        Duration backoff = Duration.ofSeconds(1);

        while (attempt < maxAttempts) {
            try {
                return chatModel.chat(UserMessage.from(message));
            } catch (WatsonxException e) {
                attempt++;

                // Only retry on transient errors
                if (isTransientError(e) && attempt < maxAttempts) {
                    logger.warn("Attempt {} failed, retrying in {}",
                        attempt, backoff);

                    try {
                        Thread.sleep(backoff.toMillis());
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new RuntimeException("Interrupted", ie);
                    }

                    // Exponential backoff
                    backoff = backoff.multipliedBy(2);
                } else {
                    throw e;
                }
            }
        }

        throw new RuntimeException("Max retry attempts exceeded");
    }

    private boolean isTransientError(WatsonxException e) {
        return e.statusCode() == 429 ||  // Rate limit
               e.statusCode() == 500 ||  // Server error
               e.statusCode() == 503;    // Service unavailable
    }
}

Automatic Token Refresh

import io.quarkiverse.langchain4j.watsonx.WatsonxUtils;

@ApplicationScoped
public class AutoRefreshService {
    @Inject
    ChatModel chatModel;

    public ChatResponse chatWithAutoRefresh(String message) {
        // WatsonxUtils.retryOn automatically handles token expiration
        return WatsonxUtils.retryOn(() ->
            chatModel.chat(UserMessage.from(message))
        );
    }
}

Circuit Breaker Pattern

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;

@ApplicationScoped
public class CircuitBreakerService {
    private final CircuitBreaker circuitBreaker;

    @Inject
    ChatModel chatModel;

    public CircuitBreakerService() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .failureRateThreshold(50)
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .slidingWindowSize(10)
            .build();

        this.circuitBreaker = CircuitBreaker.of("watsonx", config);
    }

    public Optional<String> chatWithCircuitBreaker(String message) {
        try {
            return Optional.of(circuitBreaker.executeSupplier(() -> {
                ChatResponse response = chatModel.chat(UserMessage.from(message));
                return response.aiMessage().text();
            }));
        } catch (Exception e) {
            logger.error("Chat failed with circuit breaker", e);
            return Optional.empty();
        }
    }
}

Bulk Operation Error Handling

@ApplicationScoped
public class BulkOperationService {
    @Inject
    TextExtraction textExtraction;

    public record ExtractionResult(File file, String text, String error) {}

    public List<ExtractionResult> extractMultiple(List<File> files) {
        return files.stream()
            .map(file -> {
                try {
                    String text = textExtraction.uploadExtractAndFetch(file);
                    return new ExtractionResult(file, text, null);
                } catch (TextExtractionException e) {
                    return new ExtractionResult(file, null,
                        "Extraction error: " + e.getMessage());
                } catch (COSException e) {
                    return new ExtractionResult(file, null,
                        "Storage error: " + e.getMessage());
                } catch (Exception e) {
                    return new ExtractionResult(file, null,
                        "Unexpected error: " + e.getMessage());
                }
            })
            .toList();
    }

    public void printResults(List<ExtractionResult> results) {
        long successful = results.stream()
            .filter(r -> r.error() == null)
            .count();

        long failed = results.size() - successful;

        System.out.println("Successful: " + successful);
        System.out.println("Failed: " + failed);

        results.stream()
            .filter(r -> r.error() != null)
            .forEach(r -> System.err.println(
                r.file().getName() + ": " + r.error()));
    }
}

Logging Best Practices

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@ApplicationScoped
public class LoggingService {
    private static final Logger logger = LoggerFactory.getLogger(LoggingService.class);

    @Inject
    ChatModel chatModel;

    public String chatWithLogging(String message) {
        try {
            logger.debug("Sending chat request: {}", message);
            ChatResponse response = chatModel.chat(UserMessage.from(message));
            logger.debug("Chat response received successfully");
            return response.aiMessage().text();

        } catch (WatsonxException e) {
            logger.error("Watsonx error - Status: {}, Message: {}",
                e.statusCode(), e.getMessage());

            if (e.details() != null) {
                logger.error("Error details - Code: {}, Message: {}",
                    e.details().code(), e.details().message());
            }

            // Log full stack trace at debug level
            logger.debug("Full exception:", e);
            throw e;

        } catch (Exception e) {
            logger.error("Unexpected error in chat operation", e);
            throw e;
        }
    }
}

Custom Exception Handling

// Custom exception wrapper
public class ChatServiceException extends RuntimeException {
    private final ErrorType errorType;
    private final Integer statusCode;

    public enum ErrorType {
        AUTHENTICATION_ERROR,
        RATE_LIMIT_ERROR,
        SERVICE_ERROR,
        CLIENT_ERROR,
        UNKNOWN_ERROR
    }

    public ChatServiceException(WatsonxException e) {
        super(e.getMessage(), e);
        this.statusCode = e.statusCode();
        this.errorType = determineErrorType(e);
    }

    private ErrorType determineErrorType(WatsonxException e) {
        return switch (e.statusCode()) {
            case 401, 403 -> ErrorType.AUTHENTICATION_ERROR;
            case 429 -> ErrorType.RATE_LIMIT_ERROR;
            case 500, 503 -> ErrorType.SERVICE_ERROR;
            case 400 -> ErrorType.CLIENT_ERROR;
            default -> ErrorType.UNKNOWN_ERROR;
        };
    }

    public ErrorType getErrorType() {
        return errorType;
    }

    public Integer getStatusCode() {
        return statusCode;
    }
}

// Usage
@ApplicationScoped
public class CustomExceptionService {
    @Inject
    ChatModel chatModel;

    public String chat(String message) {
        try {
            ChatResponse response = chatModel.chat(UserMessage.from(message));
            return response.aiMessage().text();
        } catch (WatsonxException e) {
            throw new ChatServiceException(e);
        }
    }
}

Exception Handling Checklist

Before Deployment

  • Identify all possible exception types for your use case
  • Implement appropriate error handling for each exception type
  • Add logging for all exceptions with relevant context
  • Test error scenarios (invalid credentials, rate limits, etc.)
  • Implement retry logic for transient errors
  • Configure timeouts appropriately
  • Set up monitoring and alerting for exceptions

During Operation

  • Monitor exception rates and patterns
  • Track error response codes
  • Identify and fix recurring errors
  • Adjust retry logic based on observed patterns
  • Update error handling as needed
  • Review exception logs regularly

Best Practices

  • Catch specific exceptions: Don't catch generic Exception unless necessary
  • Log with context: Include relevant details in error logs
  • Retry transient errors: Use exponential backoff for rate limits and server errors
  • Don't retry permanent errors: 400, 401, 403, 404 should not be retried
  • Preserve stack traces: Always log or re-throw with original exception
  • Use custom exceptions: Wrap low-level exceptions in domain-specific exceptions
  • Monitor error rates: Set up alerts for unusual error patterns
  • Document error handling: Document expected errors and handling strategies

IAM Error

Detailed error information from IAM authentication.

public class IAMError {
    public String errorCode();
    public String errorMessage();
    public Map<String, Object> additionalProperties();
}

IAM errors are typically wrapped in WatsonxException during token generation.

Install with Tessl CLI

npx tessl i tessl/maven-io-quarkiverse-langchain4j--quarkus-langchain4j-watsonx

docs

builtin-services.md

chat-models.md

configuration.md

embedding-scoring.md

exceptions.md

generation-models.md

index.md

request-parameters.md

text-extraction.md

tile.json