├── .github └── workflows │ ├── build.yml │ ├── docker-publish.yml │ └── fly-deploy.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── api ├── .dockerignore ├── Dockerfile ├── README.md ├── fly.toml ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── src │ ├── contract.ts │ ├── demo-provider.ts │ ├── redis.ts │ ├── server.ts │ └── test │ │ └── structured.integration.test.ts └── tsconfig.json ├── cli ├── Makefile ├── README.md ├── cli.go ├── go.mod └── go.sum ├── core ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── src │ ├── base64.test.ts │ ├── base64.ts │ ├── index.ts │ ├── model.test.ts │ ├── model.ts │ ├── schema.test.ts │ └── schema.ts └── tsconfig.json ├── home_page └── index.html ├── local.md ├── sdk-go ├── LICENSE ├── Makefile ├── README.md ├── client.go ├── client_test.go ├── examples │ └── main.go ├── go.mod └── go.sum ├── sdk-node ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── index.test.ts │ └── index.ts └── tsconfig.json └── sdk-python ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.cfg ├── src └── l1m │ ├── __init__.py │ └── client.py ├── tests ├── __init__.py └── test_client.py └── typings └── l1m ├── __init__.pyi └── client.pyi /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Build API 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | cache: 'npm' 24 | cache-dependency-path: './api/package-lock.json' 25 | 26 | - name: Install dependencies 27 | working-directory: ./api 28 | run: npm ci 29 | 30 | - name: Build 31 | working-directory: ./api 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ 'v*' ] 7 | 8 | env: 9 | REGISTRY: docker.io 10 | IMAGE_NAME: inferable/l1m 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v2 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ secrets.DOCKERHUB_USERNAME }} 27 | password: ${{ secrets.DOCKERHUB_TOKEN }} 28 | 29 | - name: Extract metadata (tags, labels) for Docker 30 | id: meta 31 | uses: docker/metadata-action@v4 32 | with: 33 | images: ${{ env.IMAGE_NAME }} 34 | tags: | 35 | type=ref,event=branch 36 | type=sha,format=short 37 | latest 38 | 39 | - name: Build and push Docker image 40 | uses: docker/build-push-action@v4 41 | with: 42 | context: ./api 43 | push: ${{ github.event_name != 'pull_request' }} 44 | tags: ${{ steps.meta.outputs.tags }} 45 | labels: ${{ steps.meta.outputs.labels }} 46 | -------------------------------------------------------------------------------- /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | working-directory: ./api 18 | env: 19 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | .pnpm-debug.log* 7 | 8 | # Environment variables 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Build outputs 16 | dist/ 17 | build/ 18 | *.tsbuildinfo 19 | 20 | # IDE and editor files 21 | .idea/ 22 | .vscode/ 23 | *.swp 24 | *.swo 25 | .DS_Store 26 | 27 | # Logs 28 | logs/ 29 | *.log 30 | 31 | # Cache directories 32 | .cache/ 33 | .npm/ 34 | .eslintcache 35 | .stylelintcache 36 | 37 | # Test coverage 38 | coverage/ 39 | 40 | # Redis dump files 41 | dump.rdb 42 | *.rdb 43 | 44 | # Misc 45 | .DS_Store 46 | Thumbs.db 47 | 48 | # Python specific 49 | __pycache__/ 50 | *.py[cod] 51 | *$py.class 52 | *.so 53 | .Python 54 | develop-eggs/ 55 | downloads/ 56 | eggs/ 57 | .eggs/ 58 | lib/ 59 | lib64/ 60 | parts/ 61 | sdist/ 62 | var/ 63 | wheels/ 64 | *.egg-info/ 65 | .installed.cfg 66 | *.egg 67 | MANIFEST 68 | pip-log.txt 69 | pip-delete-this-directory.txt 70 | htmlcov/ 71 | .tox/ 72 | .nox/ 73 | .coverage 74 | .coverage.* 75 | nosetests.xml 76 | coverage.xml 77 | *.cover 78 | .hypothesis/ 79 | .pytest_cache/ 80 | .env 81 | .venv 82 | env/ 83 | venv/ 84 | ENV/ 85 | env.bak/ 86 | venv.bak/ 87 | .mypy_cache/ 88 | 89 | .zed/ 90 | 91 | cli/l1m -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "bracketSpacing": true, 9 | "arrowParens": "always" 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # l1m 🚀 2 | 3 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) 4 | [![Homepage](https://img.shields.io/badge/homepage-l1m.io-blue)](https://l1m.io) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/inferablehq/l1m/sdk-go.svg)](https://pkg.go.dev/github.com/inferablehq/l1m/sdk-go) 6 | [![npm](https://img.shields.io/npm/v/l1m)](https://www.npmjs.com/package/l1m) 7 | [![PyPI](https://img.shields.io/pypi/v/l1m-dot-io)](https://pypi.org/project/l1m-dot-io/) 8 | 9 | > A Proxy to extract structured data from text and images using LLMs. 10 | 11 | ## 🌟 Why l1m? 12 | 13 | l1m is the easiest way to get structured data from unstructured text or images using LLMs. No prompt engineering, no chat history, just a simple API to extract structured JSON from text or images. 14 | 15 | ## ✨ Features 16 | 17 | - **📋 Simple Schema-First Approach:** Define your data structure in JSON Schema, get back exactly what you need 18 | - **🎯 Zero Prompt Engineering:** No need to craft complex prompts or chain multiple calls. Add context as JSON schema descriptions 19 | - **🔄 Provider Flexibility:** Bring your own provider, supports any OpenAI compatible or Anthropic provider and Anthropic models 20 | - **⚡ Caching:** Built-in caching, with `x-cache-ttl` (seconds) header to use l1m.io as a cache for your LLM requests 21 | - **🔓 Open Source:** Open-source, no vendor lock-in. Or use the hosted version with free-tier and high availability 22 | - **🔒 Privacy First:** We don't store your data, unless you use the `x-cache-ttl` header 23 | - **⚡️ Works Locally:** Use l1m locally with Ollama or any other OpenAI compatible model provider 24 | 25 | ## 🚀 Quick Start 26 | 27 | ### Text Example 28 | 29 | ```bash 30 | curl -X POST https://api.l1m.io/structured \ 31 | -H "Content-Type: application/json" \ 32 | -H "X-Provider-Url: demo" \ 33 | -H "X-Provider-Key: demo" \ 34 | -H "X-Provider-Model: demo" \ 35 | -d '{ 36 | "input": "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913", 37 | "schema": { 38 | "type": "object", 39 | "properties": { 40 | "items": { 41 | "type": "array", 42 | "items": { 43 | "type": "object", 44 | "properties": { 45 | "name": { "type": "string" }, 46 | "price": { "type": "number" } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | }' 53 | ``` 54 | 55 | ### Image Example 56 | 57 | ```bash 58 | curl -X POST https://api.l1m.io/structured \ 59 | -H "Content-Type: application/json" \ 60 | -H "X-Provider-Url: demo" \ 61 | -H "X-Provider-Key: demo" \ 62 | -H "X-Provider-Model: demo" \ 63 | -d '{ 64 | "input": "'$(curl -s https://public.l1m.io/menu.jpg | base64)'", 65 | "schema": { 66 | "type": "object", 67 | "properties": { 68 | "items": { 69 | "type": "array", 70 | "items": { 71 | "type": "object", 72 | "properties": { 73 | "name": { "type": "string" }, 74 | "price": { "type": "number" } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }' 81 | ``` 82 | 83 | ### Node.js Example 84 | 85 | See [sdk-node](https://github.com/inferablehq/l1m/tree/main/sdk-node) for a complete example. 86 | 87 | ### Python Example 88 | 89 | See [sdk-python](https://github.com/inferablehq/l1m/tree/main/sdk-python) for a complete example. 90 | 91 | ### Go Example 92 | 93 | See [sdk-go](https://github.com/inferablehq/l1m/tree/main/sdk-go) for a complete example. 94 | 95 | ## 📚 Documentation 96 | 97 | ### API Headers 98 | 99 | - `x-provider-model` (optional): Custom LLM model to use 100 | - `x-provider-url` (optional): Custom LLM provider URL (OpenAI compatible or Anthropic API) 101 | - `x-provider-key` (optional): API key for custom LLM provider 102 | - `x-cache-ttl` (optional): Cache TTL in seconds 103 | - Cache key (generated) = hash(input + schema + x-provider-key + x-provider-model) 104 | 105 | ### Supported Image Formats 106 | 107 | - `image/jpeg` 108 | - `image/png` 109 | 110 | ## 🛠️ SDKs 111 | 112 | Official SDKs are available for: 113 | 114 | - [Node.js](https://github.com/inferablehq/l1m/tree/main/sdk-node) 115 | - [Python](https://github.com/inferablehq/l1m/tree/main/sdk-python) 116 | - [Go](https://github.com/inferablehq/l1m/tree/main/sdk-go) 117 | 118 | ## ⚡️ Running Locally 119 | 120 | See [local.md](local.md) for instructions on running l1m locally (and using with Ollama). 121 | 122 | ## 🔔 Stay Updated 123 | 124 | Join our [waitlist](https://docs.google.com/forms/d/1R3AsXBlHjsxh3Mafz1ziji7IUDojlHeSRjpWHroBF-o/viewform) to get early access to the production release of our hosted version. 125 | 126 | ## 📚 Acknowledgements 127 | 128 | - [BAML](https://github.com/boundaryml/baml) 129 | - [Zod](https://github.com/colinhacks/zod) 130 | - [ts-rest](https://github.com/ts-rest/ts-rest) 131 | - [ajv](https://ajv.js.org/) 132 | 133 | ## 🏢 About 134 | 135 | Built by [Inferable](https://github.com/inferablehq/inferable). -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /node_modules 3 | .dockerignore 4 | .env 5 | Dockerfile 6 | fly.toml 7 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1 2 | 3 | # Adjust NODE_VERSION as desired 4 | ARG NODE_VERSION=20.17.0 5 | FROM node:${NODE_VERSION}-slim AS base 6 | 7 | LABEL fly_launch_runtime="Node.js" 8 | 9 | # Node.js app lives here 10 | WORKDIR /app 11 | 12 | # Set production environment 13 | ENV NODE_ENV="production" 14 | 15 | 16 | # Throw-away build stage to reduce size of final image 17 | FROM base AS build 18 | 19 | # Install packages needed to build node modules 20 | RUN apt-get update -qq && \ 21 | apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 22 | 23 | # Install node modules 24 | COPY package-lock.json package.json ./ 25 | RUN npm ci --include=dev 26 | 27 | # Copy application code 28 | COPY . . 29 | 30 | # Build application 31 | RUN npm run build 32 | 33 | # Remove development dependencies 34 | RUN npm prune --omit=dev 35 | 36 | 37 | # Final stage for app image 38 | FROM base 39 | 40 | # Install CA certificates 41 | RUN apt-get update -qq && \ 42 | apt-get install --no-install-recommends -y ca-certificates && \ 43 | apt-get clean && \ 44 | rm -rf /var/lib/apt/lists/* 45 | 46 | # Copy built application 47 | COPY --from=build /app /app 48 | 49 | # Start the server by default, this can be overwritten at runtime 50 | EXPOSE 10337 51 | CMD [ "npm", "run", "start" ] 52 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # l1m API 2 | 3 | This directory contains the API implementation for l1m. For general information about l1m, please see the [main README](../README.md). 4 | 5 | ## API Endpoints 6 | 7 | ### Structured 8 | 9 | ``` 10 | POST /structured 11 | ``` 12 | 13 | Extracts structured data from content according to the provided schema. 14 | 15 | **Request Body:** 16 | 17 | - `input` (string): Text content or base64 encoded image data 18 | - `schema` (object): JSON Schema defining the structure to extract 19 | - `instructions` (string): Instructions for the model (Optional, "e.g" to "Extract details from the provided content") 20 | 21 | For detailed information about headers, supported image formats, and example requests, see the [main README](../README.md#-documentation). 22 | 23 | ## Environment Variables 24 | 25 | - `REDIS_URL`: The URL of the Redis cache (optional, caching is disabled if not set) 26 | 27 | ## Development 28 | 29 | ```bash 30 | # Start development server with hot reload 31 | npm run dev 32 | 33 | # Run all tests 34 | npm run test 35 | 36 | # Run only unit tests (excludes integration tests) 37 | npm run test:unit 38 | 39 | # Run integration tests with various providers 40 | # Note: These tests require provider API keys and environment variables 41 | 42 | # Run integration tests (Against Groq) 43 | export TEST_PROVIDER_MODEL="llama-3.2-90b-vision-preview" 44 | export TEST_PROVIDER_URL="https://api.groq.com/openai/v1" 45 | export TEST_PROVIDER_KEY="" # Get your API key from https://console.groq.com/ 46 | npm run test:integration 47 | 48 | # Run integration tests (Against OpenRouter) 49 | export TEST_PROVIDER_MODEL="openai/gpt-4o" 50 | export TEST_PROVIDER_URL="https://openrouter.ai/api/v1" 51 | export TEST_PROVIDER_KEY="" # Get your API key from https://openrouter.ai 52 | npm run test:integration 53 | 54 | # Run integration tests (Against OpenAI) 55 | export TEST_PROVIDER_MODEL="gpt-4o-mini" 56 | export TEST_PROVIDER_URL="https://api.openai.com/v1" 57 | export TEST_PROVIDER_KEY="" # Get your API key from https://platform.openai.com 58 | npm run test:integration 59 | 60 | # Run integration tests (Against Anthropic) 61 | export TEST_PROVIDER_MODEL="claude-3-7-sonnet-20250219" 62 | export TEST_PROVIDER_URL="https://api.anthropic.com/v1/messages" 63 | export TEST_PROVIDER_KEY="" # Get your API key from https://console.anthropic.com 64 | npm run test:integration 65 | 66 | # Run integration tests (Against Google) 67 | export TEST_PROVIDER_MODEL="gemini-2.0-flash" 68 | export TEST_PROVIDER_URL="https://generativelanguage.googleapis.com/v1beta" 69 | export TEST_PROVIDER_KEY="" # Get your API key from https://ai.google.dev 70 | npm run test:integration 71 | ``` 72 | -------------------------------------------------------------------------------- /api/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for llm-extract on 2025-02-25T06:44:42+11:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'llm-extract' 7 | primary_region = 'lax' 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 10337 13 | force_https = true 14 | auto_stop_machines = 'stop' 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ['app'] 18 | 19 | [[http_service.checks]] 20 | grace_period = "10s" 21 | interval = "30s" 22 | method = "GET" 23 | path = "/health" 24 | protocol = "http" 25 | timeout = "5s" 26 | [http_service.checks.headers] 27 | Content-Type = "application/json" 28 | 29 | [[vm]] 30 | memory = '512mb' 31 | cpu_kind = 'shared' 32 | cpus = 1 33 | -------------------------------------------------------------------------------- /api/jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | // Define test patterns 6 | testMatch: [ 7 | // All tests including unit and integration 8 | "**/*.test.ts", 9 | ], 10 | // Setup for fetch-mock 11 | setupFiles: ["./jest.setup.js"], 12 | transform: { 13 | "^.+\\.tsx?$": [ 14 | "ts-jest", 15 | { 16 | tsconfig: "tsconfig.json", 17 | }, 18 | ], 19 | }, 20 | // Automatically clear mock calls and instances between every test 21 | clearMocks: true, 22 | // Allow for dotenv loading in tests 23 | setupFilesAfterEnv: ["dotenv/config"], 24 | // Test configurations for different run modes 25 | testPathIgnorePatterns: ["node_modules"], 26 | }; 27 | -------------------------------------------------------------------------------- /api/jest.setup.js: -------------------------------------------------------------------------------- 1 | // jest.setup.js 2 | const fetchMock = require("jest-fetch-mock"); 3 | fetchMock.enableMocks(); 4 | 5 | // Make sure we can load environment variables 6 | require("dotenv").config(); 7 | 8 | // Setup global fetch mock but keep real fetch available 9 | global.realFetch = global.fetch; 10 | 11 | // Jest functions are available in test files, not in setup files 12 | // We'll just make sure the mock is enabled 13 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@l1m/api", 3 | "version": "1.0.0", 4 | "main": "dist/server.js", 5 | "private": true, 6 | "dependencies": { 7 | "@l1m/core": "^0.1.5", 8 | "@ts-rest/fastify": "^3.52.0", 9 | "@types/ioredis": "^4.28.10", 10 | "fastify": "^4.26.1", 11 | "ioredis": "^5.5.0", 12 | "tsx": "^4.19.3", 13 | "zod": "^3.24.2" 14 | }, 15 | "devDependencies": { 16 | "@flydotio/dockerfile": "^0.7.8", 17 | "@types/jest": "^29.5.14", 18 | "@types/node": "^20.11.24", 19 | "dotenv": "^16.4.7", 20 | "jest": "^29.7.0", 21 | "jest-fetch-mock": "^3.0.3", 22 | "prettier": "^3.2.5", 23 | "ts-jest": "^29.2.6", 24 | "typescript": "^5.7.3" 25 | }, 26 | "jest": { 27 | "preset": "ts-jest", 28 | "testEnvironment": "node" 29 | }, 30 | "scripts": { 31 | "start": "tsx src/server.ts", 32 | "dev": "tsx -r dotenv/config --watch src/server.ts", 33 | "test": "jest --config=jest.config.js", 34 | "build": "tsc --build", 35 | "watch": "tsc -w", 36 | "format": "prettier --write \"**/*.{ts,js,json,md,html,css}\"", 37 | "docker:build": "docker build -t l1m .", 38 | "docker:publish": "docker tag l1m inferable/l1m:latest && docker push inferable/l1m:latest" 39 | } 40 | } -------------------------------------------------------------------------------- /api/src/contract.ts: -------------------------------------------------------------------------------- 1 | import { initContract } from "@ts-rest/core"; 2 | import { z } from "zod"; 3 | 4 | const c = initContract(); 5 | 6 | export const apiContract = c.router({ 7 | home: { 8 | method: "GET", 9 | path: "/", 10 | responses: { 11 | 200: z.object({ 12 | message: z.string(), 13 | }), 14 | }, 15 | }, 16 | health: { 17 | method: "GET", 18 | path: "/health", 19 | responses: { 20 | 200: z.object({ 21 | status: z.string(), 22 | timestamp: z.number(), 23 | uptime: z.number(), 24 | }), 25 | }, 26 | }, 27 | structured: { 28 | method: "POST", 29 | path: "/structured", 30 | body: z.object({ 31 | input: z.string(), 32 | instructions: z.string().optional(), 33 | schema: z.record(z.any()), 34 | }), 35 | headers: z.object({ 36 | "x-provider-model": z.string(), 37 | "x-provider-url": z.string(), 38 | "x-provider-key": z.string(), 39 | "x-max-attempts": z.string().optional().default("1"), 40 | "x-cache-ttl": z.string().optional(), 41 | }), 42 | responses: { 43 | 200: z.object({ 44 | data: z.record(z.any()), 45 | }), 46 | }, 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /api/src/redis.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | import Redis from "ioredis"; 4 | 5 | export const redis = process.env.REDIS_URL 6 | ? new Redis(process.env.REDIS_URL, { 7 | family: 6, 8 | }) 9 | : null; 10 | 11 | export const generateCacheKey = (input: string[]) => { 12 | const hash = crypto.createHash("sha256"); 13 | input.forEach((text) => hash.update(text)); 14 | return hash.digest("hex"); 15 | }; 16 | -------------------------------------------------------------------------------- /api/src/server.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | 3 | import { apiContract } from "./contract"; 4 | import { initServer } from "@ts-rest/fastify"; 5 | import { generateCacheKey, redis } from "./redis"; 6 | import { getDemoData } from "./demo-provider"; 7 | 8 | import { 9 | validateJsonSchema, 10 | structured, 11 | validTypes, 12 | inferType, 13 | } from "@l1m/core"; 14 | 15 | const server = fastify({ logger: true }); 16 | const s = initServer(); 17 | 18 | const router = s.router(apiContract, { 19 | home: async () => { 20 | return { 21 | status: 200, 22 | body: { 23 | message: 24 | "Hello, world! This is the l1m (pronounced el-one-em) API. It's a simple API that allows you to extract structured data from text. See https://l1m.io for docs.", 25 | }, 26 | }; 27 | }, 28 | health: async () => { 29 | return { 30 | status: 200, 31 | body: { 32 | status: "ok", 33 | timestamp: Date.now(), 34 | uptime: process.uptime(), 35 | }, 36 | }; 37 | }, 38 | structured: async ({ body, headers, reply }) => { 39 | const startTime = process.hrtime(); 40 | 41 | const providerKey = headers["x-provider-key"]; 42 | const providerModel = headers["x-provider-model"]; 43 | const providerUrl = headers["x-provider-url"]; 44 | 45 | const maxAttempts = headers["x-max-attempts"] ?? 1; 46 | 47 | const { input, instructions } = body; 48 | let { schema } = body; 49 | 50 | const schemaError = validateJsonSchema(schema); 51 | if (schemaError) { 52 | return { 53 | status: 400, 54 | body: { 55 | message: schemaError, 56 | }, 57 | }; 58 | } 59 | 60 | const demoData = getDemoData({ 61 | input, 62 | schema: JSON.stringify(schema), 63 | providerKey, 64 | providerModel, 65 | providerUrl, 66 | }); 67 | 68 | if (demoData) { 69 | return { 70 | status: 200, 71 | body: { 72 | data: demoData, 73 | }, 74 | }; 75 | } 76 | 77 | const type = await inferType(input); 78 | 79 | if (type && !validTypes.includes(type)) { 80 | server.log.warn({ 81 | route: "structured", 82 | error: "Invalid mime type", 83 | type, 84 | }); 85 | return { 86 | status: 400, 87 | body: { 88 | message: "Provided content has invalid mime type", 89 | type, 90 | }, 91 | }; 92 | } 93 | 94 | server.log.info({ 95 | route: "structured", 96 | schemaType: schema.type, 97 | contentLength: input.length, 98 | }); 99 | 100 | try { 101 | const [seconds, nanoseconds] = process.hrtime(startTime); 102 | const duration = seconds * 1000 + nanoseconds / 1000000; 103 | 104 | const ttl = parseInt(headers["x-cache-ttl"] ?? "0"); 105 | 106 | if (ttl < 0 || ttl > 60 * 60 * 24 * 7) { 107 | return { 108 | status: 400, 109 | body: { 110 | message: 111 | "Invalid cache TTL. Must be between 0 and 604800 seconds (7 days).", 112 | }, 113 | }; 114 | } 115 | 116 | const cacheKey: string | null = ttl 117 | ? generateCacheKey([ 118 | input, 119 | JSON.stringify(schema), 120 | providerKey, 121 | providerModel, 122 | ]) 123 | : null; 124 | 125 | const fromCache = cacheKey ? await redis?.get(cacheKey) : null; 126 | reply.header("x-cache", fromCache ? "HIT" : "MISS"); 127 | 128 | const result = fromCache 129 | ? { 130 | structured: JSON.parse(fromCache), 131 | valid: true, 132 | raw: undefined, 133 | errors: undefined, 134 | attempts: 1, 135 | } 136 | : await structured({ 137 | input, 138 | type, 139 | schema, 140 | instructions, 141 | maxAttempts: maxAttempts ? parseInt(maxAttempts) : undefined, 142 | provider: { 143 | url: providerUrl, 144 | key: providerKey, 145 | model: providerModel, 146 | }, 147 | }); 148 | 149 | reply.header("x-attempts", result.attempts); 150 | 151 | if (!result.valid) { 152 | return { 153 | status: 422, 154 | body: { 155 | message: "Failed to extract structured data", 156 | validation: result.errors, 157 | raw: result.raw, 158 | data: result.structured, 159 | }, 160 | }; 161 | } 162 | 163 | if (!fromCache && cacheKey) { 164 | await redis?.set( 165 | cacheKey, 166 | JSON.stringify(result.structured), 167 | "EX", 168 | ttl 169 | ); 170 | } 171 | 172 | server.log.info({ 173 | event: "structured_success", 174 | cached: !!fromCache, 175 | duration_ms: duration.toFixed(2), 176 | }); 177 | 178 | return { 179 | status: 200, 180 | body: { 181 | data: result.structured, 182 | }, 183 | }; 184 | } catch (error) { 185 | const [seconds, nanoseconds] = process.hrtime(startTime); 186 | const duration = seconds * 1000 + nanoseconds / 1000000; 187 | 188 | server.log.error({ 189 | route: "structured", 190 | ...(error instanceof Error 191 | ? { error: error.message } 192 | : { error: error }), 193 | duration_ms: duration.toFixed(2), 194 | }); 195 | 196 | throw error; 197 | } 198 | }, 199 | }); 200 | 201 | server.setErrorHandler((error, _, reply) => { 202 | let status = error.statusCode || 500; 203 | if ("status" in error && typeof error.status === "number") { 204 | status = error.status; 205 | } 206 | 207 | reply.status(status).send({ 208 | message: error.message || "Internal server error", 209 | }); 210 | }); 211 | 212 | // Register the routes 213 | server.register(s.plugin(router)); 214 | 215 | // Start the server 216 | const start = async () => { 217 | try { 218 | await server.listen({ 219 | port: 10337, 220 | host: "0.0.0.0", 221 | }); 222 | server.log.info("Server started"); 223 | } catch (err) { 224 | server.log.error(err); 225 | process.exit(1); 226 | } 227 | }; 228 | 229 | start(); 230 | 231 | process.on("SIGTERM", () => { 232 | server.log.info("SIGTERM signal received: closing HTTP server"); 233 | shutdown(); 234 | }); 235 | 236 | process.on("SIGINT", () => { 237 | server.log.info("SIGINT signal received: closing HTTP server"); 238 | shutdown(); 239 | }); 240 | 241 | const shutdown = async () => { 242 | try { 243 | // Close the server first (stop accepting new connections) 244 | await server.close(); 245 | server.log.info("HTTP server closed"); 246 | 247 | // Close Redis connection if it exists 248 | if (redis && redis.quit) { 249 | await redis.quit(); 250 | server.log.info("Redis connection closed"); 251 | } 252 | 253 | server.log.info("Shutdown completed"); 254 | process.exit(0); 255 | } catch (err) { 256 | server.log.error(err); 257 | process.exit(1); 258 | } 259 | }; 260 | -------------------------------------------------------------------------------- /api/src/test/structured.integration.test.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from "jest-fetch-mock"; 2 | 3 | // Configure environment checks 4 | beforeAll(() => { 5 | // Enable fetch mocking if needed 6 | fetchMock.enableMocks(); 7 | fetchMock.dontMock(); 8 | 9 | // Verify required env variables 10 | expect(process.env.TEST_PROVIDER_MODEL).toBeDefined(); 11 | expect(process.env.TEST_PROVIDER_URL).toBeDefined(); 12 | expect(process.env.TEST_PROVIDER_KEY).toBeDefined(); 13 | 14 | console.log("Testing with provider", { 15 | url: process.env.TEST_PROVIDER_URL, 16 | model: process.env.TEST_PROVIDER_MODEL, 17 | }); 18 | }); 19 | 20 | async function structured(input: { 21 | input: string; 22 | instructions?: string; 23 | schema: object; 24 | customHeaders?: Record; 25 | }) { 26 | const response = await fetch( 27 | process.env.TEST_SERVER ?? "http://localhost:10337/structured", 28 | { 29 | method: "POST", 30 | headers: { 31 | "Content-Type": "application/json", 32 | "x-provider-url": process.env.TEST_PROVIDER_URL!, 33 | "x-provider-key": process.env.TEST_PROVIDER_KEY!, 34 | "x-provider-model": process.env.TEST_PROVIDER_MODEL!, 35 | ...input.customHeaders, 36 | }, 37 | body: JSON.stringify({ 38 | input: input.input, 39 | schema: input.schema, 40 | instructions: input.instructions, 41 | }), 42 | } 43 | ); 44 | 45 | const result = await response.json(); 46 | 47 | return { response, result }; 48 | } 49 | 50 | describe("Structured Data Extraction API", () => { 51 | test("extracts JSON object correctly", async () => { 52 | const testData = { 53 | input: `{ 54 | "company": { 55 | "name": "Tech Innovations Inc.", 56 | "founded": 2010, 57 | "location": { 58 | "city": "San Francisco", 59 | "state": "CA", 60 | "country": "USA", 61 | "address": { 62 | "street": "123 Innovation Way", 63 | "zipcode": "94107", 64 | "suite": "400" 65 | } 66 | }, 67 | "departments": [ 68 | { 69 | "name": "Engineering", 70 | "employees": 120, 71 | "teams": ["Frontend", "Backend", "DevOps"] 72 | }, 73 | { 74 | "name": "Sales", 75 | "employees": 85, 76 | "regions": ["North America", "Europe", "Asia"] 77 | } 78 | ] 79 | } 80 | }`, 81 | schema: { 82 | type: "object", 83 | properties: { 84 | companyName: { 85 | type: "string", 86 | description: "The name of the company", 87 | }, 88 | foundedYear: { 89 | type: "number", 90 | description: "The year the company was founded", 91 | }, 92 | address: { 93 | type: "object", 94 | properties: { 95 | street: { type: "string" }, 96 | city: { 97 | type: "string", 98 | description: "The city the company is located in", 99 | }, 100 | state: { type: "string" }, 101 | country: { type: "string" }, 102 | zipcode: { type: "string" }, 103 | suite: { type: "string" }, 104 | }, 105 | }, 106 | engineeringTeams: { type: "array" }, 107 | departments: { 108 | type: "array", 109 | items: { 110 | type: "object", 111 | properties: { 112 | name: { type: "string" }, 113 | employees: { 114 | type: "number", 115 | description: "The number of employees in the department", 116 | }, 117 | }, 118 | }, 119 | }, 120 | }, 121 | }, 122 | }; 123 | 124 | const { response, result } = await structured(testData); 125 | 126 | expect(response.ok).toBeTruthy(); 127 | expect(result.data.companyName).toBe("Tech Innovations Inc."); 128 | expect(result.data.foundedYear).toBe(2010); 129 | expect(typeof result.data.address).toBe("object"); 130 | expect(result.data.address.street).toBe("123 Innovation Way"); 131 | expect(Array.isArray(result.data.engineeringTeams)).toBeTruthy(); 132 | expect(result.data.engineeringTeams.length).toBe(3); 133 | expect(response.headers.get("x-attempts")).toBe("1"); 134 | }); 135 | 136 | test("handles base64 encoded JSON correctly", async () => { 137 | const rawInput = `{ 138 | "company": { 139 | "name": "Tech Innovations Inc.", 140 | "founded": 2010, 141 | "location": { 142 | "city": "San Francisco", 143 | "state": "CA", 144 | "country": "USA", 145 | "address": { 146 | "street": "123 Innovation Way", 147 | "zipcode": "94107", 148 | "suite": "400" 149 | } 150 | }, 151 | "departments": [ 152 | { 153 | "name": "Engineering", 154 | "employees": 120, 155 | "teams": ["Frontend", "Backend", "DevOps"] 156 | }, 157 | { 158 | "name": "Sales", 159 | "employees": 85, 160 | "regions": ["North America", "Europe", "Asia"] 161 | } 162 | ] 163 | } 164 | }`; 165 | 166 | const input = Buffer.from(rawInput).toString("base64"); 167 | 168 | const schema = { 169 | type: "object", 170 | properties: { 171 | companyName: { type: "string" }, 172 | foundedYear: { type: "number" }, 173 | address: { 174 | type: "object", 175 | properties: { 176 | street: { type: "string" }, 177 | city: { type: "string" }, 178 | state: { type: "string" }, 179 | country: { type: "string" }, 180 | zipcode: { type: "string" }, 181 | suite: { type: "string" }, 182 | }, 183 | }, 184 | engineeringTeams: { type: "array" }, 185 | departments: { 186 | type: "array", 187 | items: { 188 | type: "object", 189 | properties: { 190 | name: { type: "string" }, 191 | employees: { type: "number" }, 192 | }, 193 | }, 194 | }, 195 | }, 196 | }; 197 | 198 | const { response, result } = await structured({ input, schema }); 199 | 200 | expect(response.ok).toBeTruthy(); 201 | expect(result.data.companyName).toBe("Tech Innovations Inc."); 202 | expect(result.data.foundedYear).toBe(2010); 203 | expect(typeof result.data.address).toBe("object"); 204 | expect(result.data.address.street).toBe("123 Innovation Way"); 205 | expect(Array.isArray(result.data.engineeringTeams)).toBeTruthy(); 206 | expect(result.data.engineeringTeams.length).toBe(3); 207 | }); 208 | 209 | test("honors schema descriptions", async () => { 210 | // Intentionally empty 211 | const input = ""; 212 | const schema = { 213 | type: "object", 214 | properties: { 215 | word: { type: "string", description: "Must be the word 'inference'" }, 216 | }, 217 | }; 218 | 219 | const { response, result } = await structured({ input, schema }); 220 | 221 | expect(response.ok).toBeTruthy(); 222 | expect(result.data.word).toBe("inference"); 223 | }); 224 | 225 | test("processes images correctly", async () => { 226 | const url = 227 | "https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png"; 228 | 229 | // Base64 encode 230 | const buffer = await fetch(url).then((response) => response.arrayBuffer()); 231 | const input = Buffer.from(buffer).toString("base64"); 232 | 233 | const schema = { 234 | type: "object", 235 | properties: { 236 | character: { type: "string" }, 237 | }, 238 | }; 239 | 240 | const { response, result } = await structured({ input, schema }); 241 | 242 | expect(response.ok).toBeTruthy(); 243 | expect(result.data.character).toBe("Shrek"); 244 | }, 10_000); 245 | 246 | test("rejects invalid input types", async () => { 247 | const url = 248 | "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"; 249 | 250 | // Base64 encode 251 | const buffer = await fetch(url).then((response) => response.arrayBuffer()); 252 | const input = Buffer.from(buffer).toString("base64"); 253 | 254 | const schema = { 255 | type: "object", 256 | properties: { 257 | character: { type: "string" }, 258 | }, 259 | }; 260 | 261 | const { response, result } = await structured({ input, schema }); 262 | 263 | expect(response.status).toBe(400); 264 | expect(result.message).toBe("Provided content has invalid mime type"); 265 | }); 266 | 267 | test.each([ 268 | ["groq", "https://api.groq.com/openai/v1", "401 Invalid API Key", 401], 269 | [ 270 | "openrouter", 271 | "https://openrouter.ai/api/v1", 272 | "401 No auth credentials found", 273 | 401, 274 | ], 275 | [ 276 | "openai", 277 | "https://api.openai.com/v1", 278 | "401 Incorrect API key provided: INVALID. You can find your API key at https://platform.openai.com/account/api-keys.", 279 | 401, 280 | ], 281 | [ 282 | "google", 283 | "https://generativelanguage.googleapis.com/v1beta", 284 | expect.stringContaining( 285 | "API key not valid. Please pass a valid API key." 286 | ), 287 | 400, 288 | ], 289 | ])( 290 | "rejects invalid API key for %s provider", 291 | async (provider, url, expectedMessage, expectedStatus) => { 292 | const input = "abc123"; 293 | const schema = { 294 | type: "object", 295 | properties: { 296 | character: { type: "string" }, 297 | }, 298 | }; 299 | 300 | const customHeaders = { 301 | "x-provider-url": url, 302 | "x-provider-model": "INVALID", 303 | "x-provider-key": "INVALID", 304 | }; 305 | 306 | const { response, result } = await structured({ 307 | input, 308 | schema, 309 | customHeaders, 310 | }); 311 | 312 | expect(response.status).toBe(expectedStatus); 313 | expect(result.message).toEqual(expectedMessage); 314 | } 315 | ); 316 | 317 | test("rejects invalid schema", async () => { 318 | const input = "abc123"; 319 | const schema = { 320 | type: "INVLAID", 321 | }; 322 | 323 | const { response, result } = await structured({ input, schema }); 324 | 325 | expect(response.status).toBe(400); 326 | expect(result.message).toBe("Provided JSON schema is invalid"); 327 | }); 328 | 329 | test("rejects non-compliant schema", async () => { 330 | const input = "abc123"; 331 | const schema = { 332 | type: "object", 333 | properties: { 334 | name: { 335 | type: "string", 336 | minLength: 5, 337 | maxLength: 10, 338 | }, 339 | }, 340 | }; 341 | 342 | const { response, result } = await structured({ input, schema }); 343 | 344 | expect(response.status).toBe(400); 345 | expect(result.message).toBe( 346 | "Disallowed property 'minLength' found in schema" 347 | ); 348 | }); 349 | 350 | test("handles enum schema correctly", async () => { 351 | const input = "Fill in the most appropriate details."; 352 | const schema = { 353 | type: "object", 354 | properties: { 355 | skyColor: { 356 | type: "string", 357 | // Use ligthBlue rather than blue to ensure the model sees the enum 358 | enum: ["lightBlue", "gray", "black"], 359 | description: "The color of the sky", 360 | }, 361 | grassColor: { 362 | type: "string", 363 | enum: ["green", "brown", "yellow"], 364 | description: "The color of grass", 365 | }, 366 | }, 367 | }; 368 | 369 | const { response, result } = await structured({ input, schema }); 370 | 371 | expect(response.ok).toBeTruthy(); 372 | expect(result.data.skyColor).toBe("lightBlue"); 373 | expect(result.data.grassColor).toBe("green"); 374 | }); 375 | 376 | test("processes instructions correctly", async () => { 377 | const instructions = "The word is haystack"; 378 | const input = "Fill in the most appropriate details."; 379 | const schema = { 380 | type: "object", 381 | properties: { 382 | word: { 383 | type: "string", 384 | }, 385 | }, 386 | }; 387 | 388 | const { response, result } = await structured({ 389 | input, 390 | schema, 391 | instructions, 392 | }); 393 | 394 | expect(response.ok).toBeTruthy(); 395 | expect(result.data.word).toBe("haystack"); 396 | }); 397 | 398 | test("uses cache correctly", async () => { 399 | const input = `The Sky is blue. The current time is ${Date.now()}.`; 400 | const schema = { 401 | type: "object", 402 | properties: { 403 | skyColor: { 404 | type: "string", 405 | }, 406 | }, 407 | }; 408 | 409 | const customHeaders = { 410 | "x-cache-ttl": "100", 411 | }; 412 | 413 | const { response, result } = await structured({ 414 | input, 415 | schema, 416 | customHeaders, 417 | }); 418 | 419 | expect(response.ok).toBeTruthy(); 420 | expect(result.data.skyColor).toBe("blue"); 421 | expect(response.headers.has("x-cache")).toBeTruthy(); 422 | expect(response.headers.get("x-cache")).toBe("MISS"); 423 | 424 | const { response: secondResponse, result: secondResult } = await structured( 425 | { input, schema, customHeaders } 426 | ); 427 | 428 | expect(secondResponse.ok).toBeTruthy(); 429 | expect(secondResult.data.skyColor).toBe("blue"); 430 | expect(secondResponse.headers.has("x-cache")).toBeTruthy(); 431 | expect(secondResponse.headers.get("x-cache")).toBe("HIT"); 432 | }); 433 | }); 434 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /cli/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: patch-release test build prepare-release install 2 | 3 | # Get the current version by sorting cli/ git tags and taking the last one 4 | CURRENT_VERSION=$(shell git --no-pager tag | grep "^cli/v" | sed 's/^cli\///' | sort -V | tail -n 1) 5 | # Default to v0.0.0 if no tags found 6 | ifeq ($(CURRENT_VERSION),) 7 | CURRENT_VERSION=v0.0.0 8 | endif 9 | # Parse major, minor, and patch versions, stripping the 'v' prefix for calculations 10 | MAJOR=$(shell echo $(CURRENT_VERSION) | sed 's/v//' | cut -d. -f1) 11 | MINOR=$(shell echo $(CURRENT_VERSION) | sed 's/v//' | cut -d. -f2) 12 | PATCH=$(shell echo $(CURRENT_VERSION) | sed 's/v//' | cut -d. -f3) 13 | # Calculate new patch version 14 | NEW_PATCH=$(shell echo $$(($(PATCH) + 1))) 15 | NEW_VERSION=v$(MAJOR).$(MINOR).$(NEW_PATCH) 16 | 17 | # Prepare go.mod for release by updating the replace directive 18 | prepare-release: 19 | @echo "Preparing go.mod for release..." 20 | @# Get the latest sdk-go version 21 | @SDK_VERSION=$$(git --no-pager tag | grep "^sdk-go/v" | sed 's/^sdk-go\///' | sort -V | tail -n 1) && \ 22 | if [ -z "$$SDK_VERSION" ]; then \ 23 | echo "No sdk-go version found, using v0.0.0"; \ 24 | SDK_VERSION="v0.0.0"; \ 25 | fi && \ 26 | echo "Using sdk-go version: $$SDK_VERSION" && \ 27 | sed -i.bak 's|require github.com/inferablehq/l1m/sdk-go.*|require github.com/inferablehq/l1m/sdk-go '$$SDK_VERSION'|' go.mod && \ 28 | sed -i.bak '/replace github.com\/inferablehq\/l1m\/sdk-go/d' go.mod && \ 29 | rm -f go.mod.bak && \ 30 | go mod tidy 31 | 32 | # Run tests 33 | test: prepare-release 34 | go test ./... 35 | 36 | # Build the CLI binary 37 | build: prepare-release 38 | go build -ldflags "-X main.Version=$(CURRENT_VERSION)" -o l1m . 39 | 40 | # Install the CLI binary 41 | install: build 42 | cp l1m $$(go env GOPATH)/bin/l1m 43 | 44 | # Create a new patch release 45 | patch-release: test 46 | @echo "Current version: $(CURRENT_VERSION)" 47 | @echo "New version: $(NEW_VERSION)" 48 | @# Commit the changes to go.mod and go.sum 49 | git add go.mod go.sum 50 | git commit -m "Release cli/$(NEW_VERSION): Update dependencies" 51 | git tag -a cli/$(NEW_VERSION) -m "Release cli/$(NEW_VERSION)" 52 | git push origin cli/$(NEW_VERSION) 53 | git push origin HEAD 54 | GOPROXY=proxy.golang.org go list -m github.com/inferablehq/l1m/cli@$(NEW_VERSION) 55 | go build -ldflags "-X main.Version=$(NEW_VERSION)" -o l1m . 56 | @echo "Built CLI binary with version $(NEW_VERSION)" 57 | @# Restore the replace directive for local development 58 | @echo "Restoring go.mod for local development..." 59 | @sed -i.bak 's|require github.com/inferablehq/l1m/sdk-go.*|require github.com/inferablehq/l1m/sdk-go v0.0.0|' go.mod && \ 60 | echo 'replace github.com/inferablehq/l1m/sdk-go => ../sdk-go' >> go.mod && \ 61 | rm -f go.mod.bak && \ 62 | go mod tidy 63 | 64 | # Show help 65 | help: 66 | @echo "Available targets:" 67 | @echo " patch-release - Create a new patch release (v0.0.X)" 68 | @echo " build - Build the CLI binary" 69 | @echo " test - Run tests" 70 | @echo " prepare-release - Prepare go.mod for release" 71 | @echo " install - Install the CLI binary to GOPATH/bin" 72 | @echo " help - Show this help message" -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # l1m CLI 2 | 3 | [![License](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE) 4 | [![Homepage](https://img.shields.io/badge/homepage-l1m.io-blue)](https://l1m.io) 5 | 6 | Command-line interface for [l1m](https://l1m.io), a proxy to extract structured data from text and images using LLMs. 7 | 8 | ## Installation 9 | 10 | ### From Source 11 | 12 | ```bash 13 | # Clone the repository 14 | git clone https://github.com/inferablehq/l1m.git 15 | 16 | # Build the CLI 17 | cd l1m/cli 18 | go build -o l1m 19 | 20 | # Move to a directory in your PATH (optional) 21 | sudo mv l1m /usr/local/bin/ 22 | ``` 23 | 24 | ### Using Go Install 25 | 26 | ```bash 27 | go install github.com/inferablehq/l1m/cli@latest 28 | ``` 29 | 30 |
31 | Setting up GOPATH in your PATH 32 | 33 | The `go install` command places binaries in the `$GOPATH/bin` directory. To use the installed binary from anywhere, you need to add this directory to your PATH. 34 | 35 | For **Bash** (add to `~/.bashrc` or `~/.bash_profile`): 36 | ```bash 37 | echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.bashrc 38 | ``` 39 | 40 | For **Zsh** (add to `~/.zshrc`): 41 | ```bash 42 | echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.zshrc 43 | ``` 44 | 45 | For **Fish** (add to `~/.config/fish/config.fish`): 46 | ```fish 47 | echo 'set -gx PATH $PATH (go env GOPATH)/bin' >> ~/.config/fish/config.fish 48 | ``` 49 | 50 | After adding this line, reload your shell configuration: 51 | ```bash 52 | # For Bash 53 | source ~/.bashrc # or source ~/.bash_profile 54 | 55 | # For Zsh 56 | source ~/.zshrc 57 | 58 | # For Fish 59 | source ~/.config/fish/config.fish 60 | ``` 61 | 62 | You can verify the installation by running: 63 | ```bash 64 | l1m -version 65 | ``` 66 | 67 |
68 | 69 | ## Usage 70 | 71 | The l1m CLI allows you to extract structured data from text using LLMs directly from your terminal. 72 | 73 | ### Basic Usage 74 | 75 | ```bash 76 | echo "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913" | l1m -s '{"type":"object","properties":{"year":{"type":"number"},"act":{"type":"string"}}}' 77 | ``` 78 | 79 | ### Command Line Options 80 | 81 | ``` 82 | Usage: l1m -s [-i ] | cat "unstructured stuff" 83 | 84 | Options: 85 | -s JSON schema for structuring the data (required) 86 | -i Optional instructions for the LLM 87 | -url Provider URL (defaults to L1M_PROVIDER_URL env var) 88 | -key Provider API key (defaults to L1M_PROVIDER_KEY env var) 89 | -model Provider model (defaults to L1M_PROVIDER_MODEL env var) 90 | -base-url L1M base URL (defaults to L1M_BASE_URL env var or https://api.l1m.io) 91 | -version Show version information 92 | -h Show this help message 93 | 94 | Environment Variables: 95 | L1M_PROVIDER_URL Default provider URL 96 | L1M_PROVIDER_KEY Default provider API key 97 | L1M_PROVIDER_MODEL Default provider model 98 | L1M_BASE_URL Default L1M base URL 99 | ``` 100 | 101 | ### Examples 102 | 103 | #### Extract Historical Information 104 | 105 | ```bash 106 | echo "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913" | l1m -s '{"type":"object","properties":{"year":{"type":"number"},"act":{"type":"string"}}}' 107 | ``` 108 | 109 | #### Process an Image 110 | 111 | ```bash 112 | curl -s https://public.l1m.io/menu.jpg | base64 | l1m -s '{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"price":{"type":"number"}}}}}}' 113 | ``` 114 | 115 | #### Add Custom Instructions 116 | 117 | ```bash 118 | echo "John Smith was born on January 15, 1980. He works at Acme Inc." | l1m -s '{"type":"object","properties":{"name":{"type":"string"},"company":{"type":"string"}}}' -i "Extract only professional information" 119 | ``` 120 | 121 | #### Use a Specific Provider 122 | 123 | ```bash 124 | echo "The price of AAPL is $150.50" | l1m -s '{"type":"object","properties":{"stock":{"type":"string"},"price":{"type":"number"}}}' -url "https://api.anthropic.com/v1/messages" -key "your-api-key" -model "claude-3-5-sonnet-latest" 125 | ``` 126 | 127 | ## Using with Local LLMs (Ollama) 128 | 129 | You can use the CLI with locally running LLMs through Ollama: 130 | 131 | ```bash 132 | echo "The price of AAPL is $150.50" | l1m -s '{"type":"object","properties":{"stock":{"type":"string"},"price":{"type":"number"}}}' -url "http://localhost:11434/v1" -key "ollama" -model "llama3:latest" 133 | ``` 134 | 135 | ## License 136 | 137 | MIT -------------------------------------------------------------------------------- /cli/cli.go: -------------------------------------------------------------------------------- 1 | // Package main provides a command-line interface for l1m, a proxy to extract structured data from text and images using LLMs. 2 | // 3 | // The CLI allows users to pipe unstructured text or base64-encoded images into the tool and get back structured JSON data 4 | // based on a provided JSON schema. It supports various configuration options through command-line flags and environment variables. 5 | // 6 | // Usage: 7 | // 8 | // echo "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913" | l1m -s '{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"price":{"type":"number"}}}}}}' 9 | // 10 | // For more information, see https://l1m.io 11 | package main 12 | 13 | import ( 14 | "encoding/json" 15 | "flag" 16 | "fmt" 17 | "io" 18 | "os" 19 | 20 | l1m "github.com/inferablehq/l1m/sdk-go" 21 | ) 22 | 23 | // Version information - will be set during build 24 | var ( 25 | Version = "dev" 26 | ) 27 | 28 | func main() { 29 | // Define command line flags 30 | schemaFlag := flag.String("s", "", "JSON schema for structuring the data (required)") 31 | instructionFlag := flag.String("i", "", "Optional instructions for the LLM") 32 | providerURLFlag := flag.String("url", os.Getenv("L1M_PROVIDER_URL"), "Provider URL (defaults to L1M_PROVIDER_URL env var)") 33 | providerKeyFlag := flag.String("key", os.Getenv("L1M_PROVIDER_KEY"), "Provider API key (defaults to L1M_PROVIDER_KEY env var)") 34 | providerModelFlag := flag.String("model", os.Getenv("L1M_PROVIDER_MODEL"), "Provider model (defaults to L1M_PROVIDER_MODEL env var)") 35 | baseURLFlag := flag.String("base-url", os.Getenv("L1M_BASE_URL"), "L1M base URL (defaults to L1M_BASE_URL env var or https://api.l1m.io)") 36 | versionFlag := flag.Bool("version", false, "Show version information") 37 | helpFlag := flag.Bool("h", false, "Show help") 38 | 39 | // Parse flags 40 | flag.Parse() 41 | 42 | // Show version if requested 43 | if *versionFlag { 44 | fmt.Printf("l1m version %s\n", Version) 45 | os.Exit(0) 46 | } 47 | 48 | // Show help if requested or if required flags are missing 49 | if *helpFlag || *schemaFlag == "" { 50 | printUsage() 51 | os.Exit(0) 52 | } 53 | 54 | // Read input from stdin 55 | input, err := io.ReadAll(os.Stdin) 56 | if err != nil { 57 | fmt.Fprintf(os.Stderr, "Error reading from stdin: %v\n", err) 58 | os.Exit(1) 59 | } 60 | 61 | // Parse the schema 62 | var schema interface{} 63 | if err := json.Unmarshal([]byte(*schemaFlag), &schema); err != nil { 64 | fmt.Fprintf(os.Stderr, "Error parsing schema: %v\n", err) 65 | os.Exit(1) 66 | } 67 | 68 | // Set up the client 69 | baseURL := *baseURLFlag 70 | if baseURL == "" { 71 | baseURL = "https://api.l1m.io" 72 | } 73 | 74 | // Ensure provider URL and key are set 75 | if *providerURLFlag == "" || *providerKeyFlag == "" || *providerModelFlag == "" { 76 | fmt.Fprintf(os.Stderr, "Provider URL, key, and model must be provided via flags or environment variables\n") 77 | printUsage() 78 | os.Exit(1) 79 | } 80 | 81 | client := l1m.NewClient(&l1m.ClientOptions{ 82 | BaseURL: baseURL, 83 | Provider: &l1m.Provider{ 84 | URL: *providerURLFlag, 85 | Key: *providerKeyFlag, 86 | Model: *providerModelFlag, 87 | }, 88 | }) 89 | 90 | // Create the request 91 | req := &l1m.StructuredRequest{ 92 | Input: string(input), 93 | Schema: schema, 94 | } 95 | 96 | // Add instructions if provided 97 | if *instructionFlag != "" { 98 | instruction := *instructionFlag 99 | req.Instruction = &instruction 100 | } 101 | 102 | // Send the request 103 | result, err := client.Structured(req, nil) 104 | if err != nil { 105 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) 106 | os.Exit(1) 107 | } 108 | 109 | // Output the result as JSON 110 | jsonResult, err := json.MarshalIndent(result, "", " ") 111 | if err != nil { 112 | fmt.Fprintf(os.Stderr, "Error marshaling result: %v\n", err) 113 | os.Exit(1) 114 | } 115 | 116 | fmt.Println(string(jsonResult)) 117 | } 118 | 119 | func printUsage() { 120 | fmt.Fprintf(os.Stderr, "Usage: l1m -s [-i ] | cat \"unstructured stuff\"\n\n") 121 | fmt.Fprintf(os.Stderr, "Options:\n") 122 | fmt.Fprintf(os.Stderr, " -s JSON schema for structuring the data (required)\n") 123 | fmt.Fprintf(os.Stderr, " -i Optional instructions for the LLM\n") 124 | fmt.Fprintf(os.Stderr, " -url Provider URL (defaults to L1M_PROVIDER_URL env var)\n") 125 | fmt.Fprintf(os.Stderr, " -key Provider API key (defaults to L1M_PROVIDER_KEY env var)\n") 126 | fmt.Fprintf(os.Stderr, " -model Provider model (defaults to L1M_PROVIDER_MODEL env var)\n") 127 | fmt.Fprintf(os.Stderr, " -base-url L1M base URL (defaults to L1M_BASE_URL env var or https://api.l1m.io)\n") 128 | fmt.Fprintf(os.Stderr, " -version Show version information\n") 129 | fmt.Fprintf(os.Stderr, " -h Show this help message\n\n") 130 | fmt.Fprintf(os.Stderr, "Environment Variables:\n") 131 | fmt.Fprintf(os.Stderr, " L1M_PROVIDER_URL Default provider URL\n") 132 | fmt.Fprintf(os.Stderr, " L1M_PROVIDER_KEY Default provider API key\n") 133 | fmt.Fprintf(os.Stderr, " L1M_PROVIDER_MODEL Default provider model\n") 134 | fmt.Fprintf(os.Stderr, " L1M_BASE_URL Default L1M base URL\n\n") 135 | fmt.Fprintf(os.Stderr, "Examples:\n") 136 | fmt.Fprintf(os.Stderr, " echo \"A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913\" | l1m -s '{\"type\":\"object\",\"properties\":{\"items\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"price\":{\"type\":\"number\"}}}}}}'\n") 137 | fmt.Fprintf(os.Stderr, " curl -s https://public.l1m.io/menu.jpg | base64 | l1m -s '{\"type\":\"object\",\"properties\":{\"items\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"price\":{\"type\":\"number\"}}}}}}'\n") 138 | } 139 | -------------------------------------------------------------------------------- /cli/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/inferablehq/l1m/cli 2 | 3 | go 1.21 4 | 5 | require github.com/inferablehq/l1m/sdk-go v0.0.10 6 | -------------------------------------------------------------------------------- /cli/go.sum: -------------------------------------------------------------------------------- 1 | github.com/inferablehq/l1m/sdk-go v0.0.10 h1:lfNNzDjHyEy550VKZtpQSildyIPc11PbiJQ6l4rU/AA= 2 | github.com/inferablehq/l1m/sdk-go v0.0.10/go.mod h1:8Q3zT+rNa2GulUvsJeqqUdR1tKflx1l3/zgcNMX1/Bw= 3 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 4 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 5 | -------------------------------------------------------------------------------- /core/jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testMatch: ["**/*.test.ts"], 6 | setupFiles: ["./jest.setup.js"], 7 | transform: { 8 | "^.+\\.tsx?$": [ 9 | "ts-jest", 10 | { 11 | tsconfig: "tsconfig.json", 12 | }, 13 | ], 14 | }, 15 | clearMocks: true, 16 | setupFilesAfterEnv: ["dotenv/config"], 17 | testPathIgnorePatterns: ["node_modules"], 18 | }; 19 | -------------------------------------------------------------------------------- /core/jest.setup.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@l1m/core", 3 | "version": "0.1.5", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "dependencies": { 7 | "ajv": "^8.17.1", 8 | "async-retry": "^1.3.3", 9 | "dereference-json-schema": "^0.2.1", 10 | "jsonschema": "^1.5.0", 11 | "mimetics": "^1.0.4", 12 | "tsx": "^4.19.3", 13 | "zod": "^3.24.2" 14 | }, 15 | "devDependencies": { 16 | "@types/async-retry": "^1.4.9", 17 | "@types/jest": "^29.5.14", 18 | "@types/node": "^20.11.24", 19 | "dotenv": "^16.4.7", 20 | "jest": "^29.7.0", 21 | "prettier": "^3.2.5", 22 | "ts-jest": "^29.2.6", 23 | "typescript": "^5.7.3" 24 | }, 25 | "peerDependencies": { 26 | "@anthropic-ai/sdk": "^0.36.3", 27 | "@google/generative-ai": "^0.22.0", 28 | "openai": "^4.85.4" 29 | }, 30 | "scripts": { 31 | "test": "jest --config=jest.config.js", 32 | "build": "tsc --build", 33 | "watch": "tsc -w", 34 | "format": "prettier --write \"**/*.{ts,js,json,md,html,css}\"" 35 | } 36 | } -------------------------------------------------------------------------------- /core/src/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { inferType } from "./base64"; 2 | 3 | describe("inferType", () => { 4 | test("should infer base64 type", async () => { 5 | const url = 6 | "https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png"; 7 | 8 | // Base64 encode 9 | const buffer = await fetch(url).then((response) => response.arrayBuffer()); 10 | const input = Buffer.from(buffer).toString("base64"); 11 | 12 | const type = await inferType(input); 13 | expect(type).toBe("image/png"); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /core/src/base64.ts: -------------------------------------------------------------------------------- 1 | import mimetics from "mimetics"; 2 | 3 | export const validTypes = [ 4 | "text/plain", 5 | "application/json", 6 | "image/jpeg", 7 | "image/png", 8 | ]; 9 | 10 | export const inferType = async ( 11 | base64: string 12 | ): Promise => { 13 | if (!isBase64(base64)) return; 14 | 15 | const buffer = Buffer.from(base64, "base64"); 16 | const type = await mimetics.parseAsync(buffer); 17 | return type?.mime; 18 | }; 19 | 20 | 21 | const isBase64 = (str: string): boolean => { 22 | try { 23 | return btoa(atob(str)) === str.replace(/\s/g, ""); // Normalize spaces 24 | } catch { 25 | return false; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /core/src/index.ts: -------------------------------------------------------------------------------- 1 | import { inferType, validTypes } from "./base64"; 2 | import { structured } from "./model"; 3 | import { validateJsonSchema } from "./schema"; 4 | 5 | export { structured, validateJsonSchema, inferType, validTypes }; 6 | -------------------------------------------------------------------------------- /core/src/model.test.ts: -------------------------------------------------------------------------------- 1 | import { parseJsonSubstring, structured } from "./model"; 2 | import { Schema } from "jsonschema"; 3 | 4 | describe("structured", () => { 5 | test("Retries on error", async () => { 6 | // Mock provider that fails first time, succeeds second time 7 | let attempts = 0; 8 | const mockProvider = async () => { 9 | attempts++; 10 | if (attempts === 1) { 11 | throw new Error("Simulated failure"); 12 | } 13 | return '{"result": "success"}'; 14 | }; 15 | 16 | const schema: Schema = { 17 | type: "object", 18 | properties: { 19 | result: { type: "string" } 20 | } 21 | }; 22 | 23 | const result = await structured({ 24 | input: "test input", 25 | schema, 26 | provider: mockProvider, 27 | maxAttempts: 2 28 | }); 29 | 30 | expect(attempts).toBe(2); 31 | expect(result.structured).toEqual({ result: "success" }); 32 | expect(result.attempts).toBe(2); 33 | }); 34 | }); 35 | 36 | describe("parseJsonSubstring", () => { 37 | test("Extracts JSON from a code block", () => { 38 | const input = 'Here is JSON:\n```json\n{\n"character": "Shrek"\n}\n```'; 39 | expect(parseJsonSubstring(input).structured).toEqual({ 40 | character: "Shrek", 41 | }); 42 | }); 43 | 44 | test("Extracts JSON from plain text", () => { 45 | const input = '\n{\n"character": "Shrek"\n}\n'; 46 | expect(parseJsonSubstring(input).structured).toEqual({ 47 | character: "Shrek", 48 | }); 49 | }); 50 | 51 | test("Chooses last JSON object when multiple exist", () => { 52 | const input = ` 53 | { 54 | "character": "Shrek" 55 | } 56 | 57 | { 58 | "character": "Donkey" 59 | }`; 60 | expect(parseJsonSubstring(input).structured).toEqual({ 61 | character: "Donkey", 62 | }); 63 | }); 64 | 65 | test("Extracts JSON from last valid code block", () => { 66 | const input = ` 67 | Here is the first JSON: 68 | \`\`\`json 69 | { "character": "Shrek" } 70 | \`\`\` 71 | 72 | And here is the second one: 73 | \`\`\`json 74 | { "character": "Donkey" } 75 | \`\`\``; 76 | expect(parseJsonSubstring(input).structured).toEqual({ 77 | character: "Donkey", 78 | }); 79 | }); 80 | 81 | test("Handles JSON with nested objects", () => { 82 | const input = ` 83 | \`\`\`json 84 | { 85 | "character": "Shrek", 86 | "details": { 87 | "age": 30, 88 | "species": "ogre" 89 | } 90 | } 91 | \`\`\``; 92 | expect(parseJsonSubstring(input).structured).toEqual({ 93 | character: "Shrek", 94 | details: { 95 | age: 30, 96 | species: "ogre", 97 | }, 98 | }); 99 | }); 100 | 101 | test("Handles text before and after JSON", () => { 102 | const input = 103 | 'Some text before\n{\n"character": "Shrek"\n}\nSome text after'; 104 | expect(parseJsonSubstring(input).structured).toEqual({ 105 | character: "Shrek", 106 | }); 107 | }); 108 | 109 | test("Handles JSON with spaces and newlines", () => { 110 | const input = ' \n { \n "character" : "Shrek" \n } \n '; 111 | expect(parseJsonSubstring(input).structured).toEqual({ 112 | character: "Shrek", 113 | }); 114 | }); 115 | 116 | test("Ignores invalid/malformed JSON", () => { 117 | const input = "Here is a malformed JSON: ```json { character: Shrek } ```"; 118 | expect(parseJsonSubstring(input).structured).toBeNull(); 119 | }); 120 | 121 | test("Returns null when no JSON is found", () => { 122 | const input = "This is just some text with no JSON."; 123 | expect(parseJsonSubstring(input).structured).toBeNull(); 124 | }); 125 | 126 | test("Returns null for empty input", () => { 127 | const input = ""; 128 | expect(parseJsonSubstring(input).structured).toBeNull(); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /core/src/model.ts: -------------------------------------------------------------------------------- 1 | import { collectDescriptions, minimalSchema, validateResult } from "./schema"; 2 | import { Schema } from "jsonschema"; 3 | import retry from "async-retry"; 4 | 5 | import OpenAI from "openai"; 6 | import Anthropic from "@anthropic-ai/sdk"; 7 | import { GoogleGenerativeAI, Part } from "@google/generative-ai"; 8 | 9 | interface ProviderConfig { 10 | url: string; 11 | key: string; 12 | model: string; 13 | } 14 | 15 | type ProviderFunc = (params: StructuredParams, initialPrompt: string, previousAttempts: {raw: string, errors?: string}[]) => Promise; 16 | 17 | interface StructuredParams { 18 | input: string; 19 | type?: string; 20 | instructions?: string; 21 | schema: Schema; 22 | provider: ProviderConfig | ProviderFunc; 23 | maxAttempts?: number; 24 | } 25 | 26 | /** 27 | * Process structured data from different providers 28 | */ 29 | export const structured = async (params: StructuredParams) => { 30 | const { schema, provider, maxAttempts = 1 } = params; 31 | 32 | const prompt = `Answer in JSON using this schema:\n${minimalSchema(schema)}\n${collectDescriptions(schema)}`; 33 | 34 | const previousAttempts: {raw: string, errors?: string}[] = []; 35 | 36 | return await retry(async (_, attempts) => { 37 | let processingResult: { 38 | raw: string; 39 | structured: Record | null; 40 | } = { 41 | raw: "", 42 | structured: null, 43 | }; 44 | 45 | const last2Attempts = previousAttempts.slice(-2); 46 | 47 | if (typeof provider === "function") { 48 | processingResult = await processWithCustomHandler({...params, provider}, prompt, last2Attempts); 49 | } else { 50 | if (provider.url.includes("generativelanguage.googleapis.com")) { 51 | processingResult = await processWithGoogle({...params, provider}, prompt, last2Attempts); 52 | } else if (provider.url.includes("api.anthropic.com")) { 53 | processingResult = await processWithAnthropic({...params, provider}, prompt, last2Attempts); 54 | } else { 55 | // Default to OpenAI if no more-specific provider is inferred 56 | processingResult = await processWithOpenAI({...params, provider}, prompt, previousAttempts); 57 | } 58 | } 59 | 60 | const validationResult = validateResult(schema, processingResult.structured); 61 | 62 | if (!validationResult.valid) { 63 | previousAttempts.push({ 64 | raw: processingResult.raw, 65 | errors: validationResult.errors ? JSON.stringify(validationResult.errors, null, 2) : undefined, 66 | }); 67 | if (attempts <= maxAttempts) { 68 | throw new Error("Failed to validate result"); 69 | } 70 | } 71 | 72 | return { 73 | raw: processingResult.raw, 74 | structured: processingResult.structured, 75 | attempts, 76 | ...validationResult, 77 | }; 78 | }, { 79 | retries: maxAttempts - 1 80 | }); 81 | }; 82 | 83 | /** 84 | * Extracts and parses a JSON object from a string 85 | */ 86 | export const parseJsonSubstring = (raw: string) => { 87 | const simpleMatch = raw.match(/{.*}/s)?.reverse(); 88 | const standaloneMatches = raw.match(/\{[\s\S]*?\}/g)?.reverse(); 89 | 90 | const matches = [...(simpleMatch ?? []), ...(standaloneMatches ?? [])]; 91 | 92 | for (const match of matches) { 93 | if (!match) { 94 | continue; 95 | } 96 | 97 | try { 98 | return { 99 | raw, 100 | structured: JSON.parse(match), 101 | }; 102 | } catch (e) { 103 | continue; 104 | } 105 | } 106 | 107 | return { 108 | raw, 109 | structured: null, 110 | }; 111 | }; 112 | 113 | 114 | const processWithCustomHandler = async ( 115 | params: StructuredParams & { provider: ProviderFunc }, 116 | initialPrompt: string, 117 | previousAttempts: {raw: string, errors?: string}[] 118 | ) => { 119 | const text = await params.provider(params, initialPrompt, previousAttempts); 120 | 121 | if (!text) { 122 | throw new Error("Custom Provider returned invalid response"); 123 | } 124 | 125 | return parseJsonSubstring(text); 126 | }; 127 | 128 | const processWithGoogle = async ( 129 | params: StructuredParams & { provider: ProviderConfig }, 130 | initialPrompt: string, 131 | previousAttempts: {raw: string, errors?: string}[] 132 | ) => { 133 | const { input, type, instructions, provider } = params; 134 | const google = new GoogleGenerativeAI(provider.key); 135 | const model = google.getGenerativeModel({ model: provider.model }); 136 | 137 | const parts: Part[] = []; 138 | 139 | if (type && type.startsWith("image/")) { 140 | parts.push({ text: `${instructions} ${initialPrompt}` }); 141 | parts.push({ 142 | inlineData: { 143 | data: input, 144 | mimeType: type, 145 | }, 146 | }); 147 | } else { 148 | parts.push({ text: `${input} ${instructions} ${initialPrompt}` }); 149 | } 150 | 151 | previousAttempts.forEach((attempt) => { 152 | parts.push({ text: "You previously responded: " + attempt.raw + " which produced validation errors: " + attempt.errors }); 153 | }); 154 | 155 | const result = await model.generateContent(parts); 156 | const text = result.response.text(); 157 | 158 | if (!text) { 159 | throw new Error("Google API returned invalid response"); 160 | } 161 | 162 | return parseJsonSubstring(text); 163 | }; 164 | 165 | 166 | const processWithAnthropic = async ( 167 | params: StructuredParams & { provider: ProviderConfig }, 168 | initialPrompt: string, 169 | previousAttempts: {raw: string, errors?: string}[] 170 | ) => { 171 | const { input, type, instructions, provider } = params; 172 | const anthropic = new Anthropic({ 173 | apiKey: provider.key, 174 | }); 175 | 176 | const messages: Anthropic.MessageParam[] = []; 177 | 178 | if (type && type.startsWith("image/")) { 179 | messages.push({ 180 | role: "user", 181 | content: [ 182 | { type: "text", text: `${instructions} ${initialPrompt}` }, 183 | { 184 | type: "image", 185 | source: { 186 | type: "base64", 187 | media_type: type as any, 188 | data: input, 189 | }, 190 | }, 191 | ], 192 | }); 193 | } else { 194 | messages.push({ 195 | role: "user", 196 | content: `${input} ${instructions} ${initialPrompt}`, 197 | }); 198 | } 199 | 200 | if (previousAttempts.length > 0) { 201 | previousAttempts.forEach((attempt) => { 202 | messages.push({ 203 | role: "user", 204 | content: "You previously responded: " + attempt.raw + " which produced validation errors: " + attempt.errors, 205 | }); 206 | }); 207 | } 208 | 209 | const response = await anthropic.messages.create({ 210 | model: provider.model, 211 | messages, 212 | max_tokens: 1024, 213 | }); 214 | 215 | if (response.content[0]?.type === "text") { 216 | return parseJsonSubstring(response.content[0].text); 217 | } else { 218 | throw new Error("Anthropic API returned invalid response"); 219 | } 220 | }; 221 | 222 | const processWithOpenAI = async ( 223 | params: StructuredParams & { provider: ProviderConfig }, 224 | initialPrompt: string, 225 | previousAttempts: {raw: string, errors?: string}[] 226 | ) => { 227 | const { input, type, instructions, provider } = params; 228 | const openai = new OpenAI({ 229 | apiKey: provider.key, 230 | baseURL: provider.url, 231 | }); 232 | 233 | const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []; 234 | 235 | if (type && type.startsWith("image/")) { 236 | messages.push({ 237 | role: "user", 238 | content: `${instructions} ${initialPrompt}`, 239 | }); 240 | 241 | messages.push({ 242 | role: "user", 243 | content: [ 244 | { 245 | type: "image_url", 246 | image_url: { 247 | url: `data:${type};base64,${input}`, 248 | }, 249 | }, 250 | ], 251 | }); 252 | } else { 253 | messages.push({ 254 | role: "user", 255 | content: `${input} ${instructions} ${initialPrompt}`, 256 | }); 257 | } 258 | 259 | if (previousAttempts.length > 0) { 260 | previousAttempts.forEach((attempt) => { 261 | messages.push({ 262 | role: "user", 263 | content: "You previously responded: " + attempt.raw + " which produced validation errors: " + attempt.errors, 264 | }); 265 | }); 266 | } 267 | 268 | const completion = await openai.chat.completions.create({ 269 | model: provider.model, 270 | messages, 271 | }); 272 | 273 | if (!completion.choices[0].message.content) { 274 | throw new Error("OpenAI API returned invalid response"); 275 | } 276 | 277 | return parseJsonSubstring(completion.choices[0].message.content); 278 | }; 279 | -------------------------------------------------------------------------------- /core/src/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { minimalSchema } from "./schema"; 2 | 3 | describe("schema", () => { 4 | test("should convert simple enum schema correctly", () => { 5 | // Example 1: Simple schema with enums 6 | const schema1 = { 7 | type: "object", 8 | properties: { 9 | skyColor: { 10 | type: "string", 11 | enum: ["lightBlue", "gray", "black"], 12 | description: "The color of the sky", 13 | }, 14 | grassColor: { 15 | type: "string", 16 | enum: ["green", "brown", "yellow"], 17 | description: "The color of grass", 18 | }, 19 | }, 20 | }; 21 | 22 | const expected1 = 23 | "{ skyColor: 'lightBlue' | 'gray' | 'black', grassColor: 'green' | 'brown' | 'yellow' }"; 24 | expect(minimalSchema(schema1)).toBe(expected1); 25 | }); 26 | 27 | test("should convert complex nested schema correctly", () => { 28 | const schema2 = { 29 | type: "object", 30 | properties: { 31 | companyName: { type: "string", description: "The name of the company" }, 32 | foundedYear: { 33 | type: "number", 34 | description: "The year the company was founded", 35 | }, 36 | address: { 37 | type: "object", 38 | properties: { 39 | street: { type: "string" }, 40 | city: { 41 | type: "string", 42 | description: "The city the company is located in", 43 | }, 44 | state: { type: "string" }, 45 | country: { type: "string" }, 46 | zipcode: { type: "string" }, 47 | suite: { type: "string" }, 48 | }, 49 | }, 50 | engineeringTeams: { type: "array" }, 51 | departments: { 52 | type: "array", 53 | items: { 54 | type: "object", 55 | properties: { 56 | name: { type: "string" }, 57 | employees: { 58 | type: "number", 59 | description: "The number of employees in the department", 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }; 66 | 67 | const expected2 = 68 | "{ companyName: string, foundedYear: float, address: { street: string, city: string, state: string, country: string, zipcode: string, suite: string }, engineeringTeams: string[], departments: [ { name: string, employees: float } ] }"; 69 | expect(minimalSchema(schema2)).toBe(expected2); 70 | }); 71 | 72 | test.only("should handle referenced schema types", () => { 73 | const schema = { 74 | $id: "https://example.com/schemas/customer", 75 | $schema: "https://json-schema.org/draft/2020-12/schema", 76 | type: "object", 77 | properties: { 78 | first_name: { type: "string" }, 79 | last_name: { type: "string" }, 80 | shipping_address: { $ref: "#/$defs/address" }, 81 | }, 82 | $defs: { 83 | address: { 84 | $id: "https://example.com/schemas/address", 85 | $schema: "http://json-schema.org/draft-07/schema#", 86 | type: "object", 87 | properties: { 88 | street_address: { type: "string" }, 89 | city: { type: "string" }, 90 | state: { $ref: "#/$defs/state" }, 91 | }, 92 | }, 93 | state: { enum: ["CA", "NY"] }, 94 | }, 95 | }; 96 | 97 | const expected = 98 | "{ first_name: string, last_name: string, shipping_address: { street_address: string, city: string, state: 'CA' | 'NY' } }"; 99 | expect(minimalSchema(schema)).toBe(expected); 100 | }); 101 | 102 | test("should handle mixed numeric and string enums", () => { 103 | const schema = { 104 | type: "object", 105 | properties: { 106 | status: { 107 | type: "number", 108 | enum: [0, 1, 2], 109 | description: "Status code", 110 | }, 111 | mode: { 112 | type: "string", 113 | enum: ["auto", "manual"], 114 | }, 115 | }, 116 | }; 117 | 118 | const expected = "{ status: 0 | 1 | 2, mode: 'auto' | 'manual' }"; 119 | expect(minimalSchema(schema)).toBe(expected); 120 | }); 121 | 122 | test("should handle array with primitive items", () => { 123 | const schema = { 124 | type: "object", 125 | properties: { 126 | tags: { 127 | type: "array", 128 | items: { 129 | type: "string", 130 | }, 131 | }, 132 | }, 133 | }; 134 | 135 | const expected = "{ tags: string[] }"; 136 | expect(minimalSchema(schema)).toBe(expected); 137 | }); 138 | 139 | test("should handle empty objects", () => { 140 | const schema = { 141 | type: "object", 142 | properties: { 143 | metadata: { 144 | type: "object", 145 | }, 146 | }, 147 | }; 148 | 149 | const expected = "{ metadata: {} }"; 150 | expect(minimalSchema(schema)).toBe(expected); 151 | }); 152 | 153 | test("should handle boolean types", () => { 154 | const schema = { 155 | type: "object", 156 | properties: { 157 | isActive: { 158 | type: "boolean", 159 | description: "Whether the account is active", 160 | }, 161 | }, 162 | }; 163 | 164 | const expected = "{ isActive: boolean }"; 165 | expect(minimalSchema(schema)).toBe(expected); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /core/src/schema.ts: -------------------------------------------------------------------------------- 1 | import Ajv from "ajv"; 2 | 3 | import { Schema } from "jsonschema"; 4 | import { dereferenceSync } from "dereference-json-schema"; 5 | 6 | const ajv = new Ajv(); 7 | 8 | // Build a minimal representation of the JSON schema for use in prompt 9 | export const minimalSchema = (schema: Schema): string => { 10 | if (!schema) return ""; 11 | 12 | schema = dereferenceSync(schema); 13 | 14 | if (schema.enum && Array.isArray(schema.enum)) { 15 | return schema.enum 16 | .map((value) => (typeof value === "string" ? `'${value}'` : value)) 17 | .join(" | "); 18 | } 19 | 20 | switch (schema.type) { 21 | case "string": 22 | return "string"; 23 | case "number": 24 | case "integer": 25 | return "float"; 26 | case "boolean": 27 | return "boolean"; 28 | case "array": 29 | if (schema.items) { 30 | const item = Array.isArray(schema.items) 31 | ? schema.items[0] 32 | : schema.items; 33 | const itemsType = minimalSchema(item); 34 | 35 | if (item.type === "object" && item.properties) { 36 | return `[ ${itemsType} ]`; 37 | } 38 | 39 | return `${itemsType}[]`; 40 | } 41 | return "string[]"; 42 | case "object": 43 | if (!schema.properties) return "{}"; 44 | 45 | const properties = Object.entries(schema.properties) 46 | .map(([key, propSchema]) => { 47 | const propValue = minimalSchema(propSchema); 48 | return `${key}: ${propValue}`; 49 | }) 50 | .join(", "); 51 | 52 | return `{ ${properties} }`; 53 | default: 54 | if (process.env.NODE_ENV === "development") { 55 | throw new Error(`Unsupported schema type: ${schema.type}`); 56 | } 57 | return "any"; 58 | } 59 | }; 60 | 61 | // Recursively collect descriptions from properties within a schema in the format 62 | // : 63 | export const collectDescriptions = ( 64 | schema: Schema, 65 | path: string = "", 66 | descriptions: string = "" 67 | ) => { 68 | if (!schema) { 69 | return descriptions; 70 | } 71 | 72 | if (schema.description) { 73 | descriptions += `${path}: ${schema.description}\n`; 74 | } 75 | 76 | if (schema.properties) { 77 | Object.keys(schema.properties).forEach((key) => { 78 | const prop = schema.properties?.[key]; 79 | 80 | if (prop) { 81 | descriptions = collectDescriptions( 82 | prop, 83 | `${path}.${key}`, 84 | descriptions 85 | ); 86 | } 87 | }); 88 | } 89 | 90 | if (schema.type === "array" && schema.items) { 91 | let item = schema.items; 92 | 93 | if (Array.isArray(schema.items)) { 94 | item = schema.items[0]; 95 | } 96 | 97 | if (item) { 98 | descriptions = collectDescriptions( 99 | schema.items as Schema, 100 | `${path}[]`, 101 | descriptions 102 | ); 103 | } 104 | } 105 | 106 | return descriptions; 107 | }; 108 | 109 | export const validateResult = (schema: object, data: unknown) => { 110 | const validate = ajv.compile(schema); 111 | const valid = validate(data); 112 | 113 | return { 114 | valid, 115 | errors: validate.errors, 116 | }; 117 | }; 118 | 119 | export const validateJsonSchema = (schema: object) => { 120 | try { 121 | ajv.compile(schema); 122 | return illegalSchemaCheck(schema); 123 | } catch { 124 | return "Provided JSON schema is invalid"; 125 | } 126 | }; 127 | 128 | // Check that the schema matches our minimal implementation 129 | const illegalSchemaCheck = ( 130 | schema: Record 131 | ): string | undefined => { 132 | const disallowedKeys = [ 133 | "minLength", 134 | "minimum", 135 | "maxLength", 136 | "maximum", 137 | "oneOf", 138 | "anyOf", 139 | "allOf", 140 | "pattern", 141 | ]; 142 | 143 | for (const key in schema) { 144 | if (disallowedKeys.includes(key)) { 145 | return `Disallowed property '${key}' found in schema`; 146 | } 147 | 148 | if (key === "properties" && typeof schema[key] === "object") { 149 | for (const prop of Object.values(schema[key])) { 150 | const error = illegalSchemaCheck(prop as Record); 151 | if (error) return error; 152 | } 153 | } 154 | 155 | if (key === "items" && typeof schema[key] === "object") { 156 | const error = illegalSchemaCheck(schema[key]); 157 | if (error) return error; 158 | } 159 | } 160 | }; 161 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "declaration": true, 8 | "sourceMap": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /home_page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | l1m - Structured Data Extraction API 8 | 9 | 214 | 215 | 216 | 217 | 218 |
219 |

l1m (pronounced "el-one-em")

220 |

A Proxy to extract structured data from text and images using LLMs.

221 |
222 | View on GitHub 223 | Join Waitlist → 225 |
226 |
227 | 228 |

Why l1m?

229 |

230 | l1m is the easiest way to get structured data from unstructured text or 231 | images using LLMs. No prompt engineering, no chat history, just a simple 232 | API to extract structured json from text or images. 233 |

234 |

Features

235 |
    236 |
  • 237 | Simple Schema-First Approach: Define your data 238 | structure in JSON Schema, get back exactly what you need 239 |
  • 240 |
  • 241 | Zero Prompt Engineering: No need to craft complex 242 | prompts or chain multiple calls. Add context as JSON schema 243 | descriptions. 244 |
  • 245 |
  • 246 | Provider Flexibility: Bring your own provider, supports 247 | any OpenAI compatible or Anthropic provider and Anthropic models. 248 |
  • 249 |
  • 250 | Caching: Built-in caching, with `x-cache-ttl` (seconds) 251 | header to use l1m.io as a cache for your LLM requests. 252 |
  • 253 |
  • 254 | Open source: Open-source, no vendor lock-in. Or use the 255 | hosted version with free-tier and high availability. 256 |
  • 257 |
  • 258 | No data retention: We don't store your data, unless you 259 | use the `x-cache-ttl` header. 260 |
  • 261 |
262 | 263 |

Quick Start

264 |

Image Example

265 |
curl -X POST https://api.l1m.io/structured \
266 | -H "Content-Type: application/json" \
267 | -H "X-Provider-Url: demo" \
268 | -H "X-Provider-Key: demo" \
269 | -H "X-Provider-Model: demo" \
270 | -d '{
271 |   "input": "'$(curl -s https://public.l1m.io/menu.jpg | base64)'",
272 |   "schema": {
273 |     "type": "object",
274 |     "properties": {
275 |       "items": {
276 |         "type": "array",
277 |         "items": {
278 |           "type": "object",
279 |           "properties": {
280 |             "name": { "type": "string" },
281 |             "price": { "type": "number" }
282 |           }
283 |         }
284 |       }
285 |     }
286 |   }
287 | }'
288 |

289 | ↑ Copy and run this example in your terminal. The demo endpoints return pre-rendered LLM responses for quick 290 | testing. 291 |

292 | 293 |

Text Example

294 |
curl -X POST https://api.l1m.io/structured \
295 | -H "Content-Type: application/json" \
296 | -H "X-Provider-Url: demo" \
297 | -H "X-Provider-Key: demo" \
298 | -H "X-Provider-Model: demo" \
299 | -d '{
300 |   "input": "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913",
301 |   "schema": {
302 |     "type": "object",
303 |     "properties": {
304 |       "items": {
305 |         "type": "array",
306 |         "items": {
307 |           "type": "object",
308 |           "properties": {
309 |             "name": { "type": "string" },
310 |             "price": { "type": "number" }
311 |           }
312 |         }
313 |       }
314 |     }
315 |   }
316 | }'
317 |

318 | ↑ Copy and run this example in your terminal. The demo endpoints return pre-rendered LLM responses for quick 319 | testing. 320 |

321 | 322 |

Recipes

323 |
324 | Cache model response with a TTL 325 |
#!/bin/bash
326 | 
327 | # Run the same request multiple times with caching enabled
328 | # A cache key is generated from the input, schema, provider key and model
329 | # Cache key = hash(input + schema + x-provider-key + x-provider-model)
330 | for i in {1..5}; do
331 |   curl -X POST https://api.l1m.io/structured \
332 |   -H "Content-Type: application/json" \
333 |   -H "X-Provider-Url: $PROVIDER_URL" \
334 |   -H "X-Provider-Key: $PROVIDER_KEY" \
335 |   -H "X-Provider-Model: $PROVIDER_MODEL" \
336 |   -H "X-Cache-TTL: 300" \
337 |   -d '{
338 |     "input": "The weather in San Francisco is sunny and 72°F",
339 |     "schema": {
340 |       "type": "object",
341 |       "properties": {
342 |         "temperature": { "type": "number" },
343 |         "conditions": { "type": "string" }
344 |       }
345 |     }
346 |   }'
347 |   echo "\n--- Request $i completed ---\n"
348 | done
349 | 
350 | # Only 1 LLM call will be made, subsequent calls will be served from cache
351 | # for the duration specified in X-Cache-TTL (300 seconds in this example)
352 |
353 | 354 |
355 | Tool calling / Routing 356 |

357 | While l1m does not support "tool calling" in the same sense as other model providers, you can achieve similar 358 | results using the enum property. 359 |

360 |
INPUT="Please find the user john.doe@example.com and get their details"
361 | 
362 | # First call to determine which tool to use
363 | TOOL_RESPONSE=$(curl -X POST https://api.l1m.io/structured \
364 | -H "Content-Type: application/json" \
365 | -H "X-Provider-Url: $PROVIDER_URL" \
366 | -H "X-Provider-Key: $PROVIDER_KEY" \
367 | -H "X-Provider-Model: $PROVIDER_MODEL" \
368 | -d '{
369 |   "input": "$INPUT",
370 |   "instructions": "Select the most appropriate tool based on the input",
371 |   "schema": {
372 |     "type": "object",
373 |     "properties": {
374 |       "selected_tool": {
375 |         "type": "string",
376 |         "enum": ["getUserByEmail", "getUserByName", "getUserById"]
377 |       }
378 |     }
379 |   }
380 | }')
381 | 
382 | # Extract the selected tool from the response
383 | SELECTED_TOOL=$(echo $TOOL_RESPONSE | jq -r '.selected_tool')
384 | 
385 | # Switch case to handle different tool types and extract appropriate arguments
386 | case $SELECTED_TOOL in
387 |   "getUserByEmail")
388 |     # Make a follow up call to extract additional argument
389 |     ARGS=$(curl -X POST https://api.l1m.io/structured \
390 |     -H "Content-Type: application/json" \
391 |     -H "X-Provider-Url: $PROVIDER_URL" \
392 |     -H "X-Provider-Key: $PROVIDER_KEY" \
393 |     -H "X-Provider-Model: $PROVIDER_MODEL" \
394 |     -d '{
395 |       "input": "$INPUT",
396 |       "schema": {
397 |         "type": "object",
398 |         "properties": {
399 |           "email": {
400 |             "type": "string",
401 |             "format": "email",
402 |             "description": "Extract the email address to use as argument"
403 |           }
404 |         }
405 |       }
406 |     }')
407 |     EMAIL=$(echo $ARGS | jq -r '.email')
408 |     echo "Calling getUserByEmail with email: $EMAIL"
409 |     ;;
410 | 
411 |   "getUserByName")
412 |     # ...
413 |   "getUserById")
414 |     # ...
415 |   *)
416 |     echo "Error: Unknown tool selected: $SELECTED_TOOL"
417 |     exit 1
418 |     ;;
419 | esac
420 |
421 | 422 |
423 | Using local models with Ollama 424 |
#!/bin/bash
425 | 
426 | # Make sure Ollama is running locally with your desired model
427 | # Example: ollama run llama3
428 | 
429 | # Set up ngrok to expose your local Ollama server
430 | # ngrok http 11434 --host-header="localhost:11434"
431 | 
432 | # Replace the ngrok URL with your actual ngrok URL or use localhost
433 | # if you're running this on the same machine as Ollama
434 | curl -X POST https://api.l1m.io/structured \
435 | -H "Content-Type: application/json" \
436 | -H "X-Provider-Url: https://your-ngrok-url.ngrok-free.app/v1" \
437 | -H "X-Provider-Key: ollama" \
438 | -H "X-Provider-Model: llama3:latest" \
439 | -d '{
440 |   "input": "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913",
441 |   "schema": {
442 |     "type": "object",
443 |     "properties": {
444 |       "year": {
445 |         "type": "number",
446 |         "description": "The year of the federal reserve act"
447 |       }
448 |     }
449 |   }
450 | }'
451 |
452 | 453 |

Documentation

454 |

API

455 |

Headers

456 |
    457 |
  • x-provider-model (optional): LLM model to use
  • 458 |
  • 459 | x-provider-url (optional): LLM provider URL (See supported providers below) 460 |
  • 461 |
  • 462 | x-provider-key (optional): API key for LLM provider 463 |
  • 464 |
  • x-cache-ttl (optional): Cache TTL in seconds.
  • 465 |
      466 |
    • 467 | Cache key (generated) = hash(input + schema + x-provider-key + 468 | x-provider-model). 469 |
    • 470 |
    • 471 | Cached responses will include the `x-cache: HIT` header. 472 |
    • 473 |
    474 |
475 |

Body

476 |
    477 |
  • 478 | input String or base64 image data. 479 |
  • 480 |
  • 481 | schema A JSON Schema-like object describing the desired output object. 482 |
      483 |
    • 484 | The supported schema is a subset of JSON schema. 485 |
    • 486 |
    • 487 | The top level schema type must be an object. 488 |
    • 489 |
    • 490 | array and enum are supported forf nested properties. 491 |
    • 492 |
    • 493 | `min/maxLength`, `one/any/allOf` are not supported. 494 |
    • 495 |
    496 |
  • 497 |
  • 498 | instructions Optional instructions to include in the request to the model. 499 |
  • 500 |
501 | 502 | 503 |

Response and Error handling

504 |
    505 |
  • 506 | A success response will be a JSON object with a single property data. 507 |
  • 508 |
  • 509 | If the status code is 200 the result will match the provided schema. 510 |
  • 511 |
  • 512 | Failure to produce an object matching the schema will result in a 422 status code and error message. 513 |
  • 514 |
  • 515 | Failures from the model provider will be returned along with the status code. 516 |
  • 517 |
518 | 519 |

Supported Data Types

520 |

521 | For images, base64 encoded data can be in one of the following formats: 522 |

523 |
    524 |
  • image/jpeg
  • 525 |
  • image/png
  • 526 |
527 | 528 |

SDKs

529 |

530 | l1m provides official SDKs for multiple programming languages to help you 531 | integrate structured data extraction into your applications: 532 |

533 | 547 | 548 |

Managed API Pricing

549 |
    550 |
  • Free Tier: First 500 requests free
  • 551 |
  • 552 | Standard: 553 | $5 per 10,000 requests 554 | (free during open beta) 555 |
  • 556 |
557 | 558 | 559 | 571 | 572 | 575 | 576 | 577 | 578 | 579 | 580 | -------------------------------------------------------------------------------- /local.md: -------------------------------------------------------------------------------- 1 | # Running L1M API Locally with Docker 2 | 3 | This guide explains how to run the L1M API service locally using Docker and connect it to OpenAI-compatible API endpoints. 4 | 5 | ## Prerequisites 6 | 7 | - [Docker](https://docs.docker.com/get-docker/) installed on your system 8 | - Access to an OpenAI-compatible API endpoint 9 | 10 | ## Setup Instructions 11 | 12 | ### Pulling the Docker Image 13 | 14 | The easiest way to get started is to pull the pre-built Docker image from the public registry: 15 | 16 | ```bash 17 | # Pull the latest image 18 | docker pull inferable/l1m:latest 19 | 20 | # Run the container 21 | docker run -p 10337:10337 inferable/l1m:latest 22 | ``` 23 | 24 | ## Using with OpenAI-Compatible API 25 | 26 | You can use the L1M API with any OpenAI-compatible API endpoint: 27 | 28 | ### Make API Requests 29 | 30 | ```bash 31 | curl -X POST http://localhost:10337/structured \ 32 | -H "Content-Type: application/json" \ 33 | -H "X-Provider-Url: $PROVIDER_URL" \ 34 | -H "X-Provider-Key: $PROVIDER_KEY" \ 35 | -H "X-Provider-Model: $PROVIDER_MODEL" \ 36 | -d '{ 37 | "input": "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913", 38 | "schema": { 39 | "type": "object", 40 | "properties": { 41 | "year": { 42 | "type": "number", 43 | "description": "The year of the federal reserve act" 44 | } 45 | } 46 | } 47 | }' 48 | ``` 49 | 50 | ## Using with Ollama (Local LLMs) 51 | 52 | As an alternative to cloud-based APIs, you can also use Ollama to run models locally: 53 | 54 | ### 1. Install and Run Ollama 55 | 56 | Download and install [Ollama](https://ollama.com/download) on your machine. 57 | 58 | Make sure Ollama is running. By default, Ollama serves its API at `http://localhost:11434/v1`. 59 | 60 | ### 2. Pull and Run Your Desired Model 61 | 62 | If you haven't already, pull the model you want to use: 63 | 64 | ```bash 65 | ollama pull llama3 66 | ollama run llama3 67 | ``` 68 | 69 | ### 3. Make API Requests to Ollama 70 | 71 | You can now make requests to your locally running L1M API, specifying Ollama as the provider: 72 | 73 | ```bash 74 | curl -X POST http://localhost:10337/structured \ 75 | -H "Content-Type: application/json" \ 76 | -H "X-Provider-Url: http://host.docker.internal:11434/v1" \ 77 | -H "X-Provider-Key: ollama" \ 78 | -H "X-Provider-Model: llama3:latest" \ 79 | -d '{ 80 | "input": "A particularly severe crisis in 1907 led Congress to enact the Federal Reserve Act in 1913", 81 | "schema": { 82 | "type": "object", 83 | "properties": { 84 | "year": { 85 | "type": "number", 86 | "description": "The year of the federal reserve act" 87 | } 88 | } 89 | } 90 | }' 91 | ``` 92 | -------------------------------------------------------------------------------- /sdk-go/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /sdk-go/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: patch-release test 2 | 3 | # Get the current version by sorting sdk-go/ git tags and taking the last one 4 | CURRENT_VERSION=$(git --no-pager tag | grep "^sdk-go/v" | sed 's/^sdk-go\///' | sort -V | tail -n 1 || echo "v0.0.0") 5 | # Parse major, minor, and patch versions, stripping the 'v' prefix for calculations 6 | MAJOR=$(shell echo $(CURRENT_VERSION) | sed 's/v//' | cut -d. -f1) 7 | MINOR=$(shell echo $(CURRENT_VERSION) | cut -d. -f2) 8 | PATCH=$(shell echo $(CURRENT_VERSION) | cut -d. -f3) 9 | # Calculate new patch version 10 | NEW_PATCH=$(shell echo $$(($(PATCH) + 1))) 11 | NEW_VERSION=v$(MAJOR).$(MINOR).$(NEW_PATCH) 12 | 13 | # Run tests before release 14 | test: 15 | go test ./... 16 | 17 | # Create a new patch release 18 | patch-release: test 19 | @echo "Current version: $(CURRENT_VERSION)" 20 | @echo "New version: $(NEW_VERSION)" 21 | git tag -a sdk-go/$(NEW_VERSION) -m "Release sdk-go/$(NEW_VERSION)" 22 | git push origin sdk-go/$(NEW_VERSION) 23 | GOPROXY=proxy.golang.org go list -m github.com/inferablehq/l1m/sdk-go@$(NEW_VERSION) 24 | 25 | # Show help 26 | help: 27 | @echo "Available targets:" 28 | @echo " patch-release - Create a new patch release (v0.0.X)" 29 | @echo " test - Run tests" 30 | @echo " help - Show this help message" -------------------------------------------------------------------------------- /sdk-go/README.md: -------------------------------------------------------------------------------- 1 | # l1m Go SDK 2 | 3 | Go SDK for the [l1m API](https://l1m.io), enabling you to extract structured, typed data from text and images using LLMs. 4 | 5 | By default, the [managed l1m](https://l1m.io) service is used, [self-hosting details are available here](https://github.com/inferablehq/l1m/blob/main/local.md). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | go get github.com/inferablehq/l1m/sdk-go 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```go 16 | package main 17 | 18 | import ( 19 | "github.com/inferablehq/l1m/sdk-go" 20 | ) 21 | 22 | func main() { 23 | // Initialize client 24 | client := l1m.NewClient(&l1m.ClientOptions{ 25 | //BaseURL: "http://localhost:10337", Optional if self-hosting l1m server 26 | Provider: &l1m.Provider{ 27 | Model: "claude-3-5-sonnet-latest", 28 | URL: "https://api.anthropic.com/v1/messages", 29 | Key: "my_secret_key", 30 | }, 31 | }) 32 | 33 | // Extract structured data from text 34 | schema := map[string]interface{}{ 35 | "type": "object", 36 | "properties": map[string]interface{}{ 37 | "name": map[string]interface{}{"type": "string"}, 38 | "company": map[string]interface{}{"type": "string"}, 39 | "contactInfo": map[string]interface{}{ 40 | "type": "object", 41 | "properties": map[string]interface{}{ 42 | "email": map[string]interface{}{"type": "string"}, 43 | "phone": map[string]interface{}{"type": "string"}, 44 | }, 45 | }, 46 | }, 47 | } 48 | 49 | result, err := client.Structured(&l1m.StructuredRequest{ 50 | Input: "John Smith was born on January 15, 1980. He works at Acme Inc...", 51 | Schema: schema, 52 | }, nil) 53 | 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | // Use result... 59 | } 60 | ``` 61 | 62 | ## Development 63 | 64 | ```bash 65 | # Run tests 66 | go test ./... 67 | ``` 68 | -------------------------------------------------------------------------------- /sdk-go/client.go: -------------------------------------------------------------------------------- 1 | package l1m 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | ) 12 | 13 | // Package l1m provides a client for the L1M API, which helps extract structured data from text and images using LLMs. 14 | // 15 | // L1M is a proxy service that converts unstructured text or images into structured JSON data using Large Language Models (LLMs). 16 | // It provides a schema-first approach where you define your desired data structure in JSON Schema and get back exactly what you need. 17 | // 18 | // Example usage: 19 | // 20 | // client := l1m.NewClient(&l1m.ClientOptions{ 21 | // Provider: &l1m.Provider{ 22 | // URL: "https://api.openai.com/v1", 23 | // Key: "your-api-key", 24 | // Model: "gpt-4", 25 | // }, 26 | // }) 27 | // 28 | // req := &l1m.StructuredRequest{ 29 | // Input: "The price of AAPL stock is $150.50", 30 | // Schema: map[string]interface{}{ 31 | // "type": "object", 32 | // "properties": map[string]interface{}{ 33 | // "stock": map[string]interface{}{"type": "string"}, 34 | // "price": map[string]interface{}{"type": "number"}, 35 | // }, 36 | // }, 37 | // } 38 | // 39 | // data, err := client.Structured(req, nil) 40 | 41 | // ClientOptions contains configuration options for the L1M client. 42 | // It allows customizing the base URL and provider settings. 43 | type ClientOptions struct { 44 | // BaseURL is the base URL for the L1M API. If not provided, defaults to https://api.l1m.io 45 | BaseURL string 46 | // Provider contains the configuration for the LLM provider 47 | Provider *Provider 48 | } 49 | 50 | // Provider contains the provider-specific configuration for the LLM service. 51 | type Provider struct { 52 | // Model specifies which LLM model to use (e.g., "gpt-4" for OpenAI) 53 | Model string 54 | // URL is the base URL of the provider's API (must be an absolute URL) 55 | URL string 56 | // Key is the API key for authentication with the provider 57 | Key string 58 | } 59 | 60 | // RequestOptions contains options for individual requests to the L1M API. 61 | type RequestOptions struct { 62 | // Provider allows overriding the default provider settings for this request 63 | Provider *Provider 64 | // CacheTTL specifies how long (in seconds) to cache the response 65 | CacheTTL int64 66 | } 67 | 68 | // Client is the main L1M API client that handles communication with the L1M service. 69 | type Client struct { 70 | baseURL string 71 | httpClient *http.Client 72 | provider *Provider 73 | } 74 | 75 | // L1MError represents an error returned by the L1M API. 76 | // It includes the error message, HTTP status code, and response body. 77 | type L1MError struct { 78 | // Message contains the error description 79 | Message string 80 | // StatusCode is the HTTP status code returned by the API 81 | StatusCode int 82 | // Body contains the raw error response from the API 83 | Body interface{} 84 | } 85 | 86 | // Error implements the error interface for L1MError. 87 | func (e *L1MError) Error() string { 88 | return fmt.Sprintf("l1m error: %s (status: %d)", e.Message, e.StatusCode) 89 | } 90 | 91 | // NewClient creates a new L1M client instance with the provided options. 92 | // If no base URL is provided in options or environment, it defaults to https://api.l1m.io. 93 | // The environment variable L1M_BASE_URL can be used to override the default base URL. 94 | func NewClient(options *ClientOptions) *Client { 95 | baseURL := os.Getenv("L1M_BASE_URL") 96 | if baseURL == "" { 97 | baseURL = "https://api.l1m.io" 98 | } 99 | 100 | if options != nil && options.BaseURL != "" { 101 | baseURL = options.BaseURL 102 | } 103 | 104 | return &Client{ 105 | baseURL: baseURL, 106 | httpClient: &http.Client{}, 107 | provider: options.Provider, 108 | } 109 | } 110 | 111 | // StructuredRequest represents the input for a structured data extraction request. 112 | type StructuredRequest struct { 113 | // Input is the text or base64-encoded image to extract data from 114 | Input string `json:"input"` 115 | // Schema defines the desired output structure using JSON Schema 116 | Schema interface{} `json:"schema"` 117 | Instructions *string `json:"instructions",omitempty` 118 | } 119 | 120 | // StructuredResponse represents the response from a structured request. 121 | type StructuredResponse struct { 122 | // Data contains the extracted structured data matching the requested schema 123 | Data interface{} `json:"data"` 124 | } 125 | 126 | // Structured sends a structured request to the L1M API to extract data according to the provided schema. 127 | // It returns the extracted data as a generic interface{} that matches the schema structure. 128 | // The opts parameter can be used to override provider settings or specify caching behavior. 129 | func (c *Client) Structured(req *StructuredRequest, opts *RequestOptions) (interface{}, error) { 130 | provider := c.provider 131 | if opts != nil && opts.Provider != nil { 132 | provider = opts.Provider 133 | } 134 | 135 | if provider == nil { 136 | return nil, &L1MError{Message: "No provider specified"} 137 | } 138 | 139 | // Ensure provider URL is absolute 140 | if !isValidURL(provider.URL) { 141 | return nil, &L1MError{Message: "Provider URL must be an absolute URL. Got: " + provider.URL} 142 | } 143 | 144 | jsonBody, err := json.Marshal(req) 145 | if err != nil { 146 | return nil, fmt.Errorf("failed to marshal request: %w", err) 147 | } 148 | 149 | httpReq, err := http.NewRequest("POST", c.baseURL+"/structured", bytes.NewBuffer(jsonBody)) 150 | if err != nil { 151 | return nil, fmt.Errorf("failed to create request: %w", err) 152 | } 153 | 154 | headers := map[string]string{ 155 | "Content-Type": "application/json", 156 | "x-provider-model": c.provider.Model, 157 | "x-provider-url": c.provider.URL, 158 | "x-provider-key": c.provider.Key, 159 | } 160 | 161 | // Add cache TTL header if specified 162 | if opts != nil && opts.CacheTTL > 0 { 163 | headers["x-cache-ttl"] = strconv.FormatInt(opts.CacheTTL, 10) 164 | } 165 | 166 | for key, value := range headers { 167 | httpReq.Header.Set(key, value) 168 | } 169 | 170 | resp, err := c.httpClient.Do(httpReq) 171 | if err != nil { 172 | return nil, fmt.Errorf("failed to send request: %w", err) 173 | } 174 | defer resp.Body.Close() 175 | 176 | body, err := io.ReadAll(resp.Body) 177 | if err != nil { 178 | return nil, fmt.Errorf("failed to read response body: %w", err) 179 | } 180 | 181 | if resp.StatusCode >= 400 { 182 | var errorResp map[string]interface{} 183 | if err := json.Unmarshal(body, &errorResp); err != nil { 184 | return nil, &L1MError{ 185 | Message: "Failed to parse error response", 186 | StatusCode: resp.StatusCode, 187 | Body: string(body), 188 | } 189 | } 190 | return nil, &L1MError{ 191 | Message: fmt.Sprintf("%v", errorResp["message"]), 192 | StatusCode: resp.StatusCode, 193 | Body: errorResp, 194 | } 195 | } 196 | 197 | var response StructuredResponse 198 | if err := json.Unmarshal(body, &response); err != nil { 199 | return nil, fmt.Errorf("failed to unmarshal response: %w", err) 200 | } 201 | 202 | return response.Data, nil 203 | } 204 | 205 | // isValidURL checks if the given string is a valid absolute URL starting with http:// or https://. 206 | func isValidURL(str string) bool { 207 | // Check if the URL starts with http:// or https:// 208 | return len(str) > 8 && (str[:7] == "http://" || str[:8] == "https://") 209 | } 210 | -------------------------------------------------------------------------------- /sdk-go/client_test.go: -------------------------------------------------------------------------------- 1 | package l1m 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/joho/godotenv" 13 | ) 14 | 15 | func init() { 16 | // Load .env file if it exists 17 | _ = godotenv.Load() 18 | } 19 | 20 | func TestStructured(t *testing.T) { 21 | client := NewClient(&ClientOptions{ 22 | Provider: &Provider{ 23 | Model: os.Getenv("TEST_PROVIDER_MODEL"), 24 | URL: os.Getenv("TEST_PROVIDER_URL"), 25 | Key: os.Getenv("TEST_PROVIDER_KEY"), 26 | }, 27 | }) 28 | 29 | // Test text input 30 | t.Run("Text Input", func(t *testing.T) { 31 | input := "John Smith was born on January 15, 1980. He works at Acme Inc. as a Senior Engineer and can be reached at john.smith@example.com or by phone at (555) 123-4567." 32 | schema := map[string]interface{}{ 33 | "type": "object", 34 | "properties": map[string]interface{}{ 35 | "name": map[string]interface{}{"type": "string"}, 36 | "company": map[string]interface{}{"type": "string"}, 37 | "contactInfo": map[string]interface{}{ 38 | "type": "object", 39 | "properties": map[string]interface{}{ 40 | "email": map[string]interface{}{"type": "string"}, 41 | "phone": map[string]interface{}{"type": "string"}, 42 | }, 43 | }, 44 | }, 45 | } 46 | 47 | result, err := client.Structured(&StructuredRequest{ 48 | Input: input, 49 | Schema: schema, 50 | }, nil) 51 | 52 | if err != nil { 53 | t.Fatalf("Expected no error, got %v", err) 54 | } 55 | 56 | data, ok := result.(map[string]interface{}) 57 | if !ok { 58 | t.Fatal("Expected map[string]interface{} result") 59 | } 60 | 61 | expectedValues := map[string]string{ 62 | "name": "John Smith", 63 | "company": "Acme Inc.", 64 | } 65 | 66 | for key, expected := range expectedValues { 67 | if data[key] != expected { 68 | t.Errorf("Expected %s '%s', got %v", key, expected, data[key]) 69 | } 70 | } 71 | 72 | contactInfo, ok := data["contactInfo"].(map[string]interface{}) 73 | if !ok { 74 | t.Fatal("Expected contactInfo to be map[string]interface{}") 75 | } 76 | 77 | expectedContact := map[string]string{ 78 | "email": "john.smith@example.com", 79 | "phone": "(555) 123-4567", 80 | } 81 | 82 | for key, expected := range expectedContact { 83 | if contactInfo[key] != expected { 84 | t.Errorf("Expected contactInfo.%s '%s', got %v", key, expected, contactInfo[key]) 85 | } 86 | } 87 | }) 88 | 89 | // Test image input 90 | t.Run("Image Input", func(t *testing.T) { 91 | resp, err := http.Get("https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png") 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | defer resp.Body.Close() 96 | 97 | imageBytes, err := io.ReadAll(resp.Body) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | 102 | base64Image := base64.StdEncoding.EncodeToString(imageBytes) 103 | 104 | schema := map[string]interface{}{ 105 | "type": "object", 106 | "properties": map[string]interface{}{ 107 | "character": map[string]interface{}{"type": "string"}, 108 | }, 109 | } 110 | 111 | result, err := client.Structured(&StructuredRequest{ 112 | Input: base64Image, 113 | Schema: schema, 114 | }, nil) 115 | 116 | if err != nil { 117 | t.Fatalf("Expected no error, got %v", err) 118 | } 119 | 120 | data, ok := result.(map[string]interface{}) 121 | if !ok { 122 | t.Fatal("Expected map[string]interface{} result") 123 | } 124 | 125 | if data["character"] != "Shrek" { 126 | t.Errorf("Expected character 'Shrek', got %v", data["character"]) 127 | } 128 | }) 129 | 130 | // Test invalid API key 131 | t.Run("Invalid API Key", func(t *testing.T) { 132 | invalidClient := NewClient(&ClientOptions{ 133 | Provider: &Provider{ 134 | Model: os.Getenv("TEST_PROVIDER_MODEL"), 135 | URL: os.Getenv("TEST_PROVIDER_URL"), 136 | Key: "INVALID", 137 | }, 138 | }) 139 | 140 | schema := map[string]interface{}{ 141 | "type": "object", 142 | "properties": map[string]interface{}{ 143 | "name": map[string]interface{}{"type": "string"}, 144 | }, 145 | } 146 | 147 | _, err := invalidClient.Structured(&StructuredRequest{ 148 | Input: "Some test input", 149 | Schema: schema, 150 | }, nil) 151 | 152 | if err == nil { 153 | t.Fatal("Expected error with invalid API key, got nil") 154 | } 155 | 156 | if !strings.Contains(err.Error(), "401") { 157 | t.Errorf("Expected 401 error, got: %v", err) 158 | } 159 | }) 160 | 161 | // Test cache TTL 162 | t.Run("Cache TTL", func(t *testing.T) { 163 | input := "Some cacheable content" 164 | schema := map[string]interface{}{ 165 | "type": "object", 166 | "properties": map[string]interface{}{ 167 | "summary": map[string]interface{}{"type": "string"}, 168 | }, 169 | } 170 | 171 | // First request with cache TTL 172 | result1, err := client.Structured(&StructuredRequest{ 173 | Input: input, 174 | Schema: schema, 175 | }, &RequestOptions{ 176 | CacheTTL: 3600, // 1 hour cache 177 | }) 178 | 179 | if err != nil { 180 | t.Fatalf("Expected no error on first request, got %v", err) 181 | } 182 | 183 | // Second request with same input should use cache 184 | result2, err := client.Structured(&StructuredRequest{ 185 | Input: input, 186 | Schema: schema, 187 | }, &RequestOptions{ 188 | CacheTTL: 3600, 189 | }) 190 | 191 | if err != nil { 192 | t.Fatalf("Expected no error on second request, got %v", err) 193 | } 194 | 195 | // Compare the string representations instead of direct map comparison 196 | result1Str := fmt.Sprintf("%v", result1) 197 | result2Str := fmt.Sprintf("%v", result2) 198 | 199 | if result1Str != result2Str { 200 | t.Error("Expected cached result to be identical to first result") 201 | } 202 | }) 203 | } 204 | -------------------------------------------------------------------------------- /sdk-go/examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | l1m "github.com/inferablehq/l1m/sdk-go" 11 | ) 12 | 13 | func main() { 14 | // Initialize client 15 | client := l1m.NewClient(&l1m.ClientOptions{ 16 | BaseURL: "https://api.l1m.io", 17 | Provider: &l1m.Provider{ 18 | Model: "claude-3-5-sonnet-latest", 19 | URL: "https://api.anthropic.com/v1/messages", 20 | Key: "my_secret_key", 21 | }, 22 | }) 23 | 24 | // Example with text input 25 | textSchema := map[string]interface{}{ 26 | "type": "object", 27 | "properties": map[string]interface{}{ 28 | "name": map[string]interface{}{"type": "string"}, 29 | "company": map[string]interface{}{"type": "string"}, 30 | "contactInfo": map[string]interface{}{ 31 | "type": "object", 32 | "properties": map[string]interface{}{ 33 | "email": map[string]interface{}{"type": "string"}, 34 | "phone": map[string]interface{}{"type": "string"}, 35 | }, 36 | }, 37 | }, 38 | } 39 | 40 | textResult, err := client.Structured(&l1m.StructuredRequest{ 41 | Input: "John Smith was born on January 15, 1980. He works at Acme Inc. as a Senior Engineer and can be reached at john.smith@example.com or by phone at (555) 123-4567.", 42 | Schema: textSchema, 43 | }, nil) 44 | 45 | if err != nil { 46 | fmt.Printf("Error: %v\n", err) 47 | os.Exit(1) 48 | } 49 | 50 | fmt.Printf("Text Result: %+v\n", textResult) 51 | 52 | // Example with image input 53 | resp, err := http.Get("https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png") 54 | if err != nil { 55 | fmt.Printf("Error: %v\n", err) 56 | os.Exit(1) 57 | } 58 | defer resp.Body.Close() 59 | 60 | imageBytes, err := io.ReadAll(resp.Body) 61 | if err != nil { 62 | fmt.Printf("Error: %v\n", err) 63 | os.Exit(1) 64 | } 65 | 66 | base64Image := base64.StdEncoding.EncodeToString(imageBytes) 67 | 68 | imageSchema := map[string]interface{}{ 69 | "type": "object", 70 | "properties": map[string]interface{}{ 71 | "character": map[string]interface{}{"type": "string"}, 72 | }, 73 | } 74 | 75 | imageResult, err := client.Structured(&l1m.StructuredRequest{ 76 | Input: base64Image, 77 | Schema: imageSchema, 78 | }, nil) 79 | 80 | if err != nil { 81 | fmt.Printf("Error: %v\n", err) 82 | os.Exit(1) 83 | } 84 | 85 | fmt.Printf("Image Result: %+v\n", imageResult) 86 | } 87 | -------------------------------------------------------------------------------- /sdk-go/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/inferablehq/l1m/sdk-go 2 | 3 | go 1.21 4 | 5 | require github.com/joho/godotenv v1.5.1 6 | -------------------------------------------------------------------------------- /sdk-go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 2 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 3 | -------------------------------------------------------------------------------- /sdk-node/.npmignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /sdk-node/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /sdk-node/README.md: -------------------------------------------------------------------------------- 1 | # l1m Node SDK 2 | 3 | Node.js SDK for the [l1m API](https://l1m.io), enabling you to extract structured, typed data from text and images using LLMs. 4 | 5 | By default, the [managed l1m](https://l1m.io) service is used, [self-hosting details are available here](https://github.com/inferablehq/l1m/blob/main/local.md). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install l1m 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```javascript 16 | import L1M from 'l1m'; 17 | import { z } from 'zod'; 18 | 19 | 20 | const l1m = new L1M({ 21 | //baseUrl: "http://localhost:10337", Optional if self-hosting l1m server 22 | provider: { 23 | model: "claude-3-opus-20240229", 24 | url: "https://api.anthropic.com/v1/messages", 25 | key: "your-api-key" 26 | } 27 | }); 28 | 29 | // Extract structured data 30 | const result = await l1m.structured({ 31 | input: "John Smith was born on January 15, 1980. He works at Acme Inc. as a Senior Engineer and can be reached at john.smith@example.com or by phone at (555) 123-4567.", 32 | // OR input: "", 33 | instructions: "Extract details from the provided text", // Optional 34 | schema: z.object({ 35 | name: z.string(), 36 | company: z.string(), 37 | contactInfo: z.object({ 38 | email: z.string(), 39 | phone: z.string() 40 | }) 41 | }) 42 | }, { 43 | cacheTTL: 60 // Optional 44 | }); 45 | ``` 46 | 47 | ## Development 48 | 49 | ```bash 50 | # Build the SDK 51 | npm run build 52 | 53 | # Run tests 54 | npm run test 55 | ``` 56 | -------------------------------------------------------------------------------- /sdk-node/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "l1m", 3 | "version": "0.1.3", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "l1m", 9 | "version": "0.1.3", 10 | "license": "ISC", 11 | "dependencies": { 12 | "axios": "^1.7.9", 13 | "zod": "^3.24.2", 14 | "zod-to-json-schema": "^3.24.3" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^22.13.5", 18 | "dotenv": "^16.4.7", 19 | "typescript": "^5.7.3" 20 | } 21 | }, 22 | "node_modules/@types/node": { 23 | "version": "22.13.5", 24 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", 25 | "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", 26 | "dev": true, 27 | "license": "MIT", 28 | "dependencies": { 29 | "undici-types": "~6.20.0" 30 | } 31 | }, 32 | "node_modules/asynckit": { 33 | "version": "0.4.0", 34 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 35 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", 36 | "license": "MIT" 37 | }, 38 | "node_modules/axios": { 39 | "version": "1.7.9", 40 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", 41 | "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", 42 | "license": "MIT", 43 | "dependencies": { 44 | "follow-redirects": "^1.15.6", 45 | "form-data": "^4.0.0", 46 | "proxy-from-env": "^1.1.0" 47 | } 48 | }, 49 | "node_modules/call-bind-apply-helpers": { 50 | "version": "1.0.2", 51 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 52 | "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 53 | "license": "MIT", 54 | "dependencies": { 55 | "es-errors": "^1.3.0", 56 | "function-bind": "^1.1.2" 57 | }, 58 | "engines": { 59 | "node": ">= 0.4" 60 | } 61 | }, 62 | "node_modules/combined-stream": { 63 | "version": "1.0.8", 64 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 65 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 66 | "license": "MIT", 67 | "dependencies": { 68 | "delayed-stream": "~1.0.0" 69 | }, 70 | "engines": { 71 | "node": ">= 0.8" 72 | } 73 | }, 74 | "node_modules/delayed-stream": { 75 | "version": "1.0.0", 76 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 77 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 78 | "license": "MIT", 79 | "engines": { 80 | "node": ">=0.4.0" 81 | } 82 | }, 83 | "node_modules/dotenv": { 84 | "version": "16.4.7", 85 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", 86 | "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", 87 | "dev": true, 88 | "license": "BSD-2-Clause", 89 | "engines": { 90 | "node": ">=12" 91 | }, 92 | "funding": { 93 | "url": "https://dotenvx.com" 94 | } 95 | }, 96 | "node_modules/dunder-proto": { 97 | "version": "1.0.1", 98 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 99 | "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 100 | "license": "MIT", 101 | "dependencies": { 102 | "call-bind-apply-helpers": "^1.0.1", 103 | "es-errors": "^1.3.0", 104 | "gopd": "^1.2.0" 105 | }, 106 | "engines": { 107 | "node": ">= 0.4" 108 | } 109 | }, 110 | "node_modules/es-define-property": { 111 | "version": "1.0.1", 112 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 113 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 114 | "license": "MIT", 115 | "engines": { 116 | "node": ">= 0.4" 117 | } 118 | }, 119 | "node_modules/es-errors": { 120 | "version": "1.3.0", 121 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 122 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 123 | "license": "MIT", 124 | "engines": { 125 | "node": ">= 0.4" 126 | } 127 | }, 128 | "node_modules/es-object-atoms": { 129 | "version": "1.1.1", 130 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 131 | "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 132 | "license": "MIT", 133 | "dependencies": { 134 | "es-errors": "^1.3.0" 135 | }, 136 | "engines": { 137 | "node": ">= 0.4" 138 | } 139 | }, 140 | "node_modules/es-set-tostringtag": { 141 | "version": "2.1.0", 142 | "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", 143 | "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 144 | "license": "MIT", 145 | "dependencies": { 146 | "es-errors": "^1.3.0", 147 | "get-intrinsic": "^1.2.6", 148 | "has-tostringtag": "^1.0.2", 149 | "hasown": "^2.0.2" 150 | }, 151 | "engines": { 152 | "node": ">= 0.4" 153 | } 154 | }, 155 | "node_modules/follow-redirects": { 156 | "version": "1.15.9", 157 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 158 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 159 | "funding": [ 160 | { 161 | "type": "individual", 162 | "url": "https://github.com/sponsors/RubenVerborgh" 163 | } 164 | ], 165 | "license": "MIT", 166 | "engines": { 167 | "node": ">=4.0" 168 | }, 169 | "peerDependenciesMeta": { 170 | "debug": { 171 | "optional": true 172 | } 173 | } 174 | }, 175 | "node_modules/form-data": { 176 | "version": "4.0.2", 177 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", 178 | "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", 179 | "license": "MIT", 180 | "dependencies": { 181 | "asynckit": "^0.4.0", 182 | "combined-stream": "^1.0.8", 183 | "es-set-tostringtag": "^2.1.0", 184 | "mime-types": "^2.1.12" 185 | }, 186 | "engines": { 187 | "node": ">= 6" 188 | } 189 | }, 190 | "node_modules/function-bind": { 191 | "version": "1.1.2", 192 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 193 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 194 | "license": "MIT", 195 | "funding": { 196 | "url": "https://github.com/sponsors/ljharb" 197 | } 198 | }, 199 | "node_modules/get-intrinsic": { 200 | "version": "1.3.0", 201 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 202 | "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 203 | "license": "MIT", 204 | "dependencies": { 205 | "call-bind-apply-helpers": "^1.0.2", 206 | "es-define-property": "^1.0.1", 207 | "es-errors": "^1.3.0", 208 | "es-object-atoms": "^1.1.1", 209 | "function-bind": "^1.1.2", 210 | "get-proto": "^1.0.1", 211 | "gopd": "^1.2.0", 212 | "has-symbols": "^1.1.0", 213 | "hasown": "^2.0.2", 214 | "math-intrinsics": "^1.1.0" 215 | }, 216 | "engines": { 217 | "node": ">= 0.4" 218 | }, 219 | "funding": { 220 | "url": "https://github.com/sponsors/ljharb" 221 | } 222 | }, 223 | "node_modules/get-proto": { 224 | "version": "1.0.1", 225 | "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 226 | "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 227 | "license": "MIT", 228 | "dependencies": { 229 | "dunder-proto": "^1.0.1", 230 | "es-object-atoms": "^1.0.0" 231 | }, 232 | "engines": { 233 | "node": ">= 0.4" 234 | } 235 | }, 236 | "node_modules/gopd": { 237 | "version": "1.2.0", 238 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 239 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 240 | "license": "MIT", 241 | "engines": { 242 | "node": ">= 0.4" 243 | }, 244 | "funding": { 245 | "url": "https://github.com/sponsors/ljharb" 246 | } 247 | }, 248 | "node_modules/has-symbols": { 249 | "version": "1.1.0", 250 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 251 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 252 | "license": "MIT", 253 | "engines": { 254 | "node": ">= 0.4" 255 | }, 256 | "funding": { 257 | "url": "https://github.com/sponsors/ljharb" 258 | } 259 | }, 260 | "node_modules/has-tostringtag": { 261 | "version": "1.0.2", 262 | "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", 263 | "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 264 | "license": "MIT", 265 | "dependencies": { 266 | "has-symbols": "^1.0.3" 267 | }, 268 | "engines": { 269 | "node": ">= 0.4" 270 | }, 271 | "funding": { 272 | "url": "https://github.com/sponsors/ljharb" 273 | } 274 | }, 275 | "node_modules/hasown": { 276 | "version": "2.0.2", 277 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 278 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 279 | "license": "MIT", 280 | "dependencies": { 281 | "function-bind": "^1.1.2" 282 | }, 283 | "engines": { 284 | "node": ">= 0.4" 285 | } 286 | }, 287 | "node_modules/math-intrinsics": { 288 | "version": "1.1.0", 289 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 290 | "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 291 | "license": "MIT", 292 | "engines": { 293 | "node": ">= 0.4" 294 | } 295 | }, 296 | "node_modules/mime-db": { 297 | "version": "1.52.0", 298 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 299 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 300 | "license": "MIT", 301 | "engines": { 302 | "node": ">= 0.6" 303 | } 304 | }, 305 | "node_modules/mime-types": { 306 | "version": "2.1.35", 307 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 308 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 309 | "license": "MIT", 310 | "dependencies": { 311 | "mime-db": "1.52.0" 312 | }, 313 | "engines": { 314 | "node": ">= 0.6" 315 | } 316 | }, 317 | "node_modules/proxy-from-env": { 318 | "version": "1.1.0", 319 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 320 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", 321 | "license": "MIT" 322 | }, 323 | "node_modules/typescript": { 324 | "version": "5.7.3", 325 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", 326 | "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", 327 | "dev": true, 328 | "license": "Apache-2.0", 329 | "bin": { 330 | "tsc": "bin/tsc", 331 | "tsserver": "bin/tsserver" 332 | }, 333 | "engines": { 334 | "node": ">=14.17" 335 | } 336 | }, 337 | "node_modules/undici-types": { 338 | "version": "6.20.0", 339 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 340 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", 341 | "dev": true, 342 | "license": "MIT" 343 | }, 344 | "node_modules/zod": { 345 | "version": "3.24.2", 346 | "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", 347 | "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", 348 | "license": "MIT", 349 | "funding": { 350 | "url": "https://github.com/sponsors/colinhacks" 351 | } 352 | }, 353 | "node_modules/zod-to-json-schema": { 354 | "version": "3.24.3", 355 | "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.3.tgz", 356 | "integrity": "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==", 357 | "license": "ISC", 358 | "peerDependencies": { 359 | "zod": "^3.24.1" 360 | } 361 | } 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /sdk-node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "l1m", 3 | "version": "0.1.7", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "private": false, 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "tsx -r dotenv/config src/index.test.ts", 10 | "prepublishOnly": "npm run build" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "description": "Node SDK for the L1M API", 16 | "devDependencies": { 17 | "@types/node": "^22.13.5", 18 | "dotenv": "^16.4.7", 19 | "typescript": "^5.7.3" 20 | }, 21 | "dependencies": { 22 | "axios": "^1.7.9", 23 | "zod": "^3.24.2", 24 | "zod-to-json-schema": "^3.24.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sdk-node/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import L1M from "."; 3 | import { z } from "zod"; 4 | 5 | async function runTest(name: string, fn: () => Promise) { 6 | try { 7 | console.log(`Running test: ${name}`); 8 | await fn(); 9 | console.log(`✅ ${name} passed`); 10 | } catch (error) { 11 | console.error(`❌ ${name} failed:`, error); 12 | process.exitCode = 1; 13 | } 14 | } 15 | 16 | async function testReadme() { 17 | const l1m = new L1M({ 18 | provider: { 19 | model: process.env.TEST_PROVIDER_MODEL!, 20 | key: process.env.TEST_PROVIDER_KEY!, 21 | url: process.env.TEST_PROVIDER_URL!, 22 | } 23 | }); 24 | 25 | const input = "John Smith was born on January 15, 1980. He works at Acme Inc. as a Senior Engineer and can be reached at john.smith@example.com or by phone at (555) 123-4567."; 26 | 27 | const result = await l1m.structured({ 28 | input, 29 | schema: z.object({ 30 | name: z.string(), 31 | company: z.string(), 32 | contactInfo: z.object({ 33 | email: z.string(), 34 | phone: z.string() 35 | }) 36 | }) 37 | }); 38 | 39 | console.log("Text Result", { 40 | result, 41 | }); 42 | 43 | assert.strictEqual(result.name, "John Smith"); 44 | assert.strictEqual(result.company, "Acme Inc."); 45 | assert.strictEqual(result.contactInfo.email, "john.smith@example.com"); 46 | assert.strictEqual(result.contactInfo.phone, "(555) 123-4567"); 47 | } 48 | 49 | async function testCallStructuredZod() { 50 | const l1m = new L1M({ 51 | provider: { 52 | model: process.env.TEST_PROVIDER_MODEL!, 53 | key: process.env.TEST_PROVIDER_KEY!, 54 | url: process.env.TEST_PROVIDER_URL!, 55 | } 56 | }); 57 | 58 | const url = "https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png"; 59 | const buffer = await fetch(url).then((response) => response.arrayBuffer()); 60 | const input = Buffer.from(buffer).toString("base64"); 61 | 62 | const result = await l1m.structured({ 63 | input, 64 | schema: z.object({ 65 | character: z.string(), 66 | }) 67 | }) 68 | 69 | console.log("Result", { 70 | result, 71 | }); 72 | 73 | assert.strictEqual(result.character, "Shrek"); 74 | } 75 | 76 | async function testInvalidApiKey() { 77 | const l1m = new L1M({ 78 | provider: { 79 | model: process.env.TEST_PROVIDER_MODEL!, 80 | key: "INVALID", 81 | url: process.env.TEST_PROVIDER_URL!, 82 | } 83 | }); 84 | 85 | const url = "https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png"; 86 | const buffer = await fetch(url).then((response) => response.arrayBuffer()); 87 | const input = Buffer.from(buffer).toString("base64"); 88 | 89 | const result = l1m.structured({ 90 | input, 91 | schema: z.object({ 92 | character: z.string(), 93 | }) 94 | }) 95 | 96 | assert.rejects(result, "Should fail with invalid API key"); 97 | const error = await result.catch((e) => e); 98 | 99 | console.log("Result", { 100 | error, 101 | }); 102 | 103 | assert.strictEqual(error.statusCode, 401); 104 | } 105 | 106 | // Main test runner - executes all tests 107 | (async function runAllTests() { console.log("Starting tests..."); 108 | await runTest("Readme", testReadme); 109 | await runTest("structured (zod)", testCallStructuredZod); 110 | await runTest("invalid api key", testInvalidApiKey); 111 | 112 | console.log("All tests completed"); 113 | })().catch(console.error); 114 | -------------------------------------------------------------------------------- /sdk-node/src/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosError } from "axios"; 2 | import { z } from "zod"; 3 | import zodToJsonSchema from "zod-to-json-schema"; 4 | 5 | type ClientOptions = { 6 | baseUrl?: string; 7 | /** 8 | * Optional default provider details. This can be overridden per request 9 | */ 10 | provider?: { 11 | model: string; 12 | url: string; 13 | key: string; 14 | }; 15 | /** 16 | * Optional additional headers to include in all requests 17 | */ 18 | additionalHeaders?: Record; 19 | }; 20 | 21 | type StructuredRequestInput | unknown> = { 22 | /** 23 | * String input (Base64 encoded if image data) 24 | */ 25 | input: string; 26 | /** 27 | * Json Schema (or Zod) to be returned 28 | */ 29 | schema: T; 30 | /** 31 | * (Optional) Instructions to inject into the prompt 32 | */ 33 | instructions?: string; 34 | }; 35 | 36 | type RequestOptions = { 37 | /** 38 | * Provider details, optional if the client is initialized with a provider 39 | */ 40 | provider?: { 41 | model: string; 42 | url: string; 43 | key: string; 44 | }; 45 | /** 46 | * Optional cache TTL in seconds 47 | */ 48 | cacheTTL?: number; 49 | 50 | /** 51 | * Optional additional headers for this specific request 52 | */ 53 | additionalHeaders?: Record; 54 | 55 | /** 56 | * Optional number of times to attempt the request 57 | */ 58 | maxAttempts?: number; 59 | }; 60 | 61 | class L1MError extends Error { 62 | statusCode?: number; 63 | body?: any; 64 | 65 | constructor(message: string, statusCode?: number, body?: any) { 66 | super(message); 67 | this.name = "L1MError"; 68 | this.statusCode = statusCode; 69 | this.body = body; 70 | } 71 | } 72 | 73 | /** 74 | * L1M API Client 75 | */ 76 | export class L1M { 77 | private baseUrl: string; 78 | private client: AxiosInstance; 79 | private provider?: RequestOptions["provider"]; 80 | private additionalHeaders?: Record; 81 | 82 | constructor(options?: ClientOptions) { 83 | this.baseUrl = options?.baseUrl || "https://api.l1m.io"; 84 | this.provider = options?.provider; 85 | this.additionalHeaders = options?.additionalHeaders; 86 | 87 | this.client = axios.create({ 88 | baseURL: this.baseUrl, 89 | headers: { 90 | "Content-Type": "application/json", 91 | ...this.additionalHeaders, 92 | }, 93 | }); 94 | } 95 | 96 | /** 97 | * 98 | * Generate a structured response from the L1M API. 99 | */ 100 | async structured, TOutput = z.infer>( 101 | { input, schema, instructions }: StructuredRequestInput, 102 | options?: RequestOptions 103 | ): Promise { 104 | const provider = options?.provider ?? this.provider; 105 | 106 | if (!provider) { 107 | throw new L1MError("No provider specified"); 108 | } 109 | 110 | try { 111 | const result = await this.client.post( 112 | "/structured", 113 | { 114 | input, 115 | schema: zodToJsonSchema(schema), 116 | instructions, 117 | }, 118 | { 119 | headers: { 120 | "x-provider-model": provider.model, 121 | "x-provider-url": provider.url, 122 | "x-provider-key": provider.key, 123 | ...(options?.cacheTTL 124 | ? { 125 | "x-cache-ttl": options.cacheTTL, 126 | } 127 | : {}), 128 | ...(options?.maxAttempts 129 | ? { 130 | "x-max-attempts": options.maxAttempts, 131 | } 132 | : {}), 133 | ...options?.additionalHeaders, 134 | }, 135 | } 136 | ); 137 | 138 | return result.data?.data; 139 | } catch (error) { 140 | if (axios.isAxiosError(error)) { 141 | const axiosError = error as AxiosError; 142 | const statusCode = axiosError.response?.status; 143 | const errorMessage = 144 | (axiosError.response?.data as any).message || 145 | axiosError.message || 146 | "An error occurred with the L1M API"; 147 | const body = axiosError.response?.data; 148 | 149 | throw new L1MError(errorMessage, statusCode, body); 150 | } 151 | 152 | throw error; 153 | } 154 | } 155 | } 156 | 157 | export default L1M; 158 | -------------------------------------------------------------------------------- /sdk-node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "declaration": true, 8 | "sourceMap": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "skipLibCheck": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } -------------------------------------------------------------------------------- /sdk-python/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /sdk-python/README.md: -------------------------------------------------------------------------------- 1 | # L1M Python SDK 2 | 3 | Python SDK for the [l1m API](https://l1m.io), enabling you to extract structured, typed data from text and images using LLMs. 4 | 5 | By default, the [managed l1m](https://l1m.io) service is used, [self-hosting details are available here](https://github.com/inferablehq/l1m/blob/main/local.md). 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pip install l1m-dot-io 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```python 16 | from pydantic import BaseModel 17 | from l1m import L1M, ClientOptions, ProviderOptions 18 | 19 | class ContactDetails(BaseModel): 20 | email: str 21 | phone: str 22 | 23 | class UserProfile(BaseModel): 24 | name: str 25 | company: str 26 | contactInfo: ContactDetails 27 | 28 | 29 | client = L1M( 30 | options=ClientOptions( 31 | #base_url: "http://localhost:10337", Optional if self-hosting l1m server 32 | provider=ProviderOptions( 33 | model="gpt-4", 34 | url="https://api.openai.com/v1/chat/completions", 35 | key="your-openai-key" 36 | ) 37 | ) 38 | ) 39 | 40 | # Generate a structured response 41 | user_profile = client.structured( 42 | input="John Smith was born on January 15, 1980. He works at Acme Inc. as a Senior Engineer and can be reached at john.smith@example.com or by phone at (555) 123-4567.", 43 | # OR input="", 44 | schema=UserProfile, 45 | instructions="Extract details from the provided text.", # Optional 46 | options=RequestOptions(cache_ttl=100) # Optional 47 | ) 48 | ``` 49 | 50 | ## Development 51 | 52 | ```bash 53 | # Run tests 54 | pytest 55 | ``` 56 | 57 | -------------------------------------------------------------------------------- /sdk-python/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "l1m-dot-io" 7 | version = "0.1.6" 8 | description = "Python SDK for L1M" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | dependencies = [ 12 | "requests>=2.28.0", 13 | "pydantic>=2.0.0", 14 | "python-dotenv>=1.0.0", 15 | ] 16 | classifiers = [ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | ] 21 | 22 | [project.urls] 23 | "Homepage" = "https://github.com/inferablehq/l1m" 24 | "Bug Tracker" = "https://github.com/inferablehq/l1m/issues" 25 | -------------------------------------------------------------------------------- /sdk-python/setup.cfg: -------------------------------------------------------------------------------- 1 | [options] 2 | package_dir = 3 | = src 4 | packages = find: 5 | 6 | [options.packages.find] 7 | where = src 8 | 9 | [options.extras_require] 10 | dev = 11 | pytest>=7.0.0 12 | pytest-cov>=4.0.0 13 | pytest-dotenv>=0.5.2 14 | black>=23.0.0 15 | isort>=5.12.0 16 | mypy>=1.0.0 17 | 18 | [tool:pytest] 19 | testpaths = tests 20 | python_files = test_*.py 21 | python_functions = test_* 22 | env_files = .env 23 | env_override = true 24 | 25 | [tool:mypy] 26 | python_version = 3.8 27 | warn_return_any = True 28 | warn_unused_configs = True 29 | disallow_untyped_defs = True 30 | disallow_incomplete_defs = True 31 | 32 | [tool:isort] 33 | profile = black 34 | line_length = 88 -------------------------------------------------------------------------------- /sdk-python/src/l1m/__init__.py: -------------------------------------------------------------------------------- 1 | """L1M Python SDK.""" 2 | 3 | from .client import ClientOptions, L1M, L1MError, ProviderOptions, RequestOptions 4 | 5 | __all__ = ["L1M", "L1MError", "ClientOptions", "ProviderOptions", "RequestOptions"] -------------------------------------------------------------------------------- /sdk-python/src/l1m/client.py: -------------------------------------------------------------------------------- 1 | """Client module for the L1M Python SDK.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Any, Optional, Type, TypeVar 5 | 6 | import requests 7 | from pydantic import BaseModel 8 | 9 | 10 | class L1MError(Exception): 11 | """Error returned by the L1M API.""" 12 | 13 | def __init__(self, message: str, status_code: Optional[int] = None, body: Any = None): 14 | """Initialize a new L1M error. 15 | 16 | Args: 17 | message: Error message 18 | status_code: HTTP status code 19 | body: Response body 20 | """ 21 | super().__init__(message) 22 | self.name = "L1MError" 23 | self.message = message 24 | self.status_code = status_code 25 | self.body = body 26 | 27 | 28 | @dataclass 29 | class ProviderOptions: 30 | """Provider options for the L1M API.""" 31 | 32 | model: str 33 | url: str 34 | key: str 35 | 36 | 37 | @dataclass 38 | class ClientOptions: 39 | """Options for the L1M client.""" 40 | 41 | base_url: str = "https://api.l1m.io" 42 | provider: Optional[ProviderOptions] = None 43 | 44 | 45 | @dataclass 46 | class RequestOptions: 47 | """Options for a request to the L1M API.""" 48 | 49 | provider: Optional[ProviderOptions] = None 50 | cache_ttl: Optional[int] = None 51 | 52 | 53 | T = TypeVar("T", bound=BaseModel) 54 | 55 | 56 | class L1M: 57 | """L1M API Client.""" 58 | 59 | def __init__(self, options: Optional[ClientOptions] = None): 60 | """Initialize a new L1M client. 61 | 62 | Args: 63 | options: Client options 64 | """ 65 | if options is None: 66 | options = ClientOptions() 67 | 68 | self.base_url = options.base_url 69 | self.provider = options.provider 70 | 71 | self.session = requests.Session() 72 | self.session.headers.update({ 73 | "Content-Type": "application/json" 74 | }) 75 | 76 | def structured( 77 | self, 78 | input: str, 79 | schema: Type[T], 80 | instructions: Optional[str] = None, 81 | options: Optional[RequestOptions] = None 82 | ) -> T: 83 | """Generate a structured response from the L1M API. 84 | 85 | Args: 86 | input: Input text (Base64 encoded if image data) 87 | schema: Pydantic model to validate the response against 88 | instructions: Instructions to inject into the prompt (Optional) 89 | options: Request options 90 | 91 | Returns: 92 | Structured response from the L1M API 93 | 94 | Raises: 95 | L1MError: If the request fails 96 | """ 97 | cache_ttl = options.cache_ttl if options else None 98 | 99 | provider = options.provider if (options and options.provider) else (self.provider if self.provider else None) 100 | 101 | if not provider: 102 | raise L1MError("No provider specified") 103 | 104 | try: 105 | # Convert Pydantic model to JSON schema 106 | schema_dict = schema.model_json_schema() 107 | headers = { 108 | "x-provider-model": provider.model, 109 | "x-provider-url": provider.url, 110 | "x-provider-key": provider.key, 111 | } 112 | 113 | if cache_ttl: 114 | headers["x-cache-ttl"] = str(cache_ttl) 115 | 116 | response = self.session.post( 117 | f"{self.base_url}/structured", 118 | headers=headers, 119 | json={ 120 | "input": input, 121 | "schema": schema_dict, 122 | **({"instructions": instructions} if instructions is not None else {}) 123 | } 124 | ) 125 | 126 | response.raise_for_status() 127 | data = response.json() 128 | 129 | # Parse the response with the provided schema 130 | return schema.model_validate(data["data"]) 131 | 132 | except requests.exceptions.HTTPError as e: 133 | # For HTTP errors, we can directly access the response 134 | status_code = e.response.status_code 135 | 136 | try: 137 | body = e.response.json() 138 | message = body.get("message", str(e)) 139 | except Exception: 140 | body = e.response.text 141 | message = str(e) 142 | 143 | raise L1MError(message, status_code, body) from e 144 | 145 | except requests.exceptions.RequestException as e: 146 | # For other request exceptions 147 | status_code = None 148 | if hasattr(e, "response") and e.response: 149 | status_code = e.response.status_code 150 | 151 | body = None 152 | message = str(e) 153 | if hasattr(e, "response") and e.response: 154 | try: 155 | body = e.response.json() 156 | message = body.get("message", str(e)) 157 | except Exception: 158 | body = e.response.text 159 | 160 | raise L1MError(message, status_code, body) from e 161 | 162 | except Exception as e: 163 | raise e 164 | -------------------------------------------------------------------------------- /sdk-python/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for the L1M Python SDK.""" -------------------------------------------------------------------------------- /sdk-python/tests/test_client.py: -------------------------------------------------------------------------------- 1 | """Tests for the L1M client.""" 2 | 3 | import base64 4 | import os 5 | 6 | import pytest 7 | import requests 8 | from dotenv import load_dotenv 9 | from pydantic import BaseModel 10 | 11 | from src.l1m.client import ClientOptions, L1M, L1MError, ProviderOptions 12 | 13 | # Load environment variables from .env file 14 | load_dotenv() 15 | 16 | class CharacterSchema(BaseModel): 17 | """Schema for character recognition test.""" 18 | 19 | character: str 20 | 21 | class ContactDetails(BaseModel): 22 | email: str 23 | phone: str 24 | 25 | class UserProfile(BaseModel): 26 | """Schema for user profile extraction test.""" 27 | name: str 28 | company: str 29 | contactInfo: ContactDetails 30 | 31 | def test_call_structured(): 32 | """Test structured method with Pydantic model.""" 33 | # Skip if environment variables are not set 34 | if not all([ 35 | os.environ.get("TEST_PROVIDER_MODEL"), 36 | os.environ.get("TEST_PROVIDER_KEY"), 37 | os.environ.get("TEST_PROVIDER_URL") 38 | ]): 39 | pytest.skip("Missing required TEST_PROVIDER environment variables") 40 | 41 | l1m = L1M( 42 | options=ClientOptions( 43 | provider=ProviderOptions( 44 | model=os.environ["TEST_PROVIDER_MODEL"], 45 | key=os.environ["TEST_PROVIDER_KEY"], 46 | url=os.environ["TEST_PROVIDER_URL"], 47 | ) 48 | ) 49 | ) 50 | 51 | # Fetch image and convert to base64 52 | url = "https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png" 53 | response = requests.get(url) 54 | response.raise_for_status() 55 | image_data = response.content 56 | input_data = base64.b64encode(image_data).decode("utf-8") 57 | 58 | # Call the API 59 | result = l1m.structured( 60 | input=input_data, 61 | schema=CharacterSchema 62 | ) 63 | 64 | print("Result:", result) 65 | 66 | # Verify the result 67 | assert result.character == "Shrek" 68 | 69 | 70 | def test_readme_example(): 71 | """Test the example from the README.""" 72 | # Skip if environment variables are not set 73 | if not all([ 74 | os.environ.get("TEST_PROVIDER_MODEL"), 75 | os.environ.get("TEST_PROVIDER_KEY"), 76 | os.environ.get("TEST_PROVIDER_URL") 77 | ]): 78 | pytest.skip("Missing required TEST_PROVIDER environment variables") 79 | 80 | # Initialize the client as shown in the README 81 | client = L1M( 82 | options=ClientOptions( 83 | provider=ProviderOptions( 84 | model=os.environ["TEST_PROVIDER_MODEL"], 85 | key=os.environ["TEST_PROVIDER_KEY"], 86 | url=os.environ["TEST_PROVIDER_URL"], 87 | ) 88 | ) 89 | ) 90 | 91 | # Generate a structured response using the example from the README 92 | user_profile = client.structured( 93 | input="John Smith was born on January 15, 1980. He works at Acme Inc. as a Senior Engineer and can be reached at john.smith@example.com or by phone at (555) 123-4567.", 94 | schema=UserProfile 95 | ) 96 | 97 | print("User Profile:", user_profile) 98 | 99 | # Verify the result matches expected output 100 | assert user_profile.name == "John Smith" 101 | assert user_profile.company == "Acme Inc." 102 | assert user_profile.contactInfo.email == "john.smith@example.com" 103 | assert user_profile.contactInfo.phone == "(555) 123-4567" 104 | 105 | 106 | def test_invalid_api_key(): 107 | """Test that invalid API key raises appropriate error.""" 108 | # Skip if environment variables are not set 109 | if not all([ 110 | os.environ.get("TEST_PROVIDER_MODEL"), 111 | os.environ.get("TEST_PROVIDER_URL") 112 | ]): 113 | pytest.skip("Missing required TEST_PROVIDER environment variables") 114 | 115 | l1m = L1M( 116 | options=ClientOptions( 117 | provider=ProviderOptions( 118 | model=os.environ["TEST_PROVIDER_MODEL"], 119 | key="INVALID", 120 | url=os.environ["TEST_PROVIDER_URL"], 121 | ) 122 | ) 123 | ) 124 | 125 | # Fetch image and convert to base64 126 | url = "https://upload.wikimedia.org/wikipedia/en/4/4d/Shrek_%28character%29.png" 127 | response = requests.get(url) 128 | response.raise_for_status() 129 | image_data = response.content 130 | input_data = base64.b64encode(image_data).decode("utf-8") 131 | 132 | # Call the API and expect it to fail 133 | with pytest.raises(L1MError) as excinfo: 134 | l1m.structured( 135 | input=input_data, 136 | schema=CharacterSchema 137 | ) 138 | 139 | error = excinfo.value 140 | print("Error:", error) 141 | 142 | assert error.status_code == 401 143 | -------------------------------------------------------------------------------- /sdk-python/typings/l1m/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from .client import ClientOptions, L1M, L1MError, ProviderOptions, RequestOptions 6 | 7 | """L1M Python SDK.""" 8 | __all__ = ["L1M", "L1MError", "ClientOptions", "ProviderOptions", "RequestOptions"] 9 | -------------------------------------------------------------------------------- /sdk-python/typings/l1m/client.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | from typing import Any, Optional, Type, TypeVar 7 | from pydantic import BaseModel 8 | 9 | """Client module for the L1M Python SDK.""" 10 | class L1MError(Exception): 11 | """Error returned by the L1M API.""" 12 | def __init__(self, message: str, status_code: Optional[int] = ..., body: Any = ...) -> None: 13 | """Initialize a new L1M error. 14 | 15 | Args: 16 | message: Error message 17 | status_code: HTTP status code 18 | body: Response body 19 | """ 20 | ... 21 | 22 | 23 | 24 | @dataclass 25 | class ProviderOptions: 26 | """Provider options for the L1M API.""" 27 | model: str 28 | url: str 29 | key: str 30 | ... 31 | 32 | 33 | @dataclass 34 | class ClientOptions: 35 | """Options for the L1M client.""" 36 | base_url: str = ... 37 | provider: Optional[ProviderOptions] = ... 38 | 39 | 40 | @dataclass 41 | class RequestOptions: 42 | """Options for a request to the L1M API.""" 43 | provider: ProviderOptions 44 | cache_ttl: Optional[str] = ... 45 | 46 | 47 | T = TypeVar("T", bound=BaseModel) 48 | class L1M: 49 | """L1M API Client.""" 50 | def __init__(self, options: Optional[ClientOptions] = ...) -> None: 51 | """Initialize a new L1M client. 52 | 53 | Args: 54 | options: Client options 55 | """ 56 | ... 57 | 58 | def structured(self, input: str, schema: Type[T], options: Optional[RequestOptions] = ...) -> T: 59 | """Generate a structured response from the L1M API. 60 | 61 | Args: 62 | input: Input text (Base64 encoded if image data) 63 | schema: Pydantic model to validate the response against 64 | options: Request options 65 | 66 | Returns: 67 | Structured response from the L1M API 68 | 69 | Raises: 70 | L1MError: If the request fails 71 | """ 72 | ... 73 | 74 | 75 | 76 | --------------------------------------------------------------------------------