# Available Models Source: https://docs.sutro.sh/available-models/index Reference documentation for Available Models. # Available Models Sutro currently offers access to category leading open-source language models which are extremely adept at a wide range of canonical batch inference tasks. See our [available models and pricing page](https://sutro.sh/pricing) for more information. # Cancelling a Job Source: https://docs.sutro.sh/batch-api-reference/cancel-job GET https://YOUR-SUTRO-DEPLOYMENT/v1/job-cancel/{job_id} Cancel a batch inference job by its job_id Using the API directly is not recommended for most users. Instead, we recommend using the [Python SDK](/python-sdk/setup). Cancel a batch inference job by its job\_id. ## Request Parameters The job\_id returned when you submitted the batch inference job ## Headers Your Sutro API key using Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Response Returns the cancellation status of the job. True if the job was cancelled, False otherwise Verbose message describing the job's cancellation status ```json Job Successfully Cancelled theme={null} { "cancelled": true, "message": "Job job-12345678-1234-1234-1234-1234567890ab has been successfully cancelled" } ``` ```json Job Could Not Be Cancelled theme={null} { "cancelled": false, "message": "Job job-12345678-1234-1234-1234-1234567890ab could not be cancelled because it has already completed" } ``` ```json Job Not Found theme={null} { "cancelled": false, "message": "Job job-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee not found" } ``` ## Code Examples ```python Python theme={null} import requests job_id = "job-12345678-1234-1234-1234-1234567890ab" response = requests.get( f'https://YOUR-SUTRO-DEPLOYMENT/v1/job-cancel/{job_id}', headers={ 'Authorization': 'Key YOUR_SUTRO_API_KEY', 'Content-Type': 'application/json' } ) result = response.json() if result['cancelled']: print(f"Job {job_id} was successfully cancelled") else: print(f"Failed to cancel job: {result['message']}") ``` ```javascript Node.js theme={null} const jobId = 'job-12345678-1234-1234-1234-1234567890ab'; const response = await fetch(`https://YOUR-SUTRO-DEPLOYMENT/v1/job-cancel/${jobId}`, { method: 'GET', headers: { 'Authorization': 'Key YOUR_SUTRO_API_KEY', 'Content-Type': 'application/json' } }); const result = await response.json(); if (result.cancelled) { console.log(`Job ${jobId} was successfully cancelled`); } else { console.log(`Failed to cancel job: ${result.message}`); } ``` ```curl cURL theme={null} curl -X GET https://YOUR-SUTRO-DEPLOYMENT/v1/job-cancel/job-12345678-1234-1234-1234-1234567890ab \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -H "Content-Type: application/json" ``` ## Notes * Jobs can only be cancelled if they are in a cancellable state (e.g., pending, submitted, starting, or running) * Jobs that have already completed, failed, or been previously cancelled cannot be cancelled * The cancellation is asynchronous - the job may take a moment to fully stop after receiving the cancellation request # Fetch Job Details Source: https://docs.sutro.sh/batch-api-reference/fetch-job GET https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/{job_id} Retrieve details for a specific job by its ID Using the API directly is not recommended for most users. Instead, we recommend using the [Python SDK](/python-sdk/setup). Retrieve detailed information about a specific job using its job ID. ## Path Parameters The unique identifier of the job to retrieve. Example: `job-xyz123...` ## Headers Your Sutro API key using Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Response The job object containing all metadata and status information for the requested job. See the [job object fields](#job-object-fields) section for detailed field descriptions. ### Error Response Returns a 404 status code if the job is not found. Error message indicating the job was not found ```json Success Response theme={null} { "job": { "model": "llama-3.1-8b", "system_prompt": "You are a helpful assistant.", "job_priority": 1, "status": "succeeded", "datetime_created": "2024-01-15T10:30:00Z", "datetime_started": "2024-01-15T10:30:15Z", "datetime_completed": "2024-01-15T10:32:15Z", "json_schema": null, "sampling_params": { "temperature": 0.7, "top_p": 0.95, "max_tokens": 2048 }, "name": "Customer Support Batch", "description": "Processing customer inquiries", "input_tokens": 15420, "output_tokens": 8230, "job_cost": 0.0234, "num_rows": 50, "failure_reason": null, "cost_estimate": 0.025, "job_id": "job-12345678-1234-1234-1234-1234567890ab" } } ``` ```json Error Response theme={null} { "message": "No job found with ID job-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" } ``` ## Code Examples ```python Python theme={null} import requests job_id = "job-12345678-1234-1234-1234-1234567890ab" response = requests.get( f'https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/{job_id}', headers={'Authorization': 'Key YOUR_SUTRO_API_KEY'} ) if response.status_code == 200: job = response.json()['job'] print(f"Job ID: {job['job_id']}") print(f"Status: {job['status']}") print(f"Model: {job['model']}") print(f"Created: {job['datetime_created']}") if job['status'] == 'succeeded': print(f"Rows processed: {job['num_rows']}") print(f"Cost: ${job['job_cost']:.4f}") print(f"Tokens: {job['input_tokens']} in, {job['output_tokens']} out") elif job['status'] == 'failed': print(f"Failure reason: {job['failure_reason']}") elif response.status_code == 404: print(f"Job not found: {job_id}") else: print(f"Error: {response.status_code}") ``` ```javascript Node.js theme={null} const jobId = 'job-12345678-1234-1234-1234-1234567890ab'; const response = await fetch(`https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/${jobId}`, { headers: { 'Authorization': 'Key YOUR_SUTRO_API_KEY' } }); if (response.ok) { const data = await response.json(); const job = data.job; console.log(`Job ID: ${job.job_id}`); console.log(`Status: ${job.status}`); console.log(`Model: ${job.model}`); if (job.status === 'succeeded') { console.log(`Rows processed: ${job.num_rows}`); console.log(`Cost: $${job.job_cost.toFixed(4)}`); console.log(`Tokens: ${job.input_tokens} in, ${job.output_tokens} out`); } else if (job.status === 'failed') { console.log(`Failure reason:`, job.failure_reason); } } else if (response.status === 404) { console.log(`Job not found: ${jobId}`); } ``` ```curl cURL theme={null} # Get job details curl -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" # Pretty print with jq curl -s -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" | jq '.job' # Check job status only curl -s -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" | jq -r '.job.status' ``` ## Job Object Fields The job object returned contains the same fields as documented in the [List Jobs endpoint](/batch-api-reference/list-jobs#job-object-fields): | Field | Type | Description | | -------------------- | --------------- | --------------------------------------------------------------------- | | `job_id` | string | Public identifier for the job | | `status` | string | Current status of the job (SUCCEEDED, FAILED, RUNNING, PENDING, etc.) | | `model` | string | The model used for the job | | `system_prompt` | string \| null | System prompt used for the job | | `job_priority` | integer | Priority level of the job (0 or 1) | | `datetime_created` | string | ISO timestamp of when the job was created | | `datetime_started` | string \| null | ISO timestamp of when processing began | | `datetime_completed` | string \| null | ISO timestamp of when the job completed | | `json_schema` | object \| null | JSON schema for structured output (if used) | | `sampling_params` | object | Sampling parameters used for generation | | `name` | string \| null | Optional name for the job | | `description` | string \| null | Optional description of the job | | `input_tokens` | integer \| null | Total input tokens processed | | `output_tokens` | integer \| null | Total output tokens generated | | `job_cost` | number \| null | Actual cost of the job in USD | | `num_rows` | integer \| null | Number of rows processed | | `failure_reason` | object \| null | Details if the job failed | | `cost_estimate` | number \| null | Estimated cost before processing | ## Notes * This endpoint retrieves a single job's complete metadata * The `job_cost` field represents the actual cost incurred after processing completes * For jobs still in progress, completion-related fields will be `null` # Retrieving Results Source: https://docs.sutro.sh/batch-api-reference/job-results GET https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/{job_id}/results Retrieve the results of a batch inference job by its job_id Using the API directly is not recommended for most users. Instead, we recommend using the [Python SDK](/python-sdk/setup). Download the complete results of a batch inference job. Results can be downloaded in multiple formats optimized for different use cases. If the job was submitted with `id_column_name`, the user-provided ID column is always included in the result, even when `include_inputs` is `false`. ## Path Parameters The job\_id returned when you submitted the batch inference job ## Query Parameters The format to download results in: * `csv` - CSV file (zipped for compression) * `parquet` - Parquet file * `json` - JSON object Whether to include the input prompts in the results Whether to include the cumulative log probabilities in the results ## Headers Your Sutro API key using Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Response Returns a downloadable file in the requested format. ### Parquet * Returns a single Parquet file * Recommended for large datasets * Includes the user-provided ID column when configured for the job ### CSV * Returns a ZIP file containing a CSV * File is compressed for efficient transfer * Column names may include the user-provided ID column, `inputs` (if requested), `{job_id}` outputs, and `cumulative_logprobs` (if requested) ### JSON * Returns a JSON object * Best for smaller datasets ## Structured Outputs When using structured outputs (by providing a `json_schema` when creating the job), the outputs will be JSON strings that conform to your specified schema. ### Standard Models For non-reasoning models, the output will be a JSON string following your schema: ```json theme={null} { "outputs": [ "{\"name\": \"John Doe\", \"age\": 30, \"email\": \"john@example.com\"}", "{\"name\": \"Jane Smith\", \"age\": 25, \"email\": \"jane@example.com\"}" ] } ``` ### Reasoning Models For reasoning models (like o1), the output includes both the structured content and the reasoning process: ```json theme={null} { "outputs": [ "{\"content\": {\"name\": \"John Doe\", \"age\": 30}, \"reasoning_content\": \"First, I identified the name from the text...\"}", "{\"content\": {\"name\": \"Jane Smith\", \"age\": 25}, \"reasoning_content\": \"I analyzed the passage and extracted...\"}" ] } ``` The output structure for reasoning models: * `content`: The structured output following your JSON schema (can be a text string or JSON string containing an object matching your schema) * `reasoning_content`: The model's step-by-step reasoning process (string) Currently, when using structured outputs or reasoning models, one will need to run `json.loads` or similar on each output JSON, ie `json.loads(outputs[0])` to transform from a string to a `dict` (or equivalent in other languages). ```json JSON Format theme={null} { "outputs": [ "The capital of France is Paris.", "Quantum computing uses quantum mechanics principles to process information in ways that classical computers cannot..." ], "inputs": [ "What is the capital of France?", "Explain quantum computing in simple terms" ], "cumulative_logprobs": [-0.5234, -1.2456] } ``` ```csv CSV Format (inside zip) theme={null} inputs,job-abc123,cumulative_logprobs "What is the capital of France?","The capital of France is Paris.",-0.5234 "Explain quantum computing in simple terms","Quantum computing uses quantum mechanics principles to process information in ways that classical computers cannot...",-1.2456 ``` ```text Parquet Format theme={null} Same structure as CSV, but with preserved data types: - inputs (string) - job-abc123 (string or JSON string) - cumulative_logprobs (float64) Binary format optimized for data analysis tools (pandas, polars, duckdb) A user-provided ID column appears before the job output when configured. ``` ## Code Examples ```python Python - CSV Download theme={null} import requests response = requests.get( 'https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab/results', headers={ 'Authorization': 'Key YOUR_SUTRO_API_KEY' }, params={ 'format': 'csv', 'include_inputs': True, 'include_cumulative_logprobs': False } ) # Save the zip file with open('results.zip', 'wb') as f: f.write(response.content) # Extract and read with pandas import zipfile import pandas as pd with zipfile.ZipFile('results.zip') as z: csv_filename = z.namelist()[0] with z.open(csv_filename) as csv_file: df = pd.read_csv(csv_file) print(df.head()) ``` ```python Python - Parquet Download theme={null} import requests import polars as pl response = requests.get( 'https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab/results', headers={ 'Authorization': 'Key YOUR_SUTRO_API_KEY' }, params={ 'format': 'parquet', 'include_inputs': True } ) # Save and read with Polars with open('results.parquet', 'wb') as f: f.write(response.content) df = pl.read_parquet('results.parquet') print(df.head()) ``` ```python Python - JSON Download theme={null} import requests response = requests.get( 'https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab/results', headers={ 'Authorization': 'Key YOUR_SUTRO_API_KEY' }, params={ 'format': 'json', 'include_inputs': True, 'include_cumulative_logprobs': False } ) result = response.json() # Access outputs and inputs for i, (input_text, output_text) in enumerate(zip(result['inputs'], result['outputs'])): print(f"Input {i + 1}: {input_text}") print(f"Output {i + 1}: {output_text}") print("---") ``` ```curl cURL - CSV Download theme={null} curl -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab/results?format=csv&include_inputs=true" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -o results.zip ``` ```curl cURL - JSON Download theme={null} curl -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234567890ab/results?format=json&include_inputs=true" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" ``` ## Notes * Results can only be retrieved for jobs that have completed successfully * The order of results matches the order of the original inputs * CSV format: outputs are in a column named after the job\_id * Parquet format uses Snappy compression internally * For very large datasets, request a resumable [Parquet results download URL](/batch-api-reference/job-results-url) * CSV files are automatically zipped to reduce download size # Getting a Results Download URL Source: https://docs.sutro.sh/batch-api-reference/job-results-url GET https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/{job_id}/results-url Materialize a cached unified results artifact in R2 and return presigned GET/HEAD URLs Using the API directly is not recommended for most users. The Python SDK wraps this endpoint with [`results_download_url()` and `download_job_results()`](/python-sdk/job-methods#downloading-large-results), which handle streaming, progress, and resumable downloads for you. This endpoint creates (or reuses) **one unified results artifact** for a job in object storage (R2) and returns **presigned URLs** you can use to download it. If the job was submitted with `id_column_name`, the unified artifact always includes that user-provided ID column, even when `include_inputs` is `false`. This route is built for **large results** and “real download tooling”: * Use **`urls.head`** to fetch metadata (`Content-Length`, `ETag`) without downloading the file. * Use **`urls.get`** to download the artifact, including **HTTP Range** requests for resumable downloads. Once you have the presigned URLs, **do not** send your Sutro `Authorization` header to R2. The presigned URL already contains the credentials. The job must have **succeeded** before results can be presigned: the artifact is cached permanently once materialized, so requests for jobs that are still running (or failed/cancelled) return **409** with the job's current status. Poll [job status](/batch-api-reference/job-status) or use the SDK's `await_job_completion()` first. ## Path Parameters The job\_id returned when you submitted the batch inference job. ## Query Parameters The artifact format. **Currently supported values:** * `parquet` (only) Any other value returns **400**. Whether to include the input prompts as columns in the unified artifact. Whether to include cumulative log probabilities in the unified artifact. TTL for the returned presigned URLs. * Minimum: 1 * Maximum: 604800 (7 days) ## Headers Your Sutro API key using Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Response Returns a JSON payload that describes the artifact and provides method-specific presigned URLs. The job ID you requested. The artifact format (currently `parquet`). Echoes whether inputs were included in the artifact. Echoes whether cumulative logprobs were included in the artifact. TTL (in seconds) for the returned presigned URLs. Metadata describing the stored object (bucket/key/filename/size). Presigned URLs: * `urls.get` — use with **GET** (supports `Range` requests) * `urls.head` — use with **HEAD** (metadata only) ```json Success Response theme={null} { "job_id": "job-12345678-1234-1234-1234-1234567890ab", "format": "parquet", "include_inputs": true, "include_cumulative_logprobs": false, "expires_in_seconds": 3600, "artifact": { "bucket": "sutro-data", "key": "jobs/user_abc/job-12345678-1234-1234-1234-1234567890ab/results/sutro-results~job-12345678-1234-1234-1234-1234567890ab~inputs=1~logprobs=0.parquet", "filename": "sutro-results~job-12345678-1234-1234-1234-1234567890ab~inputs=1~logprobs=0.parquet", "size_bytes": 987654321 }, "urls": { "get": "https:////?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...", "head": "https:////?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=..." } } ``` ## Download behavior ### HEAD (metadata) Use `urls.head` with the **HEAD** method to read headers like: * `Content-Length` — total bytes * `ETag` — object hash identifier (useful to detect changes) ### GET (download) Use `urls.get` with **GET** to download: * Supports `Range: bytes=...` for partial reads * Enables resumable downloads (append remaining bytes) Treat presigned URLs like credentials. Anyone with the URL can download until it expires. ## Code Examples ```python Python (requests) - Create URLs, HEAD metadata, resumable GET theme={null} import os import requests API_KEY = os.environ["SUTRO_API_KEY"] JOB_ID = "job-12345678-1234-1234-1234-1234567890ab" # 1) Ask Sutro for presigned URLs meta = requests.get( f"https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/{JOB_ID}/results-url", headers={"Authorization": f"Key {API_KEY}"}, params={ "format": "parquet", "include_inputs": True, "include_cumulative_logprobs": False, "expires_in_seconds": 3600, }, ).json() get_url = meta["urls"]["get"] head_url = meta["urls"]["head"] filename = meta["artifact"]["filename"] # 2) HEAD for size + etag (no download) head = requests.head(head_url) head.raise_for_status() size_bytes = int(head.headers["Content-Length"]) etag = head.headers.get("ETag") print("size_bytes:", size_bytes) print("etag:", etag) # 3) Resumable download using Range out_path = filename already = os.path.getsize(out_path) if os.path.exists(out_path) else 0 headers = {} if already > 0: headers["Range"] = f"bytes={already}-" with requests.get(get_url, headers=headers, stream=True) as r: r.raise_for_status() mode = "ab" if already > 0 else "wb" with open(out_path, mode) as f: for chunk in r.iter_content(chunk_size=8 * 1024 * 1024): if chunk: f.write(chunk) print("downloaded:", out_path) ``` ```javascript Node.js (fetch) - Create URLs + download to disk theme={null} import { createWriteStream } from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; const jobId = "job-12345678-1234-1234-1234-1234567890ab"; const apiKey = process.env.SUTRO_API_KEY; // 1) Ask Sutro for presigned URLs const metaRes = await fetch( `https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/${jobId}/results-url?format=parquet&include_inputs=true`, { headers: { Authorization: `Key ${apiKey}` }, } ); if (!metaRes.ok) { throw new Error(`Failed: ${metaRes.status} ${await metaRes.text()}`); } const meta = await metaRes.json(); const { get: getUrl, head: headUrl } = meta.urls; const filename = meta.artifact.filename; // 2) HEAD for metadata const headRes = await fetch(headUrl, { method: "HEAD" }); console.log("content-length:", headRes.headers.get("content-length")); console.log("etag:", headRes.headers.get("etag")); // 3) Download (stream to disk) const dlRes = await fetch(getUrl); if (!dlRes.ok) throw new Error(`Download failed: ${dlRes.status}`); await pipeline(Readable.fromWeb(dlRes.body), createWriteStream(filename)); console.log("saved:", filename); ``` ```bash cURL - Create URLs, HEAD metadata, Range + resume theme={null} # 1) Get the presigned URLs RESP="$(curl -s \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ "https://YOUR-SUTRO-DEPLOYMENT/v1/jobs/job-12345678-1234-1234-1234-1234567890ab/results-url?format=parquet&include_inputs=true&expires_in_seconds=3600")" GET_URL="$(echo "$RESP" | jq -r '.urls.get')" HEAD_URL="$(echo "$RESP" | jq -r '.urls.head')" FILENAME="$(echo "$RESP" | jq -r '.artifact.filename')" # 2) HEAD (metadata only) curl -sI "$HEAD_URL" # 3) Download curl -L "$GET_URL" -o "$FILENAME" # 4) Resume an interrupted download curl -L -C - "$GET_URL" -o "$FILENAME" # 5) Download the first 1 MiB (Range request) curl -L -H "Range: bytes=0-1048575" "$GET_URL" -o first_1MiB.bin ``` ## Notes * Only `format=parquet` is supported on this route today. # Checking Job Status Source: https://docs.sutro.sh/batch-api-reference/job-status GET https://YOUR-SUTRO-DEPLOYMENT/v1/job-status/{job_id} Retrieve the status of a batch inference job by its job_id Using the API directly is not recommended for most users. Instead, we recommend using the [Python SDK](/python-sdk/setup). Retrieve the status of a batch inference job by its job\_id. ## Request Parameters The job\_id returned when you submitted the batch inference job ## Headers Your Sutro API key using Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Response Returns the current status and details of the batch inference job. Message describing the job's current status The current status of the job. See status values below Additional information about the job. If the status is `failed` during job initialization, will contain a `failure_reason` key with details about the failure, and optionally an `additional_context` key with debugging information ### Job Status Values The `job_status` field will be one of the following: * `succeeded`: The job completed successfully * `failed`: The job failed * `cancelled`: The job was cancelled * `pending`: The job is still pending * `submitted`: The job has been submitted * `starting`: The job is starting * `running`: The job is running * `unknown`: The job status is unknown ```json Successful Job theme={null} { "message": "Job completed successfully", "job_status": "succeeded", "metadata": {} } ``` ```json Running Job theme={null} { "message": "Job is currently running", "job_status": "running", "metadata": {} } ``` ```json Failed Job theme={null} { "message": "Job failed during initialization", "job_status": "failed", "metadata": { "failure_reason": "Invalid model specified", "additional_context": "Model 'invalid-model' not found in available models list" } } ``` ## Code Examples ```python Python theme={null} import requests job_id = "job-12345678-1234-1234-1234-1234567890ab" response = requests.get( f'https://YOUR-SUTRO-DEPLOYMENT/v1/job-status/{job_id}', headers={ 'Authorization': 'Key YOUR_SUTRO_API_KEY' } ) result = response.json() print(f"Job Status: {result['job_status']}") print(f"Message: {result['message']}") if result['job_status'] == 'failed': print(f"Failure Reason: {result['metadata'].get('failure_reason', 'Unknown')}") ``` ```javascript Node.js theme={null} const jobId = 'job-12345678-1234-1234-1234-1234567890ab'; const response = await fetch(`https://YOUR-SUTRO-DEPLOYMENT/v1/job-status/${jobId}`, { method: 'GET', headers: { 'Authorization': 'Key YOUR_SUTRO_API_KEY' } }); const result = await response.json(); console.log(`Job Status: ${result.job_status}`); console.log(`Message: ${result.message}`); if (result.job_status === 'failed') { console.log(`Failure Reason: ${result.metadata?.failure_reason || 'Unknown'}`); } ``` ```curl cURL theme={null} curl -X GET https://YOUR-SUTRO-DEPLOYMENT/v1/job-status/job-12345678-1234-1234-1234-1234567890ab \ -H "Authorization: Key YOUR_SUTRO_API_KEY" ``` # Listing All Jobs Source: https://docs.sutro.sh/batch-api-reference/list-jobs GET https://YOUR-SUTRO-DEPLOYMENT/v1/list-jobs List all current and historical jobs with optional pagination Using the API directly is not recommended for most users. Instead, we recommend using the [Python SDK](/python-sdk/setup). List all current and historical jobs with optional pagination support for handling large datasets efficiently. ## Headers Your Sutro API key using Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Query Parameters Number of jobs to return per page. When provided, enables pagination. * Minimum: 1 * Maximum: 100 * If omitted, returns all jobs without pagination Opaque cursor string for pagination. Use the `next_cursor` value from the previous response to fetch the next page. Only used when `limit` is also provided. ## Response Returns a list of jobs associated with your account. The response structure varies based on whether pagination is used. ### Without Pagination (no `limit` parameter) Success message indicating jobs were retrieved Complete list of all jobs for your account ### With Pagination (`limit` parameter provided) List of jobs for the current page (up to `limit` items) Cursor to fetch the next page of results. Will be `null` if there are no more pages. ```json Without Pagination theme={null} { "message": "Jobs retrieved successfully.", "jobs": [ { "model": "llama-3.1-8b", "system_prompt": "You are a helpful assistant.", "job_priority": 1, "status": "succeeded", "datetime_created": "2024-01-15T10:30:00Z", "datetime_started": "2024-01-15T10:30:15Z", "datetime_completed": "2024-01-15T10:32:15Z", "json_schema": null, "sampling_params": { "temperature": 0.7, "top_p": 0.95, "max_tokens": 2048 }, "name": "Customer Support Batch", "description": "Processing customer inquiries", "input_tokens": 15420, "output_tokens": 8230, "job_cost": 0.0234, "num_rows": 50, "failure_reason": null, "cost_estimate": 0.025, "run_type": "FULL", "job_id": "job-12345678-1234-1234-1234-1234567890ab" } ] } ``` ```json With Pagination theme={null} { "jobs": [ { "model": "llama-3.1-8b", "system_prompt": "You are a helpful assistant.", "job_priority": 1, "status": "succeeded", "datetime_created": "2024-01-15T10:30:00Z", "datetime_started": "2024-01-15T10:30:15Z", "datetime_completed": "2024-01-15T10:32:15Z", "json_schema": null, "sampling_params": { "temperature": 0.7, "top_p": 0.95, "max_tokens": 2048 }, "name": "Customer Support Batch", "description": "Processing customer inquiries", "input_tokens": 15420, "output_tokens": 8230, "job_cost": 0.0234, "num_rows": 50, "failure_reason": null, "cost_estimate": 0.025, "run_type": "FULL", "job_id": "job-12345678-1234-1234-1234-1234567890ab" } ], "next_cursor": "eyJ0IjoiMjAyNC0wMS0xNVQxMDozMDowMFoiLCJpIjoiYmF0Y2hfam9iXzEyMzQ1In0=" } ``` ## Code Examples ```python Python - All Jobs theme={null} import requests response = requests.get( 'https://YOUR-SUTRO-DEPLOYMENT/v1/list-jobs', headers={ 'Authorization': 'Key YOUR_SUTRO_API_KEY' } ) result = response.json() print(f"Total jobs: {len(result['jobs'])}") for job in result['jobs']: print(f"{job['job_id']}: {job['status']} - {job['model']}") ``` ```python Python - Paginated theme={null} import requests api_key = 'YOUR_SUTRO_API_KEY' cursor = None page_size = 50 # Process jobs page by page while True: params = {'limit': page_size} if cursor: params['cursor'] = cursor response = requests.get( 'https://YOUR-SUTRO-DEPLOYMENT/v1/list-jobs', headers={'Authorization': f'Key {api_key}'}, params=params ) data = response.json() for job in data['jobs']: print(f"{job['job_id']}: {job['status']} - {job['model']}") cursor = data.get('next_cursor') if not cursor: break ``` ```curl cURL - With Pagination theme={null} # First page curl -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/list-jobs?limit=20" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" # Next page (using cursor from previous response) curl -X GET "https://YOUR-SUTRO-DEPLOYMENT/v1/list-jobs?limit=20&cursor=YOUR_CURSOR_HERE" \ -H "Authorization: Key YOUR_SUTRO_API_KEY" ``` ## Job Object Fields Each job in the jobs array contains the following fields: | Field | Type | Description | | -------------------- | --------------- | --------------------------------------------------------------------- | | `job_id` | string | Public identifier for the job | | `status` | string | Current status of the job (SUCCEEDED, FAILED, RUNNING, PENDING, etc.) | | `model` | string | The model used for the job | | `system_prompt` | string \| null | System prompt used for the job | | `job_priority` | integer | Priority level of the job (0 or 1) | | `datetime_created` | string | ISO timestamp of when the job was created | | `datetime_started` | string \| null | ISO timestamp of when processing began | | `datetime_completed` | string \| null | ISO timestamp of when the job completed | | `json_schema` | object \| null | JSON schema for structured output (if used) | | `sampling_params` | object | Sampling parameters used for generation | | `name` | string \| null | Optional name for the job | | `description` | string \| null | Optional description of the job | | `input_tokens` | integer \| null | Total input tokens processed | | `output_tokens` | integer \| null | Total output tokens generated | | `job_cost` | number \| null | Actual cost of the job in USD | | `num_rows` | integer \| null | Number of rows processed | | `failure_reason` | object \| null | Details if the job failed | | `cost_estimate` | number \| null | Estimated cost before processing | ## Notes * Jobs are returned in reverse chronological order (newest first) * When using pagination, the cursor is opaque and should not be modified * Use the `limit` parameter for better performance when dealing with large numbers of jobs # Creating a Batch Inference Job Source: https://docs.sutro.sh/batch-api-reference/running-batch-inference POST https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference Run batch inference on inline inputs, a download URL, or a published Sutro Function. Using the API directly is not recommended for most users. Instead, we recommend using the [Python SDK](/python-sdk/setup). Run batch inference over a list of inputs or an HTTP(S) CSV/Parquet download URL. For large production workloads, use an HTTPS presigned S3 GET URL with priority `1`. See [Presigned S3 Inputs](/python-sdk/presigned-s3-inputs) for the file contract, URL lifetime, security guidance, and end-to-end examples. ## Using a Sutro Function as `model` Set `model` to the published Sutro Function name and send rows whose keys match that Function's inputs. Use the Function name only. Do not include a namespace, owner, or revision in `model`. Sutro resolves the Function namespace from the authenticated API key's user account and loads the currently published revision through that Function's `latest.json` pointer. When `model` is a Sutro Function name: * every required Function input field must be present; optional fields may be absent and extra fields are ignored * input values are converted to strings, with null values rendered as empty strings * string rows are treated as already-rendered prompts * HTTP(S) CSV/Parquet download URLs are read as row objects whose columns match the Function inputs * `system_prompt` and `json_schema` should be omitted because they come from the published Function * request-level `sampling_params` are merged on top of the Function/runtime defaults Only text Functions are supported through the Batch API today. Image, PDF, and other multimodal Functions are not supported here yet. ## Request Body Accepts one of the following input forms: * **Array** — an array of strings, or object rows for a Sutro Function * **Download URL** — an HTTP(S) CSV or Parquet download URL, including a presigned Amazon S3 GET URL Direct standalone model runs (i.e. `model="gpt-oss-20b"`) expect string rows. Sutro Function runs expect object rows whose keys match the Function inputs, already-rendered string rows, or a CSV/Parquet download URL with matching columns. An `s3://` URI is not accepted. For production S3 inputs, generate an HTTPS presigned GET URL with enough lifetime for the worker to start its one full-object download. Sutro does not currently resume or retry a failed input download. Column name to use when `inputs` is a download URL for standalone/base-model inference. For presigned download URLs, `column_name` selects the column to run; if omitted, the first column is used. Specify it explicitly in production so upstream column reordering cannot change the model input. Omit `column_name` when `model` is a Sutro Function. Those URL inputs are matched against the Function's declared input fields and rendered by Sutro. Column containing user-provided row IDs to carry into job results. When using the Python SDK, pass this as `id_column`. This field is supported only when `inputs` is an HTTP(S) CSV or Parquet download URL. The column must exist, must differ from every inference column, and cannot use the reserved `SKYSIGHT_` prefix or the result column names `inputs`, `outputs`, `confidence_score`, or `cumulative_logprobs`. The ID column is returned with every result format even when `include_inputs` is `false`, so results can be joined back to the source table. Sutro preserves ID values but does not guarantee preservation of the source file's physical integer type. Standalone model ID or published Sutro Function name. If the value is not an available standalone model, Sutro treats it as a Function name and resolves the correct model to use based on the Function's latest spec. System prompt for standalone model batch inference. Omit this field when `model` is a Sutro Function name. Structured output schema for standalone model batch inference. Omit this field when `model` is a Sutro Function name. Sampling parameters for the batch job. See [Sampling Parameters](/concepts/sampling-parameters). For Sutro Function jobs, most users should omit this and use the published defaults. If provided, these values override the Function/runtime defaults for that job. Batch priority level. Priorities `0` and `1` are supported. If `true`, create an estimate job instead of launching the normal full job. Priority-1 estimates at or above the sampling threshold run inference on a prefix sample of approximately 1 million input tokens, which can include every row for workloads near that threshold. The submission response contains the estimate job ID; fetch that job after it succeeds to read `cost_estimate`. See [Cost Estimates](/concepts/cost-estimates/) for more information. If `true`, generate a random seed per input row. If `true`, rows that exceed the selected model's context window are truncated to fit. Truncation removes the minimal amount of text such that the token count of (input text + prompt text + max output tokens) is less than the model's context window length. If `false`, jobs with rows that exceed the context window will be marked as FAILED. Optional job name for metadata and experiment tracking. Maximum length is 45 characters. Optional job description for metadata and experiment tracking. Maximum length is 512 characters. ## Headers Your Sutro API key using the Key authentication scheme. Format: `Key YOUR_API_KEY` Example: `Authorization: Key sk_abc234...` ## Response Returns the created job ID in both `metadata.job_id` and `results`. The response confirms job creation, not successful ingestion or quota validation. Download, row-quota, token-quota, and cost-quota checks run asynchronously. Check priority-1 quotas before submission and monitor the returned job ID. Job creation is not idempotent. Before retrying an ambiguous or timed-out submission, inspect recent jobs so you do not create duplicate work. Metadata for the created job. Contains `job_id` and `message`. Job ID for the created batch inference job. This is the same value as `metadata.job_id`. ```json Response theme={null} { "metadata": { "job_id": "job-12345678-1234-1234-1234-1234567890ab", "message": "Job created successfully" }, "results": "job-12345678-1234-1234-1234-1234567890ab" } ``` ## Code Examples ### Standalone model with array inputs ```python Python theme={null} import requests response = requests.post( "https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference", headers={ "Authorization": "Key YOUR_SUTRO_API_KEY", "Content-Type": "application/json", }, json={ "model": "gpt-oss-20b", "inputs": [ "What is the capital of France?", "Explain quantum computing in simple terms.", "Write a haiku about programming.", ], "system_prompt": "You are a helpful assistant.", "job_priority": 0, }, ) result = response.json() print(f"Job created: {result['results']}") ``` ```bash cURL theme={null} curl -X POST https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-oss-20b", "inputs": [ "What is the capital of France?", "Explain quantum computing in simple terms.", "Write a haiku about programming." ], "system_prompt": "You are a helpful assistant.", "job_priority": 0 }' ``` ### Published Sutro Function with object rows Replace `lead-qualifier` and the input field names with your published Function name and schema. ```python Python theme={null} import requests response = requests.post( "https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference", headers={ "Authorization": "Key YOUR_SUTRO_API_KEY", "Content-Type": "application/json", }, json={ "model": "lead-qualifier", "inputs": [ { "query": "Find cybersecurity leaders evaluating AI vendors.", "region": "APAC", }, { "query": "Find sales operations leaders replacing manual enrichment.", "region": "EMEA", }, ], "job_priority": 0, "name": "lead-qualifier-smoke", }, ) result = response.json() print(f"Job created: {result['results']}") ``` ```bash cURL theme={null} curl -X POST https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "lead-qualifier", "inputs": [ { "query": "Find cybersecurity leaders evaluating AI vendors.", "region": "APAC" }, { "query": "Find sales operations leaders replacing manual enrichment.", "region": "EMEA" } ], "job_priority": 0, "name": "lead-qualifier-smoke" }' ``` ### Published Sutro Function with a download URL The CSV or Parquet file must contain columns matching the Function inputs. For this example, the file contains `query`, optionally `region`, and a user-provided `lead_id`. ```python Python theme={null} import requests response = requests.post( "https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference", headers={ "Authorization": "Key YOUR_SUTRO_API_KEY", "Content-Type": "application/json", }, json={ "model": "lead-qualifier", "inputs": "https://your-bucket.s3.amazonaws.com/leads.parquet?X-Amz-Algorithm=...", "id_column_name": "lead_id", "job_priority": 1, "name": "lead-qualifier-file-run", }, ) result = response.json() print(f"Job created: {result['results']}") ``` ```bash cURL theme={null} curl -X POST https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "lead-qualifier", "inputs": "https://your-bucket.s3.amazonaws.com/leads.parquet?X-Amz-Algorithm=...", "id_column_name": "lead_id", "job_priority": 1, "name": "lead-qualifier-file-run" }' ``` ### Standalone model with download URL input The URL must be presigned for `GET` and remain valid until Sutro starts its one full-object download. Priority `1` with an unwrapped Parquet object is recommended for large production jobs. Sutro first downloads and retains the complete source object; for standalone/base-model jobs, tokenization then proceeds in bounded slices. ```python Python theme={null} import requests response = requests.post( "https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference", headers={ "Authorization": "Key YOUR_SUTRO_API_KEY", "Content-Type": "application/json", }, json={ "model": "gpt-oss-20b", "inputs": "https://your-bucket.s3.amazonaws.com/data.parquet?X-Amz-Algorithm=...", "column_name": "prompt", "id_column_name": "row_id", "system_prompt": "You are a helpful assistant.", "job_priority": 1, }, ) result = response.json() print(f"Job created: {result['results']}") ``` ```bash cURL theme={null} curl -X POST https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "gpt-oss-20b", "inputs": "https://your-bucket.s3.amazonaws.com/data.parquet?X-Amz-Algorithm=...", "column_name": "prompt", "id_column_name": "row_id", "system_prompt": "You are a helpful assistant.", "job_priority": 1 }' ``` ### Cost estimate ```python Python theme={null} import requests response = requests.post( "https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference", headers={ "Authorization": "Key YOUR_SUTRO_API_KEY", "Content-Type": "application/json", }, json={ "model": "lead-qualifier", "inputs": [ { "query": "Find cybersecurity leaders evaluating AI vendors.", "region": "APAC", } ], "job_priority": 0, "cost_estimate": True, }, ) result = response.json() print(f"Estimate job created: {result['results']}") ``` ```bash cURL theme={null} curl -X POST https://YOUR-SUTRO-DEPLOYMENT/v1/batch-inference \ -H "Authorization: Key YOUR_SUTRO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "lead-qualifier", "inputs": [ { "query": "Find cybersecurity leaders evaluating AI vendors.", "region": "APAC" } ], "job_priority": 0, "cost_estimate": true }' ``` # Account Management Source: https://docs.sutro.sh/command-line-interface/account-management Sutro account management with the Command Line Interface (CLI). # Command Line Interface (CLI) The CLI provides a command-line interface to interact with the API. It's most useful for retrieving and managing job status and results. See the `installation` guide to install the CLI. ## Account Management ### Authentication Create a deployment API key from the **API Keys** panel in your Sutro UI. The CLI consumes the same environment variables as the Python SDK: If the panel is not visible, contact the Sutro team at [team@sutro.sh](mailto:team@sutro.sh) to create a key for you. ```bash theme={null} export SUTRO_API_URL="https://your-sutro-deployment.example.com" export SUTRO_API_KEY="sk_..." ``` Alternatively, configure and validate both values interactively: ```none theme={null} sutro login ``` Use `sutro set-api-url ` to update the persisted Sutro deployment URL. Both the deployment base URL and the same URL with `/v1` appended are accepted. If the deployment changes, the CLI clears the persisted key; run `sutro login` to pair the new deployment with one of its keys. ### Viewing quotas To get your current quotas, use: ```none theme={null} sutro quotas ``` # Cache Management Commands Source: https://docs.sutro.sh/command-line-interface/cache-commands Cache management commands available with the Command Line Interface (CLI). The Python SDK will cache job results locally to speed up subsequent calls to `get_job_results`. This cache is stored in the `~/.sutro/job-results` directory. The CLI provides commands to manage the job results cache. ### Clearing the job results cache To clear the job results cache, use: ```none theme={null} sutro cache clear ``` ### Showing the contents of the job results cache To show the contents and size of the job results cache, use: ```none theme={null} sutro cache show ``` # Job Commands Source: https://docs.sutro.sh/command-line-interface/job-commands Job commands available with the Command Line Interface (CLI). ### Listing current and historical jobs To list jobs associated with your API key, use: ```none theme={null} sutro jobs list --all ``` By default this will only list the most recent 25 jobs. To see all jobs, use the `--all` flag: ### Getting job status by job\_id To get the status of a job by its ID, use: ```none theme={null} sutro jobs status ``` ### Getting job results by job\_id To get the results of a job by its ID, use: ```none theme={null} sutro jobs results ``` There are several optional flags you can use to customize the results: * You can also include the inputs in the results by using the `--include-inputs` flag. * You can also include the cumulative logprobs by using the `--include-cumulative-logprobs` flag. * You can also save the results to a file by using the `--save` flag. * If using the `--save` flag, you can also specify the format of the output file by using the `--format` flag. Options are `parquet` and `csv`, default is `parquet`. ```none theme={null} sutro jobs results --include-inputs --include-cumulative-logprobs ``` You can also save the results to a file by using the `--save` flag: ```none theme={null} sutro jobs results --save ``` ### Cancelling a running job by job\_id To cancel a running priority 1 job by its ID, use: ```none theme={null} sutro jobs cancel ``` ### Attaching to a running job by job\_id To attach to a running job by its ID, use: ```none theme={null} sutro jobs attach ``` If the job is in progress, this will display a progress bar and live metrics around the job's completion. If the job is in a terminal state, the command will exit. You can also attach to the latest job by using the `--latest` flag: ```none theme={null} sutro jobs attach --latest ``` # Cost Estimates Source: https://docs.sutro.sh/concepts/cost-estimates Reference documentation for Cost Estimates. # Cost Estimates A significant benefit to batch inference is decreased costs as well as transparent pricing as inputs are known in advance. We aim to provide transparent pricing models so you know in advance how much a batch job will cost before running it. ## Understanding Pricing We charge based on the count of input and output tokens that successfully complete inference. Our pricing page contains the average cost per million-token for each model, blending both input and output tokens and weighted according to typical usage patterns. However, output tokens are generally more expensive than input tokens, and we do charge differently for each. ## Using the Dry Run Feature To estimate a batch job, set `dry_run=True` in the Python SDK. The SDK creates an estimate job, waits for completion, prints the estimate, and returns the estimate job ID. If you call the HTTP API directly, use `cost_estimate=true` in the request body; the submission response contains the estimate job ID, and the completed job resource contains `cost_estimate`. Estimate jobs do not launch the normal full job. Priority-1 estimates at or above the sampling threshold run inference on a prefix sample of approximately 1 million input tokens. For workloads near that threshold, the sample can include every row. We recommend creating an estimate before a large job. # Cumulative Logprobs (Experimental) Source: https://docs.sutro.sh/concepts/cumulative-logprobs Reference documentation for Cumulative Logprobs (Experimental). # Cumulative Logprobs (Experimental) For certain tasks, it can be helpful to inspect the logprobs of an LLM's output tokens. This can be considered a confidence estimation for the output. For example, if you are generating classification labels, you might want to know how confident the model is in its output. We store the normalized cumulative logprobs (ranging from 0 to 100) for each output. These can be obtained using the job\_results endpoint, and setting `include_cumulative_logprobs` to `True`. This feature is experimental, but we encourage you to utilize it and let us know if you have any feedback. Of course, if you have any questions or need help getting started with this feature, please reach out to us at [team@sutro.sh](mailto:team@sutro.sh). # Data Retention Source: https://docs.sutro.sh/concepts/data-retention Reference documentation for Data Retention. # Data Retention Sutro will retain job results data for up to 90 days by default. We support changing this to one of a few pre-selected options in [our web UI](https://app.sutro.sh) under **Settings > Data Retention**. Reach out to us if you need more flexible retention options. # Using Embedding Models Source: https://docs.sutro.sh/concepts/embeddings Reference documentation for Using Embedding Models. # Using Embedding Models Embedding models convert input data into high-dimensional vector representations. They are useful for a variety of tasks, such as clustering, semantic search, and more. We support several embedding models, usable in the same way as the other models with the only difference being that the output is a tensor, not a string. These will be returned as a list of floats with a length equal to the embedding dimension. See our [pricing page](https://sutro.sh/pricing) for a list of available embedding models. # Increasing Quotas Source: https://docs.sutro.sh/concepts/increasing-quotas Reference documentation for Increasing Quotas. # Increasing Quotas By default, new users have capped quotas. At the moment, these are 1000 rows and 1M tokens for priority 0 jobs, and 1M rows/10M tokens for priority 1 jobs. You can use the sutro quotas CLI command to view your current quotas: ```none theme={null} sutro quotas ``` In most cases, we'll be happy to increase these quotas for you. Please contact us at [team@sutro.sh](mailto:team@sutro.sh). # Job Priority Source: https://docs.sutro.sh/concepts/job-priority Reference documentation for Job Priority. # Job Priority Sutro's Batch API allows you to specify a priority for each job you create. For now, only two priorities are supported: 0 and 1. ## Priority 0 (Prototyping) Priority 0 is the default priority, and is primarily meant for prototyping jobs. You can expect them to complete in several minutes. By default, these jobs are soft-limited to 1000 inputs and 1 million input tokens. Please reach out to us at [team@sutro.sh](mailto:team@sutro.sh) if you need to increase this limit. ## Priority 1 (Production) Priority 1 is currently reserved for production jobs. You can generally expect these jobs to take longer; completion times are dependent on the number of inputs, the model being used, and other factors. Generally speaking you can expect these jobs to take roughly one hour to complete. In the future, we'll be expanding the number of priority levels available to better serve different cost and speed requirements. # Random Seeds Source: https://docs.sutro.sh/concepts/random-seeds Reference documentation for Using Random Seeds. # Using Random Seeds The batch API allows you to specify a random seed for each input. This is useful for generating diverse outputs in tasks such as synthetic data generation. To use this feature, you can set `random_seed_per_input=True` in the Python SDK. # Using Reasoning Models Source: https://docs.sutro.sh/concepts/reasoning-models Reference documentation for Using Reasoning Models. # Using Reasoning Models Reasoning models are specialized LLMs that excel at complex problem-solving by explicitly showing their thought process. They are particularly effective for tasks requiring multi-step logic, analytical thinking, and code generation. We support several reasoning models that provide both the final answer and the full reasoning trace used to arrive at that answer. See our [pricing page](https://sutro.sh/pricing) for a list of available reasoning models. ## What Reasoning Models Excel At Reasoning models are ideal for: * **Complex problem-solving**: Multi-step mathematical problems, logic puzzles, and analytical tasks * **Decision-making tasks**: Evaluating options with highly interpretable and explicit thought processes * **Code generation and debugging**: Writing, analyzing, and fixing code with clear explanations * **Scientific and technical analysis**: Breaking down complex concepts and providing detailed explanations ## Output Format For each input row, reasoning models return a special JSON format that includes both the reasoning process and the final answer. ```json theme={null} { "reasoning_content": "Let me work through this step by step...", "content": "The final answer or response" } ``` * **reasoning\_content**: Contains the model's step-by-step thought process * **content**: Contains the final answer or output ## Basic Example ```python theme={null} import sutro as so problems = [ "What is 15% of 240?", "Explain why the sky appears blue" ] results = so.infer( problems, model="qwen-3-14b-thinking", system_prompt="Solve this problem step by step" ) # Each result contains both reasoning and final answer for result in results: print(f"Reasoning: {result['reasoning_content']}") print(f"Answer: {result['content']}") ``` ## Using Reasoning Models with Structured Outputs Reasoning models fully support structured outputs. When using `output_schema`, the schema applies to the `content` field, while `reasoning_content` remains as free-form text. This combination allows the model to full explore the problem or task at hand, while also offering strict adherence to a specified output forma: **Example with Pydantic Model** ```python theme={null} import sutro as so from pydantic import BaseModel class MathSolution(BaseModel): numerical_answer: float unit: str is_exact: bool problems = [ "A car travels 120 miles in 2.5 hours. What is its average speed?" ] results = so.infer( problems, model="qwen-3-32b-thinking", system_prompt="Solve this physics problem", output_schema=MathSolution ) # Result format: # { # "reasoning_content": "To find average speed, I need to divide distance by time...", # "content": { # "numerical_answer": 48.0, # "unit": "miles per hour", # "is_exact": true # } # } ``` When using structured outputs with reasoning models, only the `content` field is validated against the schema. The `reasoning_content` field always contains unstructured text showing the model's thought process. ## Best Practices 1. **Leverage the reasoning**: The `reasoning_content` field is valuable for debugging, education, and building trust in AI outputs 2. **Crisp prompts**: Reasoning models work best with explicit instructions that guide its thinking process, often phrases like "consider \" can significantly boost performance and recall for nuanced tasks 3. **Structured outputs**: Use schemas when you need the final answer in a specific format while preserving a "thinking canvas" to explore the problem space # Sampling Parameters Source: https://docs.sutro.sh/concepts/sampling-parameters Sampling parameters control the token sampling process, allowing for fine-grained customization of the model's output. ## How to Use Sampling Parameters To use custom sampling parameters, pass a dictionary of your desired parameters to the `sampling_params` argument in your SDK call. Each model has a set of default sampling parameters that are recommended by the model creator for best performance. When you provide your own dictionary, **it is merged with these defaults**, and any values you specify will **always take precedence**. ### Example of Overriding Defaults Let's assume the model's default parameters are: * `temperature`: 0.6 * `top_k`: 20 * `max_tokens`: 32768 If you want a more creative response (higher temperature) and a shorter output, you can provide just those specific overrides: ```python theme={null} import sutro as so # We only specify the parameters we want to change. sampling_overrides = { "temperature": 0.9, "max_tokens": 512 } results = so.infer( inputs=..., sampling_params=sampling_overrides ) ``` The final parameters used by the model for this request will be a combination of your overrides and the defaults: * `temperature`: **0.9** * `max_tokens`: **512** * `top_k`: **20** ## Parameter Reference We support any vLLM compatible sampling parameters. Please reference [their sampling parameters class](https://docs.vllm.ai/en/stable/api/vllm/sampling_params.html#vllm.sampling_params.SamplingParams) for a complete list of valid parameters. Note: we do not set defaults for every parameter in `vllm.SamplingParams`, in this case the value used falls back to the vLLM default. ## Default Parameters by Model Family Different model families have different recommended default parameters. Here is a reference for the base configurations. Each configuration is set based off the given lab's recommended settings, e.g. [Qwen3 14B](https://huggingface.co/Qwen/Qwen3-14B#best-practices) > Sampling Parameters. ### [Llama Family](https://huggingface.co/meta-llama) | Parameter | Default Value | | -------------------- | ------------- | | `temperature` | `0.75` | | `top_p` | `1` | | `max_tokens` | `4096` | | `repetition_penalty` | `1.0` | ### [Qwen 3 Family](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f) The Qwen family has different defaults depending on whether the model is used for standard generation (`Non-Thinking`) or for tasks that require reasoning (`Thinking`). **Non-Thinking Defaults** | Parameter | Default Value | | -------------------- | ------------- | | `temperature` | `0.7` | | `top_p` | `0.8` | | `top_k` | `20` | | `max_tokens` | `4096` | | `repetition_penalty` | `1.0` | **Thinking Defaults** | Parameter | Default Value | | -------------------- | ------------- | | `temperature` | `0.6` | | `top_p` | `0.95` | | `top_k` | `20` | | `max_tokens` | `4096` | | `repetition_penalty` | `1.0` | Certain Qwen Mixture-of-Experts (MoE) models use a higher `max_tokens` default. They are as follows: Defaults to 16,384: * `qwen-3-30b-a3b` * `qwen-3-235b-a22b` Defaults to 32,768: * `qwen-3-30b-a3b-thinking` * `qwen-3-235b-a22b-thinking` This allows for sufficient length and robustness in the model's reasoning process. ### [Gemma Family](https://huggingface.co/collections/google/gemma-3-release-67c6c6f89c4f76621268bb6d) | Parameter | Default Value | | -------------------- | ------------- | | `temperature` | `0.95` | | `top_p` | `0.95` | | `top_k` | `64` | | `max_tokens` | `4096` | | `repetition_penalty` | `1.0` | # Structured Outputs Source: https://docs.sutro.sh/concepts/structured-outputs Reference documentation for Structured Outputs. # Structured Outputs Sutro's SDK allows you to enforce a JSON schema for the outputs of your inference job. This can be useful if you want to ensure that the outputs of your inference job are in a specific format, or if you want to extract specific information from the outputs. To specify a schema for the outputs of your inference job, you can pass a Pydantic Model or a JSON schema to the `output_schema` parameter in the SDK. If you pass a JSON schema, it must follow the json-schema.org specification. ## Example with Pydantic Model ```python theme={null} import sutro as so from pydantic import BaseModel texts = [ "The airplane is flying at an altitude of 30,000 feet.", "The capital of France is Paris.", "The best way to cook a steak is on a grill." ] class AviationOutput(BaseModel): is_aviation_related: bool answer_justification: str results = so.infer(texts, system_prompt="Is the following sentence aviation related? Please provide a justification for your answer.", output_schema=AviationOutput) print(results) ``` ## Example with JSON Schema ```python theme={null} import sutro as so texts = [ "The airplane is flying at an altitude of 30,000 feet.", "The capital of France is Paris.", "The best way to cook a steak is on a grill." ] json_schema = { "type": "object", "properties": { "is_aviation_related": { "type": "boolean" }, "answer_justification": { "type": "string" } }, "required": ["is_aviation_related", "answer_justification"] } results = so.infer(texts, system_prompt="Is the following sentence aviation related? Please provide a justification for your answer.", output_schema=json_schema) print(results) ``` # System Prompts Source: https://docs.sutro.sh/concepts/system-prompts Reference documentation for System Prompts. # System Prompts System prompts are used to provide consistent, task-specific instructions to the model. They are used to ensure that the model is oriented towards the task at hand. This is useful in batch inference jobs, where the task is typically uniform across all inputs. ## Basic System Prompt Example For example, let's say you are classifying customer support tickets. You can provide a consistent system prompt to the model when you run the batch inference job, like so: ```python theme={null} import sutro as so customer_support_conversations = ... # (code to retrieve customer support conversations from a database) json_schema = { "type": "object", "properties": { "was_handled_properly": { "type": "boolean" } } } system_prompt = """ You are a overseeing customer support agents. Your job is to review dialogues between customers and customer support agents, and ensure that they were handled properly. You will be provided a dialogue, and you should respond with True if the dialogue was handled properly, and False otherwise." """ results = so.infer( inputs=dialogues, system_prompt=system_prompt, output_schema=json_schema ) print(results) ``` # Large Scale Embedding Generation with Qwen3 0.6B Source: https://docs.sutro.sh/examples/large-scale-embeddings Easily (and inexpensively) create a semantic search index of over 4M document chunks from Apple's patent literature, using Sutro
10 min read \~1-2 hour project \~15 Beginner
## Overview In this example, we're going to demonstrate how to easily embed over 4M document chunks to create a searchable index. The documents we'll be embedding is the entire corpus of Apple's patent literature. By the end of this guide we'll be able to search for things like "wireless charging technology", "biometric authentication methods", or even complex queries like "patents related to reducing battery consumption in mobile devices" - and get relevant patent results in milliseconds. ### Why Embeddings and Vector Search Matter Traditional keyword search breaks down when users don't know the exact terminology - searching for "making phones last longer" won't find documents about "battery optimization." Embeddings solve this by converting text into vectors that better capture semantic meaning, enabling search that actually understands intent rather than just matching strings. What once required teams of search quality experts can now be implemented in an afternoon for around \$15, as we'll demonstrate by making 30,000 Apple patents (split into over 4M+ document chunks) semantically searchable. ## Data Source The source for the corpus of patent documents will be Google BigQuery, where we can query for the full text and all relevant metadata with the below query. The results for this query (the base for our embeddings) can be found at this [HuggingFace Dataset](https://huggingface.co/datasets/sutro/apple-patents-bigquery) ```sql theme={null} SELECT DISTINCT p.publication_number, p.application_number, p.country_code, p.kind_code, title.text as patent_title, abstract.text as patent_abstract, claims.text as patent_claims, description.text as patent_description, p.filing_date, p.publication_date, p.grant_date, p.priority_date, p.inventor, assignee.name as assignee_name, p.cpc, p.family_id, FROM `patents-public-data.patents.publications` AS p, UNNEST(title_localized) AS title, UNNEST(abstract_localized) AS abstract, UNNEST(claims_localized) AS claims, UNNEST(description_localized) AS description, UNNEST(assignee_harmonized) as assignee WHERE LOWER(assignee.name) LIKE '%apple inc%' AND p.kind_code IN ('B1', 'B2') AND p.country_code = 'US' AND p.grant_date IS NOT NULL AND title.language = 'en' AND abstract.language = 'en' AND claims.language = 'en' AND description.language = 'en' ORDER BY p.grant_date DESC ``` We'll store the results from that query into a Polars `DataFrame` using the below snippet. Polars is great for efficiently manipulating large datasets. ```python theme={null} # Authenticate credentials = service_account.Credentials.from_service_account_info( json.loads(os.environ['SERVICE_ACCOUNT_JSON']) ) project_id = 'xxxxx' client = bigquery.Client(project=project_id, credentials=credentials) # Run query query_job = client.query(query_full_text) results = query_job.result() patents_df = pl.from_arrow(results.to_arrow()) ``` ## Document Chunking After retrieving and storing the patent results, we'll need to chunk all the relevant sections into smaller sections of text. ### Why Chunking is Essential Chunking is essential when working with embeddings mainly because of **semantic precision**, essentially meaning it is important for the reader of the results to be able to interpret them with the correct contextual meaning: Smaller chunks allow for more precise retrieval of relevant information, however there is a critical balance to strike: **Too small chunks (e.g., 50-100 tokens):** * ✅ High precision - returns exactly what matches * ❌ Loss of contextual meaning - "battery life" might miss "wasn't that good" in the next sentence * ❌ Fragmented results - you might need to retrieve multiple chunks to get a complete idea * ❌ More vectors to store and search (higher costs) **Too large chunks (e.g., 2000+ tokens):** * ✅ Rich context preserved - full patent claims or entire technical descriptions * ✅ Fewer vectors to manage (lower costs) * ❌ Diluted relevance - a chunk about "display technology" might rank poorly for "OLED" even if it contains relevant OLED information buried within * ❌ Multiple concepts per chunk - retrieval becomes less precise **The sweet spot (typically 200-800 tokens) depends on:** * Your content type (patent claims are self-contained; descriptions are narrative) * Search intent (looking for specific facts vs. understanding concepts) * Embedding model characteristics (some models better preserve semantics in longer sequences) ### Chunking Strategy For this example, we're using a simple fixed-size chunking strategy with overlap, but there are many approaches to consider: * **Fixed-size chunking**: Simple and predictable, splits text every N characters/tokens * **Semantic chunking**: Uses NLP to find natural boundaries (sentences, paragraphs, sections) * **Recursive chunking**: Hierarchically splits documents while preserving structure * **Corpus-specific chunking**: For patents, you might chunk by claims, abstract, description sections; for code, you might designate chunks by function boundaries You can view the implementation of our chunking strategy in the collapsable below. We chose to write this ourselves for simplicity, but we know teams like [Unstructured](https://unstructured.io/) also do a great job here! ```python theme={null} def chunk_text(text: str, chunk_size: int = 2000, overlap: int = 400) -> List[str]: """Split text into overlapping chunks for embedding""" if not text: return [] chunks = [] start = 0 text_length = len(text) while start < text_length: end = min(start + chunk_size, text_length) chunk = text[start:end] chunks.append(chunk) start += chunk_size - overlap return chunks def prepare_patent_for_embedding(row: Dict) -> List[Dict]: """Convert patent row to embedding-ready documents""" patent_id = row['publication_number'] base_metadata = { 'patent_id': patent_id, 'title': row['patent_title'], 'assignee': row['assignee_name'], 'grant_date': str(row['grant_date']), 'inventors': row['inventor'], 'cpc_codes': row['cpc'], 'family_id': row['family_id'], } documents = [] # Title as separate document documents.append({ 'doc_id': hashlib.md5(f"{patent_id}_title".encode()).hexdigest(), 'patent_id': patent_id, 'section': 'title', 'text': row['patent_title'], 'metadata': base_metadata }) # Abstract as separate document if row['patent_abstract']: documents.append({ 'doc_id': hashlib.md5(f"{patent_id}_abstract".encode()).hexdigest(), 'patent_id': patent_id, 'section': 'abstract', 'text': row['patent_abstract'], 'metadata': base_metadata }) # Claims - chunk if long if row['patent_claims']: claims_chunks = chunk_text(row['patent_claims']) for i, chunk in enumerate(claims_chunks): documents.append({ 'doc_id': hashlib.md5(f"{patent_id}_claims_{i}".encode()).hexdigest(), 'patent_id': patent_id, 'section': 'claims', 'chunk_index': i, 'text': chunk, 'metadata': base_metadata }) # Description - chunk into smaller pieces if row['patent_description']: desc_chunks = chunk_text(row['patent_description']) for i, chunk in enumerate(desc_chunks): documents.append({ 'doc_id': hashlib.md5(f"{patent_id}_description_{i}".encode()).hexdigest(), 'patent_id': patent_id, 'section': 'description', 'chunk_index': i, 'text': chunk, 'metadata': base_metadata }) return documents ``` ```python theme={null} # Process patents for embedding all_documents = [] for row in patents_df.iter_rows(named=True): all_documents.extend(prepare_patent_for_embedding(row)) ``` ## Preparing Data for Sutro Once we have our patent documents split into chunked sections, we can then pass them to Sutro to transform them into embeddings! Since we're working with such a large amount of data, we'll write everything to a single Parquet object, upload it to our own object storage (Amazon S3 in this example), and hand Sutro a presigned HTTPS GET URL. This keeps the submission request small and avoids loading the full corpus into the SDK process. See [Presigned S3 Inputs](/python-sdk/presigned-s3-inputs) for the full contract. ```python theme={null} docs_df = pl.DataFrame({ 'doc_id': [doc['doc_id'] for doc in all_documents], 'patent_id': [doc['patent_id'] for doc in all_documents], 'section': [doc['section'] for doc in all_documents], 'text': [doc['text'] for doc in all_documents], 'metadata': [json.dumps(doc['metadata']) for doc in all_documents] }) local_file = "apple_patents_documents.parquet" docs_df.write_parquet(local_file, compression='snappy') print(f"Saved {len(docs_df)} documents to {local_file}") ``` Now we'll upload the Parquet file to S3 and generate a presigned GET URL: ```python theme={null} import boto3 s3 = boto3.client("s3") bucket = "your-bucket" key = "apple-patents/apple_patents_documents.parquet" s3.upload_file(local_file, bucket, key) presigned_url = s3.generate_presigned_url( ClientMethod="get_object", Params={"Bucket": bucket, "Key": key}, ExpiresIn=6 * 60 * 60, # leave headroom for the worker's download ) ``` ## Running the Embedding Job Once our data is uploaded, we can use it to run our embedding job. For this example, we chose to use **Qwen3-Embedding-0.6B**; it has a great balance of performance due to only having 595M parameters, but still performs very well on relevant tasks like retrieval and re-ranking. See the [MTEB leaderboard](https://huggingface.co/spaces/mteb/leaderboard) for a more in-depth and numerical comparison. Now we'll kick off our embedding job with the code below: ```python theme={null} job_id = sutro.infer( presigned_url, column="text", model="qwen-3-embedding-0.6b", job_priority=1, ) sutro.await_job_completion(job_id, obtain_results=False) ``` > **Note:** We're using `job_priority=1` here. Sutro currently has a notion of job priorities, which is essentially how we designate job SLAs. Currently, we have two priorities:- **Priority 0**: Prototyping jobs (for small scale testing, targeted at \<= 10m completion time)- **Priority 1**: Production oriented jobs (large scale jobs, targeted at 1hr completion time)More details about job priorities can be [found here](https://docs.sutro.sh/concepts/job-priority). Once we have our job started, we wait for it to complete using `await_job_completion(...)`. This will periodically poll for the job's status until its complete; alternatively, we can monitor via [Sutro's web UI](https://app.sutro.sh). We also disabled automatically fetching the results. The default `get_job_results()` helper materializes the entire result set as JSON, which isn't viable for 4M embeddings. Instead, once the job succeeds, we use `download_job_results()` to pull the results down as a single Parquet artifact: ```python theme={null} local_results = sutro.download_job_results(job_id, include_inputs=True) job_results_df = pl.read_parquet(local_results) print(f"Total patents: {len(job_results_df)}") print(f"Columns: {job_results_df.columns}") # Total patents: 4039988 # Columns: ['inputs', 'job-76844041-b2bf-4248-9603-b7f750231b34'] ``` The download streams to disk with a progress bar and, if interrupted, resumes where it left off when rerun. If you need the presigned URLs themselves (e.g. to download from a different machine), use `results_download_url()` — see [Presigned Results URLs](/batch-api-reference/job-results-url) for details. If you want to play around with the embeddings yourself, the entire set can be found [here on HuggingFace](https://huggingface.co/datasets/sutro/apple-patents-embeddings)! ## Loading into the Vector Database Now we get to upload the 4M embeddings we just pulled down from Sutro into a vector database, so that we can search over the entire corpus. When uploading, we want to preserve all the metadata associated with each chunk, so that we can correctly attribute a retrieved vector to the right patent and section within the patent. ```python theme={null} # The embeddings column in the results is named after the job vector_col_name = job_id # Combine the embeddings with docs_df containing attributing IDs and metadata combined_df = docs_df.with_columns( job_results_df[vector_col_name] ) ``` We chose **Qdrant** as our vector DB of choice for this example; its performant and easy enough to get started with. However, there are many options out there that are well adapted to different use cases, notable ones being: * **TurboPuffer** - Great for multi-tenant architectures with many tenants * **Chroma** - Simple and developer-friendly * **pgvector** - If you're already using PostgreSQL Since we have such a large dataset, we want to upload using batches: ```python theme={null} # We're using an im-memory DB here for convenience, but Qdrant Cloud # can be faster to upload to and will persist the embeddings as well client = QdrantClient(":memory:") collection_name = "apple_patents_collection" # Create the collection, inferring vector size from the first # row of the DataFrame client.recreate_collection( collection_name=collection_name, vectors_config=models.VectorParams( size=len(combined_df[vector_col_name][0]), distance=models.Distance.COSINE ), ) BATCH_SIZE = 8192 # Loop through the DataFrame in batches for i in tqdm(range(0, len(combined_df), BATCH_SIZE), desc="Uploading to Qdrant"): batch_df = combined_df.slice(i, BATCH_SIZE) points = [ models.PointStruct( id=row['doc_id'], vector=row[vector_col_name], payload={ 'patent_id': row['patent_id'], 'section': row['section'], 'text': row['text'], **json.loads(row['metadata']) } ) for row in batch_df.to_dicts() ] client.upload_points( collection_name=collection_name, points=points, wait=True, parallel=4 ) print(f"Finished uploading all {len(combined_df)} points.") ``` ## Searching Your Patents Now that we have our embeddings loaded, we can search over them! ```python theme={null} def search_patents(query_text, top_k=5): # Pick your real time provider of choice, we have heard great # things (latency & consistency wise) about Vertex, but Baseten # is the easier choice # https://www.baseten.co/library/qwen3-06b-embedding/ query_embedding = real_time_api( text=query_text, model="qwen-3-embedding-0.6b" ) # Search in Qdrant results = client.search( collection_name=collection_name, query_vector=query_embedding, limit=top_k ) for result in results: print(f"Patent: {result.payload['patent_id']}") print(f"Section: {result.payload['section']}") print(f"Score: {result.score:.3f}") print(f"Text: {result.payload['text'][:200]}...") print("-" * 80) # Let's test it out! search_patents("wireless charging efficiency improvements") ``` ## Example Queries We Tried ### `wireless charging efficiency improvements` ### 1. Patent US-11994681-B2 - description **Score:** 0.946 into waveguide 26 exhibits a relatively wide effective field of view 70 . Switchable reflective layer 56 may be switched between the first and second states at a speed greater than the response speed of the human eye (e.g., greater than 60 Hz, greater than 120 Hz, greater than 240 Hz, greater than 1 kHz, greater than 10 kHz, etc.) so that a user at eye box 24 ( FIG. 2 ) is unable to separately perceive each state and instead perceives a single effective field of view 70 . In this way, image light 22 may be coupled into waveguide 26 and provided to the eye box with a wider effective field of view than would otherwise be provided to the eye box. As an example, fields of view 62 and 66 may each be 30 degrees, 25 degrees, between 25 and 35 degrees, less than 45 degrees, etc., whereas field of view 70 is 60 degrees, between 55 and 65 degrees, greater than 45 degrees, or any other desired angle greater than field of view 62 or field of view 66 . FIG. 6 sh *** ### 2. Patent US-11054882-B2 - description **Score:** 0.874 ments thereof are shown by way of example in the drawings and will herein be described in detail. It should be understood, however, that the drawings and detailed description thereto are not intended to limit the embodiments to the particular form disclosed, but on the contrary, the intention is to cover all modifications, equivalents and alternatives falling within the spirit and scope of the appended claims. The headings used herein are for organizational purposes only and are not meant to be used to limit the scope of the description. As used throughout this application, the word “may” is used in a permissive sense (i.e., meaning having the potential to), rather than the mandatory sense (i.e., meaning must). Similarly, the words “include”, “including”, and “includes” mean “including, but not limited to.” As used herein, the terms “first,” “second,” etc. are used as labels for nouns that they precede, and do not imply any type of ordering (e.g., spatial, temporal, logical, etc.) unle *** ### 3. Patent US-11380077-B2 - description **Score:** 0.874 ed in units of pressure). Using the intensity of a contact as an attribute of a user input allows for user access to additional device functionality that may otherwise not be accessible by the user on a reduced-size device with limited real estate for displaying affordances (e.g., on a touch-sensitive display) and/or receiving user input (e.g., via a touch-sensitive display, a touch-sensitive surface, or a physical/mechanical control such as a knob or a button). As used in the specification and claims, the term “tactile output” refers to physical displacement of a device relative to a previous position of the device, physical displacement of a component (e.g., a touch-sensitive surface) of a device relative to another component (e.g., housing) of the device, or displacement of the component relative to a center of mass of the device that will be detected by a user with the user's sense of touch. For example, in situations where the device or the component of the device is in *** ### 4. Patent US-11128157-B2 - description **Score:** 0.869 scan for communications (e.g., Bluetooth communications from secondary power receiving device 24 B) at a first rate. In response to the notification, the primary power receiving device 24 A may use the antenna to scan for communications at a second rate that is faster than the first rate. By increasing the rate of scanning for communications, the primary power receiving device 24 A may receive any communications from secondary power receiving device 24 B at an earlier time than if the rate was not increased. In the event that the newly added object is not a supported wireless power receiving device, primary power receiving device 24 A will not actually receive the expected wireless communication. However, in this case, the faster scan rate may time-out after a predetermined length of time (e.g., after the predetermined length of time the scan rate will revert back to the first scan rate) without any adverse effects. Additional action may be taken by primary power receiving d *** ### 5. Patent US-10742297-B2 - description **Score:** 0.869 , where for a period larger than 1.60 ms, 240 occasions can be configured. Note that, as above, 60 subframes may be used, thus I CQI/PMI has incrementations of 60 in Tables 4 and 5. TABLE 4 mframe Value of Value of offset I CQI/PMI N pd,frame N OFFSET,CQI N OFFSET,mframe ### `biometric authentication using facial recognition` ### 1. Patent US-10928697-B1 - description **Score:** 0.907 tween the first transparent layer and the second transparent layer, the transparent light-producing layer having light-emitting diodes that are arranged in an array, and a controller for causing the transparent light-producing layer to display information using the light-emitting diodes. BRIEF DESCRIPTION OF THE DRAWINGS FIG. 1 is a side-view representation of an example configurable transparent structure for lighting and/or display. FIG. 2A is a side-view representation of an example configurable transparent structure for lighting and/or display that employs an edge-lit light guide plate. FIG. 2B is a side-view representation of an example configurable transparent structure for lighting and/or display that employs an organic light-emitting diode (OLED) display layer. FIG. 2C is a side-view representation of an example configurable transparent structure for lighting and/or display that employs a micro-light-emitting di *** ### 2. Patent US-11150734-B2 - description **Score:** 0.907 ion mechanism 210 and may couple the deflection mechanism 210 to a surface. The surface may be a cover glass of an electronic device, a housing of the electronic device, and so on. Because the surface is coupled to the deflection mechanism 210 , as the deflection mechanism 210 deflects, the surface may also deflect and provide a haptic output. Although the haptic structure 200 is specifically discussed with respect to an electronic device, the haptic structure 200 may be used with other devices including mechanical devices and electrical devices, as well as non-mechanical and non-electrical devices such as described herein. FIG. 3A illustrates another example haptic structure 300 for an electronic device. The haptic structure 300 may be referred to as a cantilevered beam structure as one end of the deflection mechanism 310 is coupled to, machined from, or otherwise integrated with a substrate of the haptic structure 300 while the other end of the defle *** ### 3. Patent US-11868258-B2 - description **Score:** 0.907 he bytes in a cache block. Thus, the coherency controller 24 may cause other agents to invalidate the cache block. If an agent has the cache block modified, the agent may supply the modified cache block to the request agent. Otherwise, the agents may not supply the cache block. The coherency controller 24 may be configured to read the directory entry for the address of the request (block 220 ). Based on the cache states in the directory entry, the coherency controller 24 may be configured to generate snoops. More particularly, if a given agent may have a modified copy of the cache block (e.g., the given agent has the cache block in exclusive or primary state) (block 222 , “yes” leg), the coherency controller 24 may generate a snoop forward-Dirty only (SnpFwdDonly) to the agent to transmit the cache block to the request agent (block 224 ). As mentioned above, the SnpFwdDonly request may cause the receiving agent to transmit the cache block if the data is modified, but o *** ### 4. Patent US-11393258-B2 - description **Score:** 0.906 more activations required to initiate biometric authentication such that the electronic device is enabled to implement the respective function. In some examples, the electronic device (e.g., 2300 , 2400 ) displays, on the display, the prompt to provide the one or more activations of the button (e.g., 2304 , 2404 ) at a first position in the biometric authentication interface (e.g., 2322 , 2420 ). Outputting a prompt requesting that one or more activations of the button be provided provides the user with feedback about the current state of the device and provides visual feedback to the user indicating what steps the user must take in order to proceed with a particular function using the device. Providing improved visual feedback to the user enhances the operability of the device and makes the user-device interface more efficient (e.g., by helping the user to provide proper inputs and reducing user mistakes when operating/interacting with the device) which, additionally, red *** ### 5. Patent US-11764907-B2 - description **Score:** 0.906 henever the product of residual SINRs is below 1, the error probability for both use cases is reduced and consequently, achieving a certain target rate requires less power. FIG. 8 , which is motivated by the finding above, shows a graph 800 of the achievable outage probability as a function of the transmit power when a single (so-called one-shot) transmission is performed with low rate. Similarly, as before, we are interested in the performance of the single device using a dedicated resource, and of two devices sharing the slot. To achieve fairness, in the case of two users, their transmit powers are adjusted so that the sum matches that of a single user. The particular values are found by solving a min-max problem. For example, the maximum of the tuple (P er 1 , er 2 ) is minimized according to the following set of equations: ### `battery thermal management` ### 1. Patent US-11700035-B2 - description **Score:** 0.838 nating elements 68 (e.g., as shown by arrow 162 of FIG. 16 ). Manufacturing equipment 148 may, for example, use lasers to activate or create a seed layer on dielectric resonating elements 68 . Manufacturing equipment 148 may then deposit conductive material over the activated portions of dielectric resonating elements 68 . The conductive material may form conductive structures 86 V and 86 H (e.g., for feed probes 100 V and 100 H of FIG. 6 ) and/or parasitic elements for the antennas. At step 178 , manufacturing equipment 148 may surface-mount connectors 123 onto the connector contact pads 168 of substrate 72 (e.g., as shown by arrow 166 of FIG. 17 ). At step 180 , manufacturing equipment 148 may dice substrate 180 into individual antenna modules 120 and may add corresponding shielding structures to the antenna modules (e.g., as shown by arrow 170 of FIG. 17 ). The shielding may serve to isolate electronic components 150 fr *** ### 2. Patent US-11297732-B2 - description **Score:** 0.838 illustrate in top plan views various stages of a partially assembled exemplary outer housing foot with integrated fan assembly according to various embodiments of the present disclosure. FIG. 6 illustrates in side cross-sectional view an exemplary electronic device having a low profile thermal flow assembly according to various embodiments of the present disclosure. FIG. 7 illustrates in top perspective view an exemplary impeller and fin stack arrangement for an integrated fan assembly according to various embodiments of the present disclosure. FIGS. 8A and 8B illustrate in bottom plan views exemplary foot and scroll geometries for an integrated fan assembly according to various embodiments of the present disclosure. FIG. 9 illustrates a flowchart of an exemplary method of cooling an electronic device according to various embodiments of the present disclosure. FIG. 10 illustrates in block diagram format an exemplary computing devi *** ### 3. Patent US-11605274-B2 - description **Score:** 0.838 ement 212 , but has to still select “yes” to end the call. In this way, a user cannot accidentally end the call with the emergency service. In some cases, block 214 is an alternate option for block 206 , where the layout of the text is different and the call time is shown. In some examples, the audio messages can be configured to continue looping as long as the call with the emergency is active or until the user selects the “Stop Recorded Message” UI element. If the emergency service responder hangs up, the call will end. In some instances, the workflow may begin to reestablish the call (or make another call) if the user remains nonresponsive. Alternatively, if the call ends, and the emergency service responder calls back, the device 102 may answer the call and begin playing the already generated audio message. This audio message could also be looped until the user selects the “Stop Recorded Message” UI element. Further, in some cases, the device 102 may detect an indicat *** ### 4. Patent US-11994681-B2 - title **Score:** 0.838 Optical systems with reflective prism input couplers *** ### 5. Patent US-10719225-B2 - description **Score:** 0.838 ronic device (e.g., the first software application), such as a background application, a suspended application, or a hibernated application. Thus, the user can perform operations that are not provided by the application currently displayed on the display of the electronic device (e.g., the second software application) but are provided by one of the currently open applications (e.g., displaying a home screen or switching to a next software application using gestures for a hidden application launcher software application). In some embodiments, the first software application is ( 804 ) an application launcher (e.g., a springboard). For example, as shown in FIG. 7A , the application launcher displays a plurality of application icons 5002 that correspond to a plurality of applications. The application launcher receives a user-selection of an application icon 5002 (e.g., based on a finger gesture on touch screen 156 ), and in response to receiving the user-selection, launches an ### `haptic feedback for touch interfaces` ### 1. Patent US-12193062-B2 - abstract **Score:** 0.897 Disclosed are techniques for reducing likelihood of Random Access Channel (RACH) transmission blockages and thereby facilitate an initial access procedure for new radio (NR) unlicensed spectrum (NR-U) operation in a fifth generation (5G) wireless communication system including an NR node. In some embodiments, a parameter generated by a gNB and received by a UE indicates that, from among a set of consecutive RACH Occasions (ROs), a gap is available for performing a listen-before-talk (LBT) procedure before commencing a RACH transmission. *** ### 2. Patent US-11468890-B2 - description **Score:** 0.897 mediums), memory controller 122 , one or more processing units (CPUs) 120 , peripherals interface 118 , RF circuitry 108 , audio circuitry 110 , speaker 111 , microphone 113 , input/output (I/O) subsystem 106 , other input control devices 116 , and external port 124 . Device 100 optionally includes one or more optical sensors 164 . Device 100 optionally includes one or more contact intensity sensors 165 for detecting intensity of contacts on device 100 (e.g., a touch-sensitive surface such as touch-sensitive display system 112 of device 100 ). Device 100 optionally includes one or more tactile output generators 167 for generating tactile outputs on device 100 (e.g., generating tactile outputs on a touch-sensitive surface such as touch-sensitive display system 112 of device 100 or touchpad 355 of device 300 ). These components optionally communicate over one or more communication buses or signal lines 103 . As used in the specification and claim *** ### 3. Patent US-11733656-B2 - description **Score:** 0.897 rt of the second user interface screen; detect (e.g., with detecting unit 2516 ) a contact on the touch-sensitive surface unit (e.g., touch-sensitive surface unit 2510 ) at the affordance for revealing an edit option; and in response to detecting the contact at the affordance for revealing an edit option, enable display (e.g., with display enabling unit 2508 ), on the display unit (e.g., display unit 2502 ), of a delete affordance in association with the first user interface preview image as part of the second user interface screen. In some embodiments, displaying the delete affordance comprises translating the first user interface preview image on-screen. In some embodiments, the processing unit 2506 is further configured to: after displaying the delete affordance as part of the second user interface screen, detect (e.g., with detecting unit 2516 ) a contact on the touch-sensitive surface unit (e.g., touch-sensitive surface unit 2510 ) at the delete affordance disp *** ### 4. Patent US-11379113-B2 - title **Score:** 0.897 Techniques for selecting text *** ### 5. Patent US-11039417-B2 - description **Score:** 0.897 at least SIB2 from the system information. At 1832 , the UE may compute a paging frame identifier I PF and a paging occasion identifier I PO based on the DRX cycle T, the parameter nB and the Range\_UE\_ID. At 1835 , the base station transmits a paging message 1840 for the link-budget-limited UE. The paging message is included in a paging frame and paging occasion consistent with the previously transmitted values of DRX cycle T, parameter nB and Range\_UE\_ID. At 1845 , the UE wakes up for every subframe consistent with the computed paging frame identifier and computed paging occasion identifier, and checks the PDCCH of the subframe for the presence of P-RNTI. At 1850 , if the UE determines that P-RNTI is present in the PDCCH, the UE decodes resource allocation information from the PDCCH, and checks PDSCH resource block(s) identified by the allocation information, e.g., PDSCH resource blocks in the same subframe as the PDCCH. At 1855 , if the paging Interestingly, none of the results for our queries seem very good! We imagine that this is mainly due to a few things. 1. The language used in our queries is *very* different from the langauge used in the patent documents, so the similarity between the query-document pairs is generally not great. There are well known fixes to this problem, commonly [HyDE](https://arxiv.org/abs/2212.10496) is used to generate queries that are more similar to langauge in the real document, and thus retrieve better results for the same source query. 2. Under retrieving: we're currently only retrieving the first 5 documents, which is not very many; if we retrieved more documents, its likely we'd have more relevant snippets in our results. 3. Not reranking: Combining a higher top\_k with a re-ranking step can lead to the finding the most relevant set documents. These two techniques used together can prove to be very powerful and is common with many folks we talk to who use vector search in production. ## Scale, Cost & Speed Breakdown ### Scale * **Chunk count**: 4.04M * **Input token count**: 879.5M ### Cost Breakdown * **BigQuery query**: \~\$6 * **Sutro embedding generation**: \$8.80 * **Total: \~\$14.80** ### Time * **Job completion time**: 44 minutes ## Conclusion In this guide, we've demonstrated how Sutro makes it trivial to: * Go from source documents to a searchable index in under 2 hours * Generate high-quality embeddings using state-of-the-art models * Build a semantic search system that can easily be productionized The entire pipeline - from data extraction to searchable index - can be run using a Jupyter notebook and cost under \$20. Sutro handled all the worker fan out, inference, and fault tolerance automatically. ### Next Steps * Try different embedding models for your case * Experiment with different techniques to improve retrieval quality * Hybrid search (combining embeddings with keyword search) * HyDE * Over retrieval and re-ranking * Productionize this workflow as part of an event driven pipeline that creates new indices for every X event (say a new user signing up) ### Resources * [MTEB Leaderboard](https://huggingface.co/spaces/mteb/leaderboard) * [Apple Patents Dataset](https://huggingface.co/datasets/sutro/apple-patents-bigquery) * [Embeddings Dataset](https://huggingface.co/datasets/sutro/apple-patents-embeddings) * [Sutro Documentation](https://docs.sutro.sh) # LLM-as-a-Judge - Iteratively Improve Models, AI Apps, and Agents Offline Source: https://docs.sutro.sh/examples/llm-as-a-judge Learn three LLM-as-a-judge techniques to improve models and agents without human feedback
25 min read 3 hour project \~\$100 Medium
### Overview If you're training LLMs, building AI apps, or developing agents - you'll need some way to evaluate their performance. This can come at different points in the development lifecycle: during initial development, as it's in production to detect regressions or drift, or as you seek to upgrade to newer models and/or configurations. Because the outputs of LLMs, AI apps, and agents are typically subjective or open-ended, there are essentially only three ways performance can be measured: 1. Human data labeling 2. Real user feedback 3. LLM-as-a-judge 4. ✨ *Vibes* ✨ Human data labeling is the most time-consuming and extremely expensive. It's also not reproducible - different human labelers can have different interpretations and preferences for the same data. Real user feedback is often the most helpful, but often not something that's available until you have a lot of users. You'll likely want a way to bootstrap evaluate your application before it hits production and scale. [Much](https://x.com/justinstorre/status/1964029634796015685) [has](https://x.com/swyx/status/1963725773355057249) [been](https://x.com/lennysan/status/1963688207280955839) [written](https://x.com/sh_reya/status/1963988545057456138) about vibes-based evals - a debate we don't intend to get into here. This leaves us with option 2: LLM-as-a-judge, a surprisingly practical, effective, and rigorous method1 when done correctly. In this guide, we'll show you a few techniques for using LLM-as-a-judge to iteratively improve your models, apps, and agents without the need for human feedback. Using our ensemble approaches, you'll also see how we can avoid the biases that are present in human labelers or any one evalautor model. We'll implement them using the [Sutro SDK](/python-sdk/setup) which will make experiments fast, cheap, scalable, collaborative, easy to run - and dare we say fun?! ### The Goal We're really going to *nerd out* on this one: our task today is to determine which open-source model family is best at creating ELI5 explanations of paper abstracts from the pre-print server [Arxiv](https://arxiv.org/). ELI5 stands for "Explain Like I'm 5", popularized by the now-defunct dataset [ELI5](https://research.facebook.com/publications/eli5-long-form-question-answering/) from Facebook. This task may seem trivial, but it's a great demonstration of LLM-as-a-judge for a few reasons: 1. It requires high-level world-knowledge and understanding of complex technical concepts 2. It requires the ability to map a high-level concept to a low-level explanation and communicate it effectively 3. It's extremely subjective, so there is no realistic way a ground-truth label set can be created But don't let the specificity of the task mislead you - **the following techniques can be applied to almost any scenario where you're evaluating model, AI app, or agent performance**. ### Getting the data We'll grab the current snapshot of the [Arxiv metadata from Kaggle](https://www.kaggle.com/datasets/Cornell-University/arxiv) and sample 100,000 rows. ```python theme={null} import sutro as so import polars as pl import json rows = [] with open('arxiv-metadata-oai-snapshot.json', 'r') as f: for line in f: data = json.loads(line) id, categories, abstract = data['id'], data['categories'], data['abstract'] if categories and abstract: rows.append({'id': id, 'categories': categories, 'abstract': abstract}) df = pl.DataFrame(rows) df.write_parquet('arxiv-metadata-id-categories-abstract-100000.snappy.parquet', compression='snappy') ``` Let's briefly inspect the data to get a sense of what we're working with. ```python theme={null} import matplotlib.pyplot as plt import seaborn as sns # read the parquet file df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000.snappy.parquet') # cast categories to list by separating on space df = df.with_columns(pl.col('categories').str.split(' ').alias('categories_list')) # create high-level categories list by splitting on '.' for each item in categories_list def get_high_level_categories(categories_list): return list(set([item.split('.')[0] for item in categories_list])) df = df.with_columns(pl.col('categories_list').map_elements(get_high_level_categories).alias('high_level_categories_list')) # explode the high_level_categories_list df = df.explode('high_level_categories_list') # aggregate by high_level_categories_list and count the number of papers df = df.group_by('high_level_categories_list').agg(pl.count()).sort('count', descending=True) # create a better looking bar chart of the high_level_categories_list plt.figure(figsize=(12, 8)) sns.set_theme(style="whitegrid") # Convert to pandas for seaborn df_pandas = df.to_pandas() # Create a more attractive bar plot ax = sns.barplot( data=df_pandas, x='high_level_categories_list', y='count', palette='viridis', order=df_pandas.sort_values('count', ascending=False)['high_level_categories_list'] ) # Improve the styling plt.xticks(rotation=45, ha='right') plt.title('ArXiv High-Level Categories Distribution', fontsize=16, fontweight='bold', pad=20) plt.xlabel('High-Level Categories', fontsize=12, fontweight='bold') plt.ylabel('Number of Papers', fontsize=12, fontweight='bold') # Improve layout plt.tight_layout() # Save with higher DPI for better quality plt.savefig('high_level_categories_distribution.png', dpi=300, bbox_inches='tight') ``` ![Sutro Web UI](https://cdn.sutro.sh/llm-as-a-judge-high-level-categories-dist.png) As you can see most of the high-level categories are computer science, math, and physics - with a long-tail of other more esoteric categories. ### Generating the explanations As of this writing, we serve 4 model "families" on the Sutro platform: Llama, Qwen, Gemma, and GPT-OSS. As a general rule of thumb, it's better to use smaller models for applications and larger models for evaluations of the applications. Generally speaking, any of the text models on the Sutro platform should be able to generate the ELI5 explanations, but the larger models should be more adept at evaluating them. Larger models generally contain more world knowledge and are better at understanding complex technical concepts. So, we'll use smaller or medium-sized variants of each model family for the explanations, and their larger counterparts for the evaluations. It's incredibly simple to generate the explanations using the Sutro SDK. For a sanity check, we'll run the first 1000 rows through each model using protoptying (`p0`) jobs. ```python theme={null} import sutro as so df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000.snappy.parquet') system_prompt = """ Explain the abstract to me like I'm a 5 year old. Return the explanation, and nothing else. """ results = so.infer( df[0:1000], column='abstract', model=["llama-3.1-8b", "qwen-3-14b", "gemma-3-12b-it", "gpt-oss-20b"], system_prompt=system_prompt, ) ``` This kicks of four jobs in parallel each of which take just a few minutes to complete. We can inspect the results quickly and easily in the Sutro Web UI: ![Sutro Web UI](https://cdn.sutro.sh/llm-as-a-judge-prototyping-jobs.gif) An example result from GPT-OSS 20B looks like this: **Input Arxiv Abstract:** `We report an experimental design where the position and resonance frequency of the Nitrogen Vacancy (NV) in a diamond are correlated with the room temperature. A simple model trained on the interpolated correlation data predicts both quantities. The predictive tracking of the NV's location enables continuous operation of the NV quantum computer under ambient conditions for a week without recalibration.` **Output ELI5 Explanation:** `We did a test to see how a tiny thing in a diamond moves and makes a special sound when the room is warm. We made a smart guess that tells us where it is and how it sounds. This lets a tiny computer in the diamond keep working all week without having to fix it.` Scaling to the 100,000 row sample is as simple as removing the slicing of the dataframe, and setting the job priority to 1. ```python theme={null} ... previous code ... results = so.infer( df, column='abstract', model=["llama-3.1-8b", "qwen-3-14b", "gemma-3-12b-it", "gpt-oss-20b"], system_prompt=system_prompt, job_priority=1, ) ``` This will kick off four jobs in parallel, each of which should take less than an hour to complete. In our case, here were the run stats: | Model | Duration | Input Tokens | Output Tokens | Cost | | -------------- | ---------- | ------------ | ------------- | ------ | | Llama 3.1 8B | 35 minutes | 27M | 16.3M | \$1.03 | | Qwen 3 14B | 48 minutes | 25.4M | 11.9M | \$5.76 | | Gemma 3 12B IT | 35 minutes | 24.1M | 18.8M | \$9.16 | | GPT-OSS 20B | 30 minutes | 29.5M | 25.8M | \$1.03 | Note this is *much* faster and less expensive compared to more latency-focused or closed-model providers. We won't try to provide exact numbers or apples-to-apples comparisons here, but you can generally expect Sutro to be about 20x faster, and up to 10x cheaper than alternatives - which is extremely important when dealing with large scale data processing and evaluation. Let's pull them down the explanations and append them to our original dataframe. ```python theme={null} jobs = { "llama-3.1-8b": "job-c5227a15-3928-479e-a988-bef4231a9f5b", "qwen-3-14b": "job-a921b731-5c24-46e5-9433-c363a25777b6", "gemma-3-12b-it": "job-c6925843-97c8-45f8-a10a-48b17be6fa86", "gpt-oss-20b": "job-2b9b70f4-fd27-40d4-9f79-276b5b34df9e", } for model, job_id in jobs.items(): results = so.get_job_results(job_id) if model == 'gpt-oss-20b': # automatically unpacks final response to content field b/c it's a reasoning model, so we handle this one differently results = results.with_columns(pl.col('content').alias(model)) results = results.drop(['content', 'reasoning_content']) else: results = results.with_columns(pl.col('inference_result').alias(model)) results = results.drop(['inference_result']) df = pl.concat([df, results], how='horizontal') df.write_parquet('arxiv-metadata-id-categories-abstract-100000-explanations.parquet') ``` ### Evaluating the explanations As mentioned earlier, it's likely that larger models will be better at evaluating the explanations. This is because they contain more world knowledge and have more free parameters with which to reason. In some cases, you may have a "trusted" model that you want to use for all evaluations. But in this, case how do we know which model is the best judge? And how do we know the larger model in a specific family won't be biased towards its smaller sibling due to similarities in the underlying training data? To combat these biases, we'll use a larger model from each of the four families to evaluate the explanations from the three smaller models. Consequentially, this means that each smaller model will be evaluated by three larger models from other families. In traditional ML, this is known as ensemble modeling - using the responses of multiple models to make a single prediction. ```python theme={null} from pydantic import BaseModel df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000-explanations.parquet') eval_pairs = { "llama-3.1-8b": ["qwen-3-32b", "gemma-3-27b-it", "gpt-oss-120b"], "qwen-3-14b": ["llama-3.3-70b", "gemma-3-27b-it", "gpt-oss-120b"], "gemma-3-12b-it": ["llama-3.3-70b", "qwen-3-32b", "gpt-oss-120b"], "gpt-oss-20b": ["llama-3.3-70b", "qwen-3-32b", "gemma-3-27b-it"], } system_prompt = """ You are a judge. You will be shown an arXiv paper abstract and an explanation of the abstract intended for a 5 year old. Your job is to evaluate the explanation. You should evaluate according to the following criteria: - technical accuracy - conceptual accuracy - clarity - effectiveness of communication - overall quality Return a score between 0 and 100, and nothing else. """ class Evaluation(BaseModel): score: int for model, eval_models in eval_pairs.items(): so.infer( df, column=["Abstract: ", "abstract", " Explanation: ", model], model=eval_models, system_prompt=system_prompt, output_schema=Evaluation, name=[eval_model + '_evals_' + model for eval_model in eval_models], # name the jobs so we can easily identify them job_priority=1, ) ``` This is a very tight way to kick off the evaluation pairs! One thing you'll notice is that we're creating names for the jobs so we can easily identify them later. As our experimental setup grows, accurately attaching relevant metadata to each job becomes increasingly important for historical analysis, tracking, and collaboration. Sutro makes this easy. We can use the same eval mapping to pull down the results and append them to our original dataframe. ```python theme={null} ... previous code ... jobs = so.list_jobs() for model, eval_models in eval_pairs.items(): for eval_model in eval_models: job_name = eval_model + '_evals_' + model job = next((job for job in jobs if job['name'] == job_name), None) if job: job_id = job['job_id'] results = so.get_job_results(job_id) if eval_model == 'gpt-oss-120b': # automatically unpacks final response to content field b/c it's a reasoning model, so we handle this one differently results = results.with_columns(pl.col('content').alias('score')) results = results.with_columns(pl.col('score').struct.field('score').alias(job_name)) results = results.drop(['content', 'reasoning_content', 'score']) else: results = results.with_columns(pl.col('score').alias(job_name)) # drop all columns except job_name results = results.drop([col for col in results.columns if col != job_name]) df = pl.concat([df, results], how='horizontal') ``` Each of the jobs took around an hour to complete, and cost between 4-10 dollars each. This comes out to around 50-80 dollars for all 1.2M evals spread across the 12 runs (100k rows each). We can now see how each model performed against each of its three evaluators. Let's plot the results in a heatmap. ```python theme={null} import pandas as pd df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000-explanations-evals.parquet') i_vals = sorted(list(set([eval_model for eval_models in eval_pairs.values() for eval_model in eval_models]))) j_vals = sorted(list(eval_pairs.keys())) matrix = np.zeros((len(i_vals), len(j_vals))) for model, eval_models in eval_pairs.items(): for eval_model in eval_models: job_name = eval_model + '_evals_' + model scores = df[job_name] matrix[i_vals.index(eval_model), j_vals.index(model)] = scores.mean() # wrap in DataFrame with labels df_matrix = pd.DataFrame(matrix, index=i_vals, columns=j_vals) plt.figure(figsize=(10, 8)) sns.heatmap(df_matrix, annot=True, fmt=".2f", cmap="RdYlGn", cbar=True, square=True) plt.title("Score Matrix") plt.xlabel("ELI5 Model") plt.ylabel("Evaluator Model") plt.savefig("llm-as-a-judge-score-matrix.png", dpi=300, bbox_inches='tight') plt.show() ``` ![Heatmap of the score matrix](https://cdn.sutro.sh/llm-as-a-judge-score-matrix.png) These are interesting and revealing results. We can see that the GPT-OSS 20B model has the strongest overall performance as reviewed by the three larger models in other families. The GPT-OSS 120B model is the also the harshest evaluator, giving the lowest scores to the other model families. The situation is inverted for the Llama models, where Llama 3.1 8B is the weakest performing model as reviewed by the three larger models in other families, yet Llama 3.3 70B is the most generous evaluator of the other model families. We already likely have our answer: GPT-OSS 20B is the best model for this task of the models we evaluated. At this point, we could move onto another set of evals to optimize our prompt, sampling parameters, or structured output schema. Instead, we'll run one more set of evals to confirm our hypothesis. ### Using relative (ranking-based) evaluations In our previous evals, we used larger models to evaluate the ELI5 explanations in isolation: just showing the abstract and the explanation to the evaluator model and asking it to score the explanation on a scale of 0 to 100. But since we're trying to understand which model is best for our task, it makes more sense to compare relative performance directly. This is where relative evaluations come in. We'll now show each evaluator all three ELI5 explanations at once, and ask it to rank them from best to worst. ```python theme={null} df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000-explanations-evals.parquet') inverse_eval_pairs = { "qwen-3-32b": ["llama-3.1-8b", "gemma-3-12b-it", "gpt-oss-20b"], "gemma-3-27b-it": ["llama-3.1-8b", "qwen-3-14b", "gpt-oss-20b"], "gpt-oss-120b": ["llama-3.1-8b", "qwen-3-14b", "gemma-3-27b-it"], "llama-3.3-70b": ["qwen-3-14b", "gemma-3-12b-it", "gpt-oss-20b"], } system_prompt = """ You are a judge. You will be shown an arXiv paper abstract and three explanations of the abstract intended for a 5 year old. They will be labeled as A, B, and C. Your job is to rank the explanations from best to worst. You should evaluate according to the following criteria: - technical accuracy - conceptual accuracy - clarity - effectiveness of communication - overall quality You should return the ranking as a list of the labels A, B, and C, and nothing else. The first item in the list should be the label of the best explanation, the second item in the list should be the label of the second best explanation, and the third item in the list should be the label of the worst explanation. """ class Ranking(BaseModel): ranking: list[str] for eval_model, eli5_models in inverse_eval_pairs.items(): so.infer( df, column=["Abstract: ", "abstract", " Explanation A: ", eli5_models[0], " Explanation B: ", eli5_models[1], " Explanation C: ", eli5_models[2]], model=eval_model, system_prompt=system_prompt, output_schema=Ranking, name=[eval_model + '_relative_evals'], job_priority=1, ) ``` These jobs were a bit more token heavy, but there were only four of them. However, in total all four only jobs cost around \$35. We can pull down the results and append them to our original dataframe. ```python theme={null} df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000-explanations-evals.parquet') relative_eval_jobs = { "qwen-3-32b": 'job-8f460128-6426-4078-b355-7790a00c2dfa', "gemma-3-27b-it": 'job-5a1eed4a-44b2-4d74-a6cd-d2424e0df113', "gpt-oss-120b": 'job-64b2a381-5f25-4917-b2c9-78140da3c475', "llama-3.3-70b": 'job-2860b900-31e5-4160-b8f1-4162767215c0', } jobs = so.list_jobs() for eval_model, job_id in relative_eval_jobs.items(): eval_job_name = eval_model + '_relative_evals' results = so.get_job_results(job_id) if eval_job_name == 'gpt-oss-120b_relative_evals': # automatically unpacks final response to content field b/c it's a reasoning model, so we handle this one differently results = results.with_columns(pl.col('content').struct.field('ranking').alias(eval_job_name)) results = results.drop(['content', 'reasoning_content']) elif eval_job_name == 'llama-3.3-70b_relative_evals': # 2 of the results had problematic json formatting, so we handle this one differently results = results.with_columns( pl.when(pl.col("inference_result").str.contains('"C", "B", "A')) .then(pl.lit('{"ranking": ["C", "B", "A"]}')) .otherwise(pl.col("inference_result")) .alias("inference_result") ) results = results.with_columns(pl.col('inference_result').str.json_decode().alias('inference_result')) results = results.with_columns(pl.col('inference_result').struct.field('ranking').alias(eval_job_name)) results = results.drop(['inference_result']) else: results = results.drop([col for col in results.columns if col != 'ranking']) results = results.with_columns(pl.col('ranking').alias(eval_job_name)) results = results.drop(['ranking']) eli5_map = {"A": inverse_eval_pairs[eval_model][0], "B": inverse_eval_pairs[eval_model][1], "C": inverse_eval_pairs[eval_model][2]} results = results.with_columns(pl.col(eval_job_name).map_elements(lambda x: [eli5_map[y] for y in x]).alias(eval_job_name)) df = pl.concat([df, results], how='horizontal') df.write_parquet('arxiv-metadata-id-categories-abstract-100000-explanations-relative-evals.parquet') ``` A little more data wrangling, and we can determine the win rate percentage matrix. ```python theme={null} df = pl.read_parquet('arxiv-metadata-id-categories-abstract-100000-explanations-relative-evals.parquet') eval_models = inverse_eval_pairs.keys() eli5_models = list(set([model for eval_models in inverse_eval_pairs.values() for model in eval_models])) eli5_model_matrix = {} for i in eli5_models: for j in eli5_models: if i != j: eli5_model_matrix[(i, j)] = 0 for eval_model in eval_models: eval_job_name = eval_model + '_relative_evals' eval_results = df[eval_job_name].to_list() for eval_result in eval_results: if eval_result is not None: eli5_model_matrix[(eval_result[0], eval_result[1])] += 1 eli5_model_matrix[(eval_result[1], eval_result[2])] += 1 eli5_model_matrix[(eval_result[0], eval_result[2])] += 1 win_rate_pct_matrix = {} for i in eli5_models: for j in eli5_models: if i != j: num = eli5_model_matrix[(i, j)] den = num + eli5_model_matrix[(j, i)] win_rate_pct_matrix[(i, j)] = num / den if den else float('nan') df = pd.DataFrame(np.nan, index=eli5_models, columns=eli5_models, dtype=float) for (i, j), v in win_rate_pct_matrix.items(): df.loc[i, j] = v np.fill_diagonal(df.values, np.nan) # optional: blank diagonal plt.figure(figsize=(10, 8)) sns.heatmap(df, annot=True, fmt=".2f", cmap="RdYlGn", cbar=True, square=True) plt.title("Win Rate Percentage Matrix (cell = P(row beats column))") plt.xlabel("ELI5 Model (column)") plt.ylabel("ELI5 Model (row)") plt.savefig("llm-as-a-judge-win-rate-percentage-matrix.png", dpi=300, bbox_inches='tight') plt.tight_layout() plt.show() ``` ![Win rate percentage matrix](https://cdn.sutro.sh/llm-as-a-judge-win-rate-percentage-matrix.png) This time, we're pooling the results of the three evaluators to determine the win rate percentage matrix. We only care about the head-to-head comparisons of the ELI5 models. Once again, even with the context of the three evaluators, we can see that GPT-OSS 20B is hands down the best model, beating all the others in head-to-head comparisons. Llama is by far the worst, getting beaten down by the others, often by a wide margin. Almost 9/10 times the GPT-OSS 20B model beats it. We like to think we're *fancy mathematicians* here at Sutro, so we'll finally use the Elo rating system to determine the best model. This is often used for human skill ratings in games like chess, and more recently to rank responses from LLMs on websites like the [LM Arena Leaderboard](https://lmarena.ai/leaderboard). ```python theme={null} import math import numpy as np import pandas as pd def bt_elo_from_pair_counts( pair_counts: dict[tuple[str, str], int], ties: dict[tuple[str, str], int] | None = None, laplace: float = 0.5, max_iter: int = 1000, tol: float = 1e-8, elo_mean: float = 1500.0, ): """ pair_counts: { (winner, loser): wins } for all observed directed pairs. ties: optional { (a, b): tie_count } counted once per unordered pair (a,b). If provided, each tie contributes 0.5 win to both directions. laplace: additive smoothing to each *directed* count (prevents zeros). """ # ---- Build model list ---- models = sorted(set([k[0] for k in pair_counts] + [k[1] for k in pair_counts])) m = len(models) idx = {name: i for i, name in enumerate(models)} # ---- Build directed wins matrix W[i,j] = times i beat j ---- W = np.zeros((m, m), dtype=float) for (w, l), c in pair_counts.items(): if w == l: continue W[idx[w], idx[l]] += float(c) # ---- Optional ties: add 0.5 to both directions for each tie ---- if ties: for (a, b), t in ties.items(): if a == b: continue i, j = idx[a], idx[b] W[i, j] += 0.5 * t W[j, i] += 0.5 * t # ---- Laplace smoothing on directed edges (excluding diagonal) ---- if laplace and laplace > 0: W += laplace np.fill_diagonal(W, 0.0) # Unordered totals N_ij = W_ij + W_ji N = W + W.T np.fill_diagonal(N, 0.0) # Guard: drop models with zero matches active = (N.sum(axis=1) > 0) if not np.all(active): keep = np.where(active)[0] models = [models[i] for i in keep] idx = {name: i for i, name in enumerate(models)} W = W[np.ix_(keep, keep)] N = N[np.ix_(keep, keep)] m = len(models) # ---- Bradley–Terry via MM updates (Hunter 2004) ---- s = np.ones(m, dtype=float) # abilities (positive) for _ in range(max_iter): s_old = s.copy() w_i = W.sum(axis=1) # total (smoothed) wins per model # denom_i = sum_j N_ij / (s_i + s_j) denom = (N / (s.reshape(-1,1) + s.reshape(1,-1) + 1e-12)).sum(axis=1) upd = denom > 0 s[upd] = w_i[upd] / denom[upd] # normalize to keep scale stable (geometric mean = 1) s /= np.prod(s) ** (1.0 / m) if np.max(np.abs(np.log(s + 1e-12) - np.log(s_old + 1e-12))) < tol: break # ---- Convert to beta and Elo-like ratings ---- beta = np.log(s + 1e-12) elo = (400.0 / math.log(10.0)) * beta elo = elo - np.mean(elo) + elo_mean # center # ---- Summaries and expected probabilities ---- wins = W.sum(axis=1) losses = W.sum(axis=0) matches = N.sum(axis=1) # unordered total vs all opponents ratings = pd.DataFrame({ "ability": s, "beta": beta, "elo": elo, "wins": wins, "losses": losses, "matches": matches, }, index=models).sort_values("elo", ascending=False) P = s.reshape(-1,1) / (s.reshape(-1,1) + s.reshape(1,-1)) np.fill_diagonal(P, np.nan) p_matrix = pd.DataFrame(P, index=models, columns=models) return ratings, p_matrix ratings, p_exp = bt_elo_from_pair_counts(eli5_model_matrix, ties=None) print(ratings[["elo","wins","losses","matches"]].to_markdown()) ``` We get the following results: | | elo | wins | losses | matches | | :------------- | ----------: | -----: | -----: | ------: | | gpt-oss-20b | **1629.77** | 425974 | 174028 | 600001 | | qwen-3-14b | 1554.52 | 351422 | 248580 | 600001 | | gemma-3-12b-it | 1523.23 | 319396 | 280606 | 600003 | | llama-3.1-8b | **1292.47** | 103212 | 496790 | 600001 | The results are clear - GPT-OSS 20B is the clear winner, followed by Qwen 3 14B, and then Gemma 3 12B. Llama 3.1 8B is the worst performer. ### Conclusion We just demonstrated how you can use Sutro to run model task evals. Our approach avoided the need for human feedback altogether, instead using an ensemble of LLMs to evaluate our task and remove the biases of any single model. This avoided the need for human labelers, or any online human feedback. We bootstrapped the evaluation process entirely offline: * in just a **few hours** * on **100,000 samples, across 4 models and 16 evaluation jobs** (but could be scaled to millions of samples and billions of tokens) * for around **100 dollars** * without the need for any custom infrastructure or GPU setup * using **only the Sutro SDK and open-source Python tools** ### Next Steps While we identified a good base model to start with, a good next step might be to use our winning model, and run further offline evals to improve prompts, sampling parameters, or structured output schemas - taking our task specification to a truly optimized state. And to reiterate once more - this iterative, offline eval process can be used to improve nearly any AI application or agent, including but not limited to: #### New model development and benchmarking: You can continuously run large scale evals as you post-train, fine-tune, and improve your models without human feedback. #### Task specialization: When trying to optimize an LLM task such as summarization, classification, or code-generation, you can evaluate your choice of model, prompt, sampling parameters, and more to optimize selections and maximize performance. #### Application and agent development/tuning: As you create more complex, compound, and agentic tasks, you can evaluate entire reasoning traces, workflow outputs, and application logs to tune performance without the need for human feedback. Hopefully this helps get you started with LLM-as-a-judge methods on the Sutro platform. We encourage you to bring your own approaches, and get creative with the task you're trying to evaluate. If you need any help getting started, contact us at [team@sutro.sh](mailto:team@sutro.sh). You can review the full, resulting MIT-licensed dataset associated with this guide here: