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.
What this part covers
Three things that are hard to do in Studio: fixing the format, connecting external data, and processing at scale.
Continuing a conversation
How to build a flow with several turns.
Getting a fixed format
Always receive the same shape so your program can use it directly.
Letting the model use tools
Have the model call your own functions or database when it needs to.
Working with documents
Process documents with the Document Parse API and chain the results.
Briefing a coding agent
What to tell an agent so the code it writes actually runs.
Solar is compatible with the OpenAI API, so you keep the library and only change the address. The official reference is Generate · API Quickstart.
pip install --upgrade openai requests
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.
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
| role | What goes in it |
|---|---|
system | Rules and tone the model should follow. Include it once at the start |
user | The request and the material |
assistant | What the model said earlier. Include it again to continue the conversation |
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.
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
| Property | JSON mode | Structured outputs |
|---|---|---|
| Output is valid JSON | Guaranteed | Guaranteed |
| Field names and types are fixed | Not guaranteed | Guaranteed |
| Schema required | No | Yes |
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
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
stricttotrueandadditionalPropertiestofalse. - Supported types are string, number, integer, boolean, object, and array.
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.
Fixing the format guarantees the shape, not the content. Validate important values such as amounts and dates against your own business rules.
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.
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",
)
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)
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.
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.
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.
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.
| Property | Solar | Document Parse |
|---|---|---|
| Address | /v1/chat/completions | /v1/document-digitization |
| How you send it | messages via the openai library | A file attachment via requests (multipart/form-data) |
| What comes back | One block of newly written text | The original, converted with its structure intact |
| Where you read it | choices[0].message.content | content.html · content.markdown · elements[] |
| Usage unit | usage.total_tokens — tokens | usage.pages — pages |
| Same input, run again | Generated, so the wording can shift | Converted, 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
| Field | Contents |
|---|---|
content.html | Headings, paragraphs, and tables with their structure intact — best for handing to Solar |
content.markdown | The same result in Markdown |
elements[] | Each element individually, with its category, page number, and position |
usage.pages | How 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.
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
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
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.
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 write | What 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/completions | Wrong address, so it fails. The error says "invalid model", which sends you looking in the wrong place |
Reaching for information-extract to pull fields | It 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.
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.
# 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.
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 tosolar-pro4? If it says something else, such assolar-mini, ask for it to be changed. - Is
base_urlhttps://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?
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.
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.
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.
| Model | What it does | List price | This program |
|---|---|---|---|
| Information Extract | Pulls fields straight out of a document | $0.04/page (Enhanced $0.06) | Uses credits |
| Document Classify | Sorts documents by type | $0.004/page | Uses credits |
Embedsolar-embedding-2-query · solar-embedding-2-passage | Turns meaning into numbers so you can find similar documents | $0.10/1M tokens | Uses credits |
| Solar Mini and others | The rest of the Solar family, outside Pro 2, 3, and 4 | Varies by model | Uses 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 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.
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.
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.
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
# 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
## 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_reasonbefore 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 after | Document | Where in this part |
|---|---|---|
| First Solar call and the basics | Generate · API Quickstart | 3.1 |
| Fixing the output format | Structured outputs | 3.2 |
| Letting the model call your functions | Tool calling | 3.3 |
| Turning documents into text | Parse · API Quickstart | 3.4 |
| Reading the Parse response | Understanding output | 3.4 |
| Documents over 100 pages | Handling large documents | 3.4 |
| API reference for coding agents | For AI Coding Assistant | 3.5 |
| Running Claude Code on Solar | Claude Code integration | 3.5 |
| Per-model limits and formats | Models catalog | 3.6 |
| Request limits (tiers) | Rate limits | 3.1 |
| Pricing per model | API pricing | 3.6 |
| Building without code | Upstage Studio | Part 2 |
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.