Part 3

Finer control,
with code

Building in Python lets you fix the output format, pull in external data, and process many documents at once.

This part assumes you have completed the first call in Part 0.

What this part covers

Three things that are hard to do in Studio: fixing the format, connecting external data, and processing at scale.

1

Continuing a conversation

How to build a flow with several turns.

2

Getting a fixed format

Always receive the same shape so your program can use it directly.

3

Letting the model use tools

Have the model call your own functions or database when it needs to.

4

Working with documents

Process documents with the Document Parse API and chain the results.

5

Briefing a coding agent

What to tell an agent so the code it writes actually runs.

Every example assumes this setup

Solar is compatible with the OpenAI API, so you keep the library and only change the address. The official reference is Generate · API Quickstart.

Install
pip install --upgrade openai requests
Shared setup
import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["UPSTAGE_API_KEY"],
    base_url="https://api.upstage.ai/v1",
)

MODEL = "solar-pro4"   # covered by the AI Initiative

3.1Continuing a conversation

Solar does not store previous turns on the server. To keep context, you need to send the whole exchange each time.

Python
messages = [
    {"role": "user", "content": "My favourite colour is green."}
]

first = client.chat.completions.create(model=MODEL, messages=messages)
messages.append({"role": "assistant", "content": first.choices[0].message.content})

# Second turn — it can answer because we sent the earlier exchange
messages.append({"role": "user", "content": "What did I say my favourite colour was?"})

second = client.chat.completions.create(model=MODEL, messages=messages)
print(second.choices[0].message.content)

The three roles

roleWhat goes in it
systemRules and tone the model should follow. Include it once at the start
userThe request and the material
assistantWhat the model said earlier. Include it again to continue the conversation
As conversations grow

Everything you resend counts towards usage. The longer the exchange, the more you send each turn — so trim or summarize older turns.

The 50,000 tokens per minute limit from Part 0 applies here too. Per-tier limits are listed in the Rate limits documentation.

Key Summary

Solar does not remember conversations. You send the context each time, and it counts towards your usage.

3.2Getting a fixed format

For real work, results need to come back in the same shape every time. Instead of asking for a format in the prompt, you can specify it up front.

Two approaches

PropertyJSON modeStructured outputs
Output is valid JSONGuaranteedGuaranteed
Field names and types are fixedNot guaranteedGuaranteed
Schema requiredNoYes

For real work we recommend structured outputs. Consistent field names and types are what let your program rely on the result. The official reference is Structured outputs.

Example — classify an enquiry and draft a reply

Python
import json

response = client.chat.completions.create(
    model=MODEL,
    messages=[{
        "role": "user",
        "content": "Classify this enquiry and draft a short reply: I was charged twice for my subscription."
    }],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "inquiry",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "category": {
                        "type": "string",
                        "enum": ["billing", "technical", "account"],
                        "description": "The category the enquiry belongs to"
                    },
                    "urgent": {
                        "type": "boolean",
                        "description": "Whether it needs an immediate response"
                    },
                    "reply": {
                        "type": "string",
                        "description": "A short reply to send to the customer"
                    }
                },
                "required": ["category", "urgent", "reply"],
                "additionalProperties": False
            }
        }
    }
)

if response.choices[0].finish_reason != "stop":
    raise RuntimeError("The response was cut off. Increase max_tokens or retry.")

result = json.loads(response.choices[0].message.content)
print(result["category"], result["urgent"])
print(result["reply"])

Because enum fixes the value to three options, the result is always billing, technical, or account. The model will not invent a similar-sounding alternative.

Rules for writing a schema

  • The outermost level must be an object.
  • Every property must be listed in required.
  • For values that may be missing, do not remove the field — write "type": ["string", "null"]. The field always appears; only the value can be null.
  • Set strict to true and additionalProperties to false.
  • Supported types are string, number, integer, boolean, object, and array.
Check finish_reason before parsing

stop means the output completed, so it is safe to parse.

length means it hit the token limit and is cut off mid-object. Do not try to parse it — increase max_tokens or retry.

A valid shape does not mean a correct value

Fixing the format guarantees the shape, not the content. Validate important values such as amounts and dates against your own business rules.

Key Summary

Specifying a format gives you the same shape every time. Validating the values is still your job.

3.3Letting the model use tools

When the model needs information it does not have, you can let it call functions you wrote — for database lookups, internal systems, or calculations. The official reference is Tool calling.

How it works

Tell it what tools exist

Describe the function name, purpose, and required values in tools. The model reads these descriptions to decide when to call them.

The model asks for a call

The model does not run anything itself. It returns a request saying "please call this function with these values".

Your code runs it

Check the values you received, then run the function. The values may differ from what you expect, so always validate first.

Send the result back

Append the result to the conversation and make one more request. The model produces its final answer using that value.

Python — declaring the tool
tools = [{
    "type": "function",
    "function": {
        "name": "get_budget",
        "description": "Look up budget execution status by department and year.",
        "parameters": {
            "type": "object",
            "properties": {
                "department": {"type": "string", "description": "Department name"},
                "year": {"type": "integer", "description": "Year to look up"}
            },
            "required": ["department", "year"],
            "additionalProperties": False
        }
    }
}]

messages = [{"role": "user", "content": "What is the 2026 budget execution rate for the education team?"}]

response = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    tools=tools,
    tool_choice="auto",
)
Python — running it and returning the result
import json

tool_calls = response.choices[0].message.tool_calls

if tool_calls:
    messages.append(response.choices[0].message)

    for call in tool_calls:
        # 1) Confirm it is a tool you allow
        if call.function.name != "get_budget":
            raise ValueError(f"Tool not allowed: {call.function.name}")

        # 2) Confirm the values have the right shape
        args = json.loads(call.function.arguments)
        department = args.get("department")
        year = args.get("year")
        if not isinstance(department, str) or not department.strip():
            raise ValueError("department must be a non-empty string")
        if not isinstance(year, int):
            raise ValueError("year must be an integer")

        # 3) Only then run it
        result = get_budget(department=department, year=year)

        messages.append({
            "tool_call_id": call.id,
            "role": "tool",
            "name": call.function.name,
            "content": json.dumps(result),
        })

    final = client.chat.completions.create(model=MODEL, messages=messages)
    print(final.choices[0].message.content)
Never run the values as they arrive

What the model returns may not match your expectations. Confirm the tool is allowed and the values are well formed before running anything.

For actions that are hard to undo — deleting files, sending mail, making payments — either do not expose them as tools, or require a person to confirm first.

Calling several tools at once

Set parallel_tool_calls=True and the model can request several independent lookups in one response, which saves time when gathering from multiple sources.

In that case you must handle every returned call and append all results before the next request.

Key Summary

The model asks for a call; it does not execute one. Running and validating are your code's responsibility.

3.4Working with documents

Document Parse uses a different address from Solar. Because you upload a file, both the request and the shape of the response are different. The official reference is Parse · API Quickstart.

Python — one document
import os
import requests

url = "https://api.upstage.ai/v1/document-digitization"
headers = {"Authorization": f"Bearer {os.environ['UPSTAGE_API_KEY']}"}

with open("sample.pdf", "rb") as f:
    response = requests.post(
        url,
        headers=headers,
        files={"document": f},
        data={"model": "document-parse", "ocr": "force"},
    )

result = response.json()

print(result["content"]["html"][:500])   # structure preserved
print(result["usage"]["pages"], "pages processed")

The response is shaped differently from Solar's

Both are Upstage APIs, but the two products do different jobs — and return different shapes. This is where copying Solar code trips people up most often.

PropertySolarDocument Parse
Address/v1/chat/completions/v1/document-digitization
How you send itmessages via the openai libraryA file attachment via requests (multipart/form-data)
What comes backOne block of newly written textThe original, converted with its structure intact
Where you read itchoices[0].message.contentcontent.html · content.markdown · elements[]
Usage unitusage.total_tokens — tokensusage.pages — pages
Same input, run againGenerated, so the wording can shiftConverted, so the result stays stable

A Document Parse response has no choices at all. Lift a Solar example as-is and read response.choices[0] and you will get a missing-key error. The reverse is also true: a Solar response has no elements or pages.

What comes back

FieldContents
content.htmlHeadings, paragraphs, and tables with their structure intact — best for handing to Solar
content.markdownThe same result in Markdown
elements[]Each element individually, with its category, page number, and position
usage.pagesHow many pages were processed

elements[] includes page numbers and positions, so you can trace a value back to where it appeared in the original — valuable whenever the work needs review. Every field is documented in Understanding output.

Processing several documents

Document Parse is limited to 1 request per second with the synchronous API. Sending many at once will exceed that, so send them one at a time with a gap.

Python — one at a time
import time
import glob

results = {}

for path in glob.glob("documents/*.pdf"):
    try:
        with open(path, "rb") as f:
            response = requests.post(
                url,
                headers=headers,
                files={"document": f},
                data={"model": "document-parse", "ocr": "force"},
            )

        if response.status_code == 429:
            time.sleep(3)          # over the limit — wait a moment
            continue

        response.raise_for_status()
        results[path] = response.json()["content"]["html"]
        print(f"done: {path}")

    except Exception as e:
        print(f"failed: {path} — {e}")

    time.sleep(1)                  # stay within 1 request per second
If files are large or numerous

The synchronous API handles up to 100 pages per file. For longer documents, split them or use the asynchronous API, which handles up to 1,000 pages — see Handling large documents.

The file size limit is 50MB. Full format support and limits are on the Document Parse model page.

Chaining it — read a document and judge it

Python — Document Parse + Solar
import json

# Step 1 — turn the document into text
with open("receipt.pdf", "rb") as f:
    parsed = requests.post(
        url, headers=headers,
        files={"document": f},
        data={"model": "document-parse", "ocr": "force"},
    ).json()

document_text = parsed["content"]["html"]

# Steps 2 and 3 — extract values and judge against criteria
response = client.chat.completions.create(
    model=MODEL,
    messages=[{
        "role": "user",
        "content": f"From the receipt below, find the merchant, date, and total. "
                   f"If the total is over 100000, mark it as HOLD.\n\n{document_text}"
    }],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "receipt",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "store_name":   {"type": ["string", "null"], "description": "merchant name at the top"},
                    "issued_date":  {"type": ["string", "null"], "description": "date of issue"},
                    "total_amount": {"type": ["integer", "null"], "description": "final amount including tax"},
                    "decision":     {"type": "string", "enum": ["APPROVE", "HOLD", "NEEDS_CHECK"]},
                    "reason":       {"type": "string", "description": "one sentence explaining the decision"}
                },
                "required": ["store_name", "issued_date", "total_amount", "decision", "reason"],
                "additionalProperties": False
            }
        }
    }
)

print(json.loads(response.choices[0].message.content))

Values may be missing, so the fields allow ["string", "null"]. A NEEDS_CHECK verdict exists for exactly those cases.

Key Summary

Turn documents into text with Document Parse, hand that text to Solar with a fixed format, and you have a complete document automation.

3.5Briefing a coding agent

Most people no longer type this code themselves — they ask a coding agent like Claude Code or Cursor to write it. What decides the outcome is not your coding skill but what you told the agent.

Why it gets it wrong unprompted

An agent writes from what it learned at training time. The Upstage API is barely in that memory, or is out of date — so an unprompted request produces plausible-looking code that does not run. Three failures come up again and again.

What agents commonly writeWhat actually happens
Old model names such as model="solar-mini"The agent invents them from what it learned. Outside this program's scope, so no response comes back
Calling Document Parse at /v1/chat/completionsWrong address, so it fails. The error says "invalid model", which sends you looking in the wrong place
Reaching for information-extract to pull fieldsIt works — and draws down your credits. The same job runs free with Parse + Solar

So two things are needed: have it read the official docs first, and pin your constraints in a file.

Step 1 — point it at the official docs

Upstage publishes its API reference at a single address in a form coding agents read well. Have the agent read it before it starts, and it will invent far fewer model names and addresses.

What to say first
https://console.upstage.ai/api/docs/for-agents/raw

Read the URL above first.
For the Upstage API, use only the spec in that document —
do not fill in gaps from memory.

The guide page is For AI Coding Assistant.

Step 2 — pin the constraints in a file

Rather than repeat yourself every session, drop a rules file in the project folder. Claude Code reads CLAUDE.md; most other tools read AGENTS.md. Get the filename right and the contents apply to every request that follows.

CLAUDE.md · AGENTS.md — copy into your project folder
# Upstage API rules for this project

## Read first
- https://console.upstage.ai/api/docs/for-agents/raw
- Use only the spec in that document. Do not rely on memory.

## Hard rules
- Use `solar-pro4` only for the language model.
  `solar-mini` and the rest of the Solar family are forbidden — outside the
  AI Initiative, so the call fails. (`solar-pro2` and `solar-pro3` are also
  covered, but standardise new code on `solar-pro4`.)
- Use `document-parse` only for document processing.
  `information-extract`, `document-classify` and the embedding models
  (`solar-embedding-2-query`, `solar-embedding-2-passage`)
  are forbidden — they draw down credits.
- Do not reach for a separate product for field extraction or classification.
  Convert with Document Parse, then use Solar structured outputs.

## Addresses
- Solar          : https://api.upstage.ai/v1  (OpenAI-compatible, openai library)
- Document Parse : https://api.upstage.ai/v1/document-digitization  (multipart/form-data)
- The two responses are shaped differently.
  Solar: choices[0].message.content. Document Parse: content.html / elements[].

## Keys
- Never write the key in code. Always read os.environ["UPSTAGE_API_KEY"].
- Never leave the key value in logs, comments, or commits.

## Limits
- Tier 0: 100 requests and 50,000 tokens per minute. Document Parse: 1 per second.
- Any loop must include a delay between calls and a 429 retry.

## Handling results
- structured outputs: strict: true, additionalProperties: false,
  every field in required. Open optional fields as ["string", "null"].
- Check finish_reason == "stop" before reading the response.
- Leave a human review step for amounts and dates.
What this file is really doing

The point of a rules file is not "what to build" but "what not to use". Agents fail in a small set of predictable places — block those and the rest usually goes fine.

Step 3 — specify conditions, not outcomes

Ask for "something that handles receipts" and the agent fills the blanks itself. Those guesses become the parts you rewrite later. Put these five things in the request.

Where the input is and how much of it

"30 PDFs in documents/" gives it enough to design the batching and the delay together.

The fields and their types

"merchant (string) · date (string) · total (integer)". The format work from 3.2 becomes the code directly.

Criteria as numbers and conditions

Not "hold if the amount is large" but "hold if the total exceeds 100,000". Vague criteria produce vague results.

What to do when a value is missing

Say "write NEEDS_CHECK, not an empty cell" — otherwise the agent will fill in empty strings or zeros.

Where the output goes and in what shape

"One result.csv, with the source filename and page number alongside" keeps the result traceable.

Step 4 — check five lines in what comes back

You do not have to read the code end to end. These five are findable by search, and they are where the real accidents happen.

  • Is model= set to solar-pro4? If it says something else, such as solar-mini, ask for it to be changed.
  • Is base_url https://api.upstage.ai/v1?
  • Is the Document Parse address /v1/document-digitization?
  • Is the API key written into the code as a string? It should come from os.environ.
  • Does the loop include a delay (time.sleep) and 429 handling?
When the agent says it does not work

Agents tend to assume the code is at fault. That is how working code gets rewritten until it is worse.

Before changing any code, walk through the checks in Part 5 — payment method, model name, address, Bearer — and paste the error message in full. Summarizing it drops the last line, which is where the cause usually is.

Running Claude Code on Solar

Upstage provides a script that runs Claude Code on Solar models (Claude Code integration).

Its default model is solar-pro4, which the program covers, so it works as installed with no extra flags.

Key Summary

Tell a coding agent what not to use before you tell it what to build. One docs URL and one rules file prevent most of the failures.

3.6What is outside the program

The AI Initiative covers Solar Pro 2, 3, and 4, and Document Parse — and Solar Pro 2 support ends in mid-October 2026, so set solar-pro4 from the start. Being part of the Document AI family does not make a model free — everything below draws on your credits the moment you call it.

ModelWhat it doesList priceThis program
Information ExtractPulls fields straight out of a document$0.04/page
(Enhanced $0.06)
Uses credits
Document ClassifySorts documents by type$0.004/pageUses credits
Embed
solar-embedding-2-query · solar-embedding-2-passage
Turns meaning into numbers so you can find similar documents$0.10/1M tokensUses credits
Solar Mini and othersThe rest of the Solar family, outside Pro 2, 3, and 4Varies by modelUses credits

Prices are the published list as of August 2026, excluding tax. Discounts and revisions happen, so check the source before committing. For reference, the covered Document Parse is $0.01/page (Enhanced $0.03).

In Upstage Studio, only Parse is covered

In Studio, the one block this program covers is Parse. The Extract block adds a per-page charge on top ($0.01 Parse + $0.03 Extract = $0.04/page).

Classify and Instruct are free while in Beta. That is a temporary Upstage policy rather than a program benefit, and pricing is to be announced. Budget for them as paid.

Conversely, building in code means both Document Parse and Solar are covered. If you plan to run things repeatedly, code is the better fit.

Getting the same results within the covered set

Field extraction can be done with the fixed formats from 3.2. Convert with Document Parse, then hand the text to Solar with a schema.

Classification works the same way. Fix the categories with enum and the model answers with one of them.

In other words, Document Parse and Solar alone cover most document work.

If you do use Information Extract separately

It uses a different address. Reusing the client you built for Solar returns a "model is invalid" error, but the real cause is the URL. Create a separate client pointing at https://api.upstage.ai/v1/information-extraction.

Usage is documented under Extract. To say it once more: these calls draw down your credits.

Key Summary

Document Parse and Solar — the two covered products — handle both extraction and classification. You rarely need anything else.

3.7[Activity] Design a schema

Take what you worked out in Parts 1 and 2 and express it in code. Once the format is decided, the rest follows.

A. Blank template

Copy and fill in
# Format design for my task

## Input
- Document format:
- How many at a time:
- Is Document Parse needed (yes / no):

## Fields to extract
| Field name | Description | Type | Can it be empty |
|---|---|---|---|
|  |  |  |  |
|  |  |  |  |
|  |  |  |  |

## Verdict values
- Allowed values (enum):
- The criteria for each, as numbers and conditions:

## Validation
- Values a person must always check:
- What to do when a value looks wrong:

## Failure handling
- On a 429:
- On an empty value:

B. A worked example — checking grant applications

For reference
## Fields to extract
| applicant_org    | Name of the applying organization      | string  | no  |
| project_title    | Project name                           | string  | no  |
| requested_amount | Amount requested (integer)             | integer | yes |
| attachments      | List of attached document names        | array   | yes |

## Verdict values
- enum: ["ACCEPT", "NEEDS_REVISION", "NEEDS_CHECK"]
- ACCEPT: all required fields present and the amount is 50,000,000 or less
- NEEDS_REVISION: at least one required attachment is missing
- NEEDS_CHECK: the amount or organization name could not be found

## Validation
- A person always checks: requested_amount
- When a value looks wrong: mark NEEDS_CHECK and pass it to the officer

## Failure handling
- On a 429: wait 3 seconds and retry, up to three times
- On an empty value: mark NEEDS_CHECK and record the source page number

C. Checklist

  • Every field is listed in required.
  • Fields that may be missing allow null.
  • Verdict values are fixed with enum.
  • There is a verdict value for "could not find it".
  • You check finish_reason before parsing.
  • You decided what to do on a 429 and on empty values.

Official documentation

The source material behind this part. Use it when you need more options — or when you need to hand a coding agent something authoritative.

What you are afterDocumentWhere in this part
First Solar call and the basicsGenerate · API Quickstart3.1
Fixing the output formatStructured outputs3.2
Letting the model call your functionsTool calling3.3
Turning documents into textParse · API Quickstart3.4
Reading the Parse responseUnderstanding output3.4
Documents over 100 pagesHandling large documents3.4
API reference for coding agentsFor AI Coding Assistant3.5
Running Claude Code on SolarClaude Code integration3.5
Per-model limits and formatsModels catalog3.6
Request limits (tiers)Rate limits3.1
Pricing per modelAPI pricing3.6
Building without codeUpstage StudioPart 2
One change when copying the official examples

The examples are written with model="solar-pro4". Since Solar Pro 4 is covered, they work as documented with nothing to swap.

Part 3 key takeaways

  • Solar does not remember conversations. Send the context each time.
  • A fixed format gives you the same shape every time, but you still validate the values.
  • The model only asks to call a tool. Running and validating are your code's job.
  • Document Parse differs from Solar in both address and response shape — read content.html, not choices.
  • Give a coding agent the docs URL and a rules file first. Invented model names and wrong addresses are the most common failures.
  • Document Parse and Solar alone handle both extraction and classification.
  • Information Extract and Document Classify are not covered — calling them draws down credits.

Where to go next

Take a look at examples for your own setting to work out how this applies to your work.